Compare commits
merge into: wanghanlin:master
wanghanlin:Spring-AI-1.1.2
wanghanlin:TDesign-AI-Chat
wanghanlin:TDesign-Vue-Next-1.20.6
wanghanlin:dev
wanghanlin:feature/test
wanghanlin:master
pull from: wanghanlin:dev
wanghanlin:Spring-AI-1.1.2
wanghanlin:TDesign-AI-Chat
wanghanlin:TDesign-Vue-Next-1.20.6
wanghanlin:dev
wanghanlin:feature/test
wanghanlin:master
3 Commits
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
5c75cb3f71 |
fix(ai): 流式回答零内容时不再返回空回答,并标记 EMPTY_COMPLETION
问题(与上一个提交同一个现场):/ai/chat/stream 只发出「开场 role 帧 + finish_reason=stop 帧
+ [DONE]」。这两个帧都是本地生成的、与模型无关(startWith / concatWith),所以这就是一个
**空回答**:客户端表现为一直停在加载态,而 llm_call_trace 把它记为 status=COMPLETE 且
error_type 为空,服务端全程没有告警痕迹 —— 线上持续 10 天、7/204 次,直到客户端报障才发现。
根因(数据库证据,非推断):llm_call_trace 全库 204 条 COMPLETE 记录中,ai_response 为空的有
7 条,且这 7 条 **100% 都 completion_tokens >= max_tokens**(精确等于配置的 2000)。CHAT 活跃
模型 deepseek-v4-flash 会把推理 token 计入 completion_tokens;开启 RAG 后 prompt_tokens 涨到
1035~3557,推理阶段把 max_tokens=2000 的预算吃干,可见答案零 token 产出。Spring AI 只取
delta.content(推理内容进 metadata 的 reasoningContent,不拼进正文),于是上游分片全为空串,
被 preserveTrailingWhitespace 的 current.isEmpty() 分支静默吞掉(该分支无日志、无计数),最终
只剩本地生成的首尾帧。同一问题的同步兜底请求因 URL 不带 enableRag、prompt 仅 334 token,
预算有富余而返回了正常答案 —— 这也解释了「流式空、同步正常」的观感。
- chatStreamOpenAi 增加零内容兜底,帧序仍为 role → 内容… → 兜底内容 → stop → [DONE]
(客户端解析器无需改动):统计非空白内容分片数与上游原始分片数,内容分片为 0 时补一帧内容
① 先复用同一 spec 做一次非流式调用(subscribeOn(boundedElastic)),成功则作为内容帧发出,
用户完全无感 —— 覆盖「provider 流式分片不带内容、但非流式正常」这类成因;
② 非流式也为空则发一条明确提示 —— 覆盖「预算被推理吃光」这类成因:非流式走的是同一套预算与
同样的 prompt,同样会返回空,所以两级都不可省。
- 可观测性:兜底发生时打 WARN(含上游分片数、内容分片数、兜底来源),并在 llm_call_trace 记
error_type=EMPTY_COMPLETION,后台「提示词追踪」可直接筛出这类记录。
- MyLoggerAdvisor:修 after() 的真实 NPE —— getResult() 在 generations 为空时返回 null
(provider 在流末下发 "choices":[] 的用量分片即命中),相邻的 ContentSafetyAdvisor 判了空、
它没判,会抛 NPE 打断整条 advisor 链;同时补注释说明「流式下 AI Response: 为空是必然现象」
(BaseAdvisor.adviseStream 只在携带 finish_reason 的收尾分片回调 after,那片 delta 内容天然
为空),避免下次排查被这行日志误导。
- README:修正流格式契约 —— data: 后**无空格**(原文档写作 data: {...},按文档实现的自研解析器
会一个字节都取不到),并补充首帧只含 delta.role、末帧 delta 为空对象 + finish_reason=stop、
全链路不发任何 event: 具名事件这三个特征。
注意:本提交不改变「推理吃光 max_tokens 预算」这一根因,只把「静默的空回答」变成「可见、不破坏
体验的失败」。真正恢复答案需要把 CHAT 的 max_tokens 从 2000 调大(配置层,另行处理)。
验证:mvn clean package -P prod 通过;编译产物已确认含 EMPTY_COMPLETION 与提示文案;用真实 SDK
回归新帧序 —— 兜底内容被正常渲染、无 stream_empty、客户端不再触发自身兜底(syncFallbackCalls=0)。
|
2 weeks ago |
|
|
20d9a889ac |
fix(sdk): 流式零内容时降级路径未渲染答案,界面永久停在「正在思考...」
问题:第三方页面反馈「对话框一直显示正在思考...,但控制台里请求与响应数据都正常」。 定位:服务端这次的流式响应只回了「开场 role 帧 + finish_reason=stop 帧 + [DONE]」三个控制帧、 零内容(服务端成因见后一个提交)。SDK 的 sendStreamMessage 在 onDone 里发现零内容时确实会 降级再发一次同步 /ai/chat,但该分支只写 `chatRequest(...).then(resolve)` —— 既不渲染气泡也不 收掉 loading;而 produceAIReply 的 streaming 分支把「渲染气泡 + 收 loading」全权委托给了 onChunk 回调,这条路径拿不到 onChunk。结果同步拿到的答案被静默丢弃、loading 永不消失。 - 降级路径补齐 hideLoading + renderAIBubble(复用 dom.ts 已有实现,终态与非流式分支一致) - 同一函数另一处同类出口:首个 token 之前点「停止生成」时 loading 同样不会被收,且会把一条 空 AI 消息写进历史。补 hideLoading;并在 produceAIReply 加守卫:aiContent 为空串时不 push、 不落库、不派发 csk:newMessage(避免历史里多出空气泡) - 流式零内容时上报 onError code=stream_empty,不再静默;README 补该错误码与触发方式说明 验证:用逐字复刻线上那 3 帧的 mock 流驱动真实 SDK(puppeteer)——降级拿到的答案被正常渲染成 气泡、「正在思考...」消失、onError 收到 stream_empty,请求序列与线上 HAR 一致;正常带内容的流 不受影响(errors 为空、syncFallbackCalls=0);首 token 前中止时 loading 消失且历史无空消息。 client 单测 92 个全绿。 (static/sdk/test.html 仅两条 link 顺序变化,hash 未变,属构建产物顺序抖动) |
2 weeks ago |
|
|
5a553ec813 |
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 选择开关供手工验证
|
2 weeks ago |
19 changed files with 1982 additions and 145 deletions
-
3CLAUDE.md
-
2README.md
-
205SDK-INTEGRATION.md
-
24client/CLAUDE.md
-
131client/README.md
-
130client/src/api.ts
-
38client/src/chat.ts
-
64client/src/config.ts
-
2client/src/i18n.ts
-
47client/src/index.ts
-
9client/src/logger.ts
-
463client/src/token.ts
-
65client/src/types.ts
-
59client/tests/config.test.ts
-
625client/tests/token.test.ts
-
89frontend/src/sdk-test/SdkTestPanel.vue
-
17src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java
-
104src/main/java/com/wok/supportbot/app/AssistantApp.java
-
4src/main/resources/static/sdk/test.html
@ -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; |
|||
} |
|||
@ -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'); |
|||
}); |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue