feat: integrate customer service chat
This commit is contained in:
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user