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
+4 -1
View File
@@ -14,9 +14,11 @@ import (
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/httpapi"
"git.118im.com/im_team/olons_server/internal/imclient"
"git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/store"
"git.118im.com/im_team/olons_server/internal/support"
"git.118im.com/im_team/olons_server/internal/tasks"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
@@ -52,7 +54,8 @@ func New(cfg *config.Config) (*App, error) {
mallService := mall.New(st)
financeService := finance.New(st, redisClient, cfg.Auth)
attachmentService := attachments.New(st, cfg.Upload)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil
supportService := support.New(st, imclient.New(cfg.IM), cfg.Auth)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil
}
func (a *App) Handler() http.Handler {
+40 -1
View File
@@ -19,10 +19,23 @@ type Config struct {
Redis RedisConfig `json:"redis"`
Auth AuthConfig `json:"auth"`
Upload UploadConfig `json:"upload"`
IM IMConfig `json:"im"`
ReadTimeout time.Duration `json:"-"`
WriteTimeout time.Duration `json:"-"`
}
type IMConfig struct {
BaseURL string `json:"base_url"`
HTTPAPIURL string `json:"http_api_url"`
WebSocketURL string `json:"websocket_url"`
FileURL string `json:"file_url"`
TenantCode string `json:"tenant_code"`
APIKey string `json:"api_key"`
InternalService string `json:"internal_service"`
InternalSecret string `json:"internal_secret"`
Timeout time.Duration `json:"-"`
}
type UploadConfig struct {
Dir string `json:"dir"`
PublicPath string `json:"public_path"`
@@ -87,7 +100,13 @@ func defaultConfig() *Config {
DefaultTenantID: 0,
RequiredAPIKey: true,
},
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
IM: IMConfig{
BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im",
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file",
TenantCode: "allrich", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
InternalService: "olons-server", Timeout: 8 * time.Second,
},
ReadTimeout: 8 * time.Second,
WriteTimeout: 8 * time.Second,
}
@@ -113,6 +132,15 @@ func applyEnv(cfg *Config) {
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR")
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
setString(&cfg.IM.BaseURL, "OLONS_IM_BASE_URL")
setString(&cfg.IM.HTTPAPIURL, "OLONS_IM_HTTP_API_URL")
setString(&cfg.IM.WebSocketURL, "OLONS_IM_WS_URL")
setString(&cfg.IM.FileURL, "OLONS_IM_FILE_URL")
setString(&cfg.IM.TenantCode, "OLONS_IM_TENANT_CODE")
setString(&cfg.IM.APIKey, "OLONS_IM_API_KEY")
setString(&cfg.IM.InternalService, "OLONS_IM_INTERNAL_SERVICE")
setString(&cfg.IM.InternalSecret, "OLONS_IM_INTERNAL_SECRET")
setDuration(&cfg.IM.Timeout, "OLONS_IM_TIMEOUT")
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_API_KEYS")); keys != "" {
cfg.Auth.APIKeys = splitCSV(keys)
}
@@ -147,6 +175,9 @@ func (c *Config) validate() error {
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
return errors.New("upload config is invalid")
}
if c.IM.Timeout <= 0 {
c.IM.Timeout = 8 * time.Second
}
return nil
}
@@ -181,6 +212,14 @@ func setBool(target *bool, key string) {
}
}
func setDuration(target *time.Duration, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
*target = parsed
}
}
}
func splitCSV(value string) []string {
parts := strings.Split(value, ",")
items := make([]string, 0, len(parts))
+5 -2
View File
@@ -9,6 +9,7 @@ import (
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/support"
"git.118im.com/im_team/olons_server/internal/tasks"
)
@@ -20,10 +21,11 @@ type API struct {
mall *mall.Service
finance *finance.Service
attachments *attachments.Service
support *support.Service
}
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service) *API {
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService}
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service, supportService *support.Service) *API {
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService, support: supportService}
}
func (a *API) Register(mux *http.ServeMux) {
@@ -40,6 +42,7 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/member-levels", a.memberLevels)
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
mux.HandleFunc("/v1/customer-services", a.customerServices)
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
mux.HandleFunc("/v1/notices", a.notices)
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
mux.HandleFunc("/v1/notices/", a.noticeResource)
+76
View File
@@ -0,0 +1,76 @@
package httpapi
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"git.118im.com/im_team/olons_server/internal/support"
)
type customerServiceSessionRequest struct {
DeviceID string `json:"device_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
}
func (a *API) customerServiceResource(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/customer-services/"), "/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "session" {
writeError(w, r, http.StatusNotFound, 30004, "RESOURCE_NOT_FOUND", "resource not found", nil)
return
}
serviceID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || serviceID <= 0 {
writeError(w, r, http.StatusNotFound, 30004, "RESOURCE_NOT_FOUND", "resource not found", nil)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
if a.support == nil {
writeSupportError(w, r, support.ErrIMUnavailable)
return
}
var body customerServiceSessionRequest
if !decodeBody(w, r, &body) {
return
}
deviceID := strings.TrimSpace(body.DeviceID)
if deviceID == "" {
deviceID = principal.DeviceID
}
result, err := a.support.OpenSession(r.Context(), principal.TenantID, principal.MemberID, serviceID,
support.SessionRequest{DeviceID: deviceID, Platform: body.Platform})
if err != nil {
writeSupportError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func writeSupportError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("customer service session failed request_id=%s err=%v", r.Header.Get("X-Request-ID"), err)
switch {
case errors.Is(err, support.ErrServiceNotFound):
writeError(w, r, http.StatusNotFound, 41001, "CUSTOMER_SERVICE_NOT_FOUND", "customer service not found", nil)
case errors.Is(err, support.ErrServiceNotBound):
writeError(w, r, http.StatusConflict, 41002, "CUSTOMER_SERVICE_NOT_BOUND", "customer service account is not configured", nil)
case errors.Is(err, support.ErrRelationPending):
writeError(w, r, http.StatusConflict, 41003, "IM_RELATION_PENDING", "customer service relationship is pending", nil)
case errors.Is(err, support.ErrRelationDenied):
writeError(w, r, http.StatusForbidden, 41004, "IM_RELATION_NOT_ALLOWED", "customer service relationship is not allowed", nil)
case errors.Is(err, support.ErrIMUnavailable):
writeError(w, r, http.StatusServiceUnavailable, 41005, "IM_SERVICE_UNAVAILABLE", "IM service unavailable", nil)
default:
writeError(w, r, http.StatusBadGateway, 41006, "IM_SERVICE_UNAVAILABLE", "IM service unavailable", nil)
}
}
+383
View File
@@ -0,0 +1,383 @@
package imclient
import (
"bytes"
"context"
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/gorilla/websocket"
"golang.org/x/crypto/bcrypt"
)
const maxResponseBody = 2 << 20
type Error struct {
Status int
Code int
Reason string
Message string
}
func (e *Error) Error() string {
if e == nil {
return "IM service error"
}
if e.Reason != "" {
return e.Reason
}
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("IM service returned HTTP %d", e.Status)
}
type Client struct {
cfg config.IMConfig
http *http.Client
}
type CreatedUser struct {
UserID FlexibleID `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}
// FlexibleID normalizes the IM internal API's numeric database ID and its
// public string user ID into one representation. User creation returns a
// numeric ID, while session issuance and product APIs return the public ID.
type FlexibleID string
func (id *FlexibleID) UnmarshalJSON(data []byte) error {
var value string
if len(data) > 0 && data[0] == '"' {
if err := json.Unmarshal(data, &value); err != nil {
return err
}
} else {
value = strings.TrimSpace(string(data))
}
if value == "" || value == "null" {
return errors.New("empty IM user ID")
}
*id = FlexibleID(value)
return nil
}
type Session struct {
UserID string `json:"user_id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
type UserProfile struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Relationship string `json:"relationship"`
Topic string `json:"topic"`
Capabilities struct {
CanSend bool `json:"can_send"`
} `json:"capabilities"`
}
type FriendRequest struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Topic string `json:"topic"`
}
type Conversation struct {
Topic string `json:"topic"`
Relationship string `json:"relationship"`
CanSend bool `json:"can_send"`
}
func New(cfg config.IMConfig) *Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 8 * time.Second
}
return &Client{cfg: cfg, http: &http.Client{Timeout: timeout}}
}
func (c *Client) Config() config.IMConfig { return c.cfg }
func (c *Client) Ready() bool {
return strings.TrimSpace(c.cfg.BaseURL) != "" && strings.TrimSpace(c.cfg.HTTPAPIURL) != "" &&
strings.TrimSpace(c.cfg.WebSocketURL) != "" &&
strings.TrimSpace(c.cfg.TenantCode) != "" && strings.TrimSpace(c.cfg.APIKey) != "" &&
strings.TrimSpace(c.cfg.InternalService) != "" && strings.TrimSpace(c.cfg.InternalSecret) != ""
}
func (c *Client) CreateUser(ctx context.Context, username, nickname, password string) (*CreatedUser, error) {
var out CreatedUser
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users"
err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"username": username, "nickname": nickname, "password": password,
}, &out)
return &out, err
}
func (c *Client) FindUserByUsername(ctx context.Context, username string) (*CreatedUser, error) {
query := url.Values{"auth_name": []string{username}, "page": []string{"1"}, "per_page": []string{"20"}}
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users?" + query.Encode()
var page struct {
Items []struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
} `json:"items"`
}
if err := c.internalJSON(ctx, http.MethodGet, path, nil, &page); err != nil {
return nil, err
}
for _, item := range page.Items {
if strings.EqualFold(strings.TrimSpace(item.Username), strings.TrimSpace(username)) {
return &CreatedUser{UserID: FlexibleID(item.UserID), Username: item.Username, Nickname: item.Nickname}, nil
}
}
return nil, &Error{Status: http.StatusNotFound, Reason: "RESOURCE_NOT_FOUND", Message: "IM user not found"}
}
func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, platform string) (*Session, error) {
endpoint, err := url.Parse(strings.TrimSpace(c.cfg.WebSocketURL))
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New("IM websocket URL is invalid")
}
query := endpoint.Query()
query.Set("apikey", c.cfg.APIKey)
endpoint.RawQuery = query.Encode()
dialer := websocket.Dialer{HandshakeTimeout: c.cfg.Timeout}
conn, _, err := dialer.DialContext(ctx, endpoint.String(), nil)
if err != nil {
return nil, err
}
defer conn.Close()
deadline := time.Now().Add(c.cfg.Timeout)
_ = conn.SetWriteDeadline(deadline)
_ = conn.SetReadDeadline(deadline)
hiID := "hi-" + requestID()
if err = conn.WriteJSON(map[string]any{"hi": map[string]any{
"id": hiID, "ver": "0.25", "ua": "olons-server/1.0", "dev": deviceID,
"platf": firstNonEmpty(platform, "server"), "lang": "zh-CN", "tenant": c.cfg.TenantCode,
}}); err != nil {
return nil, err
}
if _, err = readCtrl(conn, hiID); err != nil {
return nil, err
}
loginID := "login-" + requestID()
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
if err = conn.WriteJSON(map[string]any{"login": map[string]any{
"id": loginID, "scheme": "basic", "secret": secret,
"login_type": 0, "tenant_code": c.cfg.TenantCode,
}}); err != nil {
return nil, err
}
ctrl, err := readCtrl(conn, loginID)
if err != nil {
return nil, err
}
expiresAt, _ := time.Parse(time.RFC3339, ctrl.Params.Expires)
if ctrl.Params.User == "" || ctrl.Params.Token == "" {
return nil, errors.New("IM login response is incomplete")
}
return &Session{UserID: ctrl.Params.User, Token: ctrl.Params.Token, ExpiresAt: expiresAt}, nil
}
func (c *Client) ResetPassword(ctx context.Context, userID, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action"
return c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "Olons客服系统凭据同步",
}, nil)
}
func (c *Client) UserProfile(ctx context.Context, token, userID string) (*UserProfile, error) {
var out UserProfile
err := c.productJSON(ctx, http.MethodGet, "/users/"+url.PathEscape(userID)+"/profile", token, "", nil, &out)
return &out, err
}
func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempotencyKey string) (*FriendRequest, error) {
var out FriendRequest
err := c.productJSON(ctx, http.MethodPost, "/friend-requests", token, idempotencyKey, map[string]any{
"target_user_id": userID, "message": "咨询客服", "source": "profile_card",
}, &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,
map[string]any{"peer_user_id": userID}, &out)
return &out, err
}
func (c *Client) internalJSON(ctx context.Context, method, path string, input, output any) error {
body, err := marshalBody(input)
if err != nil {
return err
}
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" || c.cfg.InternalSecret == "" {
return errors.New("IM internal API is not configured")
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
nonce, err := randomNonce()
if err != nil {
return err
}
bodyHash := sha256.Sum256(body)
canonical := strings.Join([]string{method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, nonce}, "\n")
mac := hmac.New(sha256.New, []byte(c.cfg.InternalSecret))
_, _ = mac.Write([]byte(canonical))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Name", c.cfg.InternalService)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
return c.do(req, output)
}
func (c *Client) productJSON(ctx context.Context, method, path, token, idempotencyKey string, input, output any) error {
body, err := marshalBody(input)
if err != nil {
return err
}
baseURL := strings.TrimRight(c.cfg.HTTPAPIURL, "/")
if baseURL == "" || c.cfg.APIKey == "" {
return errors.New("IM product API is not configured")
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-MeetingIM-APIKey", c.cfg.APIKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", requestID())
req.Header.Set("Authorization", "Bearer "+token)
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
return c.do(req, output)
}
func (c *Client) do(req *http.Request, output any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return err
}
var envelope struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return &Error{Status: resp.StatusCode, Reason: "INVALID_RESPONSE", Message: "invalid IM response"}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 {
return &Error{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message}
}
if output == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
return json.Unmarshal(envelope.Data, output)
}
func marshalBody(input any) ([]byte, error) {
if input == nil {
return nil, nil
}
return json.Marshal(input)
}
func randomNonce() (string, error) {
buf := make([]byte, 16)
if _, err := cryptorand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func requestID() string {
buf := make([]byte, 16)
if _, err := cryptorand.Read(buf); err == nil {
return hex.EncodeToString(buf)
}
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
type websocketCtrl struct {
ID string `json:"id"`
Code int `json:"code"`
Text string `json:"text"`
Params struct {
User string `json:"user"`
Token string `json:"token"`
Expires string `json:"expires"`
Reason string `json:"reason"`
} `json:"params"`
}
func readCtrl(conn *websocket.Conn, expectedID string) (*websocketCtrl, error) {
for {
var packet struct {
Ctrl *websocketCtrl `json:"ctrl"`
}
if err := conn.ReadJSON(&packet); err != nil {
return nil, err
}
if packet.Ctrl == nil || packet.Ctrl.ID != expectedID {
continue
}
if packet.Ctrl.Code >= 300 {
reason := firstNonEmpty(packet.Ctrl.Params.Reason, packet.Ctrl.Text, "IM websocket request failed")
return nil, &Error{Status: packet.Ctrl.Code, Reason: reason, Message: packet.Ctrl.Text}
}
return packet.Ctrl, nil
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func IsConflict(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusConflict
}
+151
View File
@@ -0,0 +1,151 @@
package imclient
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/gorilla/websocket"
"golang.org/x/crypto/bcrypt"
)
func TestCreateUserAcceptsNumericIDAndSignsRequest(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.URL.Path != "/internal/admin-query/tenants/allrich/users" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if r.Header.Get("X-Service-Name") != "olons-server" {
t.Fatalf("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.Fatalf("invalid signature")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":42,"username":"allrich_m_7","nickname":"会员7"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second})
created, err := client.CreateUser(context.Background(), "allrich_m_7", "会员7", "password")
if err != nil {
t.Fatal(err)
}
if got := string(created.UserID); got != "42" {
t.Fatalf("user id = %q, want 42", got)
}
}
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" {
t.Fatalf("missing product API credentials")
}
if r.Header.Get("X-IM-Schema") != "1" {
t.Fatalf("missing schema header")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":"usr_agent","relationship":"friend","topic":"p2p123","capabilities":{"can_send":true}}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
profile, err := client.UserProfile(context.Background(), "user-token", "usr_agent")
if err != nil {
t.Fatal(err)
}
if profile.Relationship != "friend" || !profile.Capabilities.CanSend {
t.Fatalf("unexpected profile: %+v", profile)
}
}
func TestLoginBasicUsesExistingWebSocketProtocol(t *testing.T) {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("apikey") != "public-key" {
t.Fatalf("missing API key")
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var packet map[string]map[string]any
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
if packet["hi"]["tenant"] != "allrich" {
t.Fatalf("unexpected hi: %#v", packet)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": packet["hi"]["id"], "code": 201, "text": "created"}})
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
login := packet["login"]
secret, _ := base64.StdEncoding.DecodeString(login["secret"].(string))
if string(secret) != "member01:password" || login["login_type"] != float64(0) || login["tenant_code"] != "allrich" {
t.Fatalf("unexpected login: %#v", login)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": login["id"], "code": 200, "text": "ok",
"params": map[string]any{"user": "usr_member", "token": "token", "expires": "2026-10-27T10:00:00Z"}}})
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL,
WebSocketURL: "ws" + strings.TrimPrefix(server.URL, "http"), TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
session, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web")
if err != nil {
t.Fatal(err)
}
if session.UserID != "usr_member" || session.Token != "token" {
t.Fatalf("unexpected session: %+v", session)
}
}
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
EventType string `json:"event_type"`
PasswordHash string `json:"password_hash"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
hash, err := base64.StdEncoding.DecodeString(body.PasswordHash)
if body.EventType != "user.password.reset" || err != nil || bcrypt.CompareHashAndPassword(hash, []byte("new-password")) != nil {
t.Fatalf("unexpected reset body: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"status":"succeeded"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, WebSocketURL: "ws://example.test",
TenantCode: "allrich", APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
if err := client.ResetPassword(context.Background(), "usr_member", "new-password"); err != nil {
t.Fatal(err)
}
}
func hashHex(body []byte) string {
digest := sha256.Sum256(body)
return hex.EncodeToString(digest[:])
}
+2
View File
@@ -126,6 +126,7 @@ type CustomerService struct {
ServiceURL string `json:"service_url"`
BackupContact string `json:"backup_contact"`
PrivacyNotice string `json:"privacy_notice"`
IMEnabled bool `json:"im_enabled"`
}
func New(repository Repository) *Service {
@@ -282,6 +283,7 @@ func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]Custo
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
BackupContact: row.BackupContact,
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
IMEnabled: strings.TrimSpace(row.IMTenantCode) != "" && strings.TrimSpace(row.IMUserID) != "",
})
}
return items, nil
+5 -1
View File
@@ -83,6 +83,9 @@ type CustomerService struct {
OnlineStatus int `db:"online_status"`
ServiceURL string `db:"service_url"`
BackupContact string `db:"backup_contact"`
IMTenantCode string `db:"im_tenant_code"`
IMUserID string `db:"im_user_id"`
IMUsername string `db:"im_username"`
}
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
@@ -265,7 +268,8 @@ func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]C
}
rows := make([]CustomerService, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT id,service_code,name,avatar_url,work_time,
online_status,service_url,backup_contact FROM hg_customer_service
online_status,service_url,backup_contact,im_tenant_code,COALESCE(im_user_id,'') AS im_user_id,
COALESCE(im_username,'') AS im_username FROM hg_customer_service
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID)
return rows, err
}
+88
View File
@@ -0,0 +1,88 @@
package store
import (
"context"
"database/sql"
"errors"
)
type MemberIMIdentity struct {
ID int64 `db:"id"`
TenantID int64 `db:"tenant_id"`
MemberID int64 `db:"member_id"`
IMTenantCode string `db:"im_tenant_code"`
IMUserID string `db:"im_user_id"`
IMUsername string `db:"im_username"`
IMPasswordCipher string `db:"im_password_cipher"`
}
type MemberIMProfile struct {
MemberID int64 `db:"member_id"`
Username string `db:"username"`
MemberNo string `db:"member_no"`
}
func (s *Store) CustomerServiceByID(ctx context.Context, tenantID, serviceID int64) (*CustomerService, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row CustomerService
err := s.db.GetContext(ctx, &row, `SELECT id,service_code,name,avatar_url,work_time,online_status,
service_url,backup_contact,im_tenant_code,COALESCE(im_user_id,'') AS im_user_id,
COALESCE(im_username,'') AS im_username FROM hg_customer_service
WHERE tenant_id=? AND id=? AND status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, serviceID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
return &row, err
}
func (s *Store) MemberIMIdentity(ctx context.Context, tenantID, memberID int64) (*MemberIMIdentity, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row MemberIMIdentity
err := s.db.GetContext(ctx, &row, `SELECT id,tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher
FROM hg_app_member_im_identity WHERE tenant_id=? AND member_id=? LIMIT 1`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
return &row, err
}
func (s *Store) MemberIMProfile(ctx context.Context, tenantID, memberID int64) (*MemberIMProfile, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row MemberIMProfile
err := s.db.GetContext(ctx, &row, `SELECT id AS member_id,username,member_no FROM hg_app_member
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return &row, err
}
func (s *Store) SaveMemberIMIdentity(ctx context.Context, identity *MemberIMIdentity) (*MemberIMIdentity, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
if identity == nil {
return nil, errors.New("identity is required")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_im_identity
(tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher,sync_status,last_error,created_at,updated_at)
VALUES(?,?,?,?,?,?,1,'',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE im_user_id=VALUES(im_user_id),im_username=VALUES(im_username),
im_password_cipher=VALUES(im_password_cipher),sync_status=1,last_error='',updated_at=UTC_TIMESTAMP(3)`,
identity.TenantID, identity.MemberID, identity.IMTenantCode, identity.IMUserID, identity.IMUsername, identity.IMPasswordCipher)
if err == nil {
return s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID)
}
// A concurrent request may have completed the same deterministic mapping.
existing, findErr := s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID)
if findErr == nil {
return existing, nil
}
return nil, err
}
+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)
}
}
}