feat: integrate customer service chat

This commit is contained in:
richjohnrambo
2026-10-14 16:37:12 +08:00
parent f3f59fe7c3
commit 48b3564ac9
22 changed files with 1788 additions and 64 deletions
+11
View File
@@ -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")
)
+298
View File
@@ -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"
}
}
+92
View File
@@ -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)
}
}
}