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:
@@ -14,6 +14,7 @@ type RequestOptions = RequestInit & {
|
||||
skipAuth?: boolean
|
||||
retryOnAuthError?: boolean
|
||||
baseUrl?: string
|
||||
onResponse?: (response: Response) => void
|
||||
}
|
||||
|
||||
async function parseResult<T>(response: Response) {
|
||||
@@ -58,9 +59,10 @@ export async function request<T>(
|
||||
options: RequestOptions = {},
|
||||
retryOnAuthError = true
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
})
|
||||
onResponse?.(response)
|
||||
|
||||
try {
|
||||
return await parseResult<T>(response)
|
||||
|
||||
+176
-4
@@ -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<string, string> = {
|
||||
"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<string, string> | undefined),
|
||||
},
|
||||
onResponse: applyCustomerSessionHeaders,
|
||||
baseUrl: getRuntimeImConfig().baseUrl,
|
||||
}
|
||||
}
|
||||
@@ -197,6 +322,50 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
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) {
|
||||
return request<ImConversationDetail>(`/api/conversation/${id}`, {
|
||||
...createRequestOptions(),
|
||||
@@ -212,7 +381,6 @@ export function fetchImMessages(
|
||||
)
|
||||
}
|
||||
|
||||
/** 外部身份仅通过 createImHeaders()(Authorization 或 X-External-Id/Name)传递,无 JSON body */
|
||||
export function createOrMatchImConversation() {
|
||||
return request<ImConversation>("/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(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-18
@@ -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<ImMessage> & RealtimeConversationPatch
|
||||
payload?: RealtimeMessageCreatedPayload<ImMessage> & RealtimeConversationPatch
|
||||
data?: RealtimeMessageCreatedPayload<ImMessage> &
|
||||
RealtimeConversationPatch & {
|
||||
customerSessionToken?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
payload?: RealtimeMessageCreatedPayload<ImMessage> &
|
||||
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)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<KefuChatStore>((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<KefuChatStore>((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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user