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() 注入