From 4ec529e86090fb8988a72b51449e1a73d7fee41c Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 28 Apr 2026 10:15:15 +0800 Subject: [PATCH] feat: add user token secret management for channels - Implemented user token secret generation and retrieval in channel service. - Added ResetUserTokenSecret method to reset the user token secret for channels. - Updated channel configuration parsing to include user token secret. - Enhanced WebSocket service to utilize user token secret for external info retrieval. - Modified dashboard channel edit component to support user token secret display and reset functionality. - Introduced API endpoint for resetting user token secret. - Updated IM and SDK configurations to include user token. - Added tests for user token verification logic. --- docs | 2 +- go.mod | 1 + go.sum | 4 +- .../dashboard/channel_controller.go | 16 +++ internal/middleware/chat_middleware.go | 8 +- internal/pkg/dto/dto.go | 18 ++-- internal/pkg/dto/request/channel_request.go | 4 + internal/pkg/enums/external_identity.go | 2 +- internal/pkg/openidentity/openidentity.go | 98 ++++++++++++++++++ .../pkg/openidentity/openidentity_test.go | 86 ++++++++++++++++ internal/services/channel_service.go | 99 +++++++++++++++++++ internal/services/ws_service.go | 2 +- .../dashboard/channels/_components/edit.tsx | 98 ++++++++++++++++++ web/lib/api/admin.ts | 14 +++ web/lib/api/im.ts | 16 ++- web/lib/im-realtime.ts | 6 ++ web/lib/kefu-widget-config.ts | 3 + web/lib/sdk/cs-ai-agent-sdk.js | 4 + web/public/sdk/cs-agent-widget.js | 4 + web/public/sdk/cs-ai-agent-sdk.min.js | 2 +- 20 files changed, 467 insertions(+), 20 deletions(-) create mode 100644 internal/pkg/openidentity/openidentity_test.go diff --git a/docs b/docs index db2de47..0db3094 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit db2de47ca60f72759621e1ac508096afceeaa615 +Subproject commit 0db3094e4b48688b02ecdba6ac32cbf11810694e diff --git a/go.mod b/go.mod index a6085d1..ad1d0d0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/cloudwego/eino v0.8.7 github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 diff --git a/go.sum b/go.sum index 7f98c36..0ee5a4d 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= diff --git a/internal/controllers/dashboard/channel_controller.go b/internal/controllers/dashboard/channel_controller.go index d0f31f8..8364079 100644 --- a/internal/controllers/dashboard/channel_controller.go +++ b/internal/controllers/dashboard/channel_controller.go @@ -102,6 +102,22 @@ func (c *ChannelController) PostUpdate_status() *web.JsonResult { return web.JsonSuccess() } +func (c *ChannelController) PostReset_user_token_secret() *web.JsonResult { + operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionChannelUpdate) + if err != nil { + return web.JsonError(err) + } + req := request.ResetChannelUserTokenSecretRequest{} + if err := params.ReadJSON(c.Ctx, &req); err != nil { + return web.JsonError(err) + } + secret, err := services.ChannelService.ResetUserTokenSecret(req.ID, operator) + if err != nil { + return web.JsonError(err) + } + return web.JsonData(map[string]string{"userTokenSecret": secret}) +} + func (c *ChannelController) PostDelete() *web.JsonResult { operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionChannelDelete) if err != nil { diff --git a/internal/middleware/chat_middleware.go b/internal/middleware/chat_middleware.go index eaaa809..7b016e9 100644 --- a/internal/middleware/chat_middleware.go +++ b/internal/middleware/chat_middleware.go @@ -3,13 +3,19 @@ package middleware import ( "cs-agent/internal/pkg/irisx" "cs-agent/internal/pkg/openidentity" + "cs-agent/internal/services" "github.com/kataras/iris/v12" "github.com/mlogclub/simple/web" ) func ExternalInfoMiddleware(ctx iris.Context) { - ext, err := openidentity.GetExternalInfo(ctx) + channel := services.ChannelService.GetEnabledChannel(ctx) + var userTokenSecret string + if channel != nil { + userTokenSecret = services.ChannelService.GetUserTokenSecret(channel) + } + ext, err := openidentity.GetExternalInfoWithUserTokenSecret(ctx, userTokenSecret) if err != nil { ctx.StopExecution() _ = ctx.JSON(web.JsonError(err)) diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 7b5c2ec..00545eb 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -17,15 +17,17 @@ type WxWorkKFChannelConfig struct { } type WebChannelConfig struct { - Title string `json:"title"` - Subtitle string `json:"subtitle"` - ThemeColor string `json:"themeColor"` - Position string `json:"position"` - Width string `json:"width"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + Position string `json:"position"` + Width string `json:"width"` + UserTokenSecret string `json:"userTokenSecret,omitempty"` } type WechatMPChannelConfig struct { - Title string `json:"title"` - Subtitle string `json:"subtitle"` - ThemeColor string `json:"themeColor"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + UserTokenSecret string `json:"userTokenSecret,omitempty"` } diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index f42b90d..879a9ae 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -22,3 +22,7 @@ type UpdateChannelStatusRequest struct { type DeleteChannelRequest struct { ID int64 `json:"id"` } + +type ResetChannelUserTokenSecretRequest struct { + ID int64 `json:"id"` +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index ff858ae..0e7b7d1 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -27,7 +27,7 @@ func GetExternalSourceLabel(v ExternalSource) string { // IsAllowedOpenImExternalSource 开放 IM 入口允许的外部来源(闭集校验)。 func IsAllowedOpenImExternalSource(s ExternalSource) bool { switch s { - case ExternalSourceGuest: + case ExternalSourceGuest, ExternalSourceUser: return true default: return false diff --git a/internal/pkg/openidentity/openidentity.go b/internal/pkg/openidentity/openidentity.go index e2ca501..28503c2 100644 --- a/internal/pkg/openidentity/openidentity.go +++ b/internal/pkg/openidentity/openidentity.go @@ -4,9 +4,11 @@ package openidentity import ( "cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/errorsx" + "errors" "net/url" "strings" + "github.com/golang-jwt/jwt/v5" "github.com/kataras/iris/v12" "github.com/mlogclub/simple/common/strs" "github.com/mlogclub/simple/web/params" @@ -19,8 +21,36 @@ type ExternalInfo struct { ExternalName string `json:"externalName"` } +type UserTokenClaims struct { + UserID string `json:"userId"` + Name string `json:"name"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` +} + +type userTokenJWTClaims struct { + UserID string `json:"userId"` + Name string `json:"name"` + jwt.RegisteredClaims +} + // GetExternalInfo 从 Header(X-External-*)或 query(externalSource、externalId、externalName)解析身份。 func GetExternalInfo(ctx iris.Context) (*ExternalInfo, error) { + return GetExternalInfoWithUserTokenSecret(ctx, "") +} + +func GetExternalInfoWithUserTokenSecret(ctx iris.Context, userTokenSecret string) (*ExternalInfo, error) { + if userToken := parseUserToken(ctx); userToken != "" { + claims, err := VerifyUserToken(userToken, userTokenSecret) + if err != nil { + return nil, err + } + return &ExternalInfo{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: claims.UserID, + ExternalName: claims.Name, + }, nil + } externalSource, err := parseExternalSource(ctx) if err != nil { return nil, err @@ -28,6 +58,9 @@ func GetExternalInfo(ctx iris.Context) (*ExternalInfo, error) { if !enums.IsAllowedOpenImExternalSource(externalSource) { return nil, errorsx.InvalidParam("不支持的外部来源") } + if externalSource == enums.ExternalSourceUser { + return nil, errorsx.Unauthorized("用户身份不能为空") + } externalID, err := parseExternalID(ctx) if err != nil { return nil, err @@ -40,6 +73,71 @@ func GetExternalInfo(ctx iris.Context) (*ExternalInfo, error) { }, nil } +func VerifyUserToken(userToken, secret string) (*UserTokenClaims, error) { + userToken = strings.TrimSpace(userToken) + secret = strings.TrimSpace(secret) + if userToken == "" { + return nil, errorsx.Unauthorized("用户身份不能为空") + } + if secret == "" { + return nil, errorsx.Unauthorized("用户身份校验未配置") + } + + claims := &userTokenJWTClaims{} + token, err := jwt.ParseWithClaims(userToken, claims, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unsupported signing method") + } + return []byte(secret), nil + }, jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{ + jwt.SigningMethodHS256.Alg(), + jwt.SigningMethodHS384.Alg(), + jwt.SigningMethodHS512.Alg(), + })) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, errorsx.Unauthorized("用户身份已过期") + } + return nil, errorsx.Unauthorized("用户身份校验失败") + } + if token == nil || !token.Valid { + return nil, errorsx.Unauthorized("用户身份校验失败") + } + + userID := strings.TrimSpace(claims.UserID) + name := strings.TrimSpace(claims.Name) + if userID == "" { + return nil, errorsx.Unauthorized("用户标识不能为空") + } + if name == "" { + return nil, errorsx.Unauthorized("用户名称不能为空") + } + if claims.ExpiresAt == nil { + return nil, errorsx.Unauthorized("用户身份已过期") + } + + result := &UserTokenClaims{ + UserID: userID, + Name: name, + Exp: claims.ExpiresAt.Unix(), + } + if claims.IssuedAt != nil { + result.Iat = claims.IssuedAt.Unix() + } + return result, nil +} + +func parseUserToken(ctx iris.Context) string { + auth := strings.TrimSpace(ctx.GetHeader("Authorization")) + if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") { + if token := strings.TrimSpace(auth[7:]); token != "" { + return token + } + } + userToken, _ := params.Get(ctx, "userToken") + return strings.TrimSpace(userToken) +} + func parseExternalSource(ctx iris.Context) (enums.ExternalSource, error) { externalSource := ctx.GetHeader("X-External-Source") if strs.IsBlank(externalSource) { diff --git a/internal/pkg/openidentity/openidentity_test.go b/internal/pkg/openidentity/openidentity_test.go new file mode 100644 index 0000000..ef3f613 --- /dev/null +++ b/internal/pkg/openidentity/openidentity_test.go @@ -0,0 +1,86 @@ +package openidentity + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestVerifyUserTokenOK(t *testing.T) { + token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ + "userId": "u_10001", + "name": "张三", + "exp": time.Now().Add(time.Hour).Unix(), + }, "secret") + + claims, err := VerifyUserToken(token, "secret") + if err != nil { + t.Fatalf("expected token to verify: %v", err) + } + if claims.UserID != "u_10001" || claims.Name != "张三" { + t.Fatalf("unexpected claims: %#v", claims) + } +} + +func TestVerifyUserTokenUsesJWTHeaderAlgorithm(t *testing.T) { + token := signTestUserToken(t, jwt.SigningMethodHS384, map[string]any{ + "userId": "u_10001", + "name": "张三", + "exp": time.Now().Add(time.Hour).Unix(), + }, "secret") + + claims, err := VerifyUserToken(token, "secret") + if err != nil { + t.Fatalf("expected HS384 token to verify from JWT header: %v", err) + } + if claims.UserID != "u_10001" || claims.Name != "张三" { + t.Fatalf("unexpected claims: %#v", claims) + } +} + +func TestVerifyUserTokenRejectsInvalidSignature(t *testing.T) { + token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ + "userId": "u_10001", + "name": "张三", + "exp": time.Now().Add(time.Hour).Unix(), + }, "secret") + + if _, err := VerifyUserToken(token, "other-secret"); err == nil { + t.Fatalf("expected invalid signature to fail") + } +} + +func TestVerifyUserTokenRejectsExpiredToken(t *testing.T) { + token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ + "userId": "u_10001", + "name": "张三", + "exp": time.Now().Add(-time.Minute).Unix(), + }, "secret") + + if _, err := VerifyUserToken(token, "secret"); err == nil { + t.Fatalf("expected expired token to fail") + } +} + +func TestVerifyUserTokenRequiresUserIDAndName(t *testing.T) { + tests := []map[string]any{ + {"name": "张三", "exp": time.Now().Add(time.Hour).Unix()}, + {"userId": "u_10001", "exp": time.Now().Add(time.Hour).Unix()}, + } + for _, payload := range tests { + token := signTestUserToken(t, jwt.SigningMethodHS256, payload, "secret") + if _, err := VerifyUserToken(token, "secret"); err == nil { + t.Fatalf("expected payload %#v to fail", payload) + } + } +} + +func signTestUserToken(t *testing.T, method jwt.SigningMethod, payload map[string]any, secret string) string { + t.Helper() + token, err := jwt.NewWithClaims(method, jwt.MapClaims(payload)).SignedString([]byte(secret)) + if err != nil { + t.Fatal(err) + } + return token +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 62a2500..b06898e 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -1,6 +1,7 @@ package services import ( + "crypto/rand" "cs-agent/internal/models" "cs-agent/internal/pkg/dto" "cs-agent/internal/pkg/dto/request" @@ -11,6 +12,7 @@ import ( "cs-agent/internal/pkg/utils" "cs-agent/internal/repositories" "cs-agent/internal/wxwork" + "encoding/base64" "encoding/json" "strings" "time" @@ -236,6 +238,7 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi if cfg.Width == "" { cfg.Width = "380px" } + cfg.UserTokenSecret = strings.TrimSpace(cfg.UserTokenSecret) return cfg, nil } @@ -260,9 +263,91 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh if cfg.ThemeColor == "" { cfg.ThemeColor = "#2563eb" } + cfg.UserTokenSecret = strings.TrimSpace(cfg.UserTokenSecret) return cfg, nil } +func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { + if channel == nil { + return "" + } + switch channel.ChannelType { + case enums.ChannelTypeWeb: + cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + return "" + } + return strings.TrimSpace(cfg.UserTokenSecret) + case enums.ChannelTypeWechatMP: + cfg, err := s.ParseWechatMPChannelConfig(channel.ConfigJSON) + if err != nil { + return "" + } + return strings.TrimSpace(cfg.UserTokenSecret) + default: + return "" + } +} + +func (s *channelService) ResetUserTokenSecret(channelID int64, operator *dto.AuthPrincipal) (string, error) { + if operator == nil { + return "", errorsx.Unauthorized("未登录或登录已过期") + } + channel := s.Get(channelID) + if channel == nil || channel.Status == enums.StatusDeleted { + return "", errorsx.InvalidParam("接入渠道不存在") + } + if channel.ChannelType != enums.ChannelTypeWeb && channel.ChannelType != enums.ChannelTypeWechatMP { + return "", errorsx.InvalidParam("当前渠道不支持用户 JWT Secret") + } + secret, err := generateUserTokenSecret() + if err != nil { + return "", err + } + var configJSON string + switch channel.ChannelType { + case enums.ChannelTypeWeb: + cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + return "", err + } + cfg.UserTokenSecret = secret + raw, err := json.Marshal(cfg) + if err != nil { + return "", err + } + configJSON = string(raw) + case enums.ChannelTypeWechatMP: + cfg, err := s.ParseWechatMPChannelConfig(channel.ConfigJSON) + if err != nil { + return "", err + } + cfg.UserTokenSecret = secret + raw, err := json.Marshal(cfg) + if err != nil { + return "", err + } + configJSON = string(raw) + } + if err := repositories.ChannelRepository.Updates(sqls.DB(), channelID, map[string]any{ + "config_json": configJSON, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return "", err + } + return secret, nil +} + +func generateUserTokenSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *models.Channel { openKfID = strings.TrimSpace(openKfID) if openKfID == "" { @@ -341,6 +426,13 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if err != nil { return nil, errorsx.InvalidParam("Web渠道配置不合法") } + if strings.TrimSpace(cfg.UserTokenSecret) == "" { + secret, err := generateUserTokenSecret() + if err != nil { + return nil, err + } + cfg.UserTokenSecret = secret + } configBytes, err := json.Marshal(cfg) if err != nil { return nil, err @@ -357,6 +449,13 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if err != nil { return nil, errorsx.InvalidParam("微信公众号渠道配置不合法") } + if strings.TrimSpace(cfg.UserTokenSecret) == "" { + secret, err := generateUserTokenSecret() + if err != nil { + return nil, err + } + cfg.UserTokenSecret = secret + } configBytes, err := json.Marshal(cfg) if err != nil { return nil, err diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index 68be67e..8b02dda 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -66,7 +66,7 @@ func (s *wsService) HandleOpenWS(ctx iris.Context) { external *openidentity.ExternalInfo ) if principal == nil { - ext, err := openidentity.GetExternalInfo(ctx) + ext, err := openidentity.GetExternalInfoWithUserTokenSecret(ctx, ChannelService.GetUserTokenSecret(channel)) if err != nil { _ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err)) return diff --git a/web/app/dashboard/channels/_components/edit.tsx b/web/app/dashboard/channels/_components/edit.tsx index f495182..787f4f5 100644 --- a/web/app/dashboard/channels/_components/edit.tsx +++ b/web/app/dashboard/channels/_components/edit.tsx @@ -26,6 +26,7 @@ import { fetchAIAgentsAll, fetchChannel, fetchWxWorkKFAccounts, + resetChannelUserTokenSecret, } from "@/lib/api/admin" type ChannelFormDialogProps = { @@ -53,12 +54,14 @@ type WebChannelConfig = { themeColor?: string position?: "left" | "right" width?: string + userTokenSecret?: string } type WechatMPChannelConfig = { title?: string subtitle?: string themeColor?: string + userTokenSecret?: string } const defaultWebChannelConfig: Required = { @@ -67,6 +70,7 @@ const defaultWebChannelConfig: Required = { themeColor: "#2563eb", position: "right", width: "380px", + userTokenSecret: "", } const schema = z @@ -80,6 +84,7 @@ const schema = z widgetThemeColor: z.string().trim(), widgetPosition: z.enum(["left", "right"]), widgetWidth: z.string().trim(), + userTokenSecret: z.string().trim(), remark: z.string().trim(), }) .superRefine((values, ctx) => { @@ -110,6 +115,7 @@ const emptyForm: EditForm = { widgetThemeColor: defaultWebChannelConfig.themeColor, widgetPosition: defaultWebChannelConfig.position, widgetWidth: defaultWebChannelConfig.width, + userTokenSecret: "", remark: "", } @@ -139,6 +145,7 @@ function parseWebChannelConfig(configJson: string): Required { parsed.themeColor?.trim() || defaultWebChannelConfig.themeColor, position, width: parsed.width?.trim() || defaultWebChannelConfig.width, + userTokenSecret: parsed.userTokenSecret?.trim() || "", } } catch { return defaultWebChannelConfig @@ -150,6 +157,7 @@ function parseWechatMPChannelConfig(configJson: string): Required { async function loadAIAgents() { @@ -371,6 +385,44 @@ function ChannelFormBody({ await onSubmit(buildPayload(values, currentStatus)) } + async function handleResetUserTokenSecret() { + if (!itemId) { + return + } + if (!window.confirm("重置后旧 userToken 将在过期后失效,确认重置?")) { + return + } + try { + const result = await resetChannelUserTokenSecret(itemId) + setValue("userTokenSecret", result.userTokenSecret, { + shouldDirty: true, + }) + if (channelDetail) { + const parsed = JSON.parse(channelDetail.configJson || "{}") as Record + parsed.userTokenSecret = result.userTokenSecret + setChannelDetail({ + ...channelDetail, + configJson: JSON.stringify(parsed), + }) + } + toast.success("已重置用户 JWT Secret") + } catch (error) { + toast.error(error instanceof Error ? error.message : "重置用户 JWT Secret 失败") + } + } + + async function copyUserTokenSecret() { + if (!userTokenSecret) { + return + } + try { + await navigator.clipboard.writeText(userTokenSecret) + toast.success("已复制用户 JWT Secret") + } catch { + toast.error("复制失败") + } + } + return ( ) : null} +
+
+
用户 JWT Secret
+
+ 业务系统使用该 secret 签发 userToken。重置后请同步更新业务系统配置。 +
+
+ {!itemId ? ( +
+ 保存渠道后可生成用户 JWT Secret。 +
+ ) : ( + + Secret + +
+ +
+ + +
+
+ +
+
+ )} +
{channelType === "wechat_mp" ? ( ) : ( diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 037b7b6..261eb67 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -205,6 +205,10 @@ export type UpdateAdminChannelPayload = CreateAdminChannelPayload & { id: number } +export type ResetChannelUserTokenSecretResult = { + userTokenSecret: string +} + export type AIAgent = { id: number name: string @@ -589,6 +593,16 @@ export function updateChannelStatus(id: number, status: number) { }) } +export function resetChannelUserTokenSecret(id: number) { + return request( + "/api/dashboard/channel/reset_user_token_secret", + { + method: "POST", + body: JSON.stringify({ id }), + } + ) +} + export function deleteChannel(id: number) { return request("/api/dashboard/channel/delete", { method: "POST", diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index 4ff25fa..825b01f 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -105,6 +105,7 @@ export type ImWidgetConfig = { channelId?: string channelType?: string externalSource?: string + userToken?: string title?: string subtitle?: string themeColor?: string @@ -149,18 +150,23 @@ function getRuntimeImConfig() { (widgetConfig.externalSource || OPEN_IM_EXTERNAL_SOURCE).trim() || "web_chat", externalId: (widgetConfig.externalId || "").trim(), externalName: (widgetConfig.externalName || "").trim(), + userToken: (widgetConfig.userToken || "").trim(), } } function createImHeaders() { const config = getRuntimeImConfig() const headers: Record = { - "X-External-Source": config.externalSource, - "X-External-Id": config.externalId || getGuestId(), "X-Channel-Id": config.channelId, } - if (config.externalName) { - headers["X-External-Name"] = encodeURIComponent(config.externalName) + if (config.userToken) { + headers.Authorization = `Bearer ${config.userToken}` + } else { + headers["X-External-Source"] = config.externalSource + headers["X-External-Id"] = config.externalId || getGuestId() + if (config.externalName) { + headers["X-External-Name"] = encodeURIComponent(config.externalName) + } } return { ...headers, @@ -212,7 +218,7 @@ export function fetchImMessages( ) } -/** 外部身份仅通过 createImHeaders()(X-External-*)传递,无 JSON body */ +/** 外部身份仅通过 createImHeaders()(Authorization 或 X-External-*)传递,无 JSON body */ export function createOrMatchImConversation() { return request("/api/conversation/create_or_match", { ...createRequestOptions({ method: "POST" }), diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts index 2e50f00..18ca1e7 100644 --- a/web/lib/im-realtime.ts +++ b/web/lib/im-realtime.ts @@ -26,6 +26,12 @@ export function createImRealtimeConnection() { (config.externalSource ?? "web_chat").trim() || "web_chat" ) const channelId = encodeURIComponent(config.channelId || "") + const userToken = (config.userToken ?? "").trim() + if (userToken) { + return new WebSocket( + `${baseUrl}/api/ws/open?channelId=${channelId}&userToken=${encodeURIComponent(userToken)}` + ) + } const externalName = (config.externalName ?? "").trim() const nameQuery = externalName !== "" diff --git a/web/lib/kefu-widget-config.ts b/web/lib/kefu-widget-config.ts index 3cd9bb8..774d8aa 100644 --- a/web/lib/kefu-widget-config.ts +++ b/web/lib/kefu-widget-config.ts @@ -8,6 +8,8 @@ export type KefuWidgetHostConfig = { externalId?: string /** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端 */ externalName?: string + /** 业务系统签发的前台用户 JWT */ + userToken?: string title?: string subtitle?: string position?: "left" | "right" @@ -52,6 +54,7 @@ export function readKefuWidgetConfig(): KefuWidgetHostConfig { undefined, externalId: query.get("externalId") ?? undefined, externalName: query.get("externalName") ?? undefined, + userToken: query.get("userToken") ?? undefined, title: query.get("title") ?? undefined, subtitle: query.get("subtitle") ?? undefined, position: (query.get("position") as "left" | "right" | null) ?? undefined, diff --git a/web/lib/sdk/cs-ai-agent-sdk.js b/web/lib/sdk/cs-ai-agent-sdk.js index d91b768..fd1fddb 100644 --- a/web/lib/sdk/cs-ai-agent-sdk.js +++ b/web/lib/sdk/cs-ai-agent-sdk.js @@ -48,6 +48,9 @@ } merged.channelId = String(merged.channelId || ""); merged.externalSource = String(merged.externalSource || "web_chat"); + if (merged.userToken) { + merged.userToken = String(merged.userToken); + } return merged; } @@ -67,6 +70,7 @@ if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl); if (config.externalSource) frameUrl.searchParams.set("externalSource", config.externalSource); if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName); + if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken); return frameUrl; } diff --git a/web/public/sdk/cs-agent-widget.js b/web/public/sdk/cs-agent-widget.js index dea5c43..7872a3e 100644 --- a/web/public/sdk/cs-agent-widget.js +++ b/web/public/sdk/cs-agent-widget.js @@ -43,6 +43,9 @@ merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, ""); merged.channelId = String(merged.channelId || ""); merged.externalSource = String(merged.externalSource || "web_chat"); + if (merged.userToken) { + merged.userToken = String(merged.userToken); + } return merged; } @@ -67,6 +70,7 @@ if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor); if (config.width) frameUrl.searchParams.set("width", config.width); if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName); + if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken); return frameUrl; } diff --git a/web/public/sdk/cs-ai-agent-sdk.min.js b/web/public/sdk/cs-ai-agent-sdk.min.js index ee0f818..0fd9714 100644 --- a/web/public/sdk/cs-ai-agent-sdk.min.js +++ b/web/public/sdk/cs-ai-agent-sdk.min.js @@ -1 +1 @@ -!function(){var e={position:"right",themeColor:"#0f6cbd",width:"380px",externalSource:"web_chat"},t=window.__CS_AGENT_WIDGET_STATE__;function n(t){var n,i={};for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&(i[n]=e[n]);for(n in t=t||{})Object.prototype.hasOwnProperty.call(t,n)&&(i[n]=t[n]);return i.baseUrl=String(i.baseUrl||window.location.origin).replace(/\/$/,""),i.apiBaseUrl?i.apiBaseUrl=String(i.apiBaseUrl).replace(/\/$/,""):delete i.apiBaseUrl,i.channelId=String(i.channelId||""),i.externalSource=String(i.externalSource||"web_chat"),i}function i(e){var t=document.currentScript;return t&&t.src?t.src.replace(/\/sdk\/cs-ai-agent-sdk\.min\.js(?:\?.*)?$/,""):String(e.widgetBaseUrl||e.baseUrl||window.location.origin).replace(/\/$/,"")}function r(){t.frameHideTimer&&(window.clearTimeout(t.frameHideTimer),t.frameHideTimer=null),t.frameDestroyTimer&&(window.clearTimeout(t.frameDestroyTimer),t.frameDestroyTimer=null)}function a(){var e=t.frame,n=t.config;if(e&&n){if(e.style.position="fixed",e.style.border="0",e.style.overflow="hidden",e.style.background="#fff",e.style.zIndex="2147483000",e.style.boxShadow="0 28px 80px rgba(15, 35, 65, 0.28)",e.style.willChange="top,right,bottom,left,width,height,opacity,transform,border-radius",e.style.transition="top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease",e.style.transformOrigin="left"===n.position?"left bottom":"right bottom",t.isMaximized)return e.style.top="20px",e.style.right="20px",e.style.bottom="20px",e.style.left="20px",e.style.width="calc(100vw - 40px)",e.style.maxWidth="none",e.style.height="calc(100vh - 40px)",void(e.style.borderRadius="24px");e.style.top="",e.style.bottom="112px",e.style.right="left"===n.position?"":"24px",e.style.left="left"===n.position?"24px":"",e.style.width=n.width||"380px",e.style.maxWidth="calc(100vw - 24px)",e.style.height="min(760px, calc(100vh - 136px))",e.style.borderRadius="28px"}}function o(e){if(t.frame&&t.frame.contentWindow&&t.frameUrl)try{t.frame.contentWindow.postMessage(e,t.frameUrl.origin)}catch(e){console.error("[cs-agent-widget] postMessage failed",e)}}function s(){t.frame&&t.frameLoaded&&t.frameReady&&(t.initSent||(t.initSent=!0,o({type:"cs-agent:init",payload:t.config})),o({type:t.isOpen?"cs-agent:open":"cs-agent:minimize"}),o({type:"cs-agent:maximized",payload:{isMaximized:t.isMaximized}}))}function l(){var e=t.frame;if(e){if(r(),a(),e.style.display="block",t.isOpen)return e.style.visibility="visible",e.style.pointerEvents="auto",t.frameHideTimer=window.setTimeout(function(){t.frame&&(t.frame.style.opacity="1",t.frame.style.transform="translate3d(0, 0, 0) scale(1)")},16),void s();e.style.pointerEvents="none",e.style.opacity="0",e.style.transform=t.isMaximized?"translate3d(0, 10px, 0) scale(0.985)":"translate3d(0, 16px, 0) scale(0.96)",t.frameHideTimer=window.setTimeout(function(){t.frame&&!t.isOpen&&(t.frame.style.visibility="hidden")},t.animationDuration),s()}}function c(){return t.frame?t.frame:t.frameUrl&&t.config?(t.frame=document.createElement("iframe"),t.frame.dataset.csAgentWidget="frame",t.frame.title=t.config.title||"\u5728\u7ebf\u5ba2\u670d",t.frame.src=t.frameUrl.toString(),a(),t.frame.style.display="block",t.frame.style.visibility="hidden",t.frame.style.pointerEvents="none",t.frame.style.opacity="0",t.frame.style.transform="translate3d(0, 18px, 0) scale(0.96)",t.frame.addEventListener("load",function(){t.frameLoaded=!0,l()}),document.body.appendChild(t.frame),t.frame):null}function d(e){var r=e||window.CSAgentConfig||{};t.config=n(r);var a=i(t.config);r.baseUrl||(t.config.baseUrl=a),t.config.channelId?(t.configLoading=!0,function(e){var t=String(e.apiBaseUrl||e.baseUrl||"").replace(/\/$/,"");if(!t||!e.channelId||"function"!=typeof fetch)return Promise.resolve(e);var n=t+"/api/channel/config?channelId="+encodeURIComponent(e.channelId);return fetch(n,{method:"GET",cache:"no-store",headers:{"X-Channel-Id":e.channelId}}).then(function(e){return e.json()}).then(function(t){return t&&!1!==t.success?function(e,t){if(!t)return e;var n,i={};for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&(i[n]=e[n]);for(var r=["title","subtitle","themeColor","position","width"],a=0;a