diff --git a/internal/imclient/client.go b/internal/imclient/client.go index 0366a64..66283c0 100644 --- a/internal/imclient/client.go +++ b/internal/imclient/client.go @@ -107,6 +107,11 @@ type Conversation struct { CanSend bool `json:"can_send"` } +type DirectFriendResult struct { + Relationship string `json:"relationship"` + Topic string `json:"topic"` +} + func New(cfg config.IMConfig) *Client { timeout := cfg.Timeout if timeout <= 0 { @@ -229,6 +234,18 @@ func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempot return &out, err } +// AddFriendDirect establishes a trusted server-side friendship without +// creating a user-visible friend request. The IM internal API authenticates +// this operation with the configured internal secret. +func (c *Client) AddFriendDirect(ctx context.Context, userID, peerUserID string) (*DirectFriendResult, error) { + var out DirectFriendResult + path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/friends/direct" + err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{ + "user_id": userID, "peer_user_id": peerUserID, + }, &out) + return &out, err +} + func (c *Client) EnsureP2P(ctx context.Context, token, userID, idempotencyKey string) (*Conversation, error) { var out Conversation err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey, diff --git a/internal/imclient/client_test.go b/internal/imclient/client_test.go index a4a64c5..a3737bc 100644 --- a/internal/imclient/client_test.go +++ b/internal/imclient/client_test.go @@ -52,6 +52,46 @@ func TestCreateUserAcceptsNumericIDAndSignsRequest(t *testing.T) { } } +func TestAddFriendDirectUsesSignedInternalAPI(t *testing.T) { + const secret = "internal-secret" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if r.Method != http.MethodPost || r.URL.Path != "/internal/admin-query/tenants/default/friends/direct" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("X-Service-Name") != "olons-server" { + t.Fatal("missing service header") + } + canonical := strings.Join([]string{r.Method, r.URL.RequestURI(), hashHex(body), + r.Header.Get("X-Timestamp"), r.Header.Get("X-Nonce")}, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(canonical)) + if r.Header.Get("X-Signature") != hex.EncodeToString(mac.Sum(nil)) { + t.Fatal("invalid signature") + } + var payload map[string]string + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload["user_id"] != "usr_member" || payload["peer_user_id"] != "usr_agent" { + t.Fatalf("unexpected direct friendship payload: %+v", payload) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"relationship":"friend","topic":"p2pAB"}}`)) + })) + defer server.Close() + + client := New(config.IMConfig{BaseURL: server.URL, TenantCode: "default", + InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second}) + result, err := client.AddFriendDirect(context.Background(), "usr_member", "usr_agent") + if err != nil { + t.Fatal(err) + } + if result.Relationship != "friend" || result.Topic != "p2pAB" { + t.Fatalf("unexpected direct friendship result: %+v", result) + } +} + func TestProductRequestHeaders(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer user-token" || r.Header.Get("X-MeetingIM-APIKey") != "public-key" { diff --git a/internal/support/service.go b/internal/support/service.go index 03cfe5d..59d7f95 100644 --- a/internal/support/service.go +++ b/internal/support/service.go @@ -33,7 +33,7 @@ type IM interface { LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error) ResetPassword(context.Context, string, string) error UserProfile(context.Context, string, string) (*imclient.UserProfile, error) - CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) + AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) } @@ -131,42 +131,19 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID if !imclient.IsPermissionDenied(err) { return nil, fmt.Errorf("query customer service relationship: %w", err) } - key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID) - request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key) - if imclient.IsFriendRequestConflict(requestErr) { - return nil, ErrRelationPending - } - if requestErr != nil { - if imclient.IsConflict(requestErr) { - return nil, ErrRelationDenied - } - return nil, fmt.Errorf("create customer service friend request: %w", requestErr) - } - relationship = strings.TrimSpace(request.Status) + relationship = "unknown" } else { relationship = strings.TrimSpace(profile.Relationship) } switch relationship { case "friend": - case "none", "": - key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID) - request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key) - if imclient.IsFriendRequestConflict(requestErr) { - return nil, ErrRelationPending + case "none", "", "pending_inbound", "pending_outbound", "pending", "unknown": + if _, err = s.im.AddFriendDirect(ctx, identity.IMUserID, agent.IMUserID); err != nil { + if imclient.IsPermissionDenied(err) || imclient.IsConflict(err) { + return nil, ErrRelationDenied + } + return nil, fmt.Errorf("add customer service as friend: %w", err) } - if requestErr != nil && !imclient.IsConflict(requestErr) { - return nil, fmt.Errorf("create customer service friend request: %w", requestErr) - } - if requestErr != nil { - return nil, ErrRelationDenied - } else { - relationship = strings.TrimSpace(request.Status) - } - if relationship != "accepted" && relationship != "friend" { - return nil, ErrRelationPending - } - case "pending_inbound", "pending_outbound", "pending": - return nil, ErrRelationPending default: return nil, ErrRelationDenied } diff --git a/internal/support/service_test.go b/internal/support/service_test.go index 30847f8..50a1d5f 100644 --- a/internal/support/service_test.go +++ b/internal/support/service_test.go @@ -42,15 +42,15 @@ func (r *repositoryStub) SaveMemberIMIdentity(_ context.Context, identity *store } type imStub struct { - friendRequests int - loginAttempts int - resetPasswords int - loginError error - profileError error - friendError error - conversation *imclient.Conversation - conversationErr error - ensureP2PCalls int + directFriendAdds int + loginAttempts int + resetPasswords int + loginError error + profileError error + friendError error + conversation *imclient.Conversation + conversationErr error + ensureP2PCalls int } func (i *imStub) Ready() bool { return true } @@ -83,12 +83,12 @@ func (i *imStub) UserProfile(context.Context, string, string) (*imclient.UserPro } return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil } -func (i *imStub) CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) { - i.friendRequests++ +func (i *imStub) AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error) { + i.directFriendAdds++ if i.friendError != nil { return nil, i.friendError } - return &imclient.FriendRequest{RequestID: "frq_1", Status: "accepted"}, nil + return &imclient.DirectFriendResult{Relationship: "friend", Topic: "p2p-topic"}, nil } func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) { i.ensureP2PCalls++ @@ -116,8 +116,8 @@ func TestOpenSessionAutoAcceptsAndReturnsTopic(t *testing.T) { if session.Topic != "p2p-topic" || session.CurrentUserID != "usr_member" || session.Agent.IMUserID != "usr_agent" { t.Fatalf("unexpected session: %+v", session) } - if im.friendRequests != 1 { - t.Fatalf("friend requests = %d, want 1", im.friendRequests) + if im.directFriendAdds != 1 { + t.Fatalf("direct friend adds = %d, want 1", im.directFriendAdds) } } @@ -139,25 +139,25 @@ func TestOpenSessionReturnsSameOriginEndpoints(t *testing.T) { } } -func TestOpenSessionReturnsPendingWhenPrivateProfileHidesPendingRelationship(t *testing.T) { +func TestOpenSessionDeniesWhenDirectFriendshipIsBlocked(t *testing.T) { repository := &repositoryStub{ agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1}, identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"}, } im := &imStub{ profileError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"}, - friendError: &imclient.Error{Status: 409, Reason: "FRIEND_REQUEST_CONFLICT"}, + friendError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"}, conversationErr: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"}, } service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)}) repository.identity.IMPasswordCipher, _ = service.encryptCredential("password") _, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"}) - if !errors.Is(err, ErrRelationPending) { - t.Fatalf("error = %v, want ErrRelationPending", err) + if !errors.Is(err, ErrRelationDenied) { + t.Fatalf("error = %v, want ErrRelationDenied", err) } - if im.ensureP2PCalls != 0 || im.friendRequests != 1 { - t.Fatalf("ensure p2p calls=%d friend requests=%d, want 0 and 1", im.ensureP2PCalls, im.friendRequests) + if im.ensureP2PCalls != 0 || im.directFriendAdds != 1 { + t.Fatalf("ensure p2p calls=%d direct friend adds=%d, want 0 and 1", im.ensureP2PCalls, im.directFriendAdds) } }