本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

463 lines
20 KiB

/**
* 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;
}