feat: integrate customer service chat
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package support
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrServiceNotFound = errors.New("customer service not found")
|
||||
ErrServiceNotBound = errors.New("customer service IM account not bound")
|
||||
ErrIMUnavailable = errors.New("IM service unavailable")
|
||||
ErrRelationPending = errors.New("IM friend relationship is pending")
|
||||
ErrRelationDenied = errors.New("IM friend relationship is not allowed")
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/imclient"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error)
|
||||
MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error)
|
||||
MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error)
|
||||
SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error)
|
||||
}
|
||||
|
||||
type IM interface {
|
||||
Ready() bool
|
||||
Config() config.IMConfig
|
||||
CreateUser(context.Context, string, string, string) (*imclient.CreatedUser, error)
|
||||
FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error)
|
||||
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)
|
||||
EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
im IM
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
type SessionRequest struct {
|
||||
DeviceID string
|
||||
Platform string
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID int64 `json:"id"`
|
||||
IMUserID string `json:"im_user_id"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
OnlineStatus string `json:"online_status"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
TenantCode string `json:"tenant_code"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
WebSocketURL string `json:"websocket_url"`
|
||||
FileURL string `json:"file_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CurrentUserID string `json:"current_user_id"`
|
||||
Topic string `json:"topic"`
|
||||
Relationship string `json:"relationship"`
|
||||
Agent Agent `json:"agent"`
|
||||
}
|
||||
|
||||
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
|
||||
return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)}
|
||||
}
|
||||
|
||||
func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID int64, in SessionRequest) (*Session, error) {
|
||||
if s == nil || s.repository == nil || s.im == nil || !s.im.Ready() || s.aead == nil {
|
||||
return nil, ErrIMUnavailable
|
||||
}
|
||||
agent, err := s.repository.CustomerServiceByID(ctx, tenantID, serviceID)
|
||||
if errors.Is(err, store.ErrResourceNotFound) {
|
||||
return nil, ErrServiceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := s.im.Config()
|
||||
if strings.TrimSpace(agent.IMUserID) == "" || !strings.EqualFold(strings.TrimSpace(agent.IMTenantCode), strings.TrimSpace(cfg.TenantCode)) {
|
||||
return nil, ErrServiceNotBound
|
||||
}
|
||||
identity, err := s.ensureIdentity(ctx, tenantID, memberID, cfg.TenantCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
platform := strings.TrimSpace(in.Platform)
|
||||
if platform == "" {
|
||||
platform = "web"
|
||||
}
|
||||
password, err := s.decryptCredential(identity.IMPasswordCipher)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt IM credential: %w", err)
|
||||
}
|
||||
imSession, err := s.im.LoginBasic(ctx, identity.IMUsername, password, strings.TrimSpace(in.DeviceID), platform)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login IM session: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(imSession.UserID), strings.TrimSpace(identity.IMUserID)) {
|
||||
return nil, ErrIMUnavailable
|
||||
}
|
||||
profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
||||
}
|
||||
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 requestErr != nil && !imclient.IsConflict(requestErr) {
|
||||
return nil, fmt.Errorf("create customer service friend request: %w", requestErr)
|
||||
}
|
||||
if requestErr != nil {
|
||||
profile, err = s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh customer service relationship: %w", err)
|
||||
}
|
||||
relationship = strings.TrimSpace(profile.Relationship)
|
||||
} 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
|
||||
}
|
||||
conversation, err := s.im.EnsureP2P(ctx, imSession.Token, agent.IMUserID,
|
||||
fmt.Sprintf("p2p:%s:%s", identity.IMUserID, agent.IMUserID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure customer service conversation: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(conversation.Topic) == "" || (conversation.Relationship != "" && conversation.Relationship != "friend") || !conversation.CanSend {
|
||||
return nil, ErrRelationDenied
|
||||
}
|
||||
return &Session{
|
||||
TenantCode: cfg.TenantCode, APIBaseURL: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL,
|
||||
FileURL: cfg.FileURL, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
|
||||
CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend",
|
||||
Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL,
|
||||
OnlineStatus: customerServiceStatus(agent.OnlineStatus)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
|
||||
identity, err := s.repository.MemberIMIdentity(ctx, tenantID, memberID)
|
||||
if err == nil {
|
||||
if !strings.EqualFold(identity.IMTenantCode, imTenantCode) || identity.IMUserID == "" || identity.IMUsername == "" {
|
||||
return nil, ErrIMUnavailable
|
||||
}
|
||||
if identity.IMPasswordCipher != "" {
|
||||
return identity, nil
|
||||
}
|
||||
return s.resetAndSaveCredential(ctx, identity)
|
||||
}
|
||||
if !errors.Is(err, store.ErrResourceNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
member, err := s.repository.MemberIMProfile(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := memberIMUsername(tenantID, memberID, imTenantCode)
|
||||
nickname := strings.TrimSpace(member.Username)
|
||||
if nickname == "" {
|
||||
nickname = strings.TrimSpace(member.MemberNo)
|
||||
}
|
||||
if nickname == "" {
|
||||
nickname = "会员"
|
||||
}
|
||||
password, err := randomPassword()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.im.CreateUser(ctx, username, nickname, password)
|
||||
if err != nil && imclient.IsConflict(err) {
|
||||
created, err = s.im.FindUserByUsername(ctx, username)
|
||||
if err == nil {
|
||||
err = s.im.ResetPassword(ctx, string(created.UserID), password)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create IM identity: %w", err)
|
||||
}
|
||||
issued, err := s.im.LoginBasic(ctx, created.Username, password, "provision", "server")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login provisioned IM identity: %w", err)
|
||||
}
|
||||
ciphertext, err := s.encryptCredential(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity = &store.MemberIMIdentity{TenantID: tenantID, MemberID: memberID, IMTenantCode: imTenantCode,
|
||||
IMUserID: issued.UserID, IMUsername: created.Username, IMPasswordCipher: ciphertext}
|
||||
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
||||
}
|
||||
|
||||
func (s *Service) resetAndSaveCredential(ctx context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||
password, err := randomPassword()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.im.ResetPassword(ctx, identity.IMUserID, password); err != nil {
|
||||
return nil, fmt.Errorf("reset IM credential: %w", err)
|
||||
}
|
||||
identity.IMPasswordCipher, err = s.encryptCredential(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
||||
}
|
||||
|
||||
func credentialAEAD(cfg config.AuthConfig) cipher.AEAD {
|
||||
var key []byte
|
||||
if masterKey := strings.TrimSpace(cfg.FinanceMasterKey); len([]byte(masterKey)) >= 32 {
|
||||
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||
_, _ = mac.Write([]byte("olons-im:credential:v1"))
|
||||
key = mac.Sum(nil)
|
||||
} else {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
|
||||
}
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return nil
|
||||
}
|
||||
key = decoded
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return aead
|
||||
}
|
||||
|
||||
func (s *Service) encryptCredential(value string) (string, error) {
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := cryptorand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
|
||||
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||
}
|
||||
|
||||
func (s *Service) decryptCredential(value string) (string, error) {
|
||||
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil || len(payload) <= s.aead.NonceSize() {
|
||||
return "", errors.New("encrypted IM credential is invalid")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// The IM service limits usernames to ten lowercase letters or digits. A short
|
||||
// stable hash keeps provisioning idempotent without leaking the business ID.
|
||||
func memberIMUsername(tenantID, memberID int64, imTenantCode string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.ToLower(strings.TrimSpace(imTenantCode)), tenantID, memberID)))
|
||||
return "om" + fmt.Sprintf("%x", sum[:4])
|
||||
}
|
||||
|
||||
func randomPassword() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := cryptorand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func customerServiceStatus(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
return "online"
|
||||
case 3:
|
||||
return "busy"
|
||||
default:
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/imclient"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type repositoryStub struct {
|
||||
agent *store.CustomerService
|
||||
identity *store.MemberIMIdentity
|
||||
}
|
||||
|
||||
func (r *repositoryStub) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) {
|
||||
return r.agent, nil
|
||||
}
|
||||
func (r *repositoryStub) MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error) {
|
||||
return r.identity, nil
|
||||
}
|
||||
func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) {
|
||||
return nil, store.ErrResourceNotFound
|
||||
}
|
||||
func (r *repositoryStub) SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||
return r.identity, nil
|
||||
}
|
||||
|
||||
type imStub struct {
|
||||
friendRequests int
|
||||
}
|
||||
|
||||
func (i *imStub) Ready() bool { return true }
|
||||
func (i *imStub) Config() config.IMConfig {
|
||||
return config.IMConfig{TenantCode: "allrich", HTTPAPIURL: "https://api.118im.com/v1/im",
|
||||
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file", APIKey: "key"}
|
||||
}
|
||||
func (i *imStub) CreateUser(context.Context, string, string, string) (*imclient.CreatedUser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (i *imStub) FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (i *imStub) LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error) {
|
||||
return &imclient.Session{UserID: "usr_member", Token: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
func (i *imStub) ResetPassword(context.Context, string, string) error { return nil }
|
||||
func (i *imStub) UserProfile(context.Context, string, string) (*imclient.UserProfile, error) {
|
||||
return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil
|
||||
}
|
||||
func (i *imStub) CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) {
|
||||
i.friendRequests++
|
||||
return &imclient.FriendRequest{RequestID: "frq_1", Status: "accepted"}, nil
|
||||
}
|
||||
func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) {
|
||||
return &imclient.Conversation{Topic: "p2p-topic", Relationship: "friend", CanSend: true}, nil
|
||||
}
|
||||
|
||||
func TestOpenSessionAutoAcceptsAndReturnsTopic(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{}
|
||||
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
|
||||
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
|
||||
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberIMUsernameMatchesIMConstraint(t *testing.T) {
|
||||
username := memberIMUsername(0, 9223372036854775807, "allrich")
|
||||
if len(username) != 10 {
|
||||
t.Fatalf("username length = %d, want 10: %s", len(username), username)
|
||||
}
|
||||
for _, ch := range username {
|
||||
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') {
|
||||
t.Fatalf("invalid username %q", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user