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.
This commit is contained in:
mlogclub
2026-04-28 19:56:27 +08:00
parent bfc9b317dc
commit 14e3df64f1
18 changed files with 590 additions and 47 deletions
+8
View File
@@ -20,6 +20,14 @@ auth:
maxFailedAttempts: 5 maxFailedAttempts: 5
credentialLockMinute: 15 credentialLockMinute: 15
customerSession:
# 客服会话 token 签名密钥。必须使用独立高强度随机字符串,不要复用渠道 userTokenSecret。
secret: ""
# 客服会话 token 默认有效期,单位分钟。
ttlMinutes: 120
# token 剩余有效期小于该值时自动续期,单位分钟。
refreshThresholdMinutes: 30
storage: storage:
default: local default: local
maxUploadSizeMB: 20 maxUploadSizeMB: 20
+3 -2
View File
@@ -28,9 +28,9 @@ func NewServer() (*iris.Application, error) {
app := iris.New() app := iris.New()
corsHandler := cors.New(). corsHandler := cors.New().
AllowOrigin("*"). 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). 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() Handler()
app.UseRouter(func(ctx iris.Context) { app.UseRouter(func(ctx iris.Context) {
// WebSocket upgrade is validated by the upgrader's origin policy. // 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) { mvc.Configure(app.Party("/api"), func(m *mvc.Application) {
m.Party("/auth").Handle(new(api.AuthController)) m.Party("/auth").Handle(new(api.AuthController))
m.Party("/channel").Handle(new(api.ChannelController)) 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("/conversation", middleware.ExternalUserMiddleware).Handle(new(api.ConversationController))
m.Party("/message", middleware.ExternalUserMiddleware).Handle(new(api.MessageController)) m.Party("/message", middleware.ExternalUserMiddleware).Handle(new(api.MessageController))
}) })
@@ -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)
}
+3 -4
View File
@@ -2,7 +2,6 @@ package middleware
import ( import (
"cs-agent/internal/pkg/irisx" "cs-agent/internal/pkg/irisx"
"cs-agent/internal/pkg/openidentity"
"cs-agent/internal/services" "cs-agent/internal/services"
"github.com/kataras/iris/v12" "github.com/kataras/iris/v12"
@@ -16,13 +15,13 @@ func ExternalUserMiddleware(ctx iris.Context) {
_ = ctx.JSON(web.JsonErrorMsg("接入渠道异常")) _ = ctx.JSON(web.JsonErrorMsg("接入渠道异常"))
return return
} }
secret := services.ChannelService.GetUserTokenSecret(channel) result, err := services.CustomerSessionService.VerifyRequest(ctx, channel)
ext, err := openidentity.GetExternalUser(ctx, secret)
if err != nil { if err != nil {
ctx.StopExecution() ctx.StopExecution()
_ = ctx.JSON(web.JsonError(err)) _ = ctx.JSON(web.JsonError(err))
return return
} }
irisx.SetExternalUser(ctx, ext) services.CustomerSessionService.SetRefreshHeaders(ctx, result)
irisx.SetExternalUser(ctx, result.ExternalUser)
ctx.Next() ctx.Next()
} }
+29 -8
View File
@@ -9,14 +9,15 @@ import (
) )
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
DB DBConfig `yaml:"db"` DB DBConfig `yaml:"db"`
Logger LoggerConfig `yaml:"logger"` Logger LoggerConfig `yaml:"logger"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"` Storage StorageConfig `yaml:"storage"`
VectorDB VectorDBConfig `yaml:"vectorDB"` VectorDB VectorDBConfig `yaml:"vectorDB"`
MCP MCPConfig `yaml:"mcp"` MCP MCPConfig `yaml:"mcp"`
WxWork WxWorkConfig `yaml:"wxWork"` WxWork WxWorkConfig `yaml:"wxWork"`
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
} }
type WxWorkNotifyConfig struct { type WxWorkNotifyConfig struct {
@@ -60,6 +61,26 @@ type AuthConfig struct {
CredentialLockMinute int `yaml:"credentialLockMinute"` 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 { type StorageConfig struct {
Default enums.AssetProvider `yaml:"default"` Default enums.AssetProvider `yaml:"default"`
MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"` MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"`
@@ -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"`
}
+1
View File
@@ -251,6 +251,7 @@ const (
IMRealtimeEventConversationClosed = "conversation.closed" IMRealtimeEventConversationClosed = "conversation.closed"
IMRealtimeEventConversationRead = "conversation.read" IMRealtimeEventConversationRead = "conversation.read"
IMRealtimeEventNotificationCreated = "notification.created" IMRealtimeEventNotificationCreated = "notification.created"
IMRealtimeEventCustomerSessionRefresh = "customer_session.refresh"
) )
const ( const (
@@ -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)
}
+19
View File
@@ -239,6 +239,25 @@ func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload {
return e.Payload 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 realtimeClientMessage struct {
Type string `json:"type"` Type string `json:"type"`
Topics []string `json:"topics,omitempty"` Topics []string `json:"topics,omitempty"`
+16 -6
View File
@@ -75,25 +75,27 @@ func (s *wsService) HandleOpenWS(ctx iris.Context) {
} }
var ( var (
principal = AuthService.GetAuthPrincipal(ctx) principal = AuthService.GetAuthPrincipal(ctx)
external *openidentity.ExternalUser external *openidentity.ExternalUser
customerSessionInfo *CustomerSessionVerifyResult
) )
if principal == nil { if principal == nil {
ext, err := openidentity.GetExternalUser(ctx, ChannelService.GetUserTokenSecret(channel)) result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil { if err != nil {
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err)) _ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err))
return 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) slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Path(), "channelId", channel.ChannelID, "channel_id", channel.ID)
ctx.StopExecution() ctx.StopExecution()
return 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) conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil)
if err != nil { if err != nil {
return err return err
@@ -151,6 +153,14 @@ func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrinc
Topics: session.topicList(), 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 return nil
} }
+4 -1
View File
@@ -14,6 +14,7 @@ type RequestOptions = RequestInit & {
skipAuth?: boolean skipAuth?: boolean
retryOnAuthError?: boolean retryOnAuthError?: boolean
baseUrl?: string baseUrl?: string
onResponse?: (response: Response) => void
} }
async function parseResult<T>(response: Response) { async function parseResult<T>(response: Response) {
@@ -58,9 +59,10 @@ export async function request<T>(
options: RequestOptions = {}, options: RequestOptions = {},
retryOnAuthError = true retryOnAuthError = true
): Promise<T> { ): Promise<T> {
const { headers, skipAuth, baseUrl, ...rest } = options const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).retryOnAuthError delete (rest as RequestOptions).retryOnAuthError
delete (rest as RequestOptions).baseUrl delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const session = readSession() const session = readSession()
const authHeaders = new Headers(headers) const authHeaders = new Headers(headers)
@@ -81,6 +83,7 @@ export async function request<T>(
headers: authHeaders, headers: authHeaders,
cache: "no-store", cache: "no-store",
}) })
onResponse?.(response)
try { try {
return await parseResult<T>(response) return await parseResult<T>(response)
+176 -4
View File
@@ -112,12 +112,33 @@ export type ImWidgetConfig = {
width?: string 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 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 = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "" process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
const OPEN_IM_CHANNEL_ID = const OPEN_IM_CHANNEL_ID =
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || "" process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
let entryUserTokenExchangeKey = ""
function buildGuestId() { function buildGuestId() {
return `guest_${generateUUID()}` 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 config = getRuntimeImConfig()
const headers: Record<string, string> = { const headers: Record<string, string> = {
"X-Channel-Id": config.channelId, "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( function createRequestOptions(
init?: RequestInit init?: RequestInit
): RequestInit & { baseUrl?: string; skipAuth?: boolean } { ): RequestInit & {
baseUrl?: string
skipAuth?: boolean
onResponse?: (response: Response) => void
} {
return { return {
...init, ...init,
skipAuth: true, skipAuth: true,
@@ -177,6 +301,7 @@ function createRequestOptions(
...createImHeaders(), ...createImHeaders(),
...(init?.headers as Record<string, string> | undefined), ...(init?.headers as Record<string, string> | undefined),
}, },
onResponse: applyCustomerSessionHeaders,
baseUrl: getRuntimeImConfig().baseUrl, baseUrl: getRuntimeImConfig().baseUrl,
} }
} }
@@ -197,6 +322,50 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
return output ? `?${output}` : "" return output ? `?${output}` : ""
} }
export async function exchangeCustomerSession() {
const config = getRuntimeImConfig()
const result = await request<ImCustomerSessionExchangeResponse>(
"/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) { export function fetchImConversationDetail(id: number) {
return request<ImConversationDetail>(`/api/conversation/${id}`, { return request<ImConversationDetail>(`/api/conversation/${id}`, {
...createRequestOptions(), ...createRequestOptions(),
@@ -212,7 +381,6 @@ export function fetchImMessages(
) )
} }
/** 外部身份仅通过 createImHeaders()Authorization 或 X-External-Id/Name)传递,无 JSON body */
export function createOrMatchImConversation() { export function createOrMatchImConversation() {
return request<ImConversation>("/api/conversation/create_or_match", { return request<ImConversation>("/api/conversation/create_or_match", {
...createRequestOptions({ method: "POST" }), ...createRequestOptions({ method: "POST" }),
@@ -224,7 +392,11 @@ export function fetchImWidgetConfig() {
`/api/channel/config${toQueryString({ `/api/channel/config${toQueryString({
channelId: getRuntimeImConfig().channelId, channelId: getRuntimeImConfig().channelId,
})}`, })}`,
createRequestOptions() {
skipAuth: true,
baseUrl: getRuntimeImConfig().baseUrl,
headers: createChannelHeaders(),
}
) )
} }
+13 -18
View File
@@ -1,5 +1,5 @@
import { createWebSocketBaseUrl } from "@/lib/api/websocket" 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 { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
import type { import type {
RealtimeConversationPatch, RealtimeConversationPatch,
@@ -9,8 +9,16 @@ import type {
export type ImRealtimeEnvelope = { export type ImRealtimeEnvelope = {
type: string type: string
topic?: string topic?: string
data?: RealtimeMessageCreatedPayload<ImMessage> & RealtimeConversationPatch data?: RealtimeMessageCreatedPayload<ImMessage> &
payload?: RealtimeMessageCreatedPayload<ImMessage> & RealtimeConversationPatch RealtimeConversationPatch & {
customerSessionToken?: string
expiresAt?: string
}
payload?: RealtimeMessageCreatedPayload<ImMessage> &
RealtimeConversationPatch & {
customerSessionToken?: string
expiresAt?: string
}
} }
export function createImRealtimeConnection() { export function createImRealtimeConnection() {
@@ -19,22 +27,9 @@ export function createImRealtimeConnection() {
const baseUrl = apiBaseUrl const baseUrl = apiBaseUrl
? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "") ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
: createWebSocketBaseUrl() : createWebSocketBaseUrl()
const resolvedExternalId = encodeURIComponent(
(config.externalId ?? "").trim() || getGuestId()
)
const channelId = encodeURIComponent(config.channelId || "") const channelId = encodeURIComponent(config.channelId || "")
const userToken = (config.userToken ?? "").trim() const customerSessionToken = getCustomerSessionToken()
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)}`
: ""
return new WebSocket( return new WebSocket(
`${baseUrl}/api/ws/open?externalId=${resolvedExternalId}&channelId=${channelId}${nameQuery}` `${baseUrl}/api/ws/open?channelId=${channelId}&customerSessionToken=${encodeURIComponent(customerSessionToken)}`
) )
} }
+2 -2
View File
@@ -4,9 +4,9 @@ export type KefuWidgetHostConfig = {
apiBaseUrl?: string apiBaseUrl?: string
/** 外部访客稳定标识;未传时使用浏览器本地访客 ID */ /** 外部访客稳定标识;未传时使用浏览器本地访客 ID */
externalId?: string externalId?: string
/** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端 */ /** 访客展示名,仅用于首次换取客服会话 token */
externalName?: string externalName?: string
/** 业务系统签发的前台用户 JWT */ /** 业务系统签发的前台用户 JWT,仅用于首次换取客服会话 token */
userToken?: string userToken?: string
title?: string title?: string
subtitle?: string subtitle?: string
+4
View File
@@ -46,6 +46,9 @@
delete merged.apiBaseUrl; delete merged.apiBaseUrl;
} }
merged.channelId = String(merged.channelId || ""); merged.channelId = String(merged.channelId || "");
if (merged.externalId) {
merged.externalId = String(merged.externalId);
}
if (merged.userToken) { if (merged.userToken) {
merged.userToken = String(merged.userToken); merged.userToken = String(merged.userToken);
} }
@@ -66,6 +69,7 @@
frameUrl.searchParams.set("channelId", config.channelId); frameUrl.searchParams.set("channelId", config.channelId);
frameUrl.searchParams.set("baseUrl", config.baseUrl); frameUrl.searchParams.set("baseUrl", config.baseUrl);
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl); 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.externalName) frameUrl.searchParams.set("externalName", config.externalName);
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken); if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
return frameUrl; return frameUrl;
+14 -1
View File
@@ -5,12 +5,14 @@ import { create } from "zustand"
import { import {
closeImConversation, closeImConversation,
createOrMatchImConversation, createOrMatchImConversation,
ensureCustomerSession,
fetchImMessages, fetchImMessages,
fetchImWidgetConfig, fetchImWidgetConfig,
markImMessageRead, markImMessageRead,
sendImMessage, sendImMessage,
uploadImAttachment, uploadImAttachment,
uploadImImage, uploadImImage,
applyCustomerSessionRefresh,
type ImAsset, type ImAsset,
type ImConversation, type ImConversation,
type ImMessage, type ImMessage,
@@ -159,11 +161,17 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
return return
} }
const payload = event.data ?? event.payload
if (event.type === "customer_session.refresh") {
applyCustomerSessionRefresh(payload)
return
}
const conversationId = get().conversation?.id const conversationId = get().conversation?.id
if (!conversationId) { if (!conversationId) {
return return
} }
const payload = event.data ?? event.payload
if (event.type === "resyncRequired") { if (event.type === "resyncRequired") {
void get().refreshMessages() void get().refreshMessages()
return return
@@ -281,6 +289,11 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
themeColor: widgetConfig.themeColor || "#2563eb", themeColor: widgetConfig.themeColor || "#2563eb",
}) })
await ensureCustomerSession()
if (bootstrapToken !== token || !get().isOpen) {
return
}
let currentConversation = get().conversation let currentConversation = get().conversation
if (!get().initialized || !currentConversation) { if (!get().initialized || !currentConversation) {
currentConversation = await createOrMatchImConversation() currentConversation = await createOrMatchImConversation()
+4
View File
@@ -41,6 +41,9 @@
merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, ""); merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, "");
merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, ""); merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, "");
merged.channelId = String(merged.channelId || ""); merged.channelId = String(merged.channelId || "");
if (merged.externalId) {
merged.externalId = String(merged.externalId);
}
if (merged.userToken) { if (merged.userToken) {
merged.userToken = String(merged.userToken); merged.userToken = String(merged.userToken);
} }
@@ -66,6 +69,7 @@
if (config.position) frameUrl.searchParams.set("position", config.position); if (config.position) frameUrl.searchParams.set("position", config.position);
if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor); if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor);
if (config.width) frameUrl.searchParams.set("width", config.width); 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.externalName) frameUrl.searchParams.set("externalName", config.externalName);
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken); if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
return frameUrl; return frameUrl;
File diff suppressed because one or more lines are too long