From 5a553ec813ab1b0ed43882f8cc023f97b2bddfa4 Mon Sep 17 00:00:00 2001
From: wanghanlin <1533525126@qq.com>
Date: Thu, 24 Sep 2026 16:00:16 +0800
Subject: [PATCH] =?UTF-8?q?feat(sdk):=20SDK=20=E5=86=85=E7=BD=AE=20Token?=
=?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=EF=BC=8C=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E6=AF=8F=E8=AF=B7=E6=B1=82=E5=89=8D=E5=90=91=E5=AE=BF?=
=?UTF-8?q?=E4=B8=BB=E5=8F=96=20Token?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
问题: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 选择开关供手工验证
---
CLAUDE.md | 3 +
SDK-INTEGRATION.md | 215 ++++++--
client/CLAUDE.md | 24 +-
client/README.md | 128 ++++-
client/src/api.ts | 156 ++++--
client/src/chat.ts | 8 +
client/src/config.ts | 64 ++-
client/src/i18n.ts | 2 +
client/src/index.ts | 47 +-
client/src/logger.ts | 9 +-
client/src/token.ts | 463 ++++++++++++++++++
client/src/types.ts | 65 ++-
client/tests/config.test.ts | 59 ++-
client/tests/token.test.ts | 625 ++++++++++++++++++++++++
frontend/src/sdk-test/SdkTestPanel.vue | 99 +++-
src/main/resources/static/sdk/test.html | 6 +-
16 files changed, 1834 insertions(+), 139 deletions(-)
create mode 100644 client/src/token.ts
create mode 100644 client/tests/token.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index bb55fa4..69d3c81 100644
--- a/CLAUDE.md
+++ b/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
diff --git a/SDK-INTEGRATION.md b/SDK-INTEGRATION.md
index 9990ca8..8de23ac 100644
--- a/SDK-INTEGRATION.md
+++ b/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);
```
diff --git a/client/CLAUDE.md b/client/CLAUDE.md
index 45fd222..68567aa 100644
--- a/client/CLAUDE.md
+++ b/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() 注入