Browse Source

feat(sdk): SDK 内置 Token 自动刷新,支持每请求前向宿主取 Token

问题:SDK Token 默认仅 2 小时有效,而 SDK 此前没有任何刷新入口 —— token 只能
在 init() 时传一次、只存内存,收到 401 只回调 onError 报错,想换 Token 只能
destroy() 重建整个 DOM,第三方页面长时间挂载必然掉线。

取 Token 的四种触发方式(client/src/token.ts)
- 临期主动刷新:阈值取「总寿命 10% 与 5 分钟」的较小值,需传 expiresIn
- 每请求前取:tokenStrategy: 'always',每个受保护请求发出前都向宿主取一次,
  完全不推算过期时间(该模式下不排定时器、不认「返回同一 Token」的静默窗口)
- 401 兜底重试:换一次 Token 后自动重试原请求,UI 上不会闪错误
- 手动入口:ChatbotSDK.setToken(token, expiresIn?) / refreshToken()

取 Token 的两种来源(二选一,getToken 回调优先并明确告警)
- getToken 回调:由宿主实现,可复用其请求封装与鉴权头
- tokenUrl 地址:SDK 直接 GET,兼容 {token,expiresIn} 与
  {success,token,expiresIn,roles};用 credentials: 'same-origin'(同源带
  Cookie、跨域不带,避免把宿主站点 Cookie 泄露给别的域)
两者都让 API Key 留在宿主服务端,不进浏览器;后端零改动。

关键实现约束(均有单测覆盖)
- 临期阈值必须自适应:后端 clampExpirationMillis 允许的最短 ttl 恰为 300s,
  固定 5 分钟会让它永远处于临期态,定时器延迟算成 0 退化成秒级无限刷新循环
- expiresIn 只做 24h 上界钳制、绝不做下界,避免把「只剩 20s 的 Token」当成
  300s 而带着已过期 Token 发请求(上界同时防 setTimeout 溢出与毫秒误传)
- provider 调用自带 10s 超时,refreshPromise 在 finally 无条件清空:宿主回调
  永不 settle 时,挂起会经 single-flight 传染给后续所有受保护请求
- setToken 自增 generation 但不碰 refreshPromise:既防止在途刷新返回的旧值
  覆盖宿主刚推来的新值,又不破坏 single-flight
- 刷新失败统一进 60s 熔断窗口(判断在 refreshNow 内,不在 ensureFresh),窗口
  内请求照常发出、由 401 兜底,避免宿主取 Token 挂死时每个请求多等一次超时
- isProtectedPath 改用 URL.pathname 判定并与后端 SdkAuthFilter.shouldNotFilter
  逐字对齐;顺带修掉旧实现 endsWith('/feedback') 遇 query 参数漏注入、
  /attachment/upload 注入了 Token 却不享受 401 处理的两处不一致
- 401 透明重试对写请求(POST /feedback、/attachment/upload、DELETE 会话)安全,
  依据是 SdkAuthFilter 的 401 全部发生在 chain.doFilter 之前(401 ⇒ 业务逻辑
  未执行);该前提已写入代码注释与 CLAUDE.md,改过滤器时需重新评估

顺带修复
- chat.ts:首 token 前点「停止生成」会反而多发一个同步 /ai/chat 请求
- tests/config.test.ts:2 条断言早已因窗口默认值改动而变红(不修平的话
  npm test 无法充当回归门)
- 文档:修正 width 默认值 380->500、enableRag 默认值 false->true、roleId 幽灵
  参数、「仅 console.error 输出」(控制台日志早已全量关闭)等过时描述,并补齐
  token/expiresIn/getToken/tokenStrategy/tokenUrl 全部鉴权参数

验证
- client 单测 92 个全绿(新增 token.test.ts 60 个 + config 新增 9 个),
  npx tsc --noEmit 通过
- npm run build(SDK)与 frontend npm run build 均通过,
  mvn clean package -P prod 构建成功
- 浏览器端未实测(按项目惯例由用户在 IDEA 启动服务,避免抢占 9090 端口),
  面板已加 TTL 输入与 tokenStrategy 选择开关供手工验证
dev
wanghanlin 2 weeks ago
parent
commit
5a553ec813
  1. 3
      CLAUDE.md
  2. 215
      SDK-INTEGRATION.md
  3. 24
      client/CLAUDE.md
  4. 128
      client/README.md
  5. 156
      client/src/api.ts
  6. 8
      client/src/chat.ts
  7. 64
      client/src/config.ts
  8. 2
      client/src/i18n.ts
  9. 47
      client/src/index.ts
  10. 9
      client/src/logger.ts
  11. 463
      client/src/token.ts
  12. 65
      client/src/types.ts
  13. 59
      client/tests/config.test.ts
  14. 625
      client/tests/token.test.ts
  15. 99
      frontend/src/sdk-test/SdkTestPanel.vue
  16. 6
      src/main/resources/static/sdk/test.html

3
CLAUDE.md

@ -115,6 +115,9 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。
- 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken` → **再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。
- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider` 的 `MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。
- **SDK 侧已内置自动刷新**(`client/src/token.ts`):默认策略 `tokenStrategy: 'expiry'` 下走临期主动刷新(阈值 `min(总寿命 10%, 5min)`)+ 401 兜底重试(换 Token 后自动重试原请求,UI 无感);另有 `tokenStrategy: 'always'`(**每个受保护请求前都向宿主取一次**,SDK 完全不推算过期时间,代价是每请求多一次宿主后端往返)、`tokenUrl`(配个地址让 SDK 自己 GET,兼容 `{token,expiresIn}` 与 `{success,token,expiresIn,roles}`,`credentials: 'same-origin'`)以及 `ChatbotSDK.setToken()` / `refreshToken()` 手动入口。因此**第三方前端不需要自己轮询换 Token**,只需提供取 Token 方式(推荐由其服务端持 API Key 换取,API Key 不进浏览器)。
- 后端**没有独立的 refresh 端点**:刷新 = 重新 `POST /open-api/auth/token`(需 `X-API-Key`)。`SdkJwtTokenProvider` 只有 `generateToken`/`parseToken`/`validateToken`。
- 401 由 `SdkAuthFilter` 在 `chain.doFilter` **之前**写出(缺头/空 Token/已过期/管理端 JWT 无效四类),即「401 ⇒ 业务 handler 未执行」—— 这是 SDK 允许对写请求做透明重试(不会产生重复副作用)的**唯一依据**。改这个过滤器时要重新评估该前提。
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`)
- **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true

215
SDK-INTEGRATION.md

@ -11,11 +11,15 @@
3. [第二步:绑定客服角色(可选)](#3-第二步绑定客服角色可选)
4. [第三步:后端换取 SDK Token](#4-第三步后端换取-sdk-token)
5. [第四步:前端嵌入 Chat SDK](#5-第四步前端嵌入-chat-sdk)
- [5.2 初始化(含 Token 自动刷新)](#52-初始化)
- [5.3 SDK 方法(含 setToken / refreshToken)](#53-sdk-方法)
- [5.4 不使用 Token 的兼容模式](#54-不使用-token-的兼容模式)
- [5.5 角色切换](#55-角色切换)
6. [API 接口参考](#6-api-接口参考)
7. [SDK 配置参数参考](#7-sdk-配置参数参考)
8. [错误码与排查](#8-错误码与排查)
9. [安全建议](#9-安全建议)
10. [验证 Token 自动刷新](#10-验证-token-自动刷新)
---
@ -173,13 +177,22 @@ X-API-Key: sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
### 5.2 初始化
```javascript
const chatbot = ChatbotSDK.init({
ChatbotSDK.init({
// ========== 必填参数 ==========
integrateId: '1', // 客服角色 ID(从 Token 换取接口的 roles 中选择)
requestDomain: 'https://your-domain.com', // AI 客服后端域名
// ========== 鉴权参数(推荐) ==========
token: 'eyJhbGciOiJIUzI1NiJ9...', // 后端换取的 SDK Token
expiresIn: 7200, // 该 Token 的有效期(秒,与换取接口返回的 expiresIn 一致)
tokenStrategy: 'expiry', // 'expiry'(默认)临期/401 才取;'always' 每个请求前都取
tokenUrl: '/my-backend/sdk-token', // 取 Token 的地址(与下面的 getToken 二选一,回调优先)
getToken: async function() { // 取 Token 回调:SDK 在临期 / 每请求前(always)/ 收到 401 时调用
// 注意:这里应调「你自己的后端」,由它持 API Key 去调 /open-api/auth/token
const res = await fetch('/my-backend/sdk-token');
const data = await res.json();
return { token: data.token, expiresIn: data.expiresIn };
},
roles: [ // 可用角色列表(传多个时 SDK header 显示角色切换下拉框)
{ id: '1', key: 'general', name: '通用客服' },
{ id: '2', key: 'finance', name: '财务顾问' }
@ -194,24 +207,35 @@ const chatbot = ChatbotSDK.init({
enableRag: true, // 启用 RAG 知识库检索(默认 true)
quickReplies: ['如何退款?', '联系人工客服'], // 快捷问题
position: 'right-bottom', // 悬浮按钮位置:right-bottom / left-bottom
width: 380, // 窗口宽度(px)
width: 500, // 窗口宽度(px)
height: 520, // 窗口高度(px)
debug: true, // 控制台调试日志
debug: true, // 调试日志
// ========== 回调函数 ==========
onReady: function() { console.log('SDK 就绪'); },
onMessage: function(msg) { console.log('收到消息', msg); },
onError: function(err) { console.error('SDK 错误', err); }
onError: function(err) {
// code: auth_expired / auth_refresh_failed / config_invalid / 通用错误码
console.error('SDK 错误', err.code, err.message);
}
});
```
### 5.3 SDK 方法
```javascript
// 运行时更新 Token(宿主自行换好后推送)—— 无需 destroy + init 重建 DOM,下次请求即生效
ChatbotSDK.setToken(newToken, 7200); // 第二个参数为有效期(秒),可省略
// 让 SDK 调 getToken 回调立即换新 Token,返回是否换到了不同的 Token
const refreshed = await ChatbotSDK.refreshToken();
// 销毁实例(移除 DOM 和事件监听)
chatbot.destroy();
ChatbotSDK.destroy();
```
其余可用方法:`open()` / `close()` / `toggle()` / `clearHistory()`。
### 5.4 不使用 Token 的兼容模式
如果暂时不接入后端 Token 换取,可直接使用兼容模式(**不推荐用于生产环境**):
@ -226,6 +250,11 @@ ChatbotSDK.init({
兼容模式下,`integrateId` 直接作为 `roleId` 传递给后端,无需 Token 换取步骤。由于不传 `roles`,SDK 不会显示角色选择器,用户只能使用初始指定的单一角色。
**注意**:此时 `/ai/**`、`/feedback`、`/attachment/upload` 等受 `SdkAuthFilter` 保护的接口仍会返回
**401**(对话不可用)。若只想免去自己管理过期时间、但仍要能对话,可以只配 `getToken` 不配 `token`:
SDK 会在首个受保护请求发出**之前**自动换取 Token("自举"),因此不会白跑一次 401。之后按正常刷新逻辑续期。
拿不到 `expiresIn` 时还可以配 `tokenStrategy: 'always'`,让 SDK 每次请求前都取一次(见第 8 节常见问题)。
### 5.5 角色切换
当 `roles` 数组长度 > 1 时,SDK 会在聊天窗口头部自动显示角色选择下拉框,用户可随时切换当前使用的客服角色。
@ -328,33 +357,40 @@ POST /open-api/auth/token
| `integrateId` | String/Number | **必填** | 客服角色 ID |
| `requestDomain` | String | **必填** | 后端域名 |
| `token` | String | - | SDK JWT Token(推荐) |
| `expiresIn` | Number | - | `token` 有效期(**秒**,与换取接口返回的 `expiresIn` 同单位)。传入后 SDK 会在临期前主动刷新 |
| `getToken` | Function | - | 取 Token 回调,SDK 在临期 / 每请求前(`always` 模式)/ 收到 401 时调用。推荐返回 `{ token, expiresIn }`;只返回字符串则仅能靠 401 驱动 |
| `tokenStrategy` | String | 'expiry' | 取 Token 时机:`'expiry'` 临期 / 401 才取;`'always'` **每个受保护请求前都取一次**(不依赖 `expiresIn`,代价是每请求多一次宿主后端往返) |
| `tokenUrl` | String | - | 取 Token 的地址(与 `getToken` 二选一,同时配置时 `getToken` 优先)。SDK 会 GET 它并解析 `{token,expiresIn}` 或 `{success,token,expiresIn,roles}`;请求用 `credentials: 'same-origin'`,跨域不带 Cookie |
| `roles` | Array | - | 角色列表 `[{id, key, name}]`,长度 > 1 时 header 显示切换下拉框 |
| `userId` | String | - | 用户标识(会话隔离) |
| `categoryId` | String | - | 默认知识库分类 ID |
| `categoryId` | Number | - | 默认知识库分类 ID |
| `showCategorySwitch` | Boolean | false | 显示分类切换器 |
| `title` | String | 'AI 智能助手' | 窗口标题 |
| `width` | Number | 380 | 窗口宽度(px) |
| `height` | Number | 520 | 窗口高度(px,最小 300) |
| `width` | Number | 500 | 窗口宽度(px) |
| `height` | Number | 520 | 窗口高度(px,最小 400) |
| `position` | String | 'right-bottom' | 悬浮按钮位置 |
| `primaryColor` | String | '#4F46E5' | 主题色 |
| `launcherTheme` | String | - | 按钮主题:dream-purple / mint-tech / coral-peach / sky-blue |
| `launcherIcon` | String | (内置) | 自定义悬浮按钮 SVG |
| `launcherIcon` | String | (内置) | 自定义悬浮按钮图标(URL 或 SVG 字符串) |
| `launcherSize` | Number/String | 80 | 悬浮按钮尺寸(px 或 CSS 长度字符串) |
| `theme` | String | 'light' | 界面主题:light / dark |
| `streaming` | Boolean | true | 流式回复 |
| `enableRag` | Boolean | true | RAG 知识库检索 |
| `rewriteStrategy` | String | 'REWRITE' | 查询重写策略 |
| `quickReplies` | String[] | [] | 快捷问题列表 |
| `showClear` | Boolean | true | 显示清空按钮 |
| `showAdminPanel` | Boolean | false | 显示管理入口 |
| `showTeaser` | Boolean | true | 显示提示气泡 |
| `teaserText` | String | '' | 提示气泡文本 |
| `sound` | Boolean | false | 消息提示音 |
| `notification` | Boolean | false | 浏览器通知 |
| `allowImageUpload` | Boolean | true | 允许上传图片参与对话 |
| `suggestions` | Boolean | true | AI 回复后展示推荐问题 |
| `resizable` | Boolean | true | 允许拖拽缩放窗口 |
| `watermark` | String | - | 聊天窗口水印文字 |
| `locale` | String | 'zh-CN' | 语言:zh-CN / en |
| `debug` | Boolean | true | 调试日志 |
| `onReady` | Function | - | SDK 就绪回调 |
| `onMessage` | Function | - | 消息回调 |
| `onError` | Function | - | 错误回调 |
| `onError` | Function | - | 错误回调(`code` 见第 8 节) |
---
@ -373,16 +409,60 @@ POST /open-api/auth/token
| HTTP 状态码 | SDK 提示 | 原因 |
|---|---|---|
| 401 | 鉴权失败 | Token 过期或无效,需重新换取 |
| 401 | 鉴权失败 | Token 过期或无效。**SDK 会自动换一次 Token 并重试**;若仍 401,则通过 `onError` 上报 `auth_expired` |
| 403 | 无访问权限 | roleId 不在 Token 允许范围内 |
| 429 | 请求过于频繁 | 超过 API Key 频率限制 |
| 500 | 服务器异常 | 后端错误,查看服务端日志 |
| 502/503 | 服务暂不可用 | 后端未启动或正在部署 |
`onError` 回调中的 `code`(用于程序化识别,比看中文文案可靠):
| code | 含义 | 解决方案 |
|---|---|---|
| `auth_expired` | 自动刷新后仍鉴权失败,Token 彻底失效 | 检查 API Key 是否被吊销/过期、是否绑定了启用中的客服角色;修好后调 `ChatbotSDK.setToken()` |
| `auth_refresh_failed` | 换 Token 的 `getToken` 回调失败 | 检查宿主换 Token 接口与网络(同一 code 30 秒内只上报一次,不会刷屏) |
| `config_invalid` | `init()` 传入的参数非法(如 `token` 非字符串、`expiresIn` 非正数),该参数被忽略 | 按提示修正配置 |
| `network` / `timeout` / `cors` | 网络层错误 | 检查网络、CORS 白名单、后端可用性 |
### 常见问题
**Q: Token 过期后怎么办?**
A: Token 默认 2 小时过期。建议在第三方后端实现 Token 缓存和自动刷新逻辑:检测到 401 时重新调用 `/open-api/auth/token` 换取新 Token。
**Q: Token 过期后怎么办?SDK 会自动刷新吗?**
A: **会。** SDK 内置了自动刷新,第三方无需自己轮询换 Token(Token 默认 2 小时过期)。两种触发方式:
1. **临期主动刷新**(推荐):`init()` 时传入 `expiresIn` + `getToken`,SDK 会在 Token 剩余寿命不足
「总寿命 10% 与 5 分钟中的较小值」时主动换取新 Token,用户完全无感。
2. **401 兜底重试**:任何受保护请求收到 401 时,SDK 会换一次 Token 并**自动重试原请求**,
因此 UI 上不会闪出「鉴权失败」。(安全性说明:`SdkAuthFilter` 的 401 都发生在业务逻辑执行之前,
所以重试写请求不会产生重复副作用。)
如果不想给 `getToken` 回调,也可以由第三方后端自己盯过期时间,换好后调 `ChatbotSDK.setToken(token, expiresIn)`
推送给 SDK —— 同样是即时生效,不需要 destroy + init。
**Q: 只配 `getToken` 不配 `token` 可以吗?**
A: 可以。SDK 在首个受保护请求发出**之前**会自动换取 Token("自举"),不会白跑一次 401。
**Q: 拿不到 `expiresIn`(或不想让 SDK 推算过期时间)怎么办?**
A: 用 `tokenStrategy: 'always'`。此时 SDK **每个受保护请求发出前都会向你的后端取一次 Token**,
完全不做过期时间推算,由你的后端决定何时真的重新换取(它自己的缓存命中就直接返回缓存值)。
代价是每个请求多一次宿主后端往返(含每次对话的首 token 之前)。若你的后端能给出可信的 `expiresIn`,
用默认的 `'expiry'` 更省往返。两者都建议配上取 Token 方式:
```javascript
ChatbotSDK.init({
// ...
tokenStrategy: 'always',
tokenUrl: '/my-backend/sdk-token', // 或 getToken 回调
});
```
**Q: 取 Token 接口需要带 Cookie 怎么办?**
A: `tokenUrl` 用的是 `credentials: 'same-origin'`:**同源**请求会自动带上你站点的 Cookie(宿主页面调
自己后端的最常见场景),跨域则不带。若你的取 Token 接口在另一个域上且需要 Cookie,
请改用 `getToken` 回调,用你自己的 `fetch` 控制 `credentials`。
**Q: 页面刷新后 Token 还在吗?**
A: 不在。Token 只存内存(不写 localStorage,避免被 XSS 读取)。页面刷新后请重新 `init()` 传入,
或只配 `getToken` 让 SDK 自动重取。
**Q: 如何让用户只能使用特定角色?**
A: 在 API Key 上绑定角色(`PUT /api-key/{id}/roles`),SDK 初始化时只传入绑定的角色 ID。
@ -394,12 +474,50 @@ A: 可以,但建议每个系统使用独立的 API Key,便于独立管控频
## 9. 安全建议
1. **API Key 仅在后端使用**:永远不要将 API Key 暴露到前端代码中
2. **Token 短有效期**:生产环境建议 TTL 设为 1-2 小时,配合自动刷新
3. **角色最小权限**:只绑定必要的客服角色,避免授予过多权限
4. **频率限制**:根据业务量设置合理的 rateLimit(默认 60 次/分钟)
5. **HTTPS**:生产环境必须使用 HTTPS,防止 Token 被中间人截获
6. **定期轮换 Key**:定期吊销旧 Key 并创建新 Key
1. **API Key 仅在后端使用**:永远不要将 API Key 暴露到前端代码中(`getToken` 回调应指向你自己的后端,由它持 Key 去换 Token)
2. **Token 短有效期**:生产环境建议 TTL 设为 1-2 小时。SDK 已内置自动刷新(临期 + 401 双触发),短有效期不再影响长会话体验
3. **Token 不落盘**:SDK 只在内存中持有 Token,不写 localStorage/sessionStorage;页面刷新后由宿主重新提供
4. **角色最小权限**:只绑定必要的客服角色,避免授予过多权限
5. **频率限制**:根据业务量设置合理的 rateLimit(默认 60 次/分钟)
6. **HTTPS**:生产环境必须使用 HTTPS,防止 Token 被中间人截获
7. **定期轮换 Key**:定期吊销旧 Key 并创建新 Key
8. **接好 `onError`**:`auth_expired` / `auth_refresh_failed` 是排查鉴权问题的第一手线索(控制台日志已关闭,不接回调等于静默失败)
---
## 10. 验证 Token 自动刷新
在浏览器控制台(或测试页 `http://localhost:9090/sdk/test.html`)按以下步骤可验证四种路径。
全程打开 DevTools 的 Network 面板,过滤 `Authorization` 请求头与 `/open-api/auth/token`。
```js
// 公共:一个真实的换 Token 实现(把 sk_xxx 换成你的 API Key)
let providerCalls = 0;
const provider = async () => {
providerCalls++;
const r = await fetch('/open-api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'sk_xxx' },
body: JSON.stringify({ ttl: 7200 }),
});
const d = await r.json();
return { token: d.token, expiresIn: d.expiresIn };
};
```
| # | 验证项 | 操作 | 预期 |
|---|---|---|---|
| 1 | `setToken` 生效 | `init({ token: 'invalid.jwt', ... })` 发一条消息;再 `ChatbotSDK.setToken('<真实 token>', 7200)` 发一条 | 第一条弹「鉴权失败」气泡;第二条正常回复;Network 里 `Authorization` 头变为真实 token |
| 2 | 401 自动重试 | `init({ token: 'invalid.jwt', getToken: provider, ... })` 发一条消息 | Network 上「一条 401 → 一条 200」;`providerCalls === 1`;**无**错误气泡;`onError` 未触发 |
| 3 | 防死循环 | `init({ token: 'invalid.jwt', getToken: async () => 'invalid.jwt', ... })` 反复发消息 | 只发 1 个请求、不重试;`onError` 收到 `code === 'auth_expired'`;30 秒内不重复上报 |
| 4 | 临期主动刷新 | `init({ token: '<真实 token>', expiresIn: 20, getToken: provider, ... })` 后**不做任何操作** | 约 18 秒后 Network 出现一条新的 `/open-api/auth/token`(`expiresIn: 20` 时临期阈值为 2 秒) |
| 5 | destroy 清理 | 承上,等 `providerCalls` 增长后立刻 `ChatbotSDK.destroy()`,等 1 分钟 | `providerCalls` 不再增长;再 `init()` 一次功能恢复正常 |
| 6 | 非受保护路径不受影响 | 观察 `/category/tree`、`/ai/system-config/disclaimer` 请求 | **不带** `Authorization` 头,也不会因临期检查而变慢 |
| 7 | `always` 模式每请求前取 | `init({ tokenStrategy: 'always', tokenUrl: '/api/sdk-token', ... })`,连发两条消息 | 每条消息的 `/ai/chat/stream` **之前**都各有一条取 Token 请求(Token 未过期时也照取) |
| 8 | 熔断不拖慢请求 | 把 `tokenUrl` 指向一个 404 地址后发消息 | 首次失败 `onError` 收到 `auth_refresh_failed`;随后 60 秒内不再出现取 Token 请求,消息仍照常发出(走 401 兜底) |
> 若 `getToken` 返回**裸字符串**(不带 `expiresIn`),第 4 项不会发生 —— 这是预期行为:
> 没有过期信息时只能靠 401 驱动(第 2 项)。生产环境建议始终返回 `{ token, expiresIn }`。
---
@ -416,11 +534,14 @@ const API_KEY = 'sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
// Token 缓存
let cachedToken = null;
let tokenExpireAt = 0;
let cachedRoles = [];
// 换取 Token(带缓存)
// 说明:SDK 侧已有自动刷新,这里的缓存是为了避免同一时刻多个用户各换一次 Token。
// 缓存窗口设为「有效期 - 5 分钟」,与 SDK 的临期阈值对齐,可保证 SDK 来取时拿到的是新 Token。
async function getSdkToken() {
if (cachedToken && Date.now() < tokenExpireAt) {
return cachedToken;
return { token: cachedToken, expiresIn: Math.round((tokenExpireAt - Date.now()) / 1000) };
}
const res = await fetch(`${AI_DOMAIN}/open-api/auth/token`, {
@ -437,14 +558,15 @@ async function getSdkToken() {
cachedToken = data.token;
tokenExpireAt = Date.now() + (data.expiresIn - 300) * 1000; // 提前 5 分钟刷新
return { token: data.token, roles: data.roles };
cachedRoles = data.roles;
return { token: data.token, expiresIn: data.expiresIn };
}
// 给前端提供 Token
// 给前端提供 Token(前端在 init 时取一次,之后由 SDK 自动调用续期)
app.get('/api/chatbot/token', async (req, res) => {
try {
const data = await getSdkToken();
res.json({ success: true, ...data });
res.json({ success: true, ...data, roles: cachedRoles });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
@ -457,22 +579,35 @@ app.listen(3000);
<!-- ==================== 第三方前端示例 ==================== -->
<script src="https://your-ai-domain.com/sdk/chatbot-sdk.js"></script>
<script>
// 从自己后端获取 Token
fetch('/api/chatbot/token')
.then(r => r.json())
.then(data => {
if (data.success) {
ChatbotSDK.init({
integrateId: data.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-ai-domain.com',
token: data.token,
roles: data.roles, // 传入多个角色时自动显示角色选择器
userId: 'current_user_id',
title: '在线客服',
theme: 'light',
quickReplies: ['如何退款?', '查看订单', '联系人工']
});
// 从自己后端换取 Token(后端持 API Key,API Key 永远不进浏览器)
async function fetchToken() {
const res = await fetch('/api/chatbot/token');
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Token 换取失败');
return data;
}
(async () => {
const first = await fetchToken();
ChatbotSDK.init({
integrateId: first.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-ai-domain.com',
token: first.token, // 首次 Token
expiresIn: first.expiresIn, // 有效期(秒)→ SDK 据此在临期前主动刷新
getToken: async () => { // SDK 在临期 / 收到 401 时自动调用它续期
const next = await fetchToken();
return { token: next.token, expiresIn: next.expiresIn };
},
roles: first.roles, // 传入多个角色时自动显示角色选择器
userId: 'current_user_id',
title: '在线客服',
theme: 'light',
quickReplies: ['如何退款?', '查看订单', '联系人工'],
onError: (e) => {
// 鉴权类问题看这两个 code;其余错误可忽略或上报监控
if (String(e.code).startsWith('auth_')) console.error('鉴权异常', e.code, e.message);
}
});
})();
</script>
```

24
client/CLAUDE.md

@ -26,7 +26,7 @@ npm run dev
- 构建工具:Rollup + `@rollup/plugin-typescript` + `@rollup/plugin-terser`,配置见 `rollup.config.js`
- TypeScript 配置:`tsconfig.json`,`target: ES2017`,`strict: true`,`rootDir: ./src`,不生成 `.d.ts`
- 产物双份:`chatbot-sdk.js`(未压缩 + sourcemap,~5.6MB)和 `chatbot-sdk.min.js`(压缩,~4.0MB,gzip ~1.1MB)。体积主要由内置的 TDesign Web Components 组件库贡献(按需 tree-shaking 后打包进 IIFE)
- **无测试框架**:验证通过后端的 `http://localhost:9090/sdk/test.html` 运行 10 个浏览器端核心用例(见 README 第十四节),本工程内没有可运行的自动化测试
- **自动化测试**:`tests/` 下有 vitest 单测(`npm test`,node 环境,覆盖 `config.ts` 配置解析与 `token.ts` 的刷新竞态);另有后端 `http://localhost:9090/sdk/test.html` 的 10 个浏览器端核心用例(见 README 第十四节)
## 运行时依赖
@ -47,10 +47,12 @@ SDK 本身不独立运行,需要后端在 `requestDomain`(通常 `http://loc
```
init(rawConfig)
→ logger.ts setErrorCallback() 先注入 onError(早于配置解析:控制台日志已关闭,配置错误只能靠它上报)
→ config.ts parseConfig() 解析 + 校验,返回 ResolvedConfig(失败返回 null,不抛异常)
→ i18n.ts setLocale() 设置语言字典(zh-CN / en)
→ logger.ts setDebug() 控制日志级别
→ api.ts setApiConfig() 注入 requestDomain 等,供后续 HTTP/SSE 调用复用
→ token.ts configureTokenManager() 注入 token/expiresIn/getToken,启动 Token 自动刷新
→ styles.ts injectStyles() 注入 <style data-csk-sdk>,按 primaryColor 生成主题
→ dom.ts createLauncher() / createChatWindow() 构建 DOM,返回元素引用与 loading 控制函数
→ dom.ts enableDrag() 弹窗头部拖拽,返回 cleanup 函数
@ -59,13 +61,22 @@ init(rawConfig)
```
模块间通过两种方式通信:
1. **闭包状态**:`index.ts`、`chat.ts`、`api.ts` 各自持有模块级变量,靠 `init*` 函数注入
1. **闭包状态**:`index.ts`、`chat.ts`、`api.ts`、`token.ts` 各自持有模块级变量,靠 `init*` 函数注入
2. **CustomEvent**:DOM 层(`dom.ts`)派发 `csk:categoryChange`、`csk:loadHistory`,`index.ts` 监听后转发给 `chat.ts`
### 关键模块
- **`chat.ts`**(最大、最核心):对话状态机。处理发送、SSE 流式追加、RAG 来源展示、流中断兜底、无流内容降级为同步、`chatId` 自动管理逻辑、清空会话。Markdown 渲染由 `t-chat-item` 内置 cherry-markdown 处理。修改对话行为优先改这里。
- **`api.ts`**:HTTP 封装 + SSE 流解析。三种 SSE 接口(普通流式、RAG 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。
- **`token.ts`**:Token 管理。`getToken()` 是**运行时读取 Token 的唯一出口**(`ResolvedConfig.token` 只是 init 时的输入值,自动刷新后不会跟着变)。取 Token 的「源头」有两种形式(`getToken` 回调优先,`tokenUrl` 会用 `createUrlProvider()` 包成同样的 provider),触发方式有四条:① 临期主动刷新(`setTimeout` 排到「过期时刻 - 临期阈值」);② **每请求前取**(`tokenStrategy: 'always'`,`ensureFresh` 无条件取,不推算过期时间);③ 401 兜底重试(`safeFetch` 调 `refreshNow()` 换新后重试一次原请求);④ 手动入口 `setHostToken()` / `forceRefresh()`。
- **`tokenStrategy` 两种模式的分工**:`'expiry'`(默认)由 SDK 推算过期时间,只在临期 / 401 时取;`'always'` 每请求前都取且**不排定时器**(`scheduleProactive` early return)、**不认 `staleProviderUntil`**。后者是因为该模式下宿主返回同一个仍有效的缓存 Token 是常态,若沿用过期判断会把它误当成 provider 故障,让模式退化成 401 驱动。
- **`tokenUrl` 用 `credentials: 'same-origin'`**:同源自动带 Cookie,跨域不带(避免把宿主站点 Cookie 泄露给别的域)。需跨域带 Cookie 的场景请宿主改用 `getToken`。响应兼容 `{token,expiresIn}` 与 `{success,token,expiresIn,roles}`。
- **`isProtectedPath()` 必须与后端 `SdkAuthFilter.shouldNotFilter()` 逐字对齐**(受保护 = `/ai/**` 除 `/ai/system-config/**` + `/feedback` + `/attachment/upload`)。改 `api.ts` 里鉴权相关逻辑时**必须同步核对后端过滤器**,否则会出现「该带 Token 的没带」或「把 Token 泄露给公开端点」。用 `URL.pathname` 判定,不要用整串 `includes`。
- **临期阈值必须自适应**(`min(总寿命 10%, 5min)`):后端 `clampExpirationMillis` 允许的最短 ttl 恰好是 300s,固定 5 分钟会让 300s 的 Token 永远处于临期态 → 定时器延迟算成 0 → 秒级无限刷新循环。
- **`expiresIn` 只做上界钳制**(24h),绝不做下界:钳下界会把「只剩 20s 的 Token」当成 300s,导致带着过期 Token 发请求。
- **`setToken` 不得清 `refreshPromise`**(会破坏 single-flight),但必须自增 `generation`(否则在途刷新返回的旧 Token 会覆盖宿主刚推来的新值)。`refreshPromise` 只能由 `refreshNow` 的 `finally` 无条件清空 —— 宿主 `getToken` 回调若永不 settle,挂起会传染给后续所有受保护请求,所以 provider 调用自带 10s 超时。
- **刷新失败后进 60s 退避窗口**(`backoffUntil`,判断在 `refreshNow` 顶部,不在 `ensureFresh`):窗口内所有自动触发(临期检查 / 定时器 / 401 兜底)都不再尝试,否则宿主取 Token 挂死时每个请求都要多等一个 `PROVIDER_TIMEOUT`。请求照常发出、由 401 兜底;连续 3 次失败还会停掉定时器链。`forceRefresh()` 显式绕过该窗口。
- Token **只存内存**,不落 localStorage;`destroy()` 走 `resetTokenManager()`。
- **`dom.ts`**:纯 DOM 构建与事件绑定,含知识库下拉、RAG 来源卡片、历史会话面板的渲染。
- **`styles.ts`**:所有 CSS 字符串模板,按 `primaryColor` 动态着色,并通过 `--td-*` CSS 变量透传给 TDesign 组件。改动 UI 视觉改这里,**不要**在 `dom.ts` 里写内联样式。
- **主题/聊天变量自包含约定**:TDesign 的变量分为两类,都必须自包含声明在 `.csk-root` 上(`tdThemeVars()` 负责主题变量 `--td-brand/--td-gray/--td-bg/...`,`tdChatVars()` 负责聊天/markdown/input 变量 `--td-chat-*`/`--td-chat-md-*`/`--td-chat-input-*`)。原因:这些变量在 tdesign-web-components 里定义于 `globalCSS` 的 constructable stylesheet、选择器为 `:root`,但 `adoptedStyleSheets` 里的 `:root` 不匹配 shadow root,无法进入 shadow DOM;直接声明在 `.csk-root` 上才能经 CSS 自定义属性继承穿透 shadow DOM。升级 `tdesign-web-components` 版本时需同步核对 `style/index.js` 的 `css$2`/`css`/`css$1` 三个变量块。
@ -74,7 +85,7 @@ init(rawConfig)
### 单例与生命周期
`index.ts` 用模块级变量持有 `config` 和所有 DOM 引用,`isInitialized` 防止重复初始化。`destroy()` 必须清理:移除 DOM、调用 `dragCleanup()`、`removeStyles()`、置空所有引用。新增 DOM 引用时记得在 `destroy()` 中同步置空。
`index.ts` 用模块级变量持有 `config` 和所有 DOM 引用,`isInitialized` 防止重复初始化。`destroy()` 必须清理:移除 DOM、调用 `dragCleanup()`、`removeStyles()`、`clearApiConfig()` + `resetTokenManager()`、置空所有引用。新增 DOM 引用时记得在 `destroy()` 中同步置空。
## 关键约定
@ -84,6 +95,7 @@ init(rawConfig)
|----------|----------|------|
| `integrateId` | `roleId` | 客服角色 ID,决定 AI 人设和知识库范围(**必传**) |
| `userId` | `accountId` | 客户账号 ID,账号绑定角色后服务端会覆盖 roleId |
| `token` | `Authorization: Bearer` | SDK JWT,仅注入到受保护路径(见 `token.ts`);由 `getToken` 回调自动续期 |
| (自动管理) | `chatId` | 从 `/conversation/list` 取或生成 `sdk_时间戳_随机串` |
`chatId` 缓存在 localStorage(key: `csk_chatId_{integrateId}_{userId}`),`clearHistory()` 会重新生成。改 `chat.ts` 的 chatId 逻辑时注意与后端 `ConversationController` 的会话匹配规则一致。
@ -96,12 +108,14 @@ init(rawConfig)
- 消息历史 key:`csk_history_{integrateId}`,上限 200 条,超出裁剪最早 50 条
- chatId key:`csk_chatId_{integrateId}_{userId}`
- 窗口尺寸 / 位置:`csk_size_{integrateId}` 等
- 不同 `integrateId` 隔离,互不影响
- **Token 不在此列**:只存内存,不写 localStorage / sessionStorage(避免 XSS 或同域页面读取),页面刷新后由宿主重新 `init()` 传入或靠 `getToken` 自动重取
### 错误处理
**所有错误不抛异常、不阻塞宿主页面**,仅 `console.error` 输出(`error` 始终输出,`info`/`warn` 受 `debug` 控制)。新增异步逻辑要 try/catch 包裹,失败走 `logger.warn` / `logger.error`。
**所有错误不抛异常、不阻塞宿主页面**。控制台日志已全量关闭(`logger.ts` 的 `info`/`warn`/`error` 都不输出),错误**只**通过宿主的 `onError` 回调上报 —— 因此新增错误路径时必须给 `logger.error` 传可识别的 `code`(第三个参数,如 `auth_expired` / `auth_refresh_failed` / `config_invalid`),否则宿主拿到的 code 永远是 `'error'`。新增异步逻辑要 try/catch 包裹,失败走 `logger.error`。
## 部署
构建脚本(`rollup.config.js` 的 `copyAssets` 插件)会自动把 `chatbot-sdk.js` / `chatbot-sdk.min.js` 和 `launcher-logo.png` 复制到后端 `src/main/resources/static/sdk/`,宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入即可;CDN 分发时同样只需部署这三类文件(`chatbot-sdk.js` / `chatbot-sdk.min.js` / `launcher-logo.png`)。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init/destroy/open/close/toggle/clearHistory`)或 `SDKConfig` 字段时同步更新 README。
构建脚本(`rollup.config.js` 的 `copyAssets` 插件)会自动把 `chatbot-sdk.js` / `chatbot-sdk.min.js` 和 `launcher-logo.png` 复制到后端 `src/main/resources/static/sdk/`,宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入即可;CDN 分发时同样只需部署这三类文件(`chatbot-sdk.js` / `chatbot-sdk.min.js` / `launcher-logo.png`)。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init` / `destroy` / `open` / `close` / `toggle` / `clearHistory` / `setToken` / `refreshToken`)或 `SDKConfig` 字段时同步更新 `README.md` 与根目录 `SDK-INTEGRATION.md`。

128
client/README.md

@ -1,6 +1,6 @@
# ChatbotSDK — 前端对接文档
> P0 核心链路 ✅ | P1 体验增强 ✅ | P2 运营完善 ✅ | 版本:1.2.0 | 更新日期:2026-06-26
> P0 核心链路 ✅ | P1 体验增强 ✅ | P2 运营完善 ✅ | 版本:1.4.0 | 更新日期:2026-09-24
---
@ -32,7 +32,12 @@
// 用户身份
userId: 'zhangsan',
roleId: 1,
// 鉴权(推荐 —— 详见上文「Token 鉴权与长会话」)
token: initialToken, // 首次从宿主后端换到的 SDK Token
expiresIn: 7200, // 该 Token 的有效期(秒)
tokenStrategy: 'expiry', // 'expiry'(默认)临期/401 才取;'always' 每个请求前都取
tokenUrl: '/my-backend/sdk-token', // SDK 直接 GET 它取 Token(与 getToken 二选一)
// RAG 知识库检索
enableRag: true, // 启用 RAG 增强对话(自动走 /ai/chat/stream,enableRag=true)
@ -57,6 +62,78 @@
</script>
```
### Token 鉴权与长会话(重要)
后端 `/ai/**`、`/feedback`、`/attachment/upload` 由 SDK JWT Token 保护,Token 默认有效期 **2 小时**。
第三方页面往往一挂就是一整天,因此 SDK **内置了自动刷新**,接入方无需自己盯过期时间:
| 触发方式 | 时机 | 说明 |
|---|---|---|
| 临期主动刷新 | 剩余寿命不足「总寿命 10% 与 5 分钟中的较小值」 | 需传 `expiresIn`,用户完全无感 |
| **每请求前取** | **每个受保护请求发出前** | 需 `tokenStrategy: 'always'`,完全不依赖 `expiresIn` |
| 401 兜底重试 | 请求收到 401 时 | 换一次 Token 后**自动重试原请求**,UI 上不会闪错误 |
| 手动推送 | 宿主自行换好后调 `ChatbotSDK.setToken()` | 不想暴露取 Token 逻辑时使用 |
| 手动拉取 | 宿主调 `ChatbotSDK.refreshToken()` | SDK 去调 `getToken` / `tokenUrl` 立即换新 |
**该选哪种**:
| 场景 | 推荐 |
|---|---|
| 宿主能拿到可信的 `expiresIn`(如自己后端返回了 `expiresIn`) | `tokenStrategy: 'expiry'`(默认)—— 只在临期与 401 时才取,往返最少 |
| 宿主后端已自行缓存 Token、SDK 拿不到可靠 `expiresIn`,或不想让 SDK 推算过期时间 | `tokenStrategy: 'always'` —— 每次请求前都取一次,由宿主决定何时真的换 Token。代价是每个请求多一次宿主后端往返(含每次对话的首 token 之前),且不再使用 `expiresIn` |
| 什么都不配 | 只靠 401 兜底:每次失效都要先白跑一个 401 |
取 Token 的动作由宿主提供,**推荐在宿主自己的服务端完成**(服务端持 API Key 调
`POST /open-api/auth/token`),前端只把结果交给 SDK —— 这样 API Key 永远不会进入浏览器。
两种提供方式二选一(**同时配置时 `getToken` 优先**,`tokenUrl` 被忽略并告警):
```js
// 方式 1:getToken 回调(最灵活,可复用宿主已有的请求封装 / 鉴权头 / 重试)
getToken: async () => {
const res = await fetch('/my-backend/sdk-token');
const data = await res.json();
return { token: data.token, expiresIn: data.expiresIn };
}
// 方式 2:tokenUrl 地址(零胶水代码,SDK 直接 GET 它)
tokenUrl: '/my-backend/sdk-token'
```
```js
// 最简写法:只给 Token —— SDK 不会自动续期,令牌过期后需宿主自己调 setToken() 更新
ChatbotSDK.init({ integrateId: 1, requestDomain: 'https://ai.example.com', token: 'xxx' });
// 推荐写法:Token + 有效期 + 取 Token 方式 → SDK 自动续期
ChatbotSDK.init({
integrateId: 1,
requestDomain: 'https://ai.example.com',
token: initialToken,
expiresIn: 7200, // 单位「秒」,与后端 expiresIn 字段一致
tokenStrategy: 'expiry', // 默认值;想「每次请求前都取」就改 'always'
tokenUrl: '/my-backend/sdk-token', // 或改用 getToken 回调(二选一)
onError: (e) => {
// auth_expired = 刷新后仍鉴权失败(Token 彻底失效,需检查 API Key / 角色绑定)
// auth_refresh_failed = 取 Token 失败(网络或宿主后端异常)
console.warn('SDK 错误', e.code, e.message);
},
});
```
**约定与边界**:
- 取 Token 的两种形式:`getToken` 回调(优先)、`tokenUrl` 地址。`tokenUrl` 会兼容
`{ token, expiresIn }` 与 `{ success, token, expiresIn, roles }` 两种响应。
- **`tokenUrl` 的 Cookie 行为**:请求使用 `credentials: 'same-origin'` —— 同源(宿主页面调自己后端,
最常见)自动带 Cookie;跨域则不带,避免把宿主站点的 Cookie 泄露给别的域。需要跨域带 Cookie
的场景请改用 `getToken` 自己发请求。
- 「返回对象 `{ token, expiresIn }`」可让 SDK 提前主动刷新;只返回字符串则只能靠 401 驱动重试
(功能正常,只是首个失效请求会多一次往返)。**`tokenStrategy: 'always'` 下 `expiresIn` 不参与判断。**
- `expiresIn` 单位是**秒**,只做 24 小时上界钳制(防误传毫秒),不做下界 —— 传 `expiresIn: 20` 会真的在 18 秒后刷新。
- **取 Token 失败不会打断对话**:沿用现有 Token 继续发请求(由 401 兜底),并进入 60 秒熔断窗口,
窗口内不再重复取(避免宿主端点挂死时每个请求都多等一次超时)。`refreshToken()` 可强制绕过该窗口。
- Token **只存内存**,不写 `localStorage`/`sessionStorage`。页面刷新后由宿主重新 `init()` 传入。
- 刷新失败不会吞掉错误:`onError` 会带 `auth_refresh_failed` / `auth_expired` 上报,同一错误码 30 秒内只报一次。
### 集成到现有项目
SDK 产物位于 `client/dist/` 目录:
@ -77,12 +154,17 @@ SDK 产物位于 `client/dist/` 目录:
| `integrateId` | `string \| number` | ✅ | — | P0 | 集成标识 → 后端 `roleId`(客服角色 ID,决定 AI 人设和知识库范围) |
| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 |
| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` |
| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID |
| `enableRag` | `boolean` | ❌ | `false` | P1 | 启用 RAG 知识库检索对话(走 `/ai/chat/stream` 接口,`enableRag=true`) |
| `token` | `string` | ❌ | — | P0 | SDK JWT Token(从 `/open-api/auth/token` 换取)。不传则受保护的 `/ai/**` 接口会 401 |
| `expiresIn` | `number` | ❌ | — | P0 | `token` 的有效期(**秒**,与后端 `expiresIn` 同单位)。传入后 SDK 会在临期前主动刷新 |
| `getToken` | `function` | ❌ | — | P0 | 取 Token 回调:SDK 在临期、每请求前(`always` 模式)或收到 401 时调用它。推荐返回 `{ token, expiresIn }`。详见上文「Token 鉴权与长会话」 |
| `tokenStrategy` | `'expiry' \| 'always'` | ❌ | `'expiry'` | P0 | 取 Token 的时机:`'expiry'` = 临期 / 401 才取;`'always'` = **每个受保护请求前都取一次**(此时 `expiresIn` 不参与判断,代价是每请求多一次宿主后端往返) |
| `tokenUrl` | `string` | ❌ | — | P0 | 取 Token 的地址(与 `getToken` 二选一,同时配置时 `getToken` 优先)。SDK 会 GET 它并解析 `{token,expiresIn}` 或 `{success,token,expiresIn,roles}`;请求用 `credentials: 'same-origin'` |
| `roles` | `Array<{id,key?,name?}>` | ❌ | — | P0 | 可用客服角色列表(与 `token` 配套),决定角色选择器可选项 |
| `enableRag` | `boolean` | ❌ | `true` | P1 | 启用 RAG 知识库检索对话(走 `/ai/chat/stream` 接口,`enableRag=true`) |
| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 |
| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 |
| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) |
| `width` | `number` | ❌ | `500` | P0 | 弹窗宽度(px) |
| `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 |
@ -108,6 +190,8 @@ SDK 产物位于 `client/dist/` 目录:
| `ChatbotSDK.close()` | P0 | 关闭聊天窗口 |
| `ChatbotSDK.toggle()` | P0 | 切换窗口显示/隐藏 |
| `ChatbotSDK.clearHistory()` | P0 | 清空当前会话 |
| `ChatbotSDK.setToken(token, expiresIn?)` | P0 | 运行时更新 Token(宿主自行换好后推送),无需 destroy + init 重建 DOM |
| `ChatbotSDK.refreshToken()` | P0 | 让 SDK 调 `getToken` 回调立即换新 Token,返回是否换到了不同的 Token |
---
@ -332,11 +416,21 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
- `init()` 时自动恢复历史消息
- RAG 引用来源同步缓存到 `sources` 字段
**注意:SDK Token 不在此列** —— Token 只存内存,不写 `localStorage`/`sessionStorage`,避免被 XSS
或同域其它页面读取。页面刷新后请由宿主重新 `init()` 传入(或依赖 `getToken` 回调自动重取)。
---
## 十、错误处理
所有错误不抛异常、不阻塞宿主页面,仅 `console.error` 输出。
所有错误不抛异常、不阻塞宿主页面。控制台日志已关闭,错误统一通过 `init({ onError })` 回调上报:
```js
ChatbotSDK.init({
// ...
onError: (e) => console.warn(e.code, e.message, e.detail),
});
```
| 错误场景 | 中文提示 | 英文提示 |
|----------|---------|---------|
@ -348,6 +442,14 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
| HTTP 403 | 无访问权限 | Access denied |
| 流中断 | 回复被中断 | Response interrupted |
`onError` 的 `code` 字段(除下表外,其余为通用错误码):
| code | 含义 | 建议处置 |
|------|------|---------|
| `auth_expired` | 刷新后仍返回 401,Token 彻底失效 | 检查 API Key 是否被吊销/过期、是否绑定了启用中的客服角色;修好后调 `setToken()` |
| `auth_refresh_failed` | 换 Token 的 `getToken` 回调失败(宿主后端异常或网络问题) | 检查宿主换 Token 接口;同一 code 30 秒内只上报一次,不会刷屏 |
| `config_invalid` | `init()` 传入的 `token` / `expiresIn` 等参数非法,该参数被忽略 | 按提示修正配置 |
---
## 十一、技术栈
@ -390,6 +492,7 @@ client/
│ ├── logger.ts # 日志模块(P2 增强:计时+生命周期)
│ ├── storage.ts # localStorage 封装
│ ├── api.ts # HTTP 封装 + SSE 流式 + P1 RAG + P2 会话
│ ├── token.ts # Token 管理:临期主动刷新 + 401 兜底重试 + setToken/refreshToken
│ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板
│ ├── styles.ts # CSS 注入 + 主题(--csk-* 与 --td-* 变量透传)
│ ├── chat.ts # 对话核心 + P1 RAG + P2 i18n(Markdown 由 t-chat-item 内置 cherry-markdown 处理)
@ -411,13 +514,21 @@ client/
| **P0 核心链路** | ✅ 已完成 | 项目骨架、配置校验、样式注入、悬浮按钮+弹窗、HTTP 通信、对话核心、本地缓存、打包构建 |
| **P1 体验增强** | ✅ 已完成 | SSE 流式打字机、Markdown 渲染、知识库下拉切换、RAG 引用来源展示、UI 全配置化、弹窗拖拽 |
| **P2 运营完善** | ✅ 已完成 | 多语言国际化(zh-CN/en)、控制台日志体系、会话管理面板(列表/导出/删除) |
| **Token 自动刷新** | ✅ 已完成 | 临期主动刷新 + 401 兜底重试、`setToken` / `refreshToken` 手动入口(v1.3.0);`tokenStrategy: 'always'` 每请求前取 Token + `tokenUrl` 地址取 Token(v1.4.0) |
| **可选扩展** | 🔜 待定 | 知识库管理嵌入、更多语言、主题皮肤 |
---
## 十四、验证测试
访问 `http://localhost:9090/sdk/test.html` 运行自动化验证。
**单元测试(vitest)** —— 覆盖配置解析与 Token 管理(含刷新竞态、自激循环等难以手工复现的场景):
```bash
cd client/
npm test # 等价于 npx vitest run
```
**浏览器端验证** —— 访问 `http://localhost:9090/sdk/test.html` 运行核心用例:
测试覆盖(10 个核心用例):
@ -426,3 +537,6 @@ client/
| T1-T6 | P0 | 全局加载、参数校验、DOM 创建、open/close、destroy 清理、实际对话 |
| T7-T9 | P1 | SSE 流式接口、RAG 引用来源、知识库分类切换 |
| T10 | P2 | 多语言国际化 |
Token 自动刷新的手工验证步骤(`setToken` 生效、401 自动重试、临期主动刷新、destroy 清理)见
根目录 `SDK-INTEGRATION.md` 的验证章节。

156
client/src/api.ts

@ -9,6 +9,7 @@
import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment } from './types';
import { logger } from './logger';
import { t } from './i18n';
import { getToken, ensureFresh, isProtectedPath, refreshNow, hasProvider, reportAuthExpired } from './token';
/** 请求超时时间(毫秒) */
const REQUEST_TIMEOUT = 30000;
@ -141,69 +142,126 @@ function buildRagSourcesUrl(message: string, categoryId?: number): string {
// ==================== HTTP 基础封装 ====================
/** 带超时的 fetch 封装,支持外部 AbortSignal(用于流式主动中断) */
/** 把 RequestInit.headers 的三种形态归一为普通对象 */
function normalizeHeaders(init?: HeadersInit): Record<string, string> {
const headers: Record<string, string> = {};
if (!init) return headers;
if (init instanceof Headers) {
init.forEach((value, key) => { headers[key] = value; });
} else if (Array.isArray(init)) {
init.forEach(([key, value]) => { headers[key] = value; });
} else {
Object.assign(headers, init as Record<string, string>);
}
return headers;
}
/** 把 fetch 抛出的异常翻译为 CskError(保持「用户主动中断」最高优先级) */
function mapFetchError(err: unknown, externalSignal?: AbortSignal): CskError {
// 用户主动中断(停止生成),不算错误
if (externalSignal?.aborted) return new CskError('aborted', 'aborted');
if (err instanceof DOMException && err.name === 'AbortError') return new CskError(t('error_timeout'), 'timeout');
if (err instanceof TypeError && err.message.includes('Failed to fetch')) return new CskError(t('error_cors'), 'cors');
return new CskError(t('error_network'), 'network');
}
/** 请求体是否可安全重放(ReadableStream 被读过一次后就无法重发) */
function isReplayUnsafe(options: RequestInit): boolean {
return typeof ReadableStream !== 'undefined' && options.body instanceof ReadableStream;
}
/**
* 带超时的 fetch 封装,支持外部 AbortSignal(用于流式主动中断)。
*
* 鉴权职责(受保护路径的判据见 token.ts 的 `isProtectedPath`):
* - 发请求前先做一次临期检查(`ensureFresh`),尽量不带着临期 Token 出门;
* - 收到 401 时换一次 Token 并**重试一次原请求**。这个透明重试是安全的:`SdkAuthFilter`
* 的全部 401 出口都发生在 `chain.doFilter` 之前(缺头 / 空 Token / 已过期 / 管理端 JWT 无效),
* 即「401 ⇒ 业务 handler 未执行」,因此对 `POST /feedback`、`POST /attachment/upload`、
* `DELETE /conversation/{id}` 等写请求重试**不会产生重复副作用**;
* - SSE 场景同样安全:401 时响应体尚未开始读取(`getReader()` 在重试决策之后才执行),
* 重试等价于重新建流,不会产生第二条请求,也不影响已生成内容。
*/
async function safeFetch(
url: string,
options: RequestInit = {},
timeout: number = REQUEST_TIMEOUT,
externalSignal?: AbortSignal
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const guarded = isProtectedPath(url);
// 外部信号触发时同步中断内部请求
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
// 发请求前先确保 Token 不过期(内部永不 throw、永不挂起)
if (guarded) {
await ensureFresh();
}
try {
// 注入 SDK Token 认证头(仅对 /ai/ 路径注入,避免将 SDK Token 发送到管理接口)
const headers: Record<string, string> = {};
if (options.headers) {
if (options.headers instanceof Headers) {
options.headers.forEach((value, key) => { headers[key] = value; });
} else if (Array.isArray(options.headers)) {
options.headers.forEach(([key, value]) => { headers[key] = value; });
/** 发起一次尝试:每次新建 controller 与超时定时器,并重新接上外部中断信号 */
const attempt = async (): Promise<{ response: Response; sentToken: string | null }> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
Object.assign(headers, options.headers as Record<string, string>);
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
}
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/**、/feedback、/attachment/upload)
if (currentConfig?.token &&
(url.includes('/ai/') || url.endsWith('/feedback') || url.includes('/attachment/upload'))) {
headers['Authorization'] = `Bearer ${currentConfig.token}`;
}
const response = await fetch(url, {
...options,
headers,
signal: controller.signal,
mode: 'cors',
credentials: 'include',
});
if (response.status === 401 && (url.includes('/ai/') || url.endsWith('/feedback'))) {
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
}
return response;
} catch (err: unknown) {
// 用户主动中断(停止生成),不算错误
if (externalSignal?.aborted) {
throw new CskError('aborted', 'aborted');
}
if (err instanceof DOMException && err.name === 'AbortError') {
throw new CskError(t('error_timeout'), 'timeout');
const headers = normalizeHeaders(options.headers);
// 仅为受 SdkAuthFilter 保护的路径注入,避免把 SDK Token 发送到公开端点
const sentToken = guarded ? getToken() : null;
if (sentToken) {
headers['Authorization'] = `Bearer ${sentToken}`;
}
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
throw new CskError(t('error_cors'), 'cors');
try {
const response = await fetch(url, {
...options,
headers,
signal: controller.signal,
mode: 'cors',
credentials: 'include',
});
return { response, sentToken };
} catch (err) {
throw mapFetchError(err, externalSignal);
} finally {
clearTimeout(timer);
}
throw new CskError(t('error_network'), 'network');
} finally {
clearTimeout(timer);
};
const first = await attempt();
// 中断优先级高于 401:用户点了「停止生成」时不该被当成鉴权失败弹错误气泡
if (externalSignal?.aborted) throw new CskError('aborted', 'aborted');
// 只有「受保护路径 + 有刷新能力 + 本次确实带了 Token + 请求体可重放」才值得重试。
// 首次请求没带 Token 时不重试:那属于「只配了 getToken 没配 token」的自举场景,
// 需先把 Token 刷出来,交由下一次请求生效。
const canRetry = first.response.status === 401
&& guarded
&& hasProvider()
&& first.sentToken !== null
&& !isReplayUnsafe(options);
if (!canRetry) {
if (first.response.status === 401 && guarded) reportAuthExpired();
return first.response;
}
const nextToken = await refreshNow('request');
// 仅当 Token **确实变了**才重试 —— 否则 provider 反复返回同一个失效 Token 会变成无限重试
if (!nextToken || nextToken === first.sentToken || externalSignal?.aborted) {
reportAuthExpired();
return first.response;
}
// 释放首个 401 响应,避免连接迟迟不回收
void first.response.body?.cancel().catch(() => { /* 释放失败无影响 */ });
const retried = await attempt();
if (retried.response.status === 401) reportAuthExpired();
return retried.response;
}
/** 自定义错误类型 */
@ -694,6 +752,10 @@ export async function deleteConversation(conversationId: string): Promise<boolea
/**
* 导出会话 URL(SDK 安全端点)
* 使用 /ai/sdk/conversation/{id}/export,含账户归属参数
*
* 已知限制:该 URL 由 `window.open` 直接打开,浏览器无法为它附加 Authorization 头,
* 而该路径受 SdkAuthFilter 保护 —— 因此新标签页会拿到 401 JSON 而非文件。
* 与 Token 刷新无关(自动刷新也救不了「带不上请求头」),修复需改为 fetch + Blob 下载。
*/
export function getConversationExportUrl(conversationId: string): string {
const params = new URLSearchParams();

8
client/src/chat.ts

@ -615,6 +615,14 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
},
() => {
// 流结束
// 用户点了「停止生成」:流被主动中断,此处必须直接收尾。
// 否则下面的「无流内容降级」分支会因 streamStarted=false 再发一次同步 /ai/chat 请求 ——
// 用户明明点了停止,却反而多发出一个请求。
if (signal.aborted) {
if (wrapperEl && bubbleEl) finalizeAIBubble(wrapperEl, bubbleEl);
resolve(accumulated);
return;
}
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
if (!streamStarted && accumulated === '') {

64
client/src/config.ts

@ -44,6 +44,64 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
return null;
}
// 校验鉴权参数:非法值一律忽略(而非中断初始化),保证「鉴权配置写错」不会让客服窗口整个消失。
// 注意 expiresIn 只在这里做类型校验,上界钳制统一在 token.ts 的 applyToken 中处理(单一定义点)。
let authToken: string | undefined;
if (raw.token !== undefined) {
if (typeof raw.token === 'string' && raw.token.trim()) {
authToken = raw.token.trim();
} else {
logger.error('token 无效(需为非空字符串),已忽略。示例:token: "<从 /open-api/auth/token 换取>"', undefined, 'config_invalid');
}
}
let expiresIn: number | undefined;
if (raw.expiresIn !== undefined) {
if (typeof raw.expiresIn === 'number' && Number.isFinite(raw.expiresIn) && raw.expiresIn > 0) {
expiresIn = raw.expiresIn;
} else {
logger.error(`expiresIn 无效(需为大于 0 的数字,单位「秒」):${String(raw.expiresIn)},已忽略`, undefined, 'config_invalid');
}
}
let getToken: ResolvedConfig['getToken'];
if (raw.getToken !== undefined) {
if (typeof raw.getToken === 'function') {
getToken = raw.getToken;
} else {
logger.error('getToken 无效(需为函数),已忽略', undefined, 'config_invalid');
}
}
let tokenStrategy: 'expiry' | 'always' = 'expiry';
if (raw.tokenStrategy !== undefined) {
if (raw.tokenStrategy === 'expiry' || raw.tokenStrategy === 'always') {
tokenStrategy = raw.tokenStrategy;
} else {
logger.error(
`tokenStrategy 无效(只能是 "expiry" 或 "always"):${String(raw.tokenStrategy)},已回退 "expiry"`,
undefined,
'config_invalid'
);
}
}
let tokenUrl: string | undefined;
if (raw.tokenUrl !== undefined) {
if (typeof raw.tokenUrl === 'string' && raw.tokenUrl.trim()) {
tokenUrl = raw.tokenUrl.trim();
} else {
logger.error('tokenUrl 无效(需为非空字符串),已忽略', undefined, 'config_invalid');
}
}
// 两者同时配置时 getToken 优先。必须明确告知 —— 否则宿主会以为 tokenUrl 在生效,
// 排查时会去查一个根本没被调用的地址。
if (getToken && tokenUrl) {
logger.error('getToken 与 tokenUrl 同时配置,已按 getToken 生效、忽略 tokenUrl', undefined, 'config_invalid');
tokenUrl = undefined;
}
// integrateId 统一转为字符串(后端 roleId 为 Long,但 query param 传字符串也可接收)
const integrateIdStr = String(raw.integrateId).trim();
@ -93,7 +151,11 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
token: raw.token,
token: authToken,
expiresIn,
getToken,
tokenStrategy,
tokenUrl,
roles: raw.roles,
disclaimer: raw.disclaimer, // undefined = 从后端拉取,'' = 隐藏,'...' = 直接用
chatId: '', // 初始为空,由 chatId 初始化流程填充

2
client/src/i18n.ts

@ -68,6 +68,7 @@ const dictionaries: Record<string, Record<string, string>> = {
error_server: '服务器异常,请稍后重试',
error_cors: '跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
error_auth: '鉴权失败,请联系管理员',
error_token_refresh_failed: '登录凭证刷新失败,请稍后重试',
error_forbidden: '无访问权限,请联系管理员配置',
error_not_found: '请求的资源不存在',
error_rate_limit: '请求过于频繁,请稍后重试',
@ -142,6 +143,7 @@ const dictionaries: Record<string, Record<string, string>> = {
error_server: 'Server error, please try again later',
error_cors: 'CORS request blocked. Please contact admin to whitelist your domain',
error_auth: 'Authentication failed, please contact admin',
error_token_refresh_failed: 'Failed to refresh credentials, please try again',
error_forbidden: 'Access denied, please contact admin',
error_not_found: 'Resource not found',
error_rate_limit: 'Too many requests, please try again later',

47
client/src/index.ts

@ -17,6 +17,7 @@ import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config';
import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api';
import { configureTokenManager, resetTokenManager, setHostToken, forceRefresh } from './token';
import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
@ -32,6 +33,8 @@ export type {
RagSource,
CategoryNode,
ChatbotSDKInstance,
TokenProvider,
TokenProviderResult,
} from './types';
// ==================== 剪贴板降级 polyfill ====================
@ -112,9 +115,16 @@ function init(rawConfig: SDKConfig): void {
return;
}
// 0. 先设置错误回调:parseConfig 的校验失败只能通过 onError 告知宿主(控制台日志已禁用),
// 若放在解析之后,配置写错时 SDK 会静默不初始化,宿主完全无感知
setErrorCallback(rawConfig.onError);
// 1. 配置解析与校验
const parsed = parseConfig(rawConfig);
if (!parsed) return;
if (!parsed) {
setErrorCallback(undefined); // 初始化失败,不留残余回调
return;
}
config = parsed;
// 2. 设置国际化语言
@ -123,12 +133,18 @@ function init(rawConfig: SDKConfig): void {
// 3. 设置日志级别
setDebug(config.debug);
// 3.1 设置错误回调
setErrorCallback(config.onError);
// 4. 设置 API 配置
setApiConfig(config);
// 4.1 设置 Token 管理(必须晚于 setErrorCallback,否则刷新期的错误宿主收不到)
configureTokenManager({
token: config.token,
expiresIn: config.expiresIn,
getToken: config.getToken,
tokenStrategy: config.tokenStrategy,
tokenUrl: config.tokenUrl,
});
// 5. 注入样式
injectStyles(config);
@ -454,6 +470,7 @@ function destroy(): void {
audioCtx = null;
disclaimerEl = null;
clearApiConfig();
resetTokenManager();
logger.lifecycleDestroy(oldIntegrateId || '');
}
@ -504,6 +521,26 @@ function clearHistory(): void {
else { clearMessages(config.integrateId); }
}
/**
* 运行时更新 SDK Token(宿主自行换好 Token 后调用)。
* 无需 destroy + init 重建整个 DOM,下一次请求即生效。
*
* @param token 新的 SDK JWT Token
* @param expiresIn 有效期(**秒**),传了 SDK 才能在临期前主动刷新;不传则只能靠 401 驱动重取
*/
function setToken(token: string, expiresIn?: number): void {
setHostToken(token, expiresIn);
}
/**
* 让 SDK 调配置里的 `getToken` 回调立即换取新 Token。
* 可在收到 `onError({ code: 'auth_expired' })` 后调用以主动恢复。
* @returns 是否换到了与当前不同的 Token
*/
function refreshToken(): Promise<boolean> {
return forceRefresh();
}
// ==================== 窗口尺寸记忆 ====================
/** localStorage key:窗口尺寸 */
@ -673,6 +710,8 @@ const ChatbotSDK: ChatbotSDKInstance = {
close,
toggle,
clearHistory,
setToken,
refreshToken,
};
if (typeof window !== 'undefined') {

9
client/src/logger.ts

@ -27,13 +27,14 @@ export const logger = {
// 控制台日志已禁用
},
/** 错误日志(不输出控制台,仅触发 onError 回调) */
error(msg: string, data?: unknown): void {
/** 错误日志(不输出控制台,仅触发 onError 回调)
* @param code 上报给宿主的错误码;不传时按旧约定从 data.type 推导 */
error(msg: string, data?: unknown, code?: string): void {
// 触发宿主的 onError 回调
if (errorCallback) {
try {
const code = data instanceof Error ? (data as Error & { type?: string }).type || 'error' : 'error';
errorCallback({ message: msg, code: String(code), detail: data });
const resolved = code ?? (data instanceof Error ? (data as Error & { type?: string }).type || 'error' : 'error');
errorCallback({ message: msg, code: String(resolved), detail: data });
} catch { /* 回调异常不影响 SDK */ }
}
},

463
client/src/token.ts

@ -0,0 +1,463 @@
/**
* SDK Token 管理模块 —— 自动刷新 + 401 兜底重试
*
* 背景:SDK Token 默认只有 2 小时(后端 `jwt.sdk-expiration`),第三方页面长时间挂着必然失效。
* 本模块负责三件事:
* 1. 临期主动刷新 —— token 剩余寿命不足阈值时,通过宿主提供的 `getToken` 回调换新,用户无感;
* 2. 401 兜底重试 —— 主动刷新没覆盖到的场景(宿主未给 expiresIn、后台标签页定时器被节流等),
* 由 api.ts 在收到 401 时调 `refreshNow()` 换新并重试一次原请求;
* 3. 手动入口 —— 宿主自行换好 Token 后调 `setHostToken()` 推送,或调 `forceRefresh()` 让 SDK 去拉。
*
* Token 只存内存,**不落 localStorage / sessionStorage**(避免 XSS 或同域页面读取)。
*
* 本模块禁止 import api.ts(会形成循环依赖),也不触碰 DOM(保证 vitest 的 node 环境可直接测)。
*/
import { TokenProvider } from './types';
import { logger } from './logger';
import { t } from './i18n';
/** 临期阈值上限:token 剩余寿命不足该值时触发主动刷新 */
const REFRESH_SKEW_MAX_MS = 5 * 60 * 1000;
/** Token 有效期上限(秒),对齐后端 `SdkJwtTokenProvider.MAX_EXPIRATION` */
const MAX_TTL_SECONDS = 86400;
/** 定时器延迟下限,防止 delay ≤ 0 排成 0ms 定时器形成自激循环 */
const MIN_TIMER_DELAY_MS = 1000;
/** 两次自主刷新之间的最小间隔(自激的第二道闸) */
const MIN_PROACTIVE_INTERVAL_MS = 5000;
/** 自主刷新失败后的退避间隔 */
const PROACTIVE_BACKOFF_MS = 60000;
/** 自主刷新连续失败次数上限,超过后彻底停掉定时器链(改由业务请求 / 401 驱动) */
const MAX_PROACTIVE_FAILURES = 3;
/** provider 调用超时:宿主回调可能永不 settle,必须自带超时兜底 */
const PROVIDER_TIMEOUT_MS = 10000;
/** 同一错误码的上报冷却,避免并发 401 引发 onError 风暴 */
const REPORT_COOLDOWN_MS = 30000;
/** provider 反复返回同一个 token 时的静默窗口,避免每个请求都白跑两趟 */
const STALE_PROVIDER_COOLDOWN_MS = 30000;
// ==================== 运行时状态 ====================
/** 当前 Token */
let token: string | null = null;
/** 绝对过期时间(毫秒时间戳),无过期信息时为 null */
let expireAt: number | null = null;
/** 当前 Token 的总寿命(毫秒),用于按比例计算临期阈值 */
let ttlMs: number | null = null;
/** 宿主提供的换 Token 回调 */
let provider: TokenProvider | null = null;
/**
* 取 Token 的策略:
* - `'expiry'`(默认)SDK 自己推算过期时间,只在临期 / 401 时才换;
* - `'always'` 每个受保护请求发出前都向宿主取一次,SDK 完全不推算过期时间。
*/
let strategy: 'expiry' | 'always' = 'expiry';
/** 是否已初始化(configureTokenManager 后为 true,resetTokenManager 后为 false) */
let configured = false;
/** 在途的刷新 Promise(single-flight) */
let refreshPromise: Promise<string | null> | null = null;
/** 自主刷新定时器 */
let timer: ReturnType<typeof setTimeout> | null = null;
/**
* 代际计数:宿主 setToken / destroy 时自增,用于让在途刷新的结果作废。
* 没有它,「在途刷新返回旧 token」会覆盖宿主刚推来的新值并重排定时器。
*/
let generation = 0;
/** 自主刷新连续失败次数 */
let proactiveFailures = 0;
/** 上次自主刷新的时间戳,用于最小间隔节流 */
let lastProactiveAt = 0;
/** 自主刷新失败后的退避截止时间戳 */
let backoffUntil = 0;
/** provider 返回同一 token 后的静默截止时间戳 */
let staleProviderUntil = 0;
/** 上次错误上报的 code / 时间戳,用于同码冷却 */
let lastReportCode = '';
let lastReportAt = 0;
// ==================== 路径判定 ====================
/**
* 判断 URL 是否属于后端 `SdkAuthFilter` 保护的路径(需要携带 SDK Token)。
*
* **必须与 `SdkAuthFilter.shouldNotFilter()` 逐字对齐**:受保护 = `/ai/**`(除显式放行的
* `/ai/system-config/**`)+ `/feedback` + `/attachment/upload`。改 api.ts 的鉴权相关逻辑时,
* 请同步核对后端过滤器,否则会出现「该带 Token 的没带」或「公开端点泄露 Token」。
*
* 用 URL.pathname 而非整串 includes:requestDomain 可能自带路径前缀(如 `https://gw.example.com/ai/`),
* 那种情况下 `includes('/ai/')` 对所有 URL 都成立,判据会全真。
*/
export function isProtectedPath(url: string): boolean {
let pathname: string;
try {
pathname = new URL(url, 'http://localhost').pathname;
} catch {
return false;
}
// 后端显式放行的公开端点(SecurityConfig 中 permitAll 且 SdkAuthFilter 不拦)
if (pathname.startsWith('/ai/system-config/')) return false;
return pathname.startsWith('/ai/') || pathname === '/feedback' || pathname === '/attachment/upload';
}
// ==================== 内部工具 ====================
/**
* 把 `tokenUrl` 包装成一个 provider,复用与 `getToken` 完全相同的刷新链路。
*
* - `credentials: 'same-origin'`:同源(宿主页面调自己后端,最常见)自动带 Cookie;
* 跨域则不带,避免把宿主站点的 Cookie 泄露给别的域。需要跨域带 Cookie 的场景请改用 `getToken`。
* - 自带超时中断:`callProvider` 的 `Promise.race` 只保证不阻塞调用方,不会取消 fetch 本身,
* 宿主端点挂死时得靠这里把连接掐掉。
* - 不做 URL 白名单校验:`tokenUrl` 是接入方自己写在页面里的,信任级别与 `getToken`(任意 JS)等同。
*/
function createUrlProvider(url: string): TokenProvider {
return async () => {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), PROVIDER_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
mode: 'cors',
signal: controller.signal,
});
if (!res.ok) throw new Error(`取 Token 失败:HTTP ${res.status}`);
let data: unknown;
try {
data = await res.json();
} catch {
throw new Error('取 Token 响应不是合法 JSON');
}
// 兼容两种响应:{token, expiresIn} 与 {success, token, expiresIn, roles}
const record = (data ?? {}) as { token?: unknown; expiresIn?: unknown; message?: unknown };
if (typeof record.token !== 'string' || !record.token.trim()) {
throw new Error(typeof record.message === 'string' ? record.message : '取 Token 响应缺少 token 字段');
}
return {
token: record.token.trim(),
expiresIn: typeof record.expiresIn === 'number' ? record.expiresIn : undefined,
};
} finally {
clearTimeout(timerId);
}
};
}
/**
* 临期阈值:取「token 总寿命的 10%」与 5 分钟的较小值。
*
* 必须自适应,不能固定 5 分钟:后端 `clampExpirationMillis` 允许的最短 ttl 恰好是 300s
* (`SdkJwtTokenProvider.MIN_EXPIRATION`),固定 5 分钟会让 300s 的 token 永远处于临期态 ——
* 每个请求都触发刷新、定时器延迟算成 0,退化成秒级无限刷新循环。
*/
function skewMsOf(ttl: number): number {
return Math.min(REFRESH_SKEW_MAX_MS, Math.floor(ttl * 0.1));
}
/**
* 把宿主的 expiresIn(秒)归一为毫秒。
*
* **仅做上界钳制,绝不做下界**:钳下界会把「宿主手里只剩 20s 的 token」当成 300s,
* 导致 SDK 以为它还很新而带着已过期 Token 发请求 —— 正是本模块要消灭的场景。
* 上界同时防两件事:setTimeout 超过 2^31-1 会被浏览器按 1ms 处理(又是一次刷新风暴),
* 以及单位误传(把 7200000 毫秒当秒)。
*/
function normalizeExpiresIn(expiresIn: unknown): { ttlMs: number } | null {
if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) return null;
if (expiresIn > MAX_TTL_SECONDS) {
logger.error(
`expiresIn=${expiresIn} 超出上限(疑似误传毫秒),已按 ${MAX_TTL_SECONDS} 秒处理`,
undefined,
'config_invalid'
);
return { ttlMs: MAX_TTL_SECONDS * 1000 };
}
return { ttlMs: Math.round(expiresIn * 1000) };
}
/** 写入 Token 并重排定时器(失败计数与退避状态一并复位) */
function applyToken(nextToken: string, expiresIn?: number): void {
token = nextToken;
const normalized = normalizeExpiresIn(expiresIn);
if (normalized) {
ttlMs = normalized.ttlMs;
expireAt = Date.now() + normalized.ttlMs;
} else {
// 无过期信息:不排定时器,只能靠 401 驱动重取
ttlMs = null;
expireAt = null;
}
proactiveFailures = 0;
backoffUntil = 0;
staleProviderUntil = 0;
scheduleProactive();
}
/** 按「过期时间 - 临期阈值」排定下一次自主刷新 */
function scheduleProactive(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (!configured || !provider || expireAt === null) return;
// 'always' 模式每次请求前都会取 Token,定时器纯属多余(且该模式通常没有 expiresIn,到期时间本就未知)
if (strategy === 'always') return;
// 连续失败达上限:彻底停掉定时器链,避免无意义的空转与打点
if (proactiveFailures >= MAX_PROACTIVE_FAILURES) return;
const baseDelay = expireAt - skewMsOf(ttlMs ?? 0) - Date.now();
// 延迟必须有正下限(防 delay ≤ 0 排成 0ms 定时器),退避期内也不允许提前打扰 provider
const delay = Math.max(baseDelay, MIN_TIMER_DELAY_MS, backoffUntil - Date.now());
timer = setTimeout(() => {
timer = null;
void refreshNow('timer');
}, delay);
}
/** 调用宿主 provider,自带超时兜底 */
async function callProvider(p: TokenProvider): Promise<unknown> {
let timerId: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
// 用 Promise.resolve 包住:宿主回调同步 throw 时也能进入 catch,而不是逃逸出去
Promise.resolve(p()),
new Promise<never>((_, reject) => {
timerId = setTimeout(() => reject(new Error('provider timeout')), PROVIDER_TIMEOUT_MS);
}),
]);
} finally {
if (timerId !== null) clearTimeout(timerId);
}
}
/**
* 校验 provider 的返回值,只做校验不做兜底猜测。
* 返回 null 表示本次刷新失败,调用方保持原 Token 不变。
*/
function normalizeProviderResult(raw: unknown): { token: string; expiresIn?: number } | null {
// 裸字符串:合法但无过期信息,后续只能靠 401 驱动重取(文档中把对象形式标为推荐写法)
if (typeof raw === 'string') {
return raw.trim() ? { token: raw.trim() } : null;
}
if (raw && typeof raw === 'object') {
const record = raw as { token?: unknown; expiresIn?: unknown };
if (typeof record.token !== 'string' || !record.token.trim()) return null;
return {
token: record.token.trim(),
// expiresIn 类型不对时按「未知」处理,不猜单位
expiresIn: typeof record.expiresIn === 'number' ? record.expiresIn : undefined,
};
}
return null;
}
/** 同码冷却上报,避免 provider 故障 / 并发 401 时给宿主发一连串重复错误 */
function reportThrottled(code: string, message: string, detail?: unknown): void {
const now = Date.now();
if (code === lastReportCode && now - lastReportAt < REPORT_COOLDOWN_MS) return;
lastReportCode = code;
lastReportAt = now;
logger.error(message, detail, code);
}
// ==================== 对外接口 ====================
/** 注入配置(init 时调用,必须在 setErrorCallback 之后) */
export function configureTokenManager(opts: {
token?: string;
expiresIn?: number;
getToken?: TokenProvider;
tokenStrategy?: 'expiry' | 'always';
tokenUrl?: string;
}): void {
resetTokenManager();
configured = true;
strategy = opts.tokenStrategy === 'always' ? 'always' : 'expiry';
// getToken 优先:两者都配时忽略 tokenUrl(config.ts 会就此告警,不让宿主蒙在鼓里)
if (typeof opts.getToken === 'function') {
provider = opts.getToken;
} else if (typeof opts.tokenUrl === 'string' && opts.tokenUrl.trim()) {
provider = createUrlProvider(opts.tokenUrl.trim());
} else {
provider = null;
}
if (typeof opts.token === 'string' && opts.token.trim()) {
applyToken(opts.token.trim(), opts.expiresIn);
}
// token 为空时不排定时器:'always' 模式下首个受保护请求会取回 Token;
// 'expiry' 模式下同样交给 ensureFresh 的自举分支处理
}
/**
* 运行时更新 Token(公开 API `ChatbotSDK.setToken()` 的实现)。
* 宿主自行换好 Token 后可随时调用,无需 destroy + init 重建整个 DOM。
*/
export function setHostToken(nextToken: string, expiresIn?: number): void {
if (!configured) return; // 未 init / 已 destroy:静默忽略,不产生任何副作用
if (typeof nextToken !== 'string' || !nextToken.trim()) {
logger.error('setToken 传入的 token 无效(需为非空字符串),已忽略', undefined, 'config_invalid');
return;
}
// 自增代际,使在途刷新的结果作废,避免旧 token 覆盖宿主刚推来的新值。
// 注意:**不能**在这里清 refreshPromise —— 那会破坏 single-flight
//(在途刷新期间 ensureFresh 会再发起一次重复刷新),它只能由 refreshNow 的 finally 清空。
generation++;
applyToken(nextToken.trim(), expiresIn);
}
/** 读取当前 Token(api.ts 注入 Authorization 头时使用) */
export function getToken(): string | null {
return token;
}
/** 是否具备刷新能力(已初始化且宿主提供了 getToken) */
export function hasProvider(): boolean {
return configured && provider !== null;
}
/**
* 请求前检查:确保这次请求带的是有效 Token。永不 throw、永不挂起(provider 有超时兜底),
* 可安全地在 hot path 上 await。两种策略:
* - `'always'`:无条件向宿主取一次(SDK 不推算过期时间,`expiresIn` 不参与判断);
* - `'expiry'`:仅在 Token 缺失或进入临期窗口时才取,其余情况直接放行。
*
* 刷新失败时的退避/熔断判断在 `refreshNow` 里统一处理(单一定义点)。
*/
export async function ensureFresh(): Promise<void> {
if (!configured) return;
// 没有刷新手段:什么也做不了,到期后由 401 触发 auth_expired 上报,宿主据此重新 init 或 setToken
if (!provider) return;
if (strategy === 'always') {
await refreshNow('request');
return;
}
// 自举:只配了 getToken 没配 token 时,首个受保护请求前先把 Token 换回来。
// 放在这里而不是 init() 时,是为了不给未使用受保护接口的页面增加一次额外请求。
if (token === null) {
await refreshNow('request');
return;
}
// 有 Token 但无过期信息(宿主只传了裸字符串):只能靠 401 驱动重取
if (expireAt === null || ttlMs === null) return;
if (Date.now() < expireAt - skewMsOf(ttlMs)) return;
await refreshNow('request');
}
/**
* 立即刷新一次(single-flight:并发调用共享同一次刷新)。
*
* @param reason 'request' = 业务请求驱动(临期检查 / 401 兜底),不节流;
* 'timer' = 定时器自主驱动,受最小间隔与连续失败上限约束。
* @returns 新 Token;失败或未发生变化时返回 null(调用方据此决定是否重试)
*/
export function refreshNow(reason: 'request' | 'timer' = 'request'): Promise<string | null> {
if (!configured || !provider) return Promise.resolve(null);
if (refreshPromise) return refreshPromise; // single-flight
const now = Date.now();
// 熔断:上一次刷新失败后的退避窗口内,所有自动触发(临期检查 / 定时器 / 401 兜底)都不再尝试。
// 否则 provider 挂死时,每个请求都要多等一个 PROVIDER_TIMEOUT,把 SDK 整体拖慢。
// 宿主显式调用 forceRefresh() 会清掉该窗口。
if (now < backoffUntil) return Promise.resolve(null);
// provider 刚返回过同一个 token:静默窗口内不再打扰它,避免每个请求都白跑两趟。
// 仅在 'expiry' 模式下生效 —— 'always' 模式下宿主返回同一个(仍有效的)缓存 Token 是常态,
// 不该被当成 provider 故障,否则该模式会退化成 401 驱动。
if (strategy === 'expiry' && now < staleProviderUntil) return Promise.resolve(null);
if (reason === 'timer') {
if (proactiveFailures >= MAX_PROACTIVE_FAILURES) return Promise.resolve(null);
// lastProactiveAt === 0 表示从未自主刷新过,不受最小间隔限制
if (lastProactiveAt !== 0 && now - lastProactiveAt < MIN_PROACTIVE_INTERVAL_MS) {
return Promise.resolve(token);
}
lastProactiveAt = now;
}
const gen = generation;
const activeProvider = provider;
refreshPromise = (async (): Promise<string | null> => {
try {
const raw = await callProvider(activeProvider);
const parsed = normalizeProviderResult(raw);
if (!parsed) {
// provider 返回空串 / 非字符串 / 缺 token:绝不能发出「Bearer 」空值,
// 后端会回 401「认证令牌为空」,白跑一次往返
if (gen === generation) {
proactiveFailures++;
backoffUntil = Date.now() + PROACTIVE_BACKOFF_MS;
reportThrottled('auth_refresh_failed', t('error_token_refresh_failed'));
}
return null;
}
// 期间宿主 setToken / destroy 过:丢弃本次结果,绝不回写
if (gen !== generation) return token;
const before = token;
applyToken(parsed.token, parsed.expiresIn);
if (parsed.token === before) {
// provider 反复返回同一个 token:本次刷新无效,静默一段时间
staleProviderUntil = Date.now() + STALE_PROVIDER_COOLDOWN_MS;
}
return token;
} catch (err) {
if (gen === generation) {
proactiveFailures++;
// 无论何种触发方式,失败后都进入退避窗口:让 ensureFresh 与定时器都先安静下来
backoffUntil = Date.now() + PROACTIVE_BACKOFF_MS;
reportThrottled('auth_refresh_failed', t('error_token_refresh_failed'), err);
}
return null;
} finally {
// 无条件清空:否则 provider 永不 settle 时会把挂起传染给后续所有受保护请求
refreshPromise = null;
if (gen === generation) scheduleProactive();
}
})();
return refreshPromise;
}
/**
* 主动触发一次刷新(公开 API `ChatbotSDK.refreshToken()` 的实现)。
* 宿主可在收到 `onError(code='auth_expired')` 后调用,拿到新 Token 再重试。
* @returns 是否真的换到了与原来不同的 Token
*/
export async function forceRefresh(): Promise<boolean> {
if (!configured || !provider) return false;
const before = token;
// 宿主显式要求刷新:绕过两道自动触发才受的闸 ——「provider 刚返回过同一 token」的静默窗口,
// 以及刷新失败后的退避/熔断窗口
staleProviderUntil = 0;
backoffUntil = 0;
const next = await refreshNow('request');
return next !== null && next !== before;
}
/** 上报「Token 已失效且无法自动恢复」,供 api.ts 在重试后仍 401 时调用 */
export function reportAuthExpired(): void {
reportThrottled('auth_expired', t('error_auth'));
}
/** 清空全部状态(destroy 时调用) */
export function resetTokenManager(): void {
// 先置 false 并自增代际:在途刷新的结算会被代际判定拦住,不会「复活」状态并重排定时器
configured = false;
generation++;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
token = null;
expireAt = null;
ttlMs = null;
provider = null;
strategy = 'expiry';
refreshPromise = null;
proactiveFailures = 0;
lastProactiveAt = 0;
backoffUntil = 0;
staleProviderUntil = 0;
lastReportCode = '';
lastReportAt = 0;
}

65
client/src/types.ts

@ -7,6 +7,26 @@
* chatId → 自动管理(从 /conversation/list 获取或自动生成,是对话唯一标识)
*/
/** `getToken` 回调的返回结构(带过期信息,推荐写法) */
export interface TokenProviderResult {
/** 新换取到的 SDK JWT Token */
token: string;
/** 新 Token 的有效期(**秒**),与后端 /open-api/auth/token 的 expiresIn 同单位 */
expiresIn?: number;
}
/**
* 换 Token 回调:SDK 在 Token 临近过期或收到 401 时调用,由宿主去换一个新 Token。
*
* 推荐在宿主自己的服务端完成换取(服务端持有 API Key,调 `POST /open-api/auth/token`),
* 前端只把结果透传给 SDK —— 这样 API Key 不会进入浏览器。
* 返回对象形式 `{ token, expiresIn }` 可让 SDK 提前主动刷新;只返回字符串则只能靠 401 驱动。
*/
export type TokenProvider = () =>
| TokenProviderResult
| string
| Promise<TokenProviderResult | string>;
/** SDK 初始化配置 */
export interface SDKConfig {
// === 必传参数 ===
@ -22,6 +42,27 @@ export interface SDKConfig {
// === 鉴权配置 ===
/** SDK JWT Token(从 /open-api/auth/token 换取),用于访问 /ai/** 接口 */
token?: string;
/** 上面这个 Token 的有效期(**秒**),与后端 expiresIn 同单位。传入后 SDK 会在临期前主动刷新 */
expiresIn?: number;
/**
* 换 Token 回调(推荐配置,替代宿主自己盯过期时间)。
* SDK 会在 Token 临期或收到 401 时调用它拿新 Token 并自动重试原请求。
*/
getToken?: TokenProvider;
/**
* 取 Token 的策略:
* - `'expiry'`(默认)SDK 自己推算过期时间,只在 Token 临期 / 收到 401 时才取;
* - `'always'` **每个受保护请求发出前都向宿主取一次**,SDK 完全不推算过期时间
* (此时 `expiresIn` 不参与判断)。适合宿主后端自己缓存 Token、SDK 拿不到可信 `expiresIn` 的场景。
* 代价是每个请求多一次宿主后端往返(含每次对话的首 token 之前)。
*/
tokenStrategy?: 'expiry' | 'always';
/**
* 取 Token 的地址(与 `getToken` **二选一**,同时配置时 `getToken` 优先)。
* SDK 会 GET 该地址并解析 `{ token, expiresIn }` 或 `{ success, token, expiresIn, roles }`。
* 请求使用 `credentials: 'same-origin'`:同源自动带 Cookie,跨域不带(需跨域带 Cookie 请改用 `getToken`)。
*/
tokenUrl?: string;
/** 可用客服角色列表(与 token 配套使用,决定用户可选择哪些角色) */
roles?: Array<{ id: string | number; key?: string; name?: string }>;
@ -110,8 +151,18 @@ export interface ResolvedConfig {
requestDomain: string;
/** 客户账号 ID → 后端 accountId */
userId?: string;
/** SDK JWT Token → 访问 /ai/** 接口的认证头 */
/** SDK JWT Token → 访问 /ai/** 接口的认证头。
* 注意:这只是**输入**字段,运行时请通过 `token.ts` 的 `getToken()` 读取当前值
* (Token 会被自动刷新,配置对象里的这份不会跟着变)。 */
token?: string;
/** Token 有效期(秒),与后端 expiresIn 同单位 */
expiresIn?: number;
/** 换 Token 回调 */
getToken?: TokenProvider;
/** 取 Token 的策略:'expiry'(临期/401 才取)| 'always'(每请求前都取) */
tokenStrategy: 'expiry' | 'always';
/** 取 Token 的地址(与 getToken 二选一) */
tokenUrl?: string;
/** 可用客服角色列表 */
roles?: Array<{ id: string | number; key?: string; name?: string }>;
/** 知识库分类 ID */
@ -249,6 +300,18 @@ export interface ChatbotSDKInstance {
toggle(): void;
/** 开启新对话(生成新的 chatId) */
clearHistory(): void;
/**
* 运行时更新 Token(宿主自行换好后推送)。
* 无需 destroy + init 重建 DOM,下一次请求即生效。
* @param token 新的 SDK JWT Token
* @param expiresIn 有效期(**秒**),传入后 SDK 会在临期前主动刷新
*/
setToken(token: string, expiresIn?: number): void;
/**
* 让 SDK 调用配置的 `getToken` 回调立即换取新 Token。
* 未配置 `getToken` 或换到的 Token 与当前相同则返回 false。
*/
refreshToken(): Promise<boolean>;
}
/** 后端 API 响应通用结构 */

59
client/tests/config.test.ts

@ -40,9 +40,9 @@ describe('parseConfig - 必传参数校验', () => {
});
describe('parseConfig - 默认值填充', () => {
it('width 默认 380', () => {
it('width 默认 500', () => {
const result = parseConfig(validConfig);
expect(result!.width).toBe(380);
expect(result!.width).toBe(500);
});
it('height 默认 520', () => {
@ -50,9 +50,9 @@ describe('parseConfig - 默认值填充', () => {
expect(result!.height).toBe(520);
});
it('height 最小值 300', () => {
it('height 最小值 400', () => {
const result = parseConfig({ ...validConfig, height: 100 });
expect(result!.height).toBe(300);
expect(result!.height).toBe(400);
});
it('title 默认 "AI 智能助手"', () => {
@ -132,3 +132,54 @@ describe('parseConfig - 回调函数', () => {
expect(result!.onMessage).toBe(onMessage);
});
});
describe('parseConfig - 鉴权参数(token / expiresIn / getToken / tokenStrategy / tokenUrl)', () => {
it('合法的 token 与 expiresIn 正常保留', () => {
const result = parseConfig({ ...validConfig, token: ' eyJhbGci ', expiresIn: 7200 });
expect(result!.token).toBe('eyJhbGci'); // 去除首尾空白
expect(result!.expiresIn).toBe(7200);
});
it('token 非字符串时忽略', () => {
const result = parseConfig({ ...validConfig, token: 123 as never });
expect(result!.token).toBeUndefined();
});
it('token 为空白串时忽略', () => {
const result = parseConfig({ ...validConfig, token: ' ' });
expect(result!.token).toBeUndefined();
});
it('expiresIn 非正数或非数字时忽略', () => {
expect(parseConfig({ ...validConfig, expiresIn: 0 })!.expiresIn).toBeUndefined();
expect(parseConfig({ ...validConfig, expiresIn: -5 })!.expiresIn).toBeUndefined();
expect(parseConfig({ ...validConfig, expiresIn: '7200' as never })!.expiresIn).toBeUndefined();
});
it('getToken 非函数时忽略', () => {
const result = parseConfig({ ...validConfig, getToken: 'not-a-function' as never });
expect(result!.getToken).toBeUndefined();
});
it('tokenStrategy 默认 expiry', () => {
expect(parseConfig(validConfig)!.tokenStrategy).toBe('expiry');
});
it('tokenStrategy 接受 always,非法值回退 expiry', () => {
expect(parseConfig({ ...validConfig, tokenStrategy: 'always' })!.tokenStrategy).toBe('always');
expect(parseConfig({ ...validConfig, tokenStrategy: 'everyRequest' as never })!.tokenStrategy).toBe('expiry');
});
it('tokenUrl 去空白保留,非法值忽略', () => {
expect(parseConfig({ ...validConfig, tokenUrl: ' /api/sdk-token ' })!.tokenUrl).toBe('/api/sdk-token');
expect(parseConfig({ ...validConfig, tokenUrl: ' ' })!.tokenUrl).toBeUndefined();
expect(parseConfig({ ...validConfig, tokenUrl: 42 as never })!.tokenUrl).toBeUndefined();
});
it('getToken 与 tokenUrl 同时配置时 getToken 优先,tokenUrl 被忽略', () => {
const getToken = async () => 'token';
const result = parseConfig({ ...validConfig, getToken, tokenUrl: '/api/sdk-token' });
expect(result!.getToken).toBe(getToken);
expect(result!.tokenUrl).toBeUndefined();
});
});

625
client/tests/token.test.ts

@ -0,0 +1,625 @@
/**
* TokenManager 单元测试
*
* 覆盖重点是竞态与自激循环 —— 这些是线上最难复现、最容易写错的部分,
* 必须用假定时器 + 假时钟才能真正验证(真实等待 270 秒不现实)。
* token.ts 不碰 DOM,因此 vitest 的 node 环境即可运行。
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
configureTokenManager,
setHostToken,
getToken,
hasProvider,
ensureFresh,
refreshNow,
forceRefresh,
resetTokenManager,
isProtectedPath,
} from '../src/token';
import { logger } from '../src/logger';
/** 从「现在」起推进假时钟(同时触发期间到期的定时器、并冲刷微任务) */
const tick = (ms: number) => vi.advanceTimersByTimeAsync(ms);
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
resetTokenManager();
});
afterEach(() => {
resetTokenManager();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('isProtectedPath - 与后端 SdkAuthFilter 对齐', () => {
const cases: Array<[string, boolean]> = [
// 受保护:/ai/**(后端 SdkAuthFilter 拦截)
['http://localhost:9090/ai/chat?message=hi', true],
['http://localhost:9090/ai/chat/stream?message=hi', true],
['http://localhost:9090/ai/sdk/conversation/list', true],
['http://localhost:9090/ai/conversation/123/messages', true],
// 显式放行:/ai/system-config/**(SecurityConfig 中 permitAll,不该带 Token)
['http://localhost:9090/ai/system-config/disclaimer', false],
// 受保护:无尾斜杠写法与 /feedback、/attachment/upload
['http://localhost:9090/feedback', true],
['http://localhost:9090/feedback?x=1', true],
['http://localhost:9090/attachment/upload', true],
// 不受保护
['http://localhost:9090/category/tree', false],
['http://localhost:9090/category/list', false],
['http://localhost:9090/conversation/list', false],
// 路径里只是「含」/ai/ 但实际不在 /ai/ 下时不能误判:
// 旧实现用 includes('/ai/'),会把 SDK Token 白白发给非 SDK 端点
['http://localhost:9090/conversation/list?ref=/ai/chat', false],
// 无法解析的 URL 一律视为不受保护
['not a url', false],
];
it.each(cases)('%s → %s', (url, expected) => {
expect(isProtectedPath(url)).toBe(expected);
});
});
describe('临期主动刷新', () => {
it('expiresIn=20 时约在 18 秒后自动刷新(skew = 总寿命的 10%)', async () => {
const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 20 }));
configureTokenManager({ token: 'token-1', expiresIn: 20, getToken: provider });
await tick(17_000);
expect(provider).not.toHaveBeenCalled(); // 未到临期点,不该提前打扰 provider
await tick(1_000);
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('token-2');
});
it('expiresIn=300 时临期阈值自适应为 30s,不会退化成每次请求都刷新', async () => {
let calls = 0;
const provider = vi.fn(async () => {
calls++;
return { token: `token-${calls}`, expiresIn: 300 };
});
configureTokenManager({ token: 'token-0', expiresIn: 300, getToken: provider });
// 后端允许的最短 ttl 恰好是 300s。若临期阈值固定为 5 分钟,
// 300s 的 Token 会永远处于临期态 —— 定时器延迟算成 0,形成秒级无限刷新。
await tick(271_000);
expect(provider).toHaveBeenCalledTimes(1);
// 刷新后 Token 是全新的,连续 10 次请求前检查都不该再次刷新
for (let i = 0; i < 10; i++) await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1);
// 再推进到第二个临期点:只应多刷新一次,而不是成百上千次
await tick(300_000);
expect(provider).toHaveBeenCalledTimes(2);
});
it('未配置 getToken 时不排定时器,也不因临期而报错', async () => {
configureTokenManager({ token: 'token-1', expiresIn: 300 });
expect(vi.getTimerCount()).toBe(0);
await tick(271_000);
await ensureFresh();
expect(getToken()).toBe('token-1');
});
it('未提供 expiresIn 时不排定时器(只能靠 401 驱动)', () => {
const provider = vi.fn(async () => 'whatever');
configureTokenManager({ token: 'token-1', getToken: provider });
expect(vi.getTimerCount()).toBe(0);
expect(hasProvider()).toBe(true);
});
it('连成功刷新但 provider 返回同一个 Token 时,静默窗口内不再重复调用', async () => {
const provider = vi.fn(async () => 'same-token');
configureTokenManager({ token: 'same-token', expiresIn: 300, getToken: provider });
await tick(271_000); // 定时器触发一次刷新
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('same-token');
// provider 换了半天还是同一个 Token,说明它已失效:静默窗口内不再打扰
await refreshNow('request');
expect(provider).toHaveBeenCalledTimes(1);
});
it('自主刷新连续失败 3 次后停掉定时器链', async () => {
const provider = vi.fn(async () => {
throw new Error('provider 挂了');
});
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
await tick(271_000); // 第 1 次失败
await tick(61_000); // 退避 60s 后第 2 次
await tick(61_000); // 第 3 次
const afterThree = provider.mock.calls.length;
expect(afterThree).toBe(3);
// 已达上限:定时器链停止,不再有任何自主调用
await tick(600_000);
expect(provider).toHaveBeenCalledTimes(afterThree);
});
});
describe('single-flight 与并发', () => {
it('并发请求共享同一次刷新,provider 只被调用一次', async () => {
let settleProvider!: (value: unknown) => void;
const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; }));
// expiresIn=2 → 临期阈值 200ms → 定时器在 1800ms 触发自主刷新
configureTokenManager({ token: 'token-0', expiresIn: 2, getToken: provider });
await tick(1_800);
expect(provider).toHaveBeenCalledTimes(1); // 自主刷新已在途(provider 尚未 settle)
// 刷新在途期间三个并发请求,都应搭上同一次刷新而不是各发一次
const inFlight = [ensureFresh(), ensureFresh(), ensureFresh()];
settleProvider({ token: 'token-1', expiresIn: 2 });
await Promise.all(inFlight);
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('token-1');
});
it('自主刷新有最小间隔节流,不会在同一个窗口内反复打点', async () => {
const provider = vi.fn(async () => ({ token: `t-${Date.now()}`, expiresIn: 300 }));
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
await refreshNow('timer');
expect(provider).toHaveBeenCalledTimes(1);
// 5 秒内再次请求自主刷新 → 被节流
await tick(1_000);
await refreshNow('timer');
expect(provider).toHaveBeenCalledTimes(1);
await tick(5_000);
await refreshNow('timer');
expect(provider).toHaveBeenCalledTimes(2);
});
});
describe('provider 返回值与异常处理', () => {
const invalidProviders: Array<[string, () => unknown]> = [
['空字符串', () => ''],
['纯空白字符串', () => ' '],
['null', () => null],
['数字', () => 123],
['缺少 token 字段的对象', () => ({ expiresIn: 300 })],
['token 为空串的对象', () => ({ token: '' })],
['同步抛错', () => { throw new Error('boom'); }],
];
it.each(invalidProviders)('provider 返回%s时保持原 Token 不变', async (_name, impl) => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: impl as never });
await refreshNow('request');
// 绝不能变成空串 —— 那会让请求带上「Bearer 」空值,白跑一次往返
expect(getToken()).toBe('token-ok');
});
it('provider 返回 reject 时保持原 Token 不变', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(async () => {
throw new Error('网络不可达');
});
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
await refreshNow('request');
expect(getToken()).toBe('token-ok');
});
it('provider 永不 settle 时有超时兜底,不会挂起整个 SDK', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(() => new Promise(() => { /* 永不 settle */ }));
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
const pending = refreshNow('request');
await tick(10_000); // PROVIDER_TIMEOUT_MS
await expect(pending).resolves.toBeNull();
expect(getToken()).toBe('token-ok');
});
it('刷新失败后刷新锁被清空,且退避窗口一过就能成功刷新', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
let shouldFail = true;
const provider = vi.fn(async () => {
if (shouldFail) throw new Error('首次失败');
return { token: 'token-new', expiresIn: 300 };
});
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
await expect(refreshNow('request')).resolves.toBeNull();
// 退避窗口内(60s):即使 provider 已恢复也不再尝试,先安静下来
shouldFail = false;
await expect(refreshNow('request')).resolves.toBeNull();
expect(provider).toHaveBeenCalledTimes(1);
// 退避窗口过后:刷新锁确实被清空了,能正常成功
await tick(60_000);
await expect(refreshNow('request')).resolves.toBe('token-new');
expect(getToken()).toBe('token-new');
});
it('失败上报有 30 秒同码冷却,且退避窗口过后可再次上报', async () => {
const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(async () => {
throw new Error('provider 挂了');
});
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
await refreshNow('request');
await refreshNow('request');
await refreshNow('request');
expect(spy).toHaveBeenCalledTimes(1); // 同码冷却 + 退避窗口内不再尝试
expect(spy).toHaveBeenLastCalledWith(expect.any(String), expect.anything(), 'auth_refresh_failed');
// 60s 后同时越过「退避窗口(60s)」与「上报冷却(30s)」,可以再试一次并再次上报
await tick(60_000);
await refreshNow('request');
expect(spy).toHaveBeenCalledTimes(2);
});
});
describe('宿主主动更新 Token', () => {
it('setToken 立即生效,并按新有效期重排主动刷新', async () => {
const provider = vi.fn(async () => ({ token: 'token-from-provider', expiresIn: 300 }));
configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider });
setHostToken('token-host', 7200);
expect(getToken()).toBe('token-host');
// 新有效期 7200s、临期阈值封顶 5 分钟 → 临期点 6900s
await tick(6_890_000);
expect(provider).not.toHaveBeenCalled();
await tick(10_000);
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('token-from-provider');
});
it('刷新在途时 setToken,在途返回的旧值不会覆盖宿主刚推来的新值', async () => {
let settleProvider!: (value: unknown) => void;
const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; }));
configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider });
const pending = refreshNow('request');
setHostToken('token-host', 300); // 宿主推送新值
settleProvider('token-from-provider'); // 在途刷新带着「旧」结果返回
await pending;
expect(getToken()).toBe('token-host');
});
it('setToken 后定时器只保留一个(不会因为重复排期而叠加)', () => {
const provider = vi.fn(async () => 'x');
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
expect(vi.getTimerCount()).toBe(1);
setHostToken('token-2', 300);
setHostToken('token-3', 300);
expect(vi.getTimerCount()).toBe(1);
expect(getToken()).toBe('token-3');
});
it('传入无效 Token 时忽略并保持原值', () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
configureTokenManager({ token: 'token-ok', expiresIn: 300 });
setHostToken('');
setHostToken(' ');
expect(getToken()).toBe('token-ok');
});
it('refreshToken() 仅在真的换到不同 Token 时返回 true', async () => {
let next = 'token-1';
configureTokenManager({ token: 'token-1', getToken: async () => next });
await expect(forceRefresh()).resolves.toBe(false); // 换回来的还是同一个
next = 'token-2';
await expect(forceRefresh()).resolves.toBe(true);
expect(getToken()).toBe('token-2');
});
it('未配置 getToken 时 refreshToken() 返回 false 而不报错', async () => {
configureTokenManager({ token: 'token-1', expiresIn: 300 });
await expect(forceRefresh()).resolves.toBe(false);
});
});
describe('destroy 后的状态清理', () => {
it('清空 Token 与定时器,且在途刷新结算后不会复活状态', async () => {
let settleProvider!: (value: unknown) => void;
const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; }));
configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider });
expect(vi.getTimerCount()).toBe(1); // 主动刷新定时器
const pending = refreshNow('request');
resetTokenManager();
expect(getToken()).toBeNull();
settleProvider({ token: 'token-late', expiresIn: 300 });
await pending;
// 在途结果必须被丢弃,且不能重新排上主动刷新定时器
expect(getToken()).toBeNull();
await tick(600_000); // 推进远超临期点
expect(provider).toHaveBeenCalledTimes(1); // 只有那一次在途调用,没有新的自主刷新
expect(getToken()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('destroy 后 ensureFresh / refreshNow / setToken 均为安全空操作', async () => {
await expect(ensureFresh()).resolves.toBeUndefined();
await expect(refreshNow('request')).resolves.toBeNull();
setHostToken('token-after-destroy'); // 不应抛错,也不该写入
expect(getToken()).toBeNull();
});
it('重新 configure 后可正常重入(destroy → init 场景)', async () => {
const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 300 }));
configureTokenManager({ token: 'token-1', expiresIn: 300 });
resetTokenManager();
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
await tick(271_000);
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('token-2');
});
});
describe('配置项健壮性', () => {
it('expiresIn 超过 24 小时上限时按上限钳制(防 setTimeout 溢出与单位误传)', async () => {
const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 300 }));
// 7200000 是把「毫秒」误当「秒」传的典型值
configureTokenManager({ token: 'token-1', expiresIn: 7_200_000, getToken: provider });
expect(spy).toHaveBeenCalledWith(expect.any(String), undefined, 'config_invalid');
// 上限 86400s、临期阈值封顶 5 分钟 → 临期点 86100s(而不是 7200000s)
await tick(86_000_000);
expect(provider).not.toHaveBeenCalled();
await tick(100_000);
expect(provider).toHaveBeenCalledTimes(1);
});
it('expiresIn 为 0 或负数时视为无过期信息(不排定时器)', () => {
const provider = vi.fn(async () => 'x');
configureTokenManager({ token: 'token-1', expiresIn: 0, getToken: provider });
expect(vi.getTimerCount()).toBe(0);
configureTokenManager({ token: 'token-1', expiresIn: -5, getToken: provider });
expect(vi.getTimerCount()).toBe(0);
});
it('token 为空白串时视为未配置(等首个受保护请求自举)', () => {
const provider = vi.fn(async () => 'x');
configureTokenManager({ token: ' ', expiresIn: 300, getToken: provider });
expect(getToken()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
expect(hasProvider()).toBe(true);
});
it('自举:只配 getToken 不配 token 时,请求前检查即换来 Token', async () => {
const provider = vi.fn(async () => ({ token: 'bootstrapped', expiresIn: 300 }));
configureTokenManager({ getToken: provider });
expect(getToken()).toBeNull();
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('bootstrapped');
});
it('自举失败时不抛错,保持无 Token 状态', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(async () => {
throw new Error('宿主后端不可用');
});
configureTokenManager({ getToken: provider });
await expect(ensureFresh()).resolves.toBeUndefined();
expect(getToken()).toBeNull();
});
it('刷新失败后的退避窗口内,不再让每个请求都多等一次刷新', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(async () => {
throw new Error('宿主后端挂了');
});
// 无 token → 每次 ensureFresh 都会尝试自举,最容易暴露「每个请求都等一次超时」的问题
configureTokenManager({ getToken: provider });
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1);
// 退避窗口内:请求前检查直接放行,不再等刷新(否则 provider 挂死时每个请求都要多等 10s)
await ensureFresh();
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1);
// 退避窗口过后恢复尝试
await tick(60_000);
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(2);
});
it('configureTokenManager 会清空上一轮状态(重复 init 不叠加)', () => {
const provider = vi.fn(async () => 'x');
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
configureTokenManager({ token: 'token-2', expiresIn: 300 });
expect(getToken()).toBe('token-2');
expect(hasProvider()).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
});
describe("tokenStrategy: 'always' - 每请求前都向宿主取一次", () => {
it('Token 新鲜时仍会取(SDK 不推算过期时间)', async () => {
const provider = vi.fn(async () => ({ token: 'token-new', expiresIn: 7200 }));
configureTokenManager({ token: 'token-old', expiresIn: 7200, getToken: provider, tokenStrategy: 'always' });
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('token-new');
// 第二次请求前同样会取 —— 这正是该模式与 'expiry' 的核心差别
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(2);
});
it('provider 反复返回同一 Token 也每次照取(静默窗口在该模式不生效)', async () => {
const provider = vi.fn(async () => 'token-ok');
configureTokenManager({ token: 'token-ok', getToken: provider, tokenStrategy: 'always' });
await ensureFresh();
await ensureFresh();
await ensureFresh();
// 'expiry' 模式下第二次就会被静默窗口拦下;'always' 模式下宿主返回缓存 Token 是常态,不该拦
expect(provider).toHaveBeenCalledTimes(3);
});
it('不排定时器:该模式不推算过期时间', async () => {
const provider = vi.fn(async () => ({ token: 'x', expiresIn: 300 }));
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider, tokenStrategy: 'always' });
expect(vi.getTimerCount()).toBe(0);
// 推进远超有效期的时长,也不该有自主刷新发生
await tick(10_000_000);
expect(provider).not.toHaveBeenCalled();
});
it('不传 expiresIn 也能每请求前取(这正是要解决的缺口)', async () => {
const provider = vi.fn(async () => ({ token: 'token-new' }));
configureTokenManager({ getToken: provider, tokenStrategy: 'always' });
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1);
expect(getToken()).toBe('token-new');
});
it('取 Token 失败时沿用现有 Token 继续,且退避窗口内不再取', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
const provider = vi.fn(async () => {
throw new Error('宿主后端挂了');
});
configureTokenManager({ token: 'token-ok', getToken: provider, tokenStrategy: 'always' });
await ensureFresh();
// 取不到新 Token 不该把旧 Token 清掉 —— 请求照常发出,由 401 兜底
expect(getToken()).toBe('token-ok');
expect(provider).toHaveBeenCalledTimes(1);
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(1); // 熔断窗口内不再取
await tick(60_000);
await ensureFresh();
expect(provider).toHaveBeenCalledTimes(2);
});
});
describe('tokenUrl - 用地址代替 getToken 回调', () => {
/** 构造返回给定响应的 fetch stub */
const stubFetch = (payload: unknown, opts: { ok?: boolean; status?: number } = {}) => {
const fn = vi.fn(async () => ({
ok: opts.ok ?? true,
status: opts.status ?? 200,
json: async () => payload,
}));
vi.stubGlobal('fetch', fn);
return fn;
};
afterEach(() => {
vi.unstubAllGlobals();
});
it('解析扁平的 { token, expiresIn } 响应,且用 same-origin 取 Cookie', async () => {
const fetchMock = stubFetch({ token: 'token-from-url', expiresIn: 3600 });
configureTokenManager({ token: 'token-old', tokenUrl: 'https://host.example.com/api/sdk-token' });
await refreshNow('request');
expect(getToken()).toBe('token-from-url');
expect(fetchMock).toHaveBeenCalledWith(
'https://host.example.com/api/sdk-token',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
});
it('解析 { success, token, expiresIn, roles } 封装响应', async () => {
stubFetch({ success: true, token: 'token-wrapped', expiresIn: 7200, roles: [{ id: '1', name: '客服' }] });
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' });
await refreshNow('request');
expect(getToken()).toBe('token-wrapped');
});
it('HTTP 非 2xx 时视为刷新失败,保持原 Token', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
stubFetch({ message: 'not found' }, { ok: false, status: 404 });
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' });
await expect(refreshNow('request')).resolves.toBeNull();
expect(getToken()).toBe('token-old');
});
it('响应缺少 token 字段时视为刷新失败,保持原 Token', async () => {
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
stubFetch({ success: true });
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' });
await expect(refreshNow('request')).resolves.toBeNull();
expect(getToken()).toBe('token-old');
});
it('与 getToken 同时配置时 getToken 优先,不请求 tokenUrl', async () => {
const fetchMock = stubFetch({ token: 'never-used' });
const provider = vi.fn(async () => ({ token: 'token-from-callback' }));
configureTokenManager({ token: 'token-old', getToken: provider, tokenUrl: '/api/sdk-token' });
await refreshNow('request');
expect(provider).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(getToken()).toBe('token-from-callback');
});
it('配合 always 策略时可做到每请求前都走 URL 取 Token', async () => {
const fetchMock = stubFetch({ token: 'token-from-url', expiresIn: 7200 });
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token', tokenStrategy: 'always' });
await ensureFresh();
await ensureFresh();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
});
describe('forceRefresh 绕过自动触发的两道闸', () => {
it('能绕过刷新失败后的退避(熔断)窗口', async () => {
const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
let shouldFail = true;
const provider = vi.fn(async () => {
if (shouldFail) throw new Error('先失败一次');
return { token: 'token-new', expiresIn: 300 };
});
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
await refreshNow('request'); // 失败 → 进入 60s 退避
expect(spy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 'auth_refresh_failed');
shouldFail = false;
// 自动路径此时会被熔断拦下,但宿主显式要求刷新应当放行
await expect(refreshNow('request')).resolves.toBeNull();
await expect(forceRefresh()).resolves.toBe(true);
expect(getToken()).toBe('token-new');
});
});

99
frontend/src/sdk-test/SdkTestPanel.vue

@ -43,7 +43,26 @@
<div class="cfg-field">
<label>SDK Token(自动填充或手动粘贴)</label>
<t-input v-model="sdkToken" placeholder="eyJhbGciOi...(获取后自动填充)" size="small" />
<div class="hint">Token 有效期默认 2 小时,过期需重新获取</div>
<div class="hint">Token 只存内存、不落盘;开启自动刷新后 SDK 会在临期前自行续期</div>
</div>
<div class="cfg-field">
<label>Token 有效期 TTL(秒,后端范围 300 ~ 86400)</label>
<t-input-number v-model="tokenTtl" :min="300" :max="86400" size="small" />
<div class="hint">设为 300 后开启自动刷新,约 4.5 分钟后可观察到 SDK 自动续期</div>
</div>
<div class="cfg-switch">
<span>启用自动刷新(向 SDK 传 getToken / tokenUrl)</span>
<t-switch v-model="autoRefresh" size="small" />
</div>
<div class="cfg-field">
<label>取 Token 时机(tokenStrategy)</label>
<t-radio-group v-model="tokenStrategy" variant="default-filled" size="small">
<t-radio-button value="expiry">expiry(临期 / 401 才取)</t-radio-button>
<t-radio-button value="always">always(每请求前都取)</t-radio-button>
</t-radio-group>
<div class="hint">
always 模式下每条消息发出前都会取一次 Token(Network 里可见),且不再依赖上方 TTL
</div>
</div>
</div>
@ -356,6 +375,14 @@ const apiKey = ref('')
const sdkToken = ref('')
const sdkRoles = ref<any[] | null>(null)
const fetchingToken = ref(false)
/** 换取 Token 时请求的 TTL(秒),调小便于观察 SDK 自动刷新 */
const tokenTtl = ref(7200)
/** 当前 sdkToken 的有效期(秒),来自换取接口响应 */
const sdkTokenExpiresIn = ref<number | null>(null)
/** 是否向 SDK 传 getToken 回调(开启后 SDK 自动续期 + 401 自动重试) */
const autoRefresh = ref(false)
/** 取 Token 时机:expiry(临期/401 才取)| always(每个受保护请求前都取) */
const tokenStrategy = ref<'expiry' | 'always'>('expiry')
const sdkReady = ref(false)
const backendOnline = ref<boolean | null>(null)
@ -415,6 +442,12 @@ function buildSdkInitConfig(): Record<string, any> {
if (token) {
cfg.token = token
if (sdkRoles.value) cfg.roles = sdkRoles.value
if (sdkTokenExpiresIn.value) cfg.expiresIn = sdkTokenExpiresIn.value
// 开启自动刷新后把取 Token 逻辑本身交给 SDK:临期/每请求前主动取 + 401 兜底重试
if (autoRefresh.value) {
if (tokenStrategy.value === 'always') cfg.tokenStrategy = 'always'
cfg.getToken = () => exchangeToken()
}
}
return cfg
}
@ -440,32 +473,40 @@ function loadSdkScript(): void {
}
// ==================== Token 鉴权 ====================
async function fetchToken(): Promise<void> {
/**
* 调后端换取 Token。同时被「获取 Token」按钮和传给 SDK 的 getToken 回调复用,
* 因此不做 MessagePlugin 提示,失败直接抛错(SDK 会通过 onError 上报 auth_refresh_failed)。
*/
async function exchangeToken(): Promise<{ token: string; expiresIn?: number }> {
const key = apiKey.value.trim()
if (!key) {
assert(key, '请先输入 API Key')
const res = await fetch(normalizeDomain(config.requestDomain) + '/open-api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
body: JSON.stringify({ ttl: tokenTtl.value }),
})
const data = await res.json()
if (!data.success || !data.token) throw new Error(data.message || '未知错误')
sdkRoles.value = data.roles || null
if (data.roles && data.roles.length > 0) {
config.integrateId = String(data.roles[0].id)
}
return { token: data.token, expiresIn: data.expiresIn }
}
async function fetchToken(): Promise<void> {
if (!apiKey.value.trim()) {
MessagePlugin.warning('请先输入 API Key')
return
}
fetchingToken.value = true
try {
const res = await fetch(normalizeDomain(config.requestDomain) + '/open-api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
body: JSON.stringify({ ttl: 7200 }),
})
const data = await res.json()
if (data.success && data.token) {
sdkToken.value = data.token
sdkRoles.value = data.roles || null
if (data.roles && data.roles.length > 0) {
config.integrateId = String(data.roles[0].id)
}
MessagePlugin.success('Token 获取成功')
} else {
MessagePlugin.error('换取失败: ' + (data.message || '未知错误'))
}
const { token, expiresIn } = await exchangeToken()
sdkToken.value = token
sdkTokenExpiresIn.value = expiresIn ?? null
MessagePlugin.success('Token 获取成功' + (expiresIn ? `(有效期 ${expiresIn} 秒)` : ''))
} catch (e: any) {
MessagePlugin.error('请求失败: ' + (e.message || e))
MessagePlugin.error('换取失败: ' + (e.message || e))
} finally {
fetchingToken.value = false
}
@ -482,7 +523,19 @@ function buildCodeSnippet(): string {
const token = sdkToken.value.trim()
if (token) {
params.push(' token: ' + JSON.stringify(token) + ', // SDK JWT Token(有效期约2小时,生产环境请从后端动态换取)')
params.push(' token: ' + JSON.stringify(token) + ', // SDK JWT Token(首次从后端换取)')
if (sdkTokenExpiresIn.value) {
params.push(' expiresIn: ' + sdkTokenExpiresIn.value + ', // 有效期(秒),SDK 据此在临期前主动刷新')
}
if (autoRefresh.value) {
if (tokenStrategy.value === 'always') {
params.push(" tokenStrategy: 'always', // 每个受保护请求前都向宿主取一次 Token(不依赖 expiresIn)")
}
params.push(' getToken: async () => { // SDK 在临期 / 每请求前(always)/ 收到 401 时调用它')
params.push(" const d = await (await fetch('/后端换取Token接口')).json();")
params.push(' return { token: d.token, expiresIn: d.expiresIn };')
params.push(' },')
}
if (sdkRoles.value) {
params.push(' roles: ' + JSON.stringify(sdkRoles.value) + ', // 可用客服角色列表')
}
@ -533,7 +586,7 @@ const currentCode = computed(() => (codeTab.value === 'fullpage' ? buildFullPage
function highlightCode(code: string): string {
const esc = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const re =
/(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|watermark|showAdminPanel|launcherIcon|token|roles)(?=\s*:)/g
/(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|watermark|showAdminPanel|launcherIcon|token|tokenStrategy|tokenUrl|expiresIn|getToken|roles)(?=\s*:)/g
return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => {
if (htmlCmt) return '<span class="cm">' + htmlCmt + '</span>'
if (str) return '<span class="st">' + str + '</span>'
@ -787,7 +840,7 @@ const testResults = ref<TestCaseResult[]>([
log('检测 window.ChatbotSDK ...', 'info')
const sdk = getSdk()
assert(sdk, 'window.ChatbotSDK 未定义(SDK 脚本未加载)')
const methods = ['init', 'destroy', 'open', 'close', 'toggle', 'clearHistory']
const methods = ['init', 'destroy', 'open', 'close', 'toggle', 'clearHistory', 'setToken', 'refreshToken']
for (const m of methods) {
assert(typeof sdk[m] === 'function', 'ChatbotSDK.' + m + ' 不是函数')
log('✓ ChatbotSDK.' + m + '()', 'pass')

6
src/main/resources/static/sdk/test.html

@ -4,16 +4,16 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-jNxPvN6s.js"></script>
<script type="module" crossorigin src="/assets/sdk-test-Cnee66Jv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-C0UlJGfx.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-Ig5YS_WX.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-C_axd2by.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-BzlZwYco.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-BMDefjTd.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-wRid8JYa.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-C9_UiJCh.css">
</head>
<body>
<div id="sdk-test-app"></div>

Loading…
Cancel
Save