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" } }