From 14e3df64f189c0b8f6d8a1119872af646c0c94ad Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 28 Apr 2026 19:56:27 +0800 Subject: [PATCH] feat: add customer session refresh functionality and improve session management - Introduced RealtimeCustomerSessionRefreshPayload and RealtimeCustomerSessionRefreshEvent types for handling session refresh events. - Updated ws_service to verify customer session and handle session refresh notifications. - Enhanced API client to manage customer session tokens and expiration. - Implemented customer session validation and storage in session storage. - Added functions to exchange and ensure customer sessions. - Updated IM real-time connection to include customer session tokens in WebSocket requests. - Modified SDK and widget configurations to support external IDs and user tokens. --- config/config.example.yaml | 8 + internal/bootstrap/server.go | 5 +- .../controllers/api/customer_controller.go | 29 ++ internal/middleware/chat_middleware.go | 7 +- internal/pkg/config/config.go | 37 ++- .../dto/response/customer_session_response.go | 13 + internal/pkg/enums/im.go | 1 + internal/services/customer_session_service.go | 251 ++++++++++++++++++ internal/services/ws_realtime_types.go | 19 ++ internal/services/ws_service.go | 22 +- web/lib/api/client.ts | 5 +- web/lib/api/im.ts | 180 ++++++++++++- web/lib/im-realtime.ts | 31 +-- web/lib/kefu-widget-config.ts | 4 +- web/lib/sdk/cs-ai-agent-sdk.js | 4 + web/lib/stores/kefu-chat.ts | 15 +- web/public/sdk/cs-agent-widget.js | 4 + web/public/sdk/cs-ai-agent-sdk.min.js | 2 +- 18 files changed, 590 insertions(+), 47 deletions(-) create mode 100644 internal/controllers/api/customer_controller.go create mode 100644 internal/pkg/dto/response/customer_session_response.go create mode 100644 internal/services/customer_session_service.go diff --git a/config/config.example.yaml b/config/config.example.yaml index 2dd6e81..be88c2f 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -20,6 +20,14 @@ auth: maxFailedAttempts: 5 credentialLockMinute: 15 +customerSession: + # 客服会话 token 签名密钥。必须使用独立高强度随机字符串,不要复用渠道 userTokenSecret。 + secret: "" + # 客服会话 token 默认有效期,单位分钟。 + ttlMinutes: 120 + # token 剩余有效期小于该值时自动续期,单位分钟。 + refreshThresholdMinutes: 30 + storage: default: local maxUploadSizeMB: 20 diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index ef04fd8..030ae02 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -28,9 +28,9 @@ func NewServer() (*iris.Application, error) { app := iris.New() corsHandler := cors.New(). AllowOrigin("*"). - AllowHeaders("Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name"). + AllowHeaders("Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name", "X-Customer-Session-Token", "X-Customer-Session-Expires-At"). MaxAge(600). - ExposeHeaders("Content-Length", "Content-Type", "Authorization", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name"). + ExposeHeaders("Content-Length", "Content-Type", "Authorization", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name", "X-Customer-Session-Token", "X-Customer-Session-Expires-At"). Handler() app.UseRouter(func(ctx iris.Context) { // WebSocket upgrade is validated by the upgrader's origin policy. @@ -95,6 +95,7 @@ func addRouter(app *iris.Application) { mvc.Configure(app.Party("/api"), func(m *mvc.Application) { m.Party("/auth").Handle(new(api.AuthController)) m.Party("/channel").Handle(new(api.ChannelController)) + m.Party("/customer").Handle(new(api.CustomerController)) m.Party("/conversation", middleware.ExternalUserMiddleware).Handle(new(api.ConversationController)) m.Party("/message", middleware.ExternalUserMiddleware).Handle(new(api.MessageController)) }) diff --git a/internal/controllers/api/customer_controller.go b/internal/controllers/api/customer_controller.go new file mode 100644 index 0000000..b499bf0 --- /dev/null +++ b/internal/controllers/api/customer_controller.go @@ -0,0 +1,29 @@ +package api + +import ( + "cs-agent/internal/pkg/openidentity" + "cs-agent/internal/services" + + "github.com/kataras/iris/v12" + "github.com/mlogclub/simple/web" +) + +type CustomerController struct { + Ctx iris.Context +} + +func (c *CustomerController) PostSession_exchange() *web.JsonResult { + channel := services.ChannelService.GetEnabledChannel(c.Ctx) + if channel == nil { + return web.JsonErrorMsg("接入渠道不存在或已停用") + } + externalUser, err := openidentity.GetExternalUser(c.Ctx, services.ChannelService.GetUserTokenSecret(channel)) + if err != nil { + return web.JsonError(err) + } + resp, err := services.CustomerSessionService.Exchange(channel, *externalUser) + if err != nil { + return web.JsonError(err) + } + return web.JsonData(resp) +} diff --git a/internal/middleware/chat_middleware.go b/internal/middleware/chat_middleware.go index 7b63241..9027c9b 100644 --- a/internal/middleware/chat_middleware.go +++ b/internal/middleware/chat_middleware.go @@ -2,7 +2,6 @@ package middleware import ( "cs-agent/internal/pkg/irisx" - "cs-agent/internal/pkg/openidentity" "cs-agent/internal/services" "github.com/kataras/iris/v12" @@ -16,13 +15,13 @@ func ExternalUserMiddleware(ctx iris.Context) { _ = ctx.JSON(web.JsonErrorMsg("接入渠道异常")) return } - secret := services.ChannelService.GetUserTokenSecret(channel) - ext, err := openidentity.GetExternalUser(ctx, secret) + result, err := services.CustomerSessionService.VerifyRequest(ctx, channel) if err != nil { ctx.StopExecution() _ = ctx.JSON(web.JsonError(err)) return } - irisx.SetExternalUser(ctx, ext) + services.CustomerSessionService.SetRefreshHeaders(ctx, result) + irisx.SetExternalUser(ctx, result.ExternalUser) ctx.Next() } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 049f04a..7e555fd 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -9,14 +9,15 @@ import ( ) type Config struct { - Server ServerConfig `yaml:"server"` - DB DBConfig `yaml:"db"` - Logger LoggerConfig `yaml:"logger"` - Auth AuthConfig `yaml:"auth"` - Storage StorageConfig `yaml:"storage"` - VectorDB VectorDBConfig `yaml:"vectorDB"` - MCP MCPConfig `yaml:"mcp"` - WxWork WxWorkConfig `yaml:"wxWork"` + Server ServerConfig `yaml:"server"` + DB DBConfig `yaml:"db"` + Logger LoggerConfig `yaml:"logger"` + Auth AuthConfig `yaml:"auth"` + Storage StorageConfig `yaml:"storage"` + VectorDB VectorDBConfig `yaml:"vectorDB"` + MCP MCPConfig `yaml:"mcp"` + WxWork WxWorkConfig `yaml:"wxWork"` + CustomerSession CustomerSessionConfig `yaml:"customerSession"` } type WxWorkNotifyConfig struct { @@ -60,6 +61,26 @@ type AuthConfig struct { CredentialLockMinute int `yaml:"credentialLockMinute"` } +type CustomerSessionConfig struct { + Secret string `yaml:"secret"` + TTLMinutes int `yaml:"ttlMinutes"` + RefreshThresholdMinutes int `yaml:"refreshThresholdMinutes"` +} + +func (c CustomerSessionConfig) TTL() int { + if c.TTLMinutes <= 0 { + return 120 + } + return c.TTLMinutes +} + +func (c CustomerSessionConfig) RefreshThreshold() int { + if c.RefreshThresholdMinutes <= 0 { + return 30 + } + return c.RefreshThresholdMinutes +} + type StorageConfig struct { Default enums.AssetProvider `yaml:"default"` MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"` diff --git a/internal/pkg/dto/response/customer_session_response.go b/internal/pkg/dto/response/customer_session_response.go new file mode 100644 index 0000000..814d9ee --- /dev/null +++ b/internal/pkg/dto/response/customer_session_response.go @@ -0,0 +1,13 @@ +package response + +type CustomerSessionCustomerResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type CustomerSessionExchangeResponse struct { + CustomerSessionToken string `json:"customerSessionToken"` + ExpiresAt string `json:"expiresAt"` + IdentityKey string `json:"identityKey"` + Customer CustomerSessionCustomerResponse `json:"customer"` +} diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index 994b3e5..2edbdf6 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -251,6 +251,7 @@ const ( IMRealtimeEventConversationClosed = "conversation.closed" IMRealtimeEventConversationRead = "conversation.read" IMRealtimeEventNotificationCreated = "notification.created" + IMRealtimeEventCustomerSessionRefresh = "customer_session.refresh" ) const ( diff --git a/internal/services/customer_session_service.go b/internal/services/customer_session_service.go new file mode 100644 index 0000000..944b0bc --- /dev/null +++ b/internal/services/customer_session_service.go @@ -0,0 +1,251 @@ +package services + +import ( + "errors" + "strings" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/config" + "cs-agent/internal/pkg/dto/response" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/errorsx" + "cs-agent/internal/pkg/openidentity" + "cs-agent/internal/repositories" + + "github.com/golang-jwt/jwt/v5" + "github.com/kataras/iris/v12" + "github.com/mlogclub/simple/sqls" + "github.com/mlogclub/simple/web/params" +) + +const ( + customerSessionTokenType = "customer_session" + customerSessionHeader = "X-Customer-Session-Token" + customerSessionExpHeader = "X-Customer-Session-Expires-At" +) + +var CustomerSessionService = newCustomerSessionService() + +func newCustomerSessionService() *customerSessionService { + return &customerSessionService{} +} + +type customerSessionService struct { +} + +type customerSessionClaims struct { + TokenType string `json:"typ"` + ChannelID int64 `json:"channelId"` + ChannelCode string `json:"channelCode"` + CustomerID int64 `json:"customerId"` + CustomerName string `json:"customerName"` + IdentityKey string `json:"identityKey"` + jwt.RegisteredClaims +} + +type CustomerSessionVerifyResult struct { + ExternalUser *openidentity.ExternalUser + Token string + ExpiresAt time.Time + Refreshed bool +} + +func (s *customerSessionService) Exchange(channel *models.Channel, externalUser openidentity.ExternalUser) (*response.CustomerSessionExchangeResponse, error) { + if channel == nil || channel.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("接入渠道不存在或已停用") + } + var customerID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := CustomerService.EnsureExternalCustomer(ctx.Tx, externalUser) + if err != nil { + return err + } + customerID = id + return nil + }); err != nil { + return nil, err + } + customer := CustomerService.Get(customerID) + if customer == nil || customer.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParam("客户不存在") + } + token, expiresAt, err := s.Sign(channel, customer, externalUser) + if err != nil { + return nil, err + } + return &response.CustomerSessionExchangeResponse{ + CustomerSessionToken: token, + ExpiresAt: expiresAt.Format(time.DateTime), + IdentityKey: s.identityKey(externalUser), + Customer: response.CustomerSessionCustomerResponse{ + ID: customer.ID, + Name: strings.TrimSpace(customer.Name), + }, + }, nil +} + +func (s *customerSessionService) Sign(channel *models.Channel, customer *models.Customer, externalUser openidentity.ExternalUser) (string, time.Time, error) { + cfg := config.Current().CustomerSession + secret := strings.TrimSpace(cfg.Secret) + if secret == "" { + return "", time.Time{}, errorsx.BusinessError(1, "客服会话密钥未配置") + } + if channel == nil || customer == nil { + return "", time.Time{}, errorsx.InvalidParam("客服会话参数不完整") + } + now := time.Now() + expiresAt := now.Add(time.Duration(cfg.TTL()) * time.Minute) + claims := customerSessionClaims{ + TokenType: customerSessionTokenType, + ChannelID: channel.ID, + ChannelCode: strings.TrimSpace(channel.ChannelID), + CustomerID: customer.ID, + CustomerName: strings.TrimSpace(customer.Name), + IdentityKey: s.identityKey(externalUser), + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + return "", time.Time{}, err + } + return token, expiresAt, nil +} + +func (s *customerSessionService) VerifyRequest(ctx iris.Context, channel *models.Channel) (*CustomerSessionVerifyResult, error) { + token := s.getCustomerSessionToken(ctx) + if token == "" { + return nil, errorsx.Unauthorized("客服会话不能为空") + } + claims, err := s.verifyToken(token) + if err != nil { + return nil, err + } + if channel == nil || channel.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("接入渠道不存在或已停用") + } + if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) { + return nil, errorsx.Unauthorized("客服会话校验失败") + } + customer := CustomerService.Get(claims.CustomerID) + if customer == nil || customer.Status == enums.StatusDeleted { + return nil, errorsx.Unauthorized("客服会话校验失败") + } + external, err := s.externalUserFromClaims(claims, customer) + if err != nil { + return nil, err + } + result := &CustomerSessionVerifyResult{ + ExternalUser: external, + Token: token, + ExpiresAt: claims.ExpiresAt.Time, + } + if s.shouldRefresh(claims.ExpiresAt.Time) { + newToken, expiresAt, err := s.Sign(channel, customer, *external) + if err != nil { + return nil, err + } + result.Token = newToken + result.ExpiresAt = expiresAt + result.Refreshed = true + } + return result, nil +} + +func (s *customerSessionService) SetRefreshHeaders(ctx iris.Context, result *CustomerSessionVerifyResult) { + if ctx == nil || result == nil || !result.Refreshed { + return + } + ctx.Header(customerSessionHeader, result.Token) + ctx.Header(customerSessionExpHeader, result.ExpiresAt.Format(time.DateTime)) +} + +func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionClaims, error) { + cfg := config.Current().CustomerSession + secret := strings.TrimSpace(cfg.Secret) + if secret == "" { + return nil, errorsx.BusinessError(1, "客服会话密钥未配置") + } + claims := &customerSessionClaims{} + token, err := jwt.ParseWithClaims(rawToken, 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 || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil { + return nil, errorsx.Unauthorized("客服会话校验失败") + } + if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" { + return nil, errorsx.Unauthorized("客服会话校验失败") + } + return claims, nil +} + +func (s *customerSessionService) externalUserFromClaims(claims *customerSessionClaims, customer *models.Customer) (*openidentity.ExternalUser, error) { + identityKey := strings.TrimSpace(claims.IdentityKey) + parts := strings.SplitN(identityKey, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" { + return nil, errorsx.Unauthorized("客服会话校验失败") + } + var source enums.ExternalSource + switch parts[0] { + case "user": + source = enums.ExternalSourceUser + case "guest": + source = enums.ExternalSourceGuest + default: + return nil, errorsx.Unauthorized("客服会话校验失败") + } + identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1]) + if identity == nil || identity.CustomerID != claims.CustomerID { + return nil, errorsx.Unauthorized("客服会话校验失败") + } + name := strings.TrimSpace(claims.CustomerName) + if customer != nil && strings.TrimSpace(customer.Name) != "" { + name = strings.TrimSpace(customer.Name) + } + return &openidentity.ExternalUser{ + ExternalSource: source, + ExternalID: parts[1], + ExternalName: name, + }, nil +} + +func (s *customerSessionService) shouldRefresh(expiresAt time.Time) bool { + threshold := config.Current().CustomerSession.RefreshThreshold() + return time.Until(expiresAt) <= time.Duration(threshold)*time.Minute +} + +func (s *customerSessionService) identityKey(externalUser openidentity.ExternalUser) string { + switch externalUser.ExternalSource { + case enums.ExternalSourceUser: + return "user:" + strings.TrimSpace(externalUser.ExternalID) + default: + return "guest:" + strings.TrimSpace(externalUser.ExternalID) + } +} + +func (s *customerSessionService) getCustomerSessionToken(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 + } + } + token, _ := params.Get(ctx, "customerSessionToken") + return strings.TrimSpace(token) +} diff --git a/internal/services/ws_realtime_types.go b/internal/services/ws_realtime_types.go index c6e70ea..5c73a7e 100644 --- a/internal/services/ws_realtime_types.go +++ b/internal/services/ws_realtime_types.go @@ -239,6 +239,25 @@ func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload { return e.Payload } +type RealtimeCustomerSessionRefreshPayload struct { + CustomerSessionToken string `json:"customerSessionToken"` + ExpiresAt string `json:"expiresAt"` +} + +func (RealtimeCustomerSessionRefreshPayload) realtimeEventPayload() {} + +type RealtimeCustomerSessionRefreshEvent struct { + Payload RealtimeCustomerSessionRefreshPayload +} + +func (e RealtimeCustomerSessionRefreshEvent) EventType() string { + return enums.IMRealtimeEventCustomerSessionRefresh +} + +func (e RealtimeCustomerSessionRefreshEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + type realtimeClientMessage struct { Type string `json:"type"` Topics []string `json:"topics,omitempty"` diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index d03ee4d..93f3ee8 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -75,25 +75,27 @@ func (s *wsService) HandleOpenWS(ctx iris.Context) { } var ( - principal = AuthService.GetAuthPrincipal(ctx) - external *openidentity.ExternalUser + principal = AuthService.GetAuthPrincipal(ctx) + external *openidentity.ExternalUser + customerSessionInfo *CustomerSessionVerifyResult ) if principal == nil { - ext, err := openidentity.GetExternalUser(ctx, ChannelService.GetUserTokenSecret(channel)) + result, err := CustomerSessionService.VerifyRequest(ctx, channel) if err != nil { _ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err)) return } - external = ext + external = result.ExternalUser + customerSessionInfo = result } - if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser); err != nil { + if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil { slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Path(), "channelId", channel.ChannelID, "channel_id", channel.ID) ctx.StopExecution() return } } -func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string) error { +func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error { conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil) if err != nil { return err @@ -151,6 +153,14 @@ func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrinc Topics: session.topicList(), }, })) + if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed { + session.enqueueEvent(s.newEvent("", RealtimeCustomerSessionRefreshEvent{ + Payload: RealtimeCustomerSessionRefreshPayload{ + CustomerSessionToken: customerSessionInfo[0].Token, + ExpiresAt: customerSessionInfo[0].ExpiresAt.Format(time.DateTime), + }, + })) + } return nil } diff --git a/web/lib/api/client.ts b/web/lib/api/client.ts index aa89953..3f98a2e 100644 --- a/web/lib/api/client.ts +++ b/web/lib/api/client.ts @@ -14,6 +14,7 @@ type RequestOptions = RequestInit & { skipAuth?: boolean retryOnAuthError?: boolean baseUrl?: string + onResponse?: (response: Response) => void } async function parseResult(response: Response) { @@ -58,9 +59,10 @@ export async function request( options: RequestOptions = {}, retryOnAuthError = true ): Promise { - const { headers, skipAuth, baseUrl, ...rest } = options + const { headers, skipAuth, baseUrl, onResponse, ...rest } = options delete (rest as RequestOptions).retryOnAuthError delete (rest as RequestOptions).baseUrl + delete (rest as RequestOptions).onResponse const session = readSession() const authHeaders = new Headers(headers) @@ -81,6 +83,7 @@ export async function request( headers: authHeaders, cache: "no-store", }) + onResponse?.(response) try { return await parseResult(response) diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index 0928037..ea711c4 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -112,12 +112,33 @@ export type ImWidgetConfig = { width?: string } +export type ImCustomerSessionCustomer = { + id: number + name: string +} + +export type ImCustomerSessionExchangeResponse = { + customerSessionToken: string + expiresAt: string + identityKey: string + customer: ImCustomerSessionCustomer +} + +export type ImCustomerSession = ImCustomerSessionExchangeResponse & { + channelId: string +} + const GUEST_STORAGE_KEY = "cs_agent_im_guest_id" +const CUSTOMER_SESSION_STORAGE_KEY = "cs_agent_customer_session" +const CUSTOMER_SESSION_TOKEN_HEADER = "X-Customer-Session-Token" +const CUSTOMER_SESSION_EXPIRES_HEADER = "X-Customer-Session-Expires-At" const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "" const OPEN_IM_CHANNEL_ID = process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || "" +let entryUserTokenExchangeKey = "" + function buildGuestId() { return `guest_${generateUUID()}` } @@ -149,7 +170,95 @@ function getRuntimeImConfig() { } } -function createImHeaders() { +function parseExpiresAt(value: string) { + const normalized = value.trim().replace(" ", "T") + const timestamp = Date.parse(normalized) + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function isCustomerSessionValid( + session: ImCustomerSession | null, + channelId?: string, + identityKey?: string +) { + if (!session?.customerSessionToken || !session.expiresAt) { + return false + } + if (channelId && session.channelId !== channelId) { + return false + } + if (identityKey && session.identityKey !== identityKey) { + return false + } + return parseExpiresAt(session.expiresAt) > Date.now() + 5000 +} + +export function readCustomerSession(): ImCustomerSession | null { + if (typeof window === "undefined") { + return null + } + const raw = window.sessionStorage.getItem(CUSTOMER_SESSION_STORAGE_KEY) + if (!raw) { + return null + } + try { + return JSON.parse(raw) as ImCustomerSession + } catch { + window.sessionStorage.removeItem(CUSTOMER_SESSION_STORAGE_KEY) + return null + } +} + +function writeCustomerSession(session: ImCustomerSession) { + if (typeof window === "undefined") { + return + } + window.sessionStorage.setItem(CUSTOMER_SESSION_STORAGE_KEY, JSON.stringify(session)) +} + +export function getCustomerSessionToken() { + const config = getRuntimeImConfig() + const session = readCustomerSession() + return isCustomerSessionValid(session, config.channelId) + ? session?.customerSessionToken ?? "" + : "" +} + +export function applyCustomerSessionRefresh(payload?: { + customerSessionToken?: string + expiresAt?: string +}) { + const token = payload?.customerSessionToken?.trim() + const expiresAt = payload?.expiresAt?.trim() + if (!token || !expiresAt) { + return + } + const current = readCustomerSession() + if (!current) { + return + } + writeCustomerSession({ + ...current, + customerSessionToken: token, + expiresAt, + }) +} + +function applyCustomerSessionHeaders(response: Response) { + applyCustomerSessionRefresh({ + customerSessionToken: response.headers.get(CUSTOMER_SESSION_TOKEN_HEADER) ?? "", + expiresAt: response.headers.get(CUSTOMER_SESSION_EXPIRES_HEADER) ?? "", + }) +} + +function createChannelHeaders() { + const config = getRuntimeImConfig() + return { + "X-Channel-Id": config.channelId, + } +} + +function createExchangeHeaders() { const config = getRuntimeImConfig() const headers: Record = { "X-Channel-Id": config.channelId, @@ -167,9 +276,24 @@ function createImHeaders() { } } +function createImHeaders() { + const sessionToken = getCustomerSessionToken() + if (!sessionToken) { + throw new Error("客服会话未初始化") + } + return { + ...createChannelHeaders(), + Authorization: `Bearer ${sessionToken}`, + } +} + function createRequestOptions( init?: RequestInit -): RequestInit & { baseUrl?: string; skipAuth?: boolean } { +): RequestInit & { + baseUrl?: string + skipAuth?: boolean + onResponse?: (response: Response) => void +} { return { ...init, skipAuth: true, @@ -177,6 +301,7 @@ function createRequestOptions( ...createImHeaders(), ...(init?.headers as Record | undefined), }, + onResponse: applyCustomerSessionHeaders, baseUrl: getRuntimeImConfig().baseUrl, } } @@ -197,6 +322,50 @@ function toQueryString(query?: Record) { return output ? `?${output}` : "" } +export async function exchangeCustomerSession() { + const config = getRuntimeImConfig() + const result = await request( + "/api/customer/session_exchange", + { + method: "POST", + skipAuth: true, + baseUrl: config.baseUrl, + headers: createExchangeHeaders(), + } + ) + const session = { + ...result, + channelId: config.channelId, + } + writeCustomerSession(session) + if (config.userToken) { + entryUserTokenExchangeKey = `${config.channelId}:${config.userToken}` + } + return session +} + +export async function ensureCustomerSession() { + const config = getRuntimeImConfig() + const cached = readCustomerSession() + if (config.userToken) { + const exchangeKey = `${config.channelId}:${config.userToken}` + if ( + entryUserTokenExchangeKey === exchangeKey && + isCustomerSessionValid(cached, config.channelId) + ) { + return cached + } + return exchangeCustomerSession() + } + + const externalId = config.externalId || getGuestId() + const identityKey = `guest:${externalId}` + if (isCustomerSessionValid(cached, config.channelId, identityKey)) { + return cached + } + return exchangeCustomerSession() +} + export function fetchImConversationDetail(id: number) { return request(`/api/conversation/${id}`, { ...createRequestOptions(), @@ -212,7 +381,6 @@ export function fetchImMessages( ) } -/** 外部身份仅通过 createImHeaders()(Authorization 或 X-External-Id/Name)传递,无 JSON body */ export function createOrMatchImConversation() { return request("/api/conversation/create_or_match", { ...createRequestOptions({ method: "POST" }), @@ -224,7 +392,11 @@ export function fetchImWidgetConfig() { `/api/channel/config${toQueryString({ channelId: getRuntimeImConfig().channelId, })}`, - createRequestOptions() + { + skipAuth: true, + baseUrl: getRuntimeImConfig().baseUrl, + headers: createChannelHeaders(), + } ) } diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts index 5cf9dba..392efe0 100644 --- a/web/lib/im-realtime.ts +++ b/web/lib/im-realtime.ts @@ -1,5 +1,5 @@ import { createWebSocketBaseUrl } from "@/lib/api/websocket" -import { getGuestId, type ImMessage } from "@/lib/api/im" +import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im" import { readKefuWidgetConfig } from "@/lib/kefu-widget-config" import type { RealtimeConversationPatch, @@ -9,8 +9,16 @@ import type { export type ImRealtimeEnvelope = { type: string topic?: string - data?: RealtimeMessageCreatedPayload & RealtimeConversationPatch - payload?: RealtimeMessageCreatedPayload & RealtimeConversationPatch + data?: RealtimeMessageCreatedPayload & + RealtimeConversationPatch & { + customerSessionToken?: string + expiresAt?: string + } + payload?: RealtimeMessageCreatedPayload & + RealtimeConversationPatch & { + customerSessionToken?: string + expiresAt?: string + } } export function createImRealtimeConnection() { @@ -19,22 +27,9 @@ export function createImRealtimeConnection() { const baseUrl = apiBaseUrl ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "") : createWebSocketBaseUrl() - const resolvedExternalId = encodeURIComponent( - (config.externalId ?? "").trim() || getGuestId() - ) 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 !== "" - ? `&externalName=${encodeURIComponent(externalName)}` - : "" + const customerSessionToken = getCustomerSessionToken() return new WebSocket( - `${baseUrl}/api/ws/open?externalId=${resolvedExternalId}&channelId=${channelId}${nameQuery}` + `${baseUrl}/api/ws/open?channelId=${channelId}&customerSessionToken=${encodeURIComponent(customerSessionToken)}` ) } diff --git a/web/lib/kefu-widget-config.ts b/web/lib/kefu-widget-config.ts index 2e77acf..db4a141 100644 --- a/web/lib/kefu-widget-config.ts +++ b/web/lib/kefu-widget-config.ts @@ -4,9 +4,9 @@ export type KefuWidgetHostConfig = { apiBaseUrl?: string /** 外部访客稳定标识;未传时使用浏览器本地访客 ID */ externalId?: string - /** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端 */ + /** 访客展示名,仅用于首次换取客服会话 token */ externalName?: string - /** 业务系统签发的前台用户 JWT */ + /** 业务系统签发的前台用户 JWT,仅用于首次换取客服会话 token */ userToken?: string title?: string subtitle?: string diff --git a/web/lib/sdk/cs-ai-agent-sdk.js b/web/lib/sdk/cs-ai-agent-sdk.js index 8e72f4a..6246ff7 100644 --- a/web/lib/sdk/cs-ai-agent-sdk.js +++ b/web/lib/sdk/cs-ai-agent-sdk.js @@ -46,6 +46,9 @@ delete merged.apiBaseUrl; } merged.channelId = String(merged.channelId || ""); + if (merged.externalId) { + merged.externalId = String(merged.externalId); + } if (merged.userToken) { merged.userToken = String(merged.userToken); } @@ -66,6 +69,7 @@ frameUrl.searchParams.set("channelId", config.channelId); frameUrl.searchParams.set("baseUrl", config.baseUrl); if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl); + if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId); if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName); if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken); return frameUrl; diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts index cda33e7..d1f3a8e 100644 --- a/web/lib/stores/kefu-chat.ts +++ b/web/lib/stores/kefu-chat.ts @@ -5,12 +5,14 @@ import { create } from "zustand" import { closeImConversation, createOrMatchImConversation, + ensureCustomerSession, fetchImMessages, fetchImWidgetConfig, markImMessageRead, sendImMessage, uploadImAttachment, uploadImImage, + applyCustomerSessionRefresh, type ImAsset, type ImConversation, type ImMessage, @@ -159,11 +161,17 @@ export const useKefuChatStore = create((set, get) => { return } + const payload = event.data ?? event.payload + if (event.type === "customer_session.refresh") { + applyCustomerSessionRefresh(payload) + return + } + const conversationId = get().conversation?.id if (!conversationId) { return } - const payload = event.data ?? event.payload + if (event.type === "resyncRequired") { void get().refreshMessages() return @@ -281,6 +289,11 @@ export const useKefuChatStore = create((set, get) => { themeColor: widgetConfig.themeColor || "#2563eb", }) + await ensureCustomerSession() + if (bootstrapToken !== token || !get().isOpen) { + return + } + let currentConversation = get().conversation if (!get().initialized || !currentConversation) { currentConversation = await createOrMatchImConversation() diff --git a/web/public/sdk/cs-agent-widget.js b/web/public/sdk/cs-agent-widget.js index d1dfcdc..350d5c2 100644 --- a/web/public/sdk/cs-agent-widget.js +++ b/web/public/sdk/cs-agent-widget.js @@ -41,6 +41,9 @@ merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, ""); merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, ""); merged.channelId = String(merged.channelId || ""); + if (merged.externalId) { + merged.externalId = String(merged.externalId); + } if (merged.userToken) { merged.userToken = String(merged.userToken); } @@ -66,6 +69,7 @@ if (config.position) frameUrl.searchParams.set("position", config.position); if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor); if (config.width) frameUrl.searchParams.set("width", config.width); + if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId); 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 4030656..c2ffdc5 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"},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.userToken&&(i.userToken=String(i.userToken)),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 d(){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 c(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