diff --git a/go.mod b/go.mod index 565b324..7c5ba69 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/go.sum b/go.sum index eb7a30b..9c4fe25 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/internal/app/app.go b/internal/app/app.go index c241830..e29a834 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 5c2feb4..1b2cf18 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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)) diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index a2578c1..1e4d661 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -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) diff --git a/internal/httpapi/support.go b/internal/httpapi/support.go new file mode 100644 index 0000000..064fc80 --- /dev/null +++ b/internal/httpapi/support.go @@ -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) + } +} diff --git a/internal/imclient/client.go b/internal/imclient/client.go new file mode 100644 index 0000000..6ee50a0 --- /dev/null +++ b/internal/imclient/client.go @@ -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 +} diff --git a/internal/imclient/client_test.go b/internal/imclient/client_test.go new file mode 100644 index 0000000..c59a07e --- /dev/null +++ b/internal/imclient/client_test.go @@ -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[:]) +} diff --git a/internal/portal/service.go b/internal/portal/service.go index c02ba94..e069b89 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -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 diff --git a/internal/store/portal.go b/internal/store/portal.go index 62a2d9c..5dfcbe7 100644 --- a/internal/store/portal.go +++ b/internal/store/portal.go @@ -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 } diff --git a/internal/store/support.go b/internal/store/support.go new file mode 100644 index 0000000..fb348f8 --- /dev/null +++ b/internal/store/support.go @@ -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 +} diff --git a/internal/support/errors.go b/internal/support/errors.go new file mode 100644 index 0000000..e79cdcc --- /dev/null +++ b/internal/support/errors.go @@ -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") +) diff --git a/internal/support/service.go b/internal/support/service.go new file mode 100644 index 0000000..5f905e3 --- /dev/null +++ b/internal/support/service.go @@ -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" + } +} diff --git a/internal/support/service_test.go b/internal/support/service_test.go new file mode 100644 index 0000000..48b5163 --- /dev/null +++ b/internal/support/service_test.go @@ -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) + } + } +} diff --git a/web/package.json b/web/package.json index 36409fa..a5d1e31 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "@vicons/ionicons5": "^0.12.0", "axios": "^1.7.7", "naive-ui": "^2.44.1", + "tinode-sdk": "^0.25.4", "vue": "^3.4.38", "vue-router": "^4.4.3" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cd9bd31..e50c698 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: naive-ui: specifier: ^2.44.1 version: 2.45.3(vue@3.4.38(typescript@5.5.4)) + tinode-sdk: + specifier: ^0.25.4 + version: 0.25.4 vue: specifier: ^3.4.38 version: 3.4.38(typescript@5.5.4) @@ -258,46 +261,55 @@ packages: resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.21.2': resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.21.2': resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.21.2': resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-powerpc64le-gnu@4.21.2': resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.21.2': resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.21.2': resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.21.2': resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.21.2': resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.21.2': resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==} @@ -574,6 +586,9 @@ packages: engines: {node: '>=10'} hasBin: true + tinode-sdk@0.25.4: + resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==} + to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -1151,6 +1166,8 @@ snapshots: source-map-support: 0.5.21 optional: true + tinode-sdk@0.25.4: {} + to-fast-properties@2.0.0: {} treemate@0.3.11: {} diff --git a/web/src/mobile/api.ts b/web/src/mobile/api.ts index 1986438..d44a971 100644 --- a/web/src/mobile/api.ts +++ b/web/src/mobile/api.ts @@ -32,6 +32,11 @@ const reasonMessages: Record = { RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道', RECHARGE_VOUCHER_INVALID: '储入凭证无效', SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试', + CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在', + CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置', + IM_RELATION_PENDING: '好友申请已发送,请等待客服确认', + IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话', + IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试', INTERNAL: '服务异常,请稍后重试', }; @@ -134,6 +139,10 @@ function getDeviceID() { return value; } +export function deviceID() { + return getDeviceID(); +} + export function saveSession(token: string) { sessionStorage.setItem(TOKEN_KEY, token); sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1'); diff --git a/web/src/mobile/assets.ts b/web/src/mobile/assets.ts index c7d79ff..80fd065 100644 --- a/web/src/mobile/assets.ts +++ b/web/src/mobile/assets.ts @@ -31,7 +31,6 @@ import processSimple from './assets/process-simple.png'; import processMaterial from './assets/process-material.png'; import processTime from './assets/process-time.png'; import processReturn from './assets/process-return.png'; -import chatVoice from './assets/chat-voice.png'; import chatMore from './assets/chat-more.png'; import companyPlatform from './assets/company-platform.png'; import companyRules from './assets/company-rules.png'; @@ -108,7 +107,6 @@ export const mobileAssets = { processMaterial, processTime, processReturn, - chatVoice, chatMore, companyPlatform, companyRules, diff --git a/web/src/mobile/im.ts b/web/src/mobile/im.ts index 75815ef..8e8293f 100644 --- a/web/src/mobile/im.ts +++ b/web/src/mobile/im.ts @@ -76,9 +76,10 @@ export class CustomerServiceChat { const ref = await this.upload(file, onProgress); const descriptor = { mime: file.type, - // Tinode returns a relative file path. Publish an absolute reference so - // other clients do not resolve it against their own web-app origin. - refurl: new URL(ref, this.session.api_base_url).toString(), + // Keep the server-issued relative reference. Tinode only includes + // relative references in extra.attachments, which lets the server bind + // the uploaded file to this message and authorize the recipient. + refurl: ref, filename: file.name, size: file.size, width: dimensions.width, @@ -187,8 +188,8 @@ export class CustomerServiceChat { if (!ref) return ''; const apiBase = new URL(this.session.api_base_url); const resolved = new URL(ref, apiBase); - // The SDK only signs relative file URLs. Convert our absolute IM file - // reference back to a relative path for signing when rendering locally. + // The SDK signs relative file URLs. Also tolerate historical absolute IM + // references by converting same-origin URLs back to a signable path. const signable = resolved.origin === apiBase.origin ? `${resolved.pathname}${resolved.search}` : ref; diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index ee67b28..f28d4ed 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -1386,18 +1386,24 @@ body { background: #c86d45; } .m-chat { - min-height: 100dvh; + height: 100dvh; background: #f4f5f6; - padding-bottom: 80px; + display: flex; + flex-direction: column; + overflow: hidden; } .m-chat .m-page-header { + flex: 0 0 auto; padding-inline: 20px; background: #fff; + border-bottom: 1px solid #f0f0f0; } .m-chat-time { + flex: 0 0 auto; text-align: center; color: #92969b; - padding: 8px; + padding: 9px 16px; + font-size: 13px; } .m-chat-empty { min-height: 530px; @@ -1422,34 +1428,327 @@ body { width: min(280px, 100%); margin-top: 16px; } -.m-chat-input { - position: fixed; - left: 50%; - bottom: 0; - transform: translateX(-50%); - width: min(100%, 750px); - height: 72px; +.m-chat-composer { + flex: 0 0 auto; + width: 100%; background: #f1f1f1; - padding: 10px 15px calc(10px + env(safe-area-inset-bottom)); + border-top: 1px solid #e8e8e8; + padding-bottom: env(safe-area-inset-bottom); +} +.m-chat-input { + min-height: 68px; + padding: 10px 14px; display: grid; - grid-template-columns: 44px 1fr 44px; - gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; } -.m-chat-input button { +.m-chat-input .m-chat-send { + min-width: 66px; + height: 44px; + padding: 0 14px; border: 0; - background: transparent; - font-size: 27px; + border-radius: 7px; + background: linear-gradient(135deg, #e2a25f, #d18437); + color: #fff; + font-size: 16px; + font-weight: 600; + box-shadow: 0 3px 9px rgb(177 105 37 / 20%); } -.m-chat-input button img { - width: 24px; - height: 24px; +.m-chat-input .m-chat-send:disabled { + opacity: 0.55; +} +.m-chat-tool { + width: 42px; + height: 42px; + padding: 6px; + border: 0; + border-radius: 50%; + background: transparent; + display: grid; + place-items: center; +} +.m-chat-tool img { + width: 30px; + height: 30px; object-fit: contain; } .m-chat-input input { + min-width: 0; + height: 46px; border: 0; border-radius: 8px; background: #fff; - padding: 0 12px; + padding: 0 14px; + outline: none; + color: var(--m-ink); + box-shadow: inset 0 0 0 1px transparent; +} +.m-chat-input input:focus { + box-shadow: inset 0 0 0 1px #d99a55; +} +.m-chat-tools { + min-height: 142px; + padding: 10px 30px 18px; + display: grid; + grid-template-columns: repeat(3, 82px); + gap: 24px; + align-items: start; +} +.m-chat-tools button { + border: 0; + background: transparent; + color: #7a7d80; + display: grid; + justify-items: center; + gap: 8px; +} +.m-chat-tools button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.m-chat-file-input { + display: none; +} +.m-chat-tools .n-icon { + width: 62px; + height: 62px; + border-radius: 12px; + background: #fff; + display: grid; + place-items: center; + color: #555; + font-size: 31px; +} +.m-chat-emoji-panel { + min-height: 248px; + padding: 12px 18px 20px; + background: #fff; + border-radius: 18px 18px 0 0; +} +.m-chat-emoji-tabs { + min-height: 38px; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} +.m-chat-emoji-tabs button { + min-width: 54px; + height: 34px; + padding: 0 14px; + border: 0; + border-radius: 10px; + background: transparent; + color: #8b929d; + font-size: 15px; +} +.m-chat-emoji-tabs button.is-active { + background: #e3efff; + color: #1683ff; + font-weight: 600; +} +.m-chat-emojis { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 12px 8px; + align-items: center; +} +.m-chat-emojis button { + width: 100%; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 24px; + line-height: 1; +} +.m-chat-emojis button:active { + background: #eef4fb; + transform: scale(0.94); +} +.m-chat-emoji-empty { + min-height: 142px; + display: grid; + place-items: center; + color: #a1a7b0; + font-size: 14px; +} +.m-chat-uploading { + height: 34px; + padding: 0 18px 8px; + display: flex; + align-items: center; + gap: 10px; + color: #8a633d; + font-size: 12px; +} +.m-chat-uploading progress { + width: 100%; + height: 6px; + border: 0; + border-radius: 4px; + overflow: hidden; + accent-color: #d58d2b; +} +.m-chat-uploading b { + flex: 0 0 auto; + font-weight: 500; +} +.m-chat-messages { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 12px 20px 24px; +} +.m-chat-welcome { + width: max-content; + max-width: 90%; + margin: 12px auto 20px; + padding: 7px 12px; + border-radius: 14px; + background: #e5e7e9; + color: #74797e; + font-size: 13px; +} +.m-chat-history-divider { + height: 28px; + display: flex; + align-items: center; + gap: 12px; + color: #9a9ea4; + font-size: 12px; +} +.m-chat-history-divider::before, +.m-chat-history-divider::after { + content: ''; + height: 1px; + flex: 1; + background: #e4e6e9; +} +.m-chat-session-tip { + width: max-content; + max-width: 90%; + margin: 12px auto 20px; + padding: 7px 13px; + border-radius: 7px; + background: #fff; + color: #647188; + font-size: 13px; + box-shadow: 0 2px 7px rgb(0 0 0 / 4%); +} +.m-chat-system-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 18px; +} +.m-chat-avatar--agent span, +.m-chat-avatar > span { + font-size: 23px; + line-height: 1; +} +.m-chat-system-message { + max-width: calc(100% - 52px); +} +.m-chat-system-message small { + display: block; + margin: 0 0 6px 2px; + color: #68768d; + font-size: 12px; +} +.m-chat-system-message p { + margin: 0; + padding: 13px 15px; + border-radius: 4px 14px 14px; + background: #fff; + color: #e75b9f; + font-size: 14px; + font-weight: 600; + line-height: 1.65; + box-shadow: 0 2px 8px rgb(0 0 0 / 5%); +} +.m-chat-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 14px 0; +} +.m-chat-row.is-mine { + flex-direction: row-reverse; + justify-content: flex-start; +} +.m-chat-avatar { + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: 50%; + overflow: hidden; + background: #f3c883; + display: grid; + place-items: center; + color: #fff; +} +.m-chat-row.is-mine .m-chat-avatar { + background: #62a7e8; +} +.m-chat-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} +.m-chat-avatar .n-icon { + font-size: 26px; +} +.m-chat-message { + max-width: calc(78% - 52px); +} +.m-chat-row.is-mine .m-chat-message { + display: flex; + flex-direction: column; + align-items: flex-end; +} +.m-chat-bubble { + position: relative; + padding: 11px 14px; + border-radius: 4px 14px 14px; + background: #fff; + box-shadow: 0 2px 8px rgb(0 0 0 / 5%); +} +.m-chat-row.is-mine .m-chat-bubble { + border-radius: 14px 4px 14px 14px; + background: #dfa05f; +} +.m-chat-bubble p { + margin: 0; + color: var(--m-ink); + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.m-chat-row.is-mine .m-chat-bubble p { + color: #fff; +} +.m-chat-bubble.is-media { + padding: 4px; + overflow: hidden; + background: #fff; +} +.m-chat-media { + display: block; + width: auto; + max-width: min(280px, 100%); + max-height: 320px; + border-radius: 10px; + object-fit: contain; + background: #111; +} +.m-chat-message > small { + display: block; + margin-top: 5px; + color: #92969b; + font-size: 11px; } .m-subpage { diff --git a/web/src/mobile/views/SupportView.vue b/web/src/mobile/views/SupportView.vue index 8e79bf7..93ccdaa 100644 --- a/web/src/mobile/views/SupportView.vue +++ b/web/src/mobile/views/SupportView.vue @@ -37,64 +37,198 @@
-
会话内容由客服系统提供
-
+
会话内容已安全加密,请勿发送密码或验证码
+

正在连接客服

-

{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}

- 进入客服会话 -

客服会话地址尚未配置

+

首次连接会自动建立好友关系

+
+
+ +

连接失败

+

{{ chatError }}

+ +
+
+
+ +
+
+ + +

{{ message.text }}

+
+ + 已发送 · {{ messageTime(message.sent_at) }} + · 发送中 · 发送失败 + +
+
+
以上是历史会话
+
[ {{ current?.name || '在线客服' }} ] 与您建立了会话
+
+ +
+ {{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }} +

【系统消息】尊敬的用户您好,我是{{ current?.name || '在线客服' }},当前已为您接通在线客服,请问有什么可以帮您?

+
+
+
+ +
+
+ + +

{{ message.text }}

+
+ + 已发送 · {{ messageTime(message.sent_at) }} + · 发送中 · 发送失败 + +
+
+
+
+
+ + + +
+
正在上传 {{ uploadProgress }}%
+
+ + + + + +
+
+
+ +
+
+ +
+
暂无最近使用的表情
+
-
diff --git a/web/src/types/tinode-sdk.d.ts b/web/src/types/tinode-sdk.d.ts new file mode 100644 index 0000000..105e20c --- /dev/null +++ b/web/src/types/tinode-sdk.d.ts @@ -0,0 +1,4 @@ +declare module 'tinode-sdk' { + export const Drafty: any; + export const Tinode: any; +}