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
+1
View File
@@ -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
+2
View File
@@ -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=
+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)
}
}
}
+1
View File
@@ -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"
},
+17
View File
@@ -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: {}
+9
View File
@@ -32,6 +32,11 @@ const reasonMessages: Record<string, string> = {
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');
-2
View File
@@ -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,
+6 -5
View File
@@ -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;
+319 -20
View File
@@ -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 {
+274 -32
View File
@@ -37,64 +37,198 @@
<section v-else class="m-chat">
<PageHeader :title="current?.name || '在线客服'" />
<div class="m-chat-time">会话内容由客服系统提供</div>
<div class="m-chat-empty">
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
<div v-if="chatLoading" class="m-chat-empty">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>正在连接客服</h2>
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
<a
v-if="safeServiceUrl"
:href="safeServiceUrl"
target="_blank"
rel="noopener noreferrer"
class="m-primary-btn m-btn-link"
>进入客服会话</a
>
<p v-else class="m-form-error">客服会话地址尚未配置</p>
<p>首次连接会自动建立好友关系</p>
</div>
<div v-else-if="chatError" class="m-chat-empty">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>连接失败</h2>
<p class="m-form-error">{{ chatError }}</p>
<button class="m-primary-btn" @click="connectChat">重新连接</button>
</div>
<div v-else ref="messageList" class="m-chat-messages">
<div v-for="message in historicalMessages" :key="`history-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
<div class="m-chat-avatar" aria-hidden="true">
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
<span v-else-if="message.mine">👨🏻‍💼</span>
<span v-else>👩🏻‍💼</span>
</div>
<div class="m-chat-message">
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
<p v-if="message.text">{{ message.text }}</p>
</div>
<small>
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
</small>
</div>
</div>
<div v-if="historicalMessages.length" class="m-chat-history-divider"><span>以上是历史会话</span></div>
<div class="m-chat-session-tip">[ {{ current?.name || '在线客服' }} ] 与您建立了会话</div>
<div class="m-chat-system-row">
<div class="m-chat-avatar m-chat-avatar--agent" aria-hidden="true">
<img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
<span v-else>👩🏻‍💼</span>
</div>
<div class="m-chat-system-message">
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
<p><b>【系统消息】</b>尊敬的用户您好,我是{{ current?.name || '在线客服' }},当前已为您接通在线客服,请问有什么可以帮您?</p>
</div>
</div>
<div v-for="message in liveMessages" :key="`live-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
<div class="m-chat-avatar" aria-hidden="true">
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
<span v-else-if="message.mine">👨🏻‍💼</span>
<span v-else>👩🏻‍💼</span>
</div>
<div class="m-chat-message">
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
<p v-if="message.text">{{ message.text }}</p>
</div>
<small>
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
</small>
</div>
</div>
</div>
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
<form class="m-chat-input" @submit.prevent="sendMessage">
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
<button
v-if="draft.trim()"
class="m-chat-send"
type="submit"
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
>{{ sending ? '发送中' : '发送' }}</button>
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
<img :src="mobileAssets.chatMore" alt="" />
</button>
</form>
<div v-if="mediaSending" class="m-chat-uploading"><progress max="100" :value="uploadProgress"></progress><b>正在上传 {{ uploadProgress }}%</b></div>
<div v-if="toolsOpen && !emojiOpen" class="m-chat-tools">
<button type="button" @click="emojiOpen = true"><n-icon :component="HappyOutline" /><span>表情</span></button>
<button type="button" :disabled="mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button>
<button type="button" :disabled="mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><span>视频</span></button>
<input ref="photoInput" class="m-chat-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" @change="sendMedia('image', $event)" />
<input ref="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" />
</div>
<div v-if="toolsOpen && emojiOpen" class="m-chat-emoji-panel">
<div class="m-chat-emoji-tabs" role="tablist" aria-label="表情分类">
<button
v-for="category in emojiCategories"
:key="category.key"
type="button"
role="tab"
:aria-selected="activeEmojiCategory === category.key"
:class="{ 'is-active': activeEmojiCategory === category.key }"
@click="activeEmojiCategory = category.key"
>{{ category.label }}</button>
</div>
<div class="m-chat-emojis">
<button v-for="emoji in visibleEmojis" :key="emoji" type="button" @click="insertEmoji(emoji)">{{ emoji }}</button>
</div>
<div v-if="!visibleEmojis.length" class="m-chat-emoji-empty">暂无最近使用的表情</div>
</div>
</div>
<div class="m-chat-input"
><button aria-label="语音"><img :src="mobileAssets.chatVoice" alt="" /></button
><input disabled placeholder="请先连接客服会话" /><button aria-label="更多"
><img :src="mobileAssets.chatMore" alt="" /></button
></div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
import {
ChatbubbleEllipsesOutline,
HappyOutline,
HeadsetOutline,
ImageOutline,
VideocamOutline,
} from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request, safeImage } from '../api';
import { deviceID, errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
const route = useRoute();
const isChat = computed(() => Boolean(route.params.id));
const loading = ref(true);
const error = ref('');
const services = ref<any[]>([]);
const me = ref<any>();
const current = computed(
() =>
services.value.find((item) => String(item.id) === String(route.params.id)) ||
services.value[0]
);
const safeServiceUrl = computed(() => {
const value = current.value?.service_url;
if (!value) return '';
try {
const u = new URL(value);
return u.protocol === 'https:' ? u.href : '';
} catch {
return '';
}
});
const chatLoading = ref(false);
const chatError = ref('');
const messages = ref<ChatMessage[]>([]);
const historyBoundarySeq = ref(0);
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
const liveMessages = computed(() => messages.value.filter((message) => message.seq > historyBoundarySeq.value));
const draft = ref('');
const sending = ref(false);
const mediaSending = ref(false);
const uploadProgress = ref(0);
const photoInput = ref<HTMLInputElement | null>(null);
const videoInput = ref<HTMLInputElement | null>(null);
const toolsOpen = ref(false);
const emojiOpen = ref(false);
type EmojiCategory = 'recent' | 'face' | 'gesture' | 'object';
const emojiCategories: Array<{ key: EmojiCategory; label: string }> = [
{ key: 'recent', label: '最近' },
{ key: 'face', label: '表情' },
{ key: 'gesture', label: '手势' },
{ key: 'object', label: '物品' },
];
const emojiGroups: Record<Exclude<EmojiCategory, 'recent'>, string[]> = {
face: [
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭',
'😡', '🥳', '😱', '🤯', '🥰', '😇', '😮', '😉', '😆', '😅', '☺️', '😋',
'😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷',
'🤧', '😏', '😪', '🙄',
],
gesture: [
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '👏', '🙌', '👐', '🤲', '🙏',
'👋', '🤝', '💪', '✍️', '☝️', '👆', '👇', '👉', '👈', '🫶', '💅', '🤙',
],
object: [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🌹', '🌸', '🎉', '🎁',
'⭐', '✨', '🔥', '💯', '🎂', '🍰', '☕', '🍻', '⚽', '🏆', '🚀', '💡',
],
};
const recentEmojis = ref<string[]>([]);
const activeEmojiCategory = ref<EmojiCategory>('face');
const visibleEmojis = computed(() =>
activeEmojiCategory.value === 'recent' ? recentEmojis.value : emojiGroups[activeEmojiCategory.value]
);
const sessionStartedAt = ref(new Date().toISOString());
const messageList = ref<HTMLElement | null>(null);
let chat: CustomerServiceChat | null = null;
async function load() {
loading.value = true;
error.value = '';
try {
services.value = await request<any[]>({ url: '/v1/customer-services' });
const [serviceRows, profile] = await Promise.all([
request<any[]>({ url: '/v1/customer-services' }),
request<any>({ url: '/v1/me' }).catch(() => null),
]);
services.value = serviceRows;
me.value = profile;
if (isChat.value) await connectChat();
} catch (err) {
error.value = errorText(err);
const message = errorText(err);
error.value = message;
if (isChat.value) chatError.value = message;
services.value = [];
} finally {
loading.value = false;
@@ -105,5 +239,113 @@
if (status === 'busy') return '忙碌';
return '离线留言';
}
async function connectChat() {
const serviceID = Number(route.params.id);
if (!serviceID || chatLoading.value) return;
chat?.disconnect();
chat = null;
chatLoading.value = true;
chatError.value = '';
messages.value = [];
historyBoundarySeq.value = 0;
try {
const session = await request<CustomerServiceSession>({
url: `/v1/customer-services/${serviceID}/session`,
method: 'POST',
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
});
chat = new CustomerServiceChat(session, (rows) => {
messages.value = rows;
void scrollToBottom();
});
await chat.connect(session.token);
// Tinode delivers cached topic history just after subscribe resolves.
// Give that initial batch a brief window so it can be separated from the new session.
await new Promise<void>((resolve) => window.setTimeout(resolve, 250));
historyBoundarySeq.value = messages.value.reduce((max, message) => Math.max(max, message.seq), 0);
sessionStartedAt.value = new Date().toISOString();
} catch (err) {
chat?.disconnect();
chat = null;
chatError.value = errorText(err);
} finally {
chatLoading.value = false;
}
}
async function sendMessage() {
if (!chat || sending.value || !draft.value.trim()) return;
const value = draft.value;
draft.value = '';
toolsOpen.value = false;
emojiOpen.value = false;
sending.value = true;
try {
await chat.send(value);
await scrollToBottom();
} catch (err) {
draft.value = value;
chatError.value = errorText(err);
} finally {
sending.value = false;
}
}
async function sendMedia(type: 'image' | 'video', event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || !chat || mediaSending.value) return;
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (!file.type.startsWith(`${type}/`)) {
window.$message?.error(type === 'image' ? '请选择照片文件' : '请选择视频文件');
return;
}
if (file.size > maxSize) {
window.$message?.error(type === 'image' ? '照片不能超过 10MB' : '视频不能超过 50MB');
return;
}
toolsOpen.value = false;
emojiOpen.value = false;
mediaSending.value = true;
uploadProgress.value = 0;
try {
await chat.sendMedia(file, type, (progress) => {
uploadProgress.value = Math.min(99, Math.round(progress * 100));
});
uploadProgress.value = 100;
await scrollToBottom();
} catch (err) {
window.$message?.error(errorText(err));
} finally {
mediaSending.value = false;
uploadProgress.value = 0;
}
}
function toggleTools() {
toolsOpen.value = !toolsOpen.value;
if (!toolsOpen.value) emojiOpen.value = false;
}
function insertEmoji(emoji: string) {
draft.value += emoji;
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
}
async function scrollToBottom() {
await nextTick();
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
}
function messageTime(value: string) {
if (!value) return '';
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
}
function messageDateTime(value: string) {
if (!value) return '';
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(value));
}
onMounted(load);
onBeforeUnmount(() => chat?.disconnect());
</script>
+4
View File
@@ -0,0 +1,4 @@
declare module 'tinode-sdk' {
export const Drafty: any;
export const Tinode: any;
}