feat: integrate customer service chat
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+17
@@ -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: {}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module 'tinode-sdk' {
|
||||
export const Drafty: any;
|
||||
export const Tinode: any;
|
||||
}
|
||||
Reference in New Issue
Block a user