feat: directly add customer service friend
Deploy olons-server production / deploy (push) Successful in 3m26s
Deploy olons-server production / deploy (push) Successful in 3m26s
This commit is contained in:
@@ -107,6 +107,11 @@ type Conversation struct {
|
|||||||
CanSend bool `json:"can_send"`
|
CanSend bool `json:"can_send"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DirectFriendResult struct {
|
||||||
|
Relationship string `json:"relationship"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
}
|
||||||
|
|
||||||
func New(cfg config.IMConfig) *Client {
|
func New(cfg config.IMConfig) *Client {
|
||||||
timeout := cfg.Timeout
|
timeout := cfg.Timeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
@@ -229,6 +234,18 @@ func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempot
|
|||||||
return &out, err
|
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) {
|
func (c *Client) EnsureP2P(ctx context.Context, token, userID, idempotencyKey string) (*Conversation, error) {
|
||||||
var out Conversation
|
var out Conversation
|
||||||
err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey,
|
err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey,
|
||||||
|
|||||||
@@ -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) {
|
func TestProductRequestHeaders(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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" {
|
if r.Header.Get("Authorization") != "Bearer user-token" || r.Header.Get("X-MeetingIM-APIKey") != "public-key" {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type IM interface {
|
|||||||
LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error)
|
LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error)
|
||||||
ResetPassword(context.Context, string, string) error
|
ResetPassword(context.Context, string, string) error
|
||||||
UserProfile(context.Context, string, string) (*imclient.UserProfile, 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)
|
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) {
|
if !imclient.IsPermissionDenied(err) {
|
||||||
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
||||||
}
|
}
|
||||||
key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID)
|
relationship = "unknown"
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
relationship = strings.TrimSpace(profile.Relationship)
|
relationship = strings.TrimSpace(profile.Relationship)
|
||||||
}
|
}
|
||||||
switch relationship {
|
switch relationship {
|
||||||
case "friend":
|
case "friend":
|
||||||
case "none", "":
|
case "none", "", "pending_inbound", "pending_outbound", "pending", "unknown":
|
||||||
key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID)
|
if _, err = s.im.AddFriendDirect(ctx, identity.IMUserID, agent.IMUserID); err != nil {
|
||||||
request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key)
|
if imclient.IsPermissionDenied(err) || imclient.IsConflict(err) {
|
||||||
if imclient.IsFriendRequestConflict(requestErr) {
|
return nil, ErrRelationDenied
|
||||||
return nil, ErrRelationPending
|
}
|
||||||
|
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:
|
default:
|
||||||
return nil, ErrRelationDenied
|
return nil, ErrRelationDenied
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ func (r *repositoryStub) SaveMemberIMIdentity(_ context.Context, identity *store
|
|||||||
}
|
}
|
||||||
|
|
||||||
type imStub struct {
|
type imStub struct {
|
||||||
friendRequests int
|
directFriendAdds int
|
||||||
loginAttempts int
|
loginAttempts int
|
||||||
resetPasswords int
|
resetPasswords int
|
||||||
loginError error
|
loginError error
|
||||||
profileError error
|
profileError error
|
||||||
friendError error
|
friendError error
|
||||||
conversation *imclient.Conversation
|
conversation *imclient.Conversation
|
||||||
conversationErr error
|
conversationErr error
|
||||||
ensureP2PCalls int
|
ensureP2PCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *imStub) Ready() bool { return true }
|
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
|
return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil
|
||||||
}
|
}
|
||||||
func (i *imStub) CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) {
|
func (i *imStub) AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error) {
|
||||||
i.friendRequests++
|
i.directFriendAdds++
|
||||||
if i.friendError != nil {
|
if i.friendError != nil {
|
||||||
return nil, i.friendError
|
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) {
|
func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) {
|
||||||
i.ensureP2PCalls++
|
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" {
|
if session.Topic != "p2p-topic" || session.CurrentUserID != "usr_member" || session.Agent.IMUserID != "usr_agent" {
|
||||||
t.Fatalf("unexpected session: %+v", session)
|
t.Fatalf("unexpected session: %+v", session)
|
||||||
}
|
}
|
||||||
if im.friendRequests != 1 {
|
if im.directFriendAdds != 1 {
|
||||||
t.Fatalf("friend requests = %d, want 1", im.friendRequests)
|
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{
|
repository := &repositoryStub{
|
||||||
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
|
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"},
|
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
|
||||||
}
|
}
|
||||||
im := &imStub{
|
im := &imStub{
|
||||||
profileError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
|
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"},
|
conversationErr: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
|
||||||
}
|
}
|
||||||
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
|
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
|
||||||
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
|
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
|
||||||
|
|
||||||
_, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
|
_, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
|
||||||
if !errors.Is(err, ErrRelationPending) {
|
if !errors.Is(err, ErrRelationDenied) {
|
||||||
t.Fatalf("error = %v, want ErrRelationPending", err)
|
t.Fatalf("error = %v, want ErrRelationDenied", err)
|
||||||
}
|
}
|
||||||
if im.ensureP2PCalls != 0 || im.friendRequests != 1 {
|
if im.ensureP2PCalls != 0 || im.directFriendAdds != 1 {
|
||||||
t.Fatalf("ensure p2p calls=%d friend requests=%d, want 0 and 1", im.ensureP2PCalls, im.friendRequests)
|
t.Fatalf("ensure p2p calls=%d direct friend adds=%d, want 0 and 1", im.ensureP2PCalls, im.directFriendAdds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user