From 14c5ccf6ab0bb3f46fb45509b9f6332eb39ce68a Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 10 Jul 2026 16:48:46 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=81API=20Key=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E8=A7=92=E8=89=B2=E3=80=81=E7=BC=96=E5=86=99?= =?UTF-8?q?=20SDK=20=E9=9B=86=E6=88=90=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 19 + SDK-INTEGRATION.md | 437 ++++++++++++++++++ .../supportbot/config/DatabaseInitConfig.java | 88 ++++ .../supportbot/controller/AiController.java | 3 +- .../controller/ApiKeyController.java | 59 ++- .../supportbot/controller/AuthController.java | 127 +++++ .../CustomerServiceRoleController.java | 3 +- .../com/wok/supportbot/entity/ApiKey.java | 4 + .../supportbot/openapi/ApiKeyAuthFilter.java | 2 +- .../supportbot/security/JwtAuthFilter.java | 30 +- .../supportbot/security/JwtTokenProvider.java | 4 + .../supportbot/security/SdkAuthFilter.java | 114 +++++ .../security/SdkJwtTokenProvider.java | 149 ++++++ .../supportbot/security/SecurityConfig.java | 5 +- .../wok/supportbot/service/ApiKeyService.java | 82 +++- .../service/CustomerServiceRoleService.java | 61 ++- .../supportbot/service/DocumentService.java | 2 +- .../wok/supportbot/service/FaqService.java | 3 +- src/main/resources/application.yml | 5 + src/main/resources/init-database.sql | 33 +- .../static/components/ApiKeyManager.js | 122 ++++- src/main/resources/static/js/api.js | 24 +- src/main/resources/static/js/app.js | 26 +- src/main/resources/static/js/store.js | 1 + src/main/resources/static/sdk/chatbot-sdk.js | 127 ++++- src/main/resources/static/sdk/test.html | 104 ++++- 26 files changed, 1557 insertions(+), 77 deletions(-) create mode 100644 SDK-INTEGRATION.md create mode 100644 src/main/java/com/wok/supportbot/controller/AuthController.java create mode 100644 src/main/java/com/wok/supportbot/security/SdkAuthFilter.java create mode 100644 src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java diff --git a/CLAUDE.md b/CLAUDE.md index 11c1208..09aab59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,6 +174,25 @@ catch (e) { toast('操作失败', 'error') } - 消息反馈: `/feedback/*`(`MessageFeedbackController`) - 敏感词管理: `/sensitive-word/*`(`SensitiveWordController`) - FAQ 管理: `/faq/*`(`FaqController`) +- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取) +- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色) + +### Filter 优先级 + +| 优先级 | Filter | 路径 | 说明 | +|--------|--------|------|------| +| `HIGHEST + 1` | `SdkAuthFilter` | `/ai/**` | SDK JWT 鉴权 | +| `HIGHEST + 2` | `ApiKeyAuthFilter` | `/open-api/**` | API Key 鉴权 | +| SecurityFilterChain 内 | `JwtAuthFilter` | 管理接口 | 管理后台 JWT | + +### SDK 鉴权架构 + +采用两段式鉴权:客户端后端用 API Key 换取短期 JWT Token → SDK 携带 Token 请求 `/ai/**` → SdkAuthFilter 校验放行。SDK JWT 密钥独立于管理后台 JWT(`jwt.sdk-secret`)。 + +- API Key 支持绑定客服角色列表(`role_ids` JSONB 字段),Token 换取时优先返回绑定的角色 +- 未绑定角色的 API Key 返回所有启用角色(向后兼容) +- JWT Token 中 `sub` 为 apiKeyId,`rids` 为允许的角色 ID 列表 +- 第三方系统接入指南详见 `SDK-INTEGRATION.md` ## P0 阶段新增功能 diff --git a/SDK-INTEGRATION.md b/SDK-INTEGRATION.md new file mode 100644 index 0000000..458921a --- /dev/null +++ b/SDK-INTEGRATION.md @@ -0,0 +1,437 @@ +# Chat SDK 第三方系统集成指南 + +> 本文档说明第三方系统如何接入 AI 智能客服的 Chat SDK,完成从创建 API Key 到嵌入对话组件的全流程。 + +--- + +## 目录 + +1. [集成架构总览](#1-集成架构总览) +2. [第一步:创建 API Key](#2-第一步创建-api-key) +3. [第二步:绑定客服角色(可选)](#3-第二步绑定客服角色可选) +4. [第三步:后端换取 SDK Token](#4-第三步后端换取-sdk-token) +5. [第四步:前端嵌入 Chat SDK](#5-第四步前端嵌入-chat-sdk) +6. [API 接口参考](#6-api-接口参考) +7. [SDK 配置参数参考](#7-sdk-配置参数参考) +8. [错误码与排查](#8-错误码与排查) +9. [安全建议](#9-安全建议) + +--- + +## 1. 集成架构总览 + +``` +┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ 第三方前端 │ │ 第三方后端 │ │ AI 客服后端 │ +│ (嵌入 SDK) │ │ (你的服务) │ │ (本系统) │ +│ │ │ │ │ │ +│ Chat SDK ──────┼─────┼──────────────────┼─────┼─► /ai/** 对话 │ +│ ↑ token │ │ │ │ │ +│ │ │ ① 携带 API Key │ │ ② 返回 JWT Token│ +│ │ │ POST /open-api/ │─────┼─► /auth/token │ +│ │ │ auth/token │ │ │ +└─────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +**两段式鉴权流程:** + +1. **管理后台**:管理员创建 API Key,可选绑定客服角色 +2. **第三方后端**:用 API Key 换取短期 JWT Token + 可用角色列表 +3. **第三方前端**:Chat SDK 携带 JWT Token 请求 `/ai/**` 接口进行对话 + +--- + +## 2. 第一步:创建 API Key + +在管理后台「系统设置 → API Key 管理」页面创建。 + +### 管理后台操作 + +1. 登录管理后台 `http://localhost:9090/index.html` +2. 进入「系统设置 → API Key 管理」 +3. 点击「+ 新建 API Key」 +4. 填写名称、描述、频率限制等参数 +5. **(可选)** 在创建弹窗中勾选需要绑定的客服角色 +6. 创建后**立即复制保存 Key**,关闭后无法再次查看 + +### API Key 格式 + +``` +sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(sk_ 前缀 + 32 位 UUID) +``` + +--- + +## 3. 第二步:绑定客服角色(可选) + +客服角色决定 AI 的人设、知识库范围和 MCP 工具权限。 + +### 角色绑定规则 + +| role_ids 状态 | Token 换取行为 | +|---|---| +| 空数组 `[]`(默认) | 返回**所有**启用的客服角色(向后兼容) | +| 非空 `[1, 2, 3]` | 仅返回绑定的角色 | + +### 通过管理后台绑定 + +在 API Key 列表中点击「绑定角色」按钮,勾选需要的角色后保存。 + +### 通过 API 绑定 + +```bash +PUT /api-key/{keyId}/roles +Content-Type: application/json +Authorization: Bearer {管理后台 JWT Token} + +{ + "roleIds": [1, 2, 3] +} +``` + +响应: + +```json +{ + "success": true, + "message": "角色绑定更新成功" +} +``` + +--- + +## 4. 第三步:后端换取 SDK Token + +**⚠️ 重要:Token 换取必须在第三方后端完成,不可在前端暴露 API Key。** + +### 请求 + +```bash +POST /open-api/auth/token +Content-Type: application/json +X-API-Key: sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +{ + "ttl": 7200 +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `X-API-Key` | Header | 是 | 创建的 API Key | +| `ttl` | Body | 否 | Token 有效期(秒),默认 7200(2 小时),范围 [300, 86400] | + +### 成功响应 + +```json +{ + "success": true, + "token": "eyJhbGciOiJIUzI1NiJ9...", + "expiresIn": 7200, + "roles": [ + { "id": "1", "key": "general", "name": "通用客服" }, + { "id": "2", "key": "finance", "name": "财务顾问" } + ] +} +``` + +| 字段 | 说明 | +|---|---| +| `token` | SDK JWT Token,传给前端使用 | +| `expiresIn` | 过期时间(秒) | +| `roles` | 可用客服角色列表,前端用于展示角色选择器 | + +### 错误响应 + +| HTTP 状态码 | 说明 | +|---|---| +| 401 | API Key 无效或已禁用 | +| 403 | 无可用客服角色 | + +--- + +## 5. 第四步:前端嵌入 Chat SDK + +### 使用测试面板快速验证 + +访问 `http://localhost:9090/sdk/test.html` 打开测试面板: + +1. 在「🔐 SDK Token 鉴权」区域输入 API Key +2. 点击「获取 Token」按钮自动换取(或手动粘贴已有的 Token) +3. Token 获取成功后,integrateId 会自动填充为第一个可用角色 +4. 点击「🚀 初始化 SDK」即可测试对话功能 + +测试面板支持所有 P0/P1/P2 测试用例的自动化运行,包括 API 连通性验证。 + +### 5.1 引入 SDK + +```html + +``` + +### 5.2 初始化 + +```javascript +const chatbot = ChatbotSDK.init({ + // ========== 必填参数 ========== + integrateId: '1', // 客服角色 ID(从 Token 换取接口的 roles 中选择) + requestDomain: 'https://your-domain.com', // AI 客服后端域名 + + // ========== 鉴权参数(推荐) ========== + token: 'eyJhbGciOiJIUzI1NiJ9...', // 后端换取的 SDK Token + roles: [ // 可选,角色选择器数据 + { id: '1', key: 'general', name: '通用客服' }, + { id: '2', key: 'finance', name: '财务顾问' } + ], + + // ========== 可选参数 ========== + userId: 'user_12345', // 用户标识(用于会话隔离) + title: 'AI 智能助手', // 对话窗口标题 + theme: 'light', // 主题:light / dark + primaryColor: '#4F46E5', // 主题色(十六进制) + streaming: true, // 启用流式回复(默认 true) + enableRag: true, // 启用 RAG 知识库检索(默认 true) + quickReplies: ['如何退款?', '联系人工客服'], // 快捷问题 + position: 'right-bottom', // 悬浮按钮位置:right-bottom / left-bottom + width: 380, // 窗口宽度(px) + height: 520, // 窗口高度(px) + debug: true, // 控制台调试日志 + + // ========== 回调函数 ========== + onReady: function() { console.log('SDK 就绪'); }, + onMessage: function(msg) { console.log('收到消息', msg); }, + onError: function(err) { console.error('SDK 错误', err); } +}); +``` + +### 5.3 SDK 方法 + +```javascript +// 销毁实例(移除 DOM 和事件监听) +chatbot.destroy(); +``` + +### 5.4 不使用 Token 的兼容模式 + +如果暂时不接入后端 Token 换取,可直接使用兼容模式(**不推荐用于生产环境**): + +```javascript +ChatbotSDK.init({ + integrateId: '1', // 直接指定角色 ID + requestDomain: 'https://your-domain.com' + // 不传 token 和 roles +}); +``` + +兼容模式下,`integrateId` 直接作为 `roleId` 传递给后端,无需 Token 换取步骤。 + +--- + +## 6. API 接口参考 + +### 6.1 Token 换取 + +``` +POST /open-api/auth/token +``` + +详见 [第三步:后端换取 SDK Token](#4-第三步后端换取-sdk-token)。 + +### 6.2 对话接口(SDK 内部调用,无需手动对接) + +| 接口 | 方法 | 说明 | +|---|---|---| +| `/ai/assistant_app/chat/sync` | GET | 同步对话(返回完整文本) | +| `/ai/assistant_app/chat/sse` | GET | SSE 流式对话 | +| `/ai/assistant_app/chat/rag/sse` | GET | RAG 增强流式对话 | +| `/ai/assistant_app/rag/sources` | GET | 获取 RAG 引用来源 | +| `/ai/sdk/conversation/list` | GET | 会话列表 | +| `/ai/sdk/conversation/{id}/messages` | GET | 会话消息 | +| `/ai/sdk/conversation/{id}` | DELETE | 删除会话 | +| `/ai/sdk/conversation/{id}/export` | GET | 导出会话 | +| `/category/tree` | GET | 知识库分类树 | +| `/feedback` | POST | 消息反馈 | + +**通用请求参数(对话接口):** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `message` | Query | 是 | 用户消息 | +| `chatId` | Query | 是 | 会话 ID(SDK 自动管理) | +| `roleId` | Query | 否 | 客服角色 ID | +| `accountId` | Query | 否 | 用户标识 | +| `categoryId` | Query | 否 | 知识库分类 ID | +| `rewriteStrategy` | Query | 否 | RAG 查询重写策略:REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY | + +**认证方式:** 所有 `/ai/**` 请求自动在 Header 中携带 `Authorization: Bearer {token}`。 + +### 6.3 管理接口(需要管理后台 JWT) + +| 接口 | 方法 | 说明 | +|---|---|---| +| `/api-key/list` | GET | API Key 列表(admin) | +| `/api-key` | POST | 创建 API Key(admin) | +| `/api-key/{id}/revoke` | PUT | 吊销 API Key(admin) | +| `/api-key/{id}/enable` | PUT | 启用 API Key(admin) | +| `/api-key/{id}` | DELETE | 删除 API Key(admin) | +| `/api-key/{id}/roles` | PUT | 更新角色绑定(admin) | +| `/role/list` | GET | 客服角色列表(admin) | +| `/role/all` | GET | 全部角色含停用(admin) | + +--- + +## 7. SDK 配置参数参考 + +| 参数 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `integrateId` | String/Number | **必填** | 客服角色 ID | +| `requestDomain` | String | **必填** | 后端域名 | +| `token` | String | - | SDK JWT Token(推荐) | +| `roles` | Array | - | 角色列表 `[{id, key, name}]` | +| `userId` | String | - | 用户标识(会话隔离) | +| `categoryId` | String | - | 默认知识库分类 ID | +| `showCategorySwitch` | Boolean | false | 显示分类切换器 | +| `title` | String | 'AI 智能助手' | 窗口标题 | +| `width` | Number | 380 | 窗口宽度(px) | +| `height` | Number | 520 | 窗口高度(px,最小 300) | +| `position` | String | 'right-bottom' | 悬浮按钮位置 | +| `primaryColor` | String | '#4F46E5' | 主题色 | +| `launcherTheme` | String | - | 按钮主题:dream-purple / mint-tech / coral-peach / sky-blue | +| `launcherIcon` | String | (内置) | 自定义悬浮按钮 SVG | +| `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 | 浏览器通知 | +| `locale` | String | 'zh-CN' | 语言:zh-CN / en | +| `debug` | Boolean | true | 调试日志 | +| `onReady` | Function | - | SDK 就绪回调 | +| `onMessage` | Function | - | 消息回调 | +| `onError` | Function | - | 错误回调 | + +--- + +## 8. 错误码与排查 + +### Token 换取阶段 + +| 错误 | 原因 | 解决方案 | +|---|---|---| +| 401 `API Key 鉴权失败` | X-API-Key 无效或缺失 | 检查 Header 是否正确携带 | +| 401 `API Key 已被禁用` | Key 被吊销 | 在管理后台重新启用或创建新 Key | +| 401 `API Key 已过期` | Key 超过有效期 | 创建新的 API Key | +| 403 `无可用客服角色` | 未绑定角色且系统无启用角色 | 在管理后台创建并启用客服角色 | + +### SDK 对话阶段 + +| HTTP 状态码 | SDK 提示 | 原因 | +|---|---|---| +| 401 | 鉴权失败 | Token 过期或无效,需重新换取 | +| 403 | 无访问权限 | roleId 不在 Token 允许范围内 | +| 429 | 请求过于频繁 | 超过 API Key 频率限制 | +| 500 | 服务器异常 | 后端错误,查看服务端日志 | +| 502/503 | 服务暂不可用 | 后端未启动或正在部署 | + +### 常见问题 + +**Q: Token 过期后怎么办?** +A: Token 默认 2 小时过期。建议在第三方后端实现 Token 缓存和自动刷新逻辑:检测到 401 时重新调用 `/open-api/auth/token` 换取新 Token。 + +**Q: 如何让用户只能使用特定角色?** +A: 在 API Key 上绑定角色(`PUT /api-key/{id}/roles`),SDK 初始化时只传入绑定的角色 ID。 + +**Q: 多个第三方系统可以用同一个 API Key 吗?** +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 + +--- + +## 附录:完整对接示例(Node.js) + +```javascript +// ==================== 第三方后端示例 ==================== +const express = require('express'); +const app = express(); + +const AI_DOMAIN = 'https://your-ai-domain.com'; +const API_KEY = 'sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + +// Token 缓存 +let cachedToken = null; +let tokenExpireAt = 0; + +// 换取 Token(带缓存) +async function getSdkToken() { + if (cachedToken && Date.now() < tokenExpireAt) { + return cachedToken; + } + + const res = await fetch(`${AI_DOMAIN}/open-api/auth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': API_KEY + }, + body: JSON.stringify({ ttl: 7200 }) + }); + + const data = await res.json(); + if (!data.success) throw new Error(data.message); + + cachedToken = data.token; + tokenExpireAt = Date.now() + (data.expiresIn - 300) * 1000; // 提前 5 分钟刷新 + return { token: data.token, roles: data.roles }; +} + +// 给前端提供 Token +app.get('/api/chatbot/token', async (req, res) => { + try { + const data = await getSdkToken(); + res.json({ success: true, ...data }); + } catch (e) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +app.listen(3000); +``` + +```html + + + +``` diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index e1ffc95..9cb3bca 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -8,6 +8,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import java.util.List; + /** * 数据库初始化配置 * 应用启动时检查并创建必要的表、迁移列、同步种子数据。 @@ -199,6 +201,12 @@ public class DatabaseInitConfig { // 迁移 vector_store.create_time 列(Spring AI 自动建表时不包含此列) safeInit("迁移 vector_store.create_time 列", this::addVectorStoreCreateTimeColumn); + // ==================== 清理租户相关数据 ==================== + safeInit("清理租户表和 tenant_id 列", this::cleanupTenantData); + + // ==================== API Key 角色绑定 ==================== + safeInit("api_key 新增 role_ids 列", this::addApiKeyRoleIdsColumn); + // 为所有表添加注释(幂等,可重复执行) safeInit("应用数据库表注释", this::applyTableComments); @@ -1307,6 +1315,9 @@ public class DatabaseInitConfig { // ===== chat_message.user_id ===== executeComment("COLUMN chat_message.user_id", "所属系统用户ID(数据隔离,SDK调用时为null)"); + // ===== api_key.role_ids ===== + executeComment("COLUMN api_key.role_ids", "绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)"); + log.info("数据库表注释已应用"); } catch (Exception e) { log.warn("应用数据库表注释时出错", e); @@ -1320,4 +1331,81 @@ public class DatabaseInitConfig { jdbcTemplate.execute(String.format("COMMENT ON %s IS '%s'", target, comment.replace("'", "''"))); } + // ==================== 清理租户相关数据 ==================== + + /** + * 清理租户相关数据:删除 tenant 表、移除各表的 tenant_id 列和索引 + */ + private void cleanupTenantData() { + // 1. 删除 tenant 表 + if (checkTableExists("tenant")) { + jdbcTemplate.execute("DROP TABLE IF EXISTS tenant CASCADE"); + log.info("已删除 tenant 表"); + } + + // 2. 清理 api_key.tenant_id + if (checkTableExists("api_key")) { + try { + jdbcTemplate.execute("DROP INDEX IF EXISTS idx_api_key_tenant_id"); + jdbcTemplate.execute("ALTER TABLE api_key DROP COLUMN IF EXISTS tenant_id"); + } catch (Exception e) { + log.debug("清理 api_key.tenant_id 跳过: {}", e.getMessage()); + } + } + + // 3. 清理 customer_service_role.tenant_id + if (checkTableExists("customer_service_role")) { + try { + jdbcTemplate.execute("DROP INDEX IF EXISTS idx_csr_tenant_id"); + jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS tenant_id"); + } catch (Exception e) { + log.debug("清理 customer_service_role.tenant_id 跳过: {}", e.getMessage()); + } + } + + // 4. 清理 knowledge_category.tenant_id + if (checkTableExists("knowledge_category")) { + try { + jdbcTemplate.execute("DROP INDEX IF EXISTS idx_kc_tenant_id"); + jdbcTemplate.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS tenant_id"); + } catch (Exception e) { + log.debug("清理 knowledge_category.tenant_id 跳过: {}", e.getMessage()); + } + } + + // 5. 清理 knowledge_faq.tenant_id + if (checkTableExists("knowledge_faq")) { + try { + jdbcTemplate.execute("DROP INDEX IF EXISTS idx_faq_tenant_id"); + jdbcTemplate.execute("ALTER TABLE knowledge_faq DROP COLUMN IF EXISTS tenant_id"); + } catch (Exception e) { + log.debug("清理 knowledge_faq.tenant_id 跳过: {}", e.getMessage()); + } + } + + log.info("租户相关数据清理完成"); + } + + // ==================== API Key 角色绑定 ==================== + + /** + * 为 api_key 表添加 role_ids 列(JSONB 数组,存储绑定的客服角色 ID) + */ + private void addApiKeyRoleIdsColumn() { + if (!checkTableExists("api_key")) { + log.debug("api_key 表尚未创建,跳过 role_ids 迁移"); + return; + } + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'api_key' AND column_name = 'role_ids'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 api_key.role_ids 列"); + jdbcTemplate.execute("ALTER TABLE api_key ADD COLUMN role_ids JSONB DEFAULT '[]' NOT NULL"); + } + } catch (Exception e) { + log.error("添加 api_key.role_ids 列失败", e); + } + } + } diff --git a/src/main/java/com/wok/supportbot/controller/AiController.java b/src/main/java/com/wok/supportbot/controller/AiController.java index c8355b4..e1aece0 100644 --- a/src/main/java/com/wok/supportbot/controller/AiController.java +++ b/src/main/java/com/wok/supportbot/controller/AiController.java @@ -61,7 +61,8 @@ public class AiController { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); - return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools()); + String result = assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools()); + return result; } /** diff --git a/src/main/java/com/wok/supportbot/controller/ApiKeyController.java b/src/main/java/com/wok/supportbot/controller/ApiKeyController.java index 6fa8d73..1c8eee0 100644 --- a/src/main/java/com/wok/supportbot/controller/ApiKeyController.java +++ b/src/main/java/com/wok/supportbot/controller/ApiKeyController.java @@ -13,11 +13,13 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.Date; +import java.util.List; import java.util.Map; +import java.util.Objects; /** * API Key 管理接口 - * 提供 API Key 的创建、吊销、启用、删除、分页查询等操作。 + * 提供 API Key 的创建、吊销、启用、删除、分页查询、角色绑定等操作。 */ @Slf4j @RestController @@ -61,7 +63,7 @@ public class ApiKeyController { /** * 创建 API Key * - * @param body 请求体,包含 name, description, rateLimit, maxCalls, expireTime + * @param body 请求体,包含 name, description, rateLimit, maxCalls, expireTime, roleIds(可选) */ @PostMapping @PreAuthorize("hasRole('admin')") @@ -102,6 +104,13 @@ public class ApiKeyController { Long userId = (currentUser != null) ? currentUser.getId() : null; ApiKey apiKey = apiKeyService.generateKey(userId, name, description, rateLimit, maxCalls, expireTime); + + // 绑定角色(可选) + if (body.containsKey("roleIds")) { + List roleIds = parseRoleIds(body.get("roleIds")); + apiKeyService.updateKeyRoles(apiKey.getId(), roleIds); + } + return ResponseEntity.ok(Map.of( "success", true, "message", "创建成功", @@ -198,4 +207,50 @@ public class ApiKeyController { )); } } + + /** + * 更新 API Key 绑定的客服角色 + * 空数组表示不限制(返回所有启用角色) + */ + @PutMapping("/{id}/roles") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> updateRoles( + @PathVariable Long id, + @RequestBody Map body) { + try { + List roleIds = parseRoleIds(body.get("roleIds")); + apiKeyService.updateKeyRoles(id, roleIds); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "角色绑定更新成功" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("更新 API Key 角色绑定失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "更新失败:" + e.getMessage() + )); + } + } + + /** + * 解析请求体中的 roleIds 列表 + */ + @SuppressWarnings("unchecked") + private List parseRoleIds(Object raw) { + if (!(raw instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Objects::nonNull) + .map(item -> item instanceof Number n ? n.longValue() : Long.parseLong(item.toString())) + .filter(id -> id > 0) + .distinct() + .toList(); + } } diff --git a/src/main/java/com/wok/supportbot/controller/AuthController.java b/src/main/java/com/wok/supportbot/controller/AuthController.java new file mode 100644 index 0000000..e20f60e --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/AuthController.java @@ -0,0 +1,127 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.entity.ApiKey; +import com.wok.supportbot.security.SdkJwtTokenProvider; +import com.wok.supportbot.service.ApiKeyService; +import com.wok.supportbot.service.CustomerServiceRoleService; +import com.wok.supportbot.service.CustomerServiceRoleService.RoleBrief; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * SDK 认证控制器 + * 提供 Token 换取接口,客户后端用 API Key 换取 SDK JWT Token + 角色列表。 + *

+ * 路径在 /open-api/ 下,请求首先由 ApiKeyAuthFilter 校验 X-API-Key, + * 校验通过后 ApiKey 信息存入 request.attribute("apiKey"),本控制器直接使用。 + *

+ * 角色来源: 优先使用 API Key 绑定的角色列表(role_ids), + * 未绑定时返回所有启用角色(向后兼容)。 + */ +@Slf4j +@RestController("sdkAuthController") +@RequestMapping("/open-api/auth") +public class AuthController { + + private final CustomerServiceRoleService roleService; + private final SdkJwtTokenProvider sdkJwtTokenProvider; + private final ApiKeyService apiKeyService; + + public AuthController(CustomerServiceRoleService roleService, + SdkJwtTokenProvider sdkJwtTokenProvider, + ApiKeyService apiKeyService) { + this.roleService = roleService; + this.sdkJwtTokenProvider = sdkJwtTokenProvider; + this.apiKeyService = apiKeyService; + } + + /** + * Token 换取接口 + *

+ * 前置条件: ApiKeyAuthFilter 已校验 X-API-Key,存入 request.attribute("apiKey") + *

+ * 返回: SDK JWT Token、过期时间(秒)、可用角色列表 + * + * @param request HTTP 请求(含已鉴权的 API Key 信息) + * @param body 请求体(可选,支持自定义 ttl 秒数) + * @return {token, expiresIn, roles[]} + */ + @PostMapping("/token") + public ResponseEntity getToken(HttpServletRequest request, + @RequestBody(required = false) TokenRequest body) { + // 1. 获取已鉴权的 API Key(由 ApiKeyAuthFilter 注入) + ApiKey apiKey = (ApiKey) request.getAttribute("apiKey"); + if (apiKey == null) { + return ResponseEntity.status(401).body(Map.of( + "success", false, + "message", "API Key 鉴权失败" + )); + } + + // 2. 读取 API Key 绑定的角色列表 + List boundRoleIds = apiKeyService.parseRoleIds(apiKey); + + // 3. 查询角色:有绑定则用绑定列表,否则返回所有启用角色(向后兼容) + List roles; + if (!boundRoleIds.isEmpty()) { + roles = roleService.listRolesByIds(boundRoleIds); + } else { + roles = roleService.listEnabledRolesBrief(); + } + if (roles.isEmpty()) { + return ResponseEntity.status(403).body(Map.of( + "success", false, + "message", "无可用客服角色" + )); + } + + List roleIds = roles.stream() + .map(RoleBrief::id) + .collect(Collectors.toList()); + + // 4. 计算过期时间(默认 2 小时,钳制到 [5min, 24h]) + long ttlMs = 7200000L; + if (body != null && body.ttl() != null) { + ttlMs = Math.max(body.ttl() * 1000L, 300000L); + ttlMs = Math.min(ttlMs, 86400000L); + } + + // 5. 签发 SDK JWT(subject = apiKeyId) + String token = sdkJwtTokenProvider.generateToken( + apiKey.getId().toString(), + SdkJwtTokenProvider.maskApiKey(apiKey.getKeyValue()), + roleIds, + ttlMs + ); + + // 6. 返回 Token + 角色列表 + log.info("SDK Token 签发成功: apiKeyId={}, roleCount={}", apiKey.getId(), roles.size()); + return ResponseEntity.ok(Map.of( + "success", true, + "token", token, + "expiresIn", ttlMs / 1000, + "roles", roles.stream().map(r -> Map.of( + "id", r.id().toString(), + "key", r.roleKey(), + "name", r.name() + )).collect(Collectors.toList()) + )); + } + + /** + * 请求体 DTO + * + * @param ttl 期望的 Token 有效期(秒),会被钳制到 [300, 86400] + */ + public record TokenRequest(Long ttl) { + } +} diff --git a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java index 6946f3c..51bcf31 100644 --- a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java +++ b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -27,7 +28,7 @@ public class CustomerServiceRoleController { public ResponseEntity> listRoles() { return ResponseEntity.ok(Map.of( "success", true, - "data", customerServiceRoleService.listRoles() + "data", customerServiceRoleService.listRoles(false) )); } diff --git a/src/main/java/com/wok/supportbot/entity/ApiKey.java b/src/main/java/com/wok/supportbot/entity/ApiKey.java index e97f9b4..9ea2c04 100644 --- a/src/main/java/com/wok/supportbot/entity/ApiKey.java +++ b/src/main/java/com/wok/supportbot/entity/ApiKey.java @@ -60,6 +60,10 @@ public class ApiKey implements Serializable { @TableField("current_calls") private Long currentCalls; + /** 绑定的客服角色 ID 列表(JSON 字符串,如 "[1,2,3]";空数组 "[]" 表示不限制) */ + @TableField("role_ids") + private String roleIds; + /** 过期时间(null 表示永不过期) */ @TableField("expire_time") private Date expireTime; diff --git a/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java b/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java index def2000..640d816 100644 --- a/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java +++ b/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java @@ -25,7 +25,7 @@ import java.util.Map; */ @Slf4j @Component -@Order(Ordered.HIGHEST_PRECEDENCE + 1) +@Order(Ordered.HIGHEST_PRECEDENCE + 2) public class ApiKeyAuthFilter extends OncePerRequestFilter { @Autowired diff --git a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java index cde4582..dc3b84b 100644 --- a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java +++ b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java @@ -33,20 +33,30 @@ public class JwtAuthFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String path = request.getRequestURI(); String token = extractToken(request); - if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - String username = jwtTokenProvider.getUsernameFromToken(token); - List roles = jwtTokenProvider.getRolesFromToken(token); + if (StringUtils.hasText(token)) { + boolean valid = jwtTokenProvider.validateToken(token); + if (valid) { + String username = jwtTokenProvider.getUsernameFromToken(token); + List roles = jwtTokenProvider.getRolesFromToken(token); - // 构建 Spring Security 权限列表(ROLE_ 前缀) - List authorities = roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .toList(); + // 构建 Spring Security 权限列表(ROLE_ 前缀) + List authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(username, null, authorities); - SecurityContextHolder.getContext().setAuthentication(authentication); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("JWT 认证成功: path={}, user={}, roles={}", path, username, roles); + } else { + log.warn("JWT Token 验证失败: path={}, tokenPrefix={}", + path, token.length() > 10 ? token.substring(0, 10) + "..." : token); + } + } else { + log.debug("JWT 未找到 Token: path={}", path); } filterChain.doFilter(request, response); diff --git a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java index dc972d4..d1330d4 100644 --- a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java +++ b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java @@ -1,6 +1,7 @@ package com.wok.supportbot.security; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; @@ -70,6 +71,9 @@ public class JwtTokenProvider { try { parseClaims(token); return true; + } catch (ExpiredJwtException e) { + // Token 已过期,不打印堆栈 + return false; } catch (Exception e) { return false; } diff --git a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java new file mode 100644 index 0000000..ed7e128 --- /dev/null +++ b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java @@ -0,0 +1,114 @@ +package com.wok.supportbot.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +/** + * SDK 鉴权过滤器 + * 拦截 /ai/** 请求,校验 SDK JWT Token。 + *

+ * 优先级高于 ApiKeyAuthFilter,在 SecurityFilterChain 之前执行。 + * 校验通过后提取 allowedRoleIds 用于角色鉴权。 + * 校验失败直接返回 401/403,请求不会到达 AiController。 + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class SdkAuthFilter extends OncePerRequestFilter { + + @Autowired + private SdkJwtTokenProvider sdkJwtTokenProvider; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getServletPath(); + // 仅拦截 /ai/ 路径,排除静态资源和 swagger + return !path.startsWith("/ai/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + try { + // ① 提取 Authorization: Bearer {token} + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + writeError(response, 401, "缺少认证令牌"); + return; + } + String token = authHeader.substring(7).trim(); + if (token.isEmpty()) { + writeError(response, 401, "认证令牌为空"); + return; + } + + // ② JWT 签名验证 + ③ 过期检查 + Claims claims; + try { + claims = sdkJwtTokenProvider.parseToken(token); + } catch (ExpiredJwtException e) { + writeError(response, 401, "令牌已过期"); + return; + } catch (JwtException e) { + log.warn("SDK Token 验证失败: {}", e.getMessage()); + writeError(response, 401, "无效的认证令牌"); + return; + } + + // ④ 提取 allowedRoleIds + Set allowedRoleIds = sdkJwtTokenProvider.getAllowedRoleIds(claims); + + // ⑤ roleId ∈ allowedRoleIds 校验(仅当请求携带 roleId 参数时) + String roleIdParam = request.getParameter("roleId"); + if (roleIdParam != null && !roleIdParam.isEmpty()) { + try { + Long roleId = Long.parseLong(roleIdParam); + if (!allowedRoleIds.isEmpty() && !allowedRoleIds.contains(roleId)) { + writeError(response, 403, "无权使用该客服角色"); + return; + } + } catch (NumberFormatException e) { + writeError(response, 400, "无效的角色ID"); + return; + } + } + + chain.doFilter(request, response); + } finally { + // 预留清理点 + } + } + + /** + * 输出 JSON 格式的错误响应(与 ApiKeyAuthFilter 保持一致的格式) + */ + private void writeError(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + Map body = Map.of( + "success", false, + "message", message, + "code", status + ); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java new file mode 100644 index 0000000..88829cb --- /dev/null +++ b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java @@ -0,0 +1,149 @@ +package com.wok.supportbot.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * SDK 专用 JWT 令牌提供者 + * 独立于管理后台 JwtTokenProvider,使用独立密钥签发和验证 SDK Token。 + *

+ * Token 中包含: + * - sub: apiKeyId(API Key 标识) + * - ak: 脱敏后的 API Key + * - rids: 允许使用的客服角色 ID 列表 + */ +@Slf4j +@Component +public class SdkJwtTokenProvider { + + private final SecretKey key; + private final long defaultExpiration; + + /** 24 小时上限 */ + private static final long MAX_EXPIRATION = 86400000L; + /** 5 分钟下限 */ + private static final long MIN_EXPIRATION = 300000L; + + /** 默认密钥标识(禁止使用) */ + private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change"; + + public SdkJwtTokenProvider( + @Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, + @Value("${jwt.sdk-expiration:7200000}") long defaultExpiration) { + if (DEFAULT_SECRET.equals(sdkSecret)) { + log.warn("⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串"); + } + this.key = Keys.hmacShaKeyFor(sdkSecret.getBytes(StandardCharsets.UTF_8)); + this.defaultExpiration = defaultExpiration; + } + + /** + * 签发 SDK JWT Token + * + * @param apiKeyId API Key 标识 + * @param maskedApiKey 脱敏后的 API Key + * @param roleIds 允许使用的客服角色 ID 列表 + * @param expirationMs 过期时间(毫秒),会被钳制到 [5min, 24h] + * @return JWT Token 字符串 + */ + public String generateToken(String apiKeyId, String maskedApiKey, List roleIds, long expirationMs) { + long clampedExpiration = clampExpiration(expirationMs); + Date now = new Date(); + return Jwts.builder() + .subject(apiKeyId) + .claim("ak", maskedApiKey) + .claim("rids", roleIds) + .issuedAt(now) + .expiration(new Date(now.getTime() + clampedExpiration)) + .signWith(key) + .compact(); + } + + /** + * 使用默认过期时间签发 SDK JWT Token + */ + public String generateToken(String apiKeyId, String maskedApiKey, List roleIds) { + return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration); + } + + /** + * 验证并解析 Token,返回 Claims + * + * @param token JWT Token 字符串 + * @return Claims 对象 + * @throws ExpiredJwtException Token 已过期 + * @throws JwtException Token 签名无效或格式错误 + */ + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 验证 Token 是否有效(签名正确且未过期) + */ + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 从 Claims 中提取 apiKeyId(subject) + */ + public String getApiKeyId(Claims claims) { + return claims.getSubject(); + } + + /** + * 从 Claims 中提取允许的角色 ID 集合 + * JWT 中 rids 为 List<Integer>,需转换为 Set<Long> + */ + @SuppressWarnings("unchecked") + public Set getAllowedRoleIds(Claims claims) { + List rids = claims.get("rids", List.class); + if (rids == null) { + return Set.of(); + } + return rids.stream() + .map(r -> ((Number) r).longValue()) + .collect(Collectors.toSet()); + } + + /** + * 脱敏 API Key: sk_ab****xyz0 + * 保留前 4 位和后 4 位,中间用 **** 替代 + */ + public static String maskApiKey(String keyValue) { + if (keyValue == null || keyValue.length() < 12) { + return "****"; + } + return keyValue.substring(0, 4) + "****" + keyValue.substring(keyValue.length() - 4); + } + + /** + * 将过期时间钳制到 [5min, 24h] 范围内 + */ + private long clampExpiration(long expirationMs) { + return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION)); + } +} diff --git a/src/main/java/com/wok/supportbot/security/SecurityConfig.java b/src/main/java/com/wok/supportbot/security/SecurityConfig.java index 264283b..3bf4b10 100644 --- a/src/main/java/com/wok/supportbot/security/SecurityConfig.java +++ b/src/main/java/com/wok/supportbot/security/SecurityConfig.java @@ -43,7 +43,10 @@ public class SecurityConfig { .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth // ========== 公开接口(无需认证) ========== - // SDK 对话接口(方案A:不认证,SDK 直接调用) + // SDK 对话接口 + // SdkAuthFilter 作为 Servlet Filter 已在 SecurityFilterChain 之前执行, + // 校验通过时已放行,校验失败时已返回 401,不会到达此处。 + // 因此 /ai/** 保持 permitAll(),实际安全性由 SdkAuthFilter 保证。 .requestMatchers("/ai/**").permitAll() // 开放 API(由 ApiKeyAuthFilter 独立鉴权) .requestMatchers("/open-api/**").permitAll() diff --git a/src/main/java/com/wok/supportbot/service/ApiKeyService.java b/src/main/java/com/wok/supportbot/service/ApiKeyService.java index 854cb72..409fe27 100644 --- a/src/main/java/com/wok/supportbot/service/ApiKeyService.java +++ b/src/main/java/com/wok/supportbot/service/ApiKeyService.java @@ -1,6 +1,8 @@ package com.wok.supportbot.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.wok.supportbot.dao.ApiKeyMapper; import com.wok.supportbot.entity.ApiKey; import lombok.extern.slf4j.Slf4j; @@ -8,10 +10,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.*; +import java.util.stream.Collectors; /** * API Key 管理服务 - * 提供 API Key 的生成、查询、吊销、启用、删除、验证等功能。 + * 提供 API Key 的生成、查询、吊销、启用、删除、验证、角色绑定等功能。 */ @Slf4j @Service @@ -20,6 +23,11 @@ public class ApiKeyService { @Autowired private ApiKeyMapper apiKeyMapper; + @Autowired + private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + /** * 生成新的 API Key * @@ -54,6 +62,7 @@ public class ApiKeyService { /** * 分页查询 API Key 列表 + * 分两步查询:MyBatis Plus 读基础字段 + JdbcTemplate 读 JSONB role_ids * * @param page 页码(从1开始) * @param size 每页条数 @@ -68,6 +77,19 @@ public class ApiKeyService { listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); List records = apiKeyMapper.selectList(listWrapper); + // 补充读取 JSONB role_ids 字段(MyBatis Plus 无法正确读取 JSONB) + if (!records.isEmpty()) { + Map roleIdsMap = new HashMap<>(); + String ids = records.stream().map(k -> k.getId().toString()).collect(Collectors.joining(",")); + String sql = "SELECT id, COALESCE(role_ids::text, '[]') AS role_ids FROM api_key WHERE id IN (" + ids + ")"; + jdbcTemplate.query(sql, (rs) -> { + roleIdsMap.put(rs.getLong("id"), rs.getString("role_ids")); + }); + for (ApiKey key : records) { + key.setRoleIds(roleIdsMap.getOrDefault(key.getId(), "[]")); + } + } + Map result = new HashMap<>(); result.put("records", records); result.put("total", total); @@ -147,24 +169,60 @@ public class ApiKeyService { } /** - * 自增调用次数 + * 自增调用次数(SQL 原子操作,避免并发竞态) * * @param id Key ID */ public void incrementCallCount(Long id) { - // 使用 SQL 原子操作自增,避免并发问题 try { - String sql = "UPDATE api_key SET current_calls = current_calls + 1 WHERE id = ?"; - // 通过 MyBatis Plus 无法直接做原子自增,使用 updateById 兜底 - ApiKey existing = apiKeyMapper.selectById(id); - if (existing != null) { - existing.setCurrentCalls( - (existing.getCurrentCalls() != null ? existing.getCurrentCalls() : 0L) + 1 - ); - apiKeyMapper.updateById(existing); - } + jdbcTemplate.update( + "UPDATE api_key SET current_calls = current_calls + 1 WHERE id = ? AND is_delete = false", + id); } catch (Exception e) { log.warn("自增 API Key 调用次数失败: id={}", id, e); } } + + /** + * 更新 API Key 绑定的客服角色列表 + * 使用 JdbcTemplate 显式 ::jsonb 转换,避免 MyBatis-Plus 类型不匹配 + * + * @param id API Key ID + * @param roleIds 角色 ID 列表(空/null 表示不限制) + */ + public void updateKeyRoles(Long id, List roleIds) { + ApiKey existing = apiKeyMapper.selectById(id); + if (existing == null) { + throw new IllegalArgumentException("API Key 不存在,ID:" + id); + } + String json; + try { + json = objectMapper.writeValueAsString(roleIds != null ? roleIds : List.of()); + } catch (Exception e) { + json = "[]"; + } + int rows = jdbcTemplate.update( + "UPDATE api_key SET role_ids = ?::jsonb, update_time = CURRENT_TIMESTAMP WHERE id = ? AND is_delete = false", + json, id); + log.info("API Key 角色绑定已更新: id={}, roleIds={}", id, json); + } + + /** + * 解析 API Key 绑定的角色 ID 列表 + * + * @param apiKey API Key 实体 + * @return 角色 ID 列表;空数组表示不限制 + */ + public List parseRoleIds(ApiKey apiKey) { + if (apiKey == null || apiKey.getRoleIds() == null + || apiKey.getRoleIds().isBlank() || "[]".equals(apiKey.getRoleIds().trim())) { + return List.of(); + } + try { + return objectMapper.readValue(apiKey.getRoleIds(), new TypeReference>() {}); + } catch (Exception e) { + log.warn("解析 role_ids 失败: {}", apiKey.getRoleIds(), e); + return List.of(); + } + } } diff --git a/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java b/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java index fd46d50..34ce612 100644 --- a/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java +++ b/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java @@ -13,6 +13,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; @Service @Slf4j @@ -32,12 +33,16 @@ public class CustomerServiceRoleService { * @param includeDisabled true=包含已停用角色(管理页用);false=仅启用角色(对话页用) */ public List> listRoles(boolean includeDisabled) { - String roleSql = "SELECT id::text AS id, role_key, name, description, prompt, enabled, allowed_mcp_tools " - + "FROM customer_service_role WHERE is_delete = false " - + (includeDisabled ? "" : "AND enabled = true ") - + "ORDER BY id ASC"; - List> roles = jdbcTemplate.queryForList(roleSql); + StringBuilder roleSql = new StringBuilder( + "SELECT id::text AS id, role_key, name, description, prompt, enabled, allowed_mcp_tools " + + "FROM customer_service_role WHERE is_delete = false "); + if (!includeDisabled) { + roleSql.append("AND enabled = true "); + } + roleSql.append("ORDER BY id ASC"); + List> roles = jdbcTemplate.queryForList(roleSql.toString()); + // 查询角色-分类关联 String categorySql = """ SELECT rc.role_id::text AS role_id, rc.category_id::text AS category_id, c.name AS category_name FROM customer_service_role_category rc @@ -102,9 +107,9 @@ public class CustomerServiceRoleService { ? "role_" + System.currentTimeMillis() : roleKey.trim(); jdbcTemplate.update(""" - INSERT INTO customer_service_role (role_key, name, description, prompt, enabled) - VALUES (?, ?, ?, ?, ?) - """, + INSERT INTO customer_service_role (role_key, name, description, prompt, enabled) + VALUES (?, ?, ?, ?, ?) + """, key, name.trim(), description, prompt, enabled != null ? enabled : true); } @@ -214,6 +219,46 @@ public class CustomerServiceRoleService { } } + /** + * 精简角色信息(Token 换取接口用) + */ + public record RoleBrief(Long id, String roleKey, String name) {} + + /** + * 查询所有启用的客服角色(精简字段) + * + * @return 精简角色信息列表 + */ + public List listEnabledRolesBrief() { + String sql = "SELECT id, role_key, name FROM customer_service_role " + + "WHERE enabled = true AND is_delete = false ORDER BY id"; + return jdbcTemplate.query(sql, (rs, i) -> new RoleBrief( + rs.getLong("id"), + rs.getString("role_key"), + rs.getString("name") + )); + } + + /** + * 根据 ID 列表查询启用的客服角色(精简字段,用于 API Key 角色绑定的 Token 换取) + * + * @param roleIds 角色 ID 列表 + * @return 精简角色信息列表 + */ + public List listRolesByIds(List roleIds) { + if (roleIds == null || roleIds.isEmpty()) { + return List.of(); + } + String placeholders = roleIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "SELECT id, role_key, name FROM customer_service_role " + + "WHERE enabled = true AND is_delete = false AND id IN (" + placeholders + ") ORDER BY id"; + return jdbcTemplate.query(sql, (rs, i) -> new RoleBrief( + rs.getLong("id"), + rs.getString("role_key"), + rs.getString("name") + ), roleIds.toArray()); + } + /** * 角色范围:是否命中角色、角色人设、可检索的知识库分类、MCP 工具权限。 */ diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 028df7c..b3ec335 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -1001,7 +1001,7 @@ public class DocumentService { } /** - * 创建分类 + * 创建知识库分类 */ public KnowledgeCategory createCategory(String name, String description, Long parentId, Integer sortOrder) { KnowledgeCategory category = KnowledgeCategory.builder() diff --git a/src/main/java/com/wok/supportbot/service/FaqService.java b/src/main/java/com/wok/supportbot/service/FaqService.java index 070f774..dbf5d22 100644 --- a/src/main/java/com/wok/supportbot/service/FaqService.java +++ b/src/main/java/com/wok/supportbot/service/FaqService.java @@ -259,7 +259,8 @@ public class FaqService { Map stats = new HashMap<>(); // 总 FAQ 数 - Long totalCount = faqMapper.selectCount(new QueryWrapper<>()); + QueryWrapper totalWrapper = new QueryWrapper<>(); + Long totalCount = faqMapper.selectCount(totalWrapper); stats.put("totalCount", totalCount); // 启用数 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1a284aa..fd9e56d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -106,3 +106,8 @@ jwt: expiration: 86400000 # 刷新令牌有效期:7天(毫秒) refresh-expiration: 604800000 + # ==================== SDK 专用 JWT 配置 ==================== + # SDK Token 签名密钥(独立于管理后台 JWT 密钥,生产环境务必覆盖) + sdk-secret: support-bot-sdk-jwt-secret-2026-please-change + # SDK Token 默认有效期:2小时(毫秒) + sdk-expiration: 7200000 diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 2113d8a..8549303 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS chat_message ( create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete BOOLEAN NOT NULL DEFAULT FALSE, - CONSTRAINT chk_message_type CHECK (message_type IN ('USER', 'ASSISTANT', 'SYSTEM')) + CONSTRAINT chk_message_type CHECK (message_type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')) ); CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id); @@ -493,6 +493,7 @@ CREATE TABLE IF NOT EXISTS knowledge_faq ( answer TEXT NOT NULL, similar_questions TEXT NOT NULL DEFAULT '[]', category VARCHAR(128), + category_id BIGINT, status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', priority INTEGER NOT NULL DEFAULT 0, hit_count BIGINT NOT NULL DEFAULT 0, @@ -502,9 +503,10 @@ CREATE TABLE IF NOT EXISTS knowledge_faq ( is_delete BOOLEAN NOT NULL DEFAULT FALSE ); -CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = FALSE; -CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category); -CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC); +CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = FALSE; +CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category); +CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id); +CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC); COMMENT ON TABLE knowledge_faq IS 'FAQ 知识库表(用于意图路由后的精准问答匹配)'; COMMENT ON COLUMN knowledge_faq.id IS '主键(雪花算法生成)'; @@ -540,6 +542,29 @@ COMMENT ON COLUMN faq_embedding.embedding IS '向量嵌入(维度由 knowled COMMENT ON COLUMN faq_embedding.model_name IS '使用的 Embedding 模型名称(用于检测模型变更后重新计算)'; COMMENT ON COLUMN faq_embedding.create_time IS '创建时间'; +-- ============================================================ +-- 清理租户相关数据(移除多租户架构) +-- ============================================================ +DROP TABLE IF EXISTS tenant CASCADE; + +ALTER TABLE api_key DROP COLUMN IF EXISTS tenant_id; +DROP INDEX IF EXISTS idx_api_key_tenant_id; + +ALTER TABLE customer_service_role DROP COLUMN IF EXISTS tenant_id; +DROP INDEX IF EXISTS idx_csr_tenant_id; + +ALTER TABLE knowledge_category DROP COLUMN IF EXISTS tenant_id; +DROP INDEX IF EXISTS idx_kc_tenant_id; + +ALTER TABLE knowledge_faq DROP COLUMN IF EXISTS tenant_id; +DROP INDEX IF EXISTS idx_faq_tenant_id; + +-- ============================================================ +-- API Key 角色绑定:新增 role_ids 列 +-- ============================================================ +ALTER TABLE api_key ADD COLUMN IF NOT EXISTS role_ids JSONB DEFAULT '[]' NOT NULL; +COMMENT ON COLUMN api_key.role_ids IS '绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)'; + -- ============================================================ -- 完成! -- 共创建 14 张表及对应索引。 diff --git a/src/main/resources/static/components/ApiKeyManager.js b/src/main/resources/static/components/ApiKeyManager.js index 2bd9842..3b65709 100644 --- a/src/main/resources/static/components/ApiKeyManager.js +++ b/src/main/resources/static/components/ApiKeyManager.js @@ -1,9 +1,9 @@ /** * API Key 管理组件 - * 支持 API Key 的生成、吊销、启用、删除、复制、分页查询 + * 支持 API Key 的生成、吊销、启用、删除、复制、分页查询、角色绑定 */ import { ref, onMounted, computed } from 'vue' -import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey } from '../js/api.js' +import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, getAllRoles, updateApiKeyRoles } from '../js/api.js' import { toast } from '../js/utils.js' export default { @@ -25,14 +25,15 @@ export default { Key 频率限制 调用次数 + 绑定角色 过期时间 状态 操作 - 加载中... - 暂无 API Key + 加载中... + 暂无 API Key

{{ k.name || '未命名' }}
@@ -50,6 +51,10 @@ export default { / {{ k.maxCalls }} (不限) + + {{ getBoundRoleCount(k.roleIds) }} 个角色 + 不限(全部) + {{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }} @@ -59,6 +64,7 @@ export default { + @@ -100,6 +106,16 @@ export default { +
+ +
暂无可用角色
+
+ +
+
@@ -121,6 +137,36 @@ export default {
+ + +
+
+

绑定客服角色 — {{ editingKeyName }}

+

勾选允许此 API Key 使用的客服角色,不勾选任何角色则不限制(可使用所有角色)

+ +
加载角色列表...
+
暂无可用角色,请先创建客服角色
+
+
+ +
+
{{ role.name }}
+
{{ role.role_key }} · {{ role.description }}
+
+
+
+ +
+ 已选 {{ selectedRoleIds.length }} 个角色{{ selectedRoleIds.length === 0 ? '(不限制)' : '' }} +
+ + +
+
+
+
`, @@ -133,7 +179,15 @@ export default { const showCreateDialog = ref(false) const showKeyReveal = ref(false) const revealedKey = ref('') - const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '' }) + const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] }) + + // 角色绑定相关状态 + const allRoles = ref([]) + const roleLoading = ref(false) + const showRoleDialog = ref(false) + const editingKeyId = ref(null) + const editingKeyName = ref('') + const selectedRoleIds = ref([]) async function loadList() { loading.value = true @@ -149,8 +203,20 @@ export default { loading.value = false } + async function loadRoles() { + roleLoading.value = true + try { + const res = await getAllRoles() + allRoles.value = res.data || [] + } catch (e) { + console.warn('加载角色列表失败', e) + } + roleLoading.value = false + } + function openCreateDialog() { - form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '' } + form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] } + loadRoles() showCreateDialog.value = true } @@ -159,6 +225,7 @@ export default { if (form.value.rateLimit) data.rateLimit = form.value.rateLimit if (form.value.maxCalls) data.maxCalls = form.value.maxCalls if (form.value.expireTime) data.expireTime = form.value.expireTime + if (form.value.roleIds && form.value.roleIds.length > 0) data.roleIds = form.value.roleIds try { const res = await createApiKey(data) @@ -205,6 +272,45 @@ export default { } catch (e) { toast('删除失败: ' + e.message, 'error') } } + function openRoleDialog(key) { + editingKeyId.value = key.id + editingKeyName.value = key.name || '未命名' + try { + selectedRoleIds.value = key.roleIds ? JSON.parse(key.roleIds).map(Number) : [] + } catch { selectedRoleIds.value = [] } + loadRoles() + showRoleDialog.value = true + } + + function toggleRole(roleId) { + const idx = selectedRoleIds.value.indexOf(roleId) + if (idx >= 0) selectedRoleIds.value.splice(idx, 1) + else selectedRoleIds.value.push(roleId) + } + + async function doSaveRoles() { + try { + const res = await updateApiKeyRoles(editingKeyId.value, selectedRoleIds.value) + if (res.success) { + toast('角色绑定更新成功', 'success') + showRoleDialog.value = false + loadList() + } else { + toast(res.message || '更新失败', 'error') + } + } catch (e) { + toast('更新失败: ' + e.message, 'error') + } + } + + function getBoundRoleCount(roleIdsStr) { + if (!roleIdsStr) return 0 + try { + const arr = JSON.parse(roleIdsStr) + return Array.isArray(arr) ? arr.length : 0 + } catch { return 0 } + } + function maskKey(key) { if (!key || key.length < 12) return key || '***' return key.substring(0, 6) + '****' + key.substring(key.length - 4) @@ -242,9 +348,11 @@ export default { return { keys, loading, page, pageSize, total, showCreateDialog, showKeyReveal, revealedKey, form, + allRoles, roleLoading, showRoleDialog, editingKeyId, editingKeyName, selectedRoleIds, loadList, openCreateDialog, doCreate, doRevoke, doEnable, doDelete, - maskKey, copyKey, formatDate + openRoleDialog, toggleRole, doSaveRoles, + getBoundRoleCount, maskKey, copyKey, formatDate } } } diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 4d7b2ef..5585184 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -3,15 +3,19 @@ * 封装所有后端接口调用,组件只需调用函数处理业务逻辑 * P1: 所有请求自动附加 Bearer Token,401 时触发未登录事件 */ -import { API_BASE, authHeaders, clearTokens } from './utils.js' +import { API_BASE, authHeaders } from './utils.js' // ==================== 通用请求 ==================== /** * 处理 401 未授权响应 + * 仅清除 access_token,保留 refresh_token 供后续刷新使用。 + * auth:unauthorized 事件由 app.js 监听并尝试刷新 Token, + * 刷新成功则自动恢复,刷新失败时再清除 refresh_token。 */ function handleUnauthorized() { - clearTokens() + localStorage.removeItem('sb_token') + localStorage.removeItem('sb_user') window.dispatchEvent(new Event('auth:unauthorized')) } @@ -21,7 +25,10 @@ function handleUnauthorized() { async function getJSON(path) { const res = await fetch(API_BASE + path, { headers: authHeaders() }) if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - if (res.status === 403) throw new Error('403') + if (res.status === 403) { + const body = await res.json().catch(() => ({})) + throw new Error(body.message || '403') + } return res.json() } @@ -1122,3 +1129,14 @@ export function testMcpServer(id) { export function refreshMcpServers() { return postJSON('/mcp-server/refresh', {}) } + +// ==================== API Key 角色绑定 ==================== + +/** + * 更新 API Key 绑定的客服角色 + * @param {string} id API Key ID + * @param {number[]} roleIds 角色 ID 列表 + */ +export function updateApiKeyRoles(id, roleIds) { + return putJSONWithBody(`/api-key/${id}/roles`, { roleIds }) +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index b36fec6..9860cc5 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -93,20 +93,42 @@ const app = createApp({ store.currentUser = res.data store.isLoggedIn = true } else { - // Token 失效,尝试刷新 + // 业务失败(如用户被禁用),尝试刷新 tryRefreshToken().then(ok => { if (!ok) { store.isLoggedIn = false clearTokens() + } else { + // 刷新成功后重新获取用户信息 + getCurrentUser().then(r => { + if (r.success && r.data) { + store.currentUser = r.data + store.isLoggedIn = true + } else { + store.isLoggedIn = false + clearTokens() + } + }).catch(() => { store.isLoggedIn = false; clearTokens() }) } }) } }).catch(() => { - // 401 等错误,刷新尝试 + // 401 等错误,尝试刷新(不要先清除 token!) tryRefreshToken().then(ok => { if (!ok) { store.isLoggedIn = false clearTokens() + } else { + // 刷新成功后重新获取用户信息 + getCurrentUser().then(r => { + if (r.success && r.data) { + store.currentUser = r.data + store.isLoggedIn = true + } else { + store.isLoggedIn = false + clearTokens() + } + }).catch(() => { store.isLoggedIn = false; clearTokens() }) } }) }) diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js index 2263a8f..1b40e7e 100644 --- a/src/main/resources/static/js/store.js +++ b/src/main/resources/static/js/store.js @@ -243,6 +243,7 @@ export const store = reactive({ case 'dashboard': case 'user-manage': case 'api-key': + break case 'webhook': case 'mcp-server': // 新页面无额外数据预加载 diff --git a/src/main/resources/static/sdk/chatbot-sdk.js b/src/main/resources/static/sdk/chatbot-sdk.js index 64a87ea..1e47ff5 100644 --- a/src/main/resources/static/sdk/chatbot-sdk.js +++ b/src/main/resources/static/sdk/chatbot-sdk.js @@ -258,8 +258,17 @@ var ChatbotSDK = (function () { onError: typeof raw.onError === 'function' ? raw.onError : undefined, onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined, onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined, + // SDK 鉴权:token 和 roles(可选,用于新版鉴权模式) + token: typeof raw.token === 'string' && raw.token.trim() ? raw.token.trim() : undefined, + roles: Array.isArray(raw.roles) && raw.roles.length > 0 ? raw.roles : undefined, chatId: '', // 初始为空,由 chatId 初始化流程填充 }; + // 鉴权模式日志 + if (config.token) { + logger.info(`鉴权模式: SDK Token 已配置, roles=${config.roles ? config.roles.length + '个' : '未配置'}`); + } else { + logger.info('兼容模式: 未配置 token,使用 integrateId 作为 roleId'); + } logger.info(`配置解析完成 integrateId(=roleId)=${config.integrateId} userId(=accountId)=${config.userId || '(未设置)'} requestDomain=${config.requestDomain}`); return config; } @@ -483,8 +492,9 @@ var ChatbotSDK = (function () { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig.chatId); - // integrateId 映射为 roleId - setIfPresent(params, 'roleId', currentConfig.integrateId); + // 鉴权模式使用 selectedRoleId,兼容模式使用 integrateId + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + setIfPresent(params, 'roleId', roleId); // userId 映射为 accountId setIfPresent(params, 'accountId', currentConfig.userId); return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`); @@ -496,7 +506,9 @@ var ChatbotSDK = (function () { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig.chatId); - setIfPresent(params, 'roleId', currentConfig.integrateId); + // 鉴权模式使用 selectedRoleId,兼容模式使用 integrateId + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + setIfPresent(params, 'roleId', roleId); setIfPresent(params, 'accountId', currentConfig.userId); setIfPresent(params, 'categoryId', categoryId !== null && categoryId !== void 0 ? categoryId : currentConfig.categoryId); return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`); @@ -509,7 +521,9 @@ var ChatbotSDK = (function () { params.set('message', message); params.set('chatId', currentConfig.chatId); params.set('rewriteStrategy', currentConfig.rewriteStrategy || 'REWRITE'); - setIfPresent(params, 'roleId', currentConfig.integrateId); + // 鉴权模式使用 selectedRoleId,兼容模式使用 integrateId + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + setIfPresent(params, 'roleId', roleId); setIfPresent(params, 'accountId', currentConfig.userId); setIfPresent(params, 'categoryId', categoryId !== null && categoryId !== void 0 ? categoryId : currentConfig.categoryId); return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`); @@ -522,7 +536,9 @@ var ChatbotSDK = (function () { params.set('message', message); params.set('chatId', currentConfig.chatId); params.set('rewriteStrategy', currentConfig.rewriteStrategy || 'REWRITE'); - setIfPresent(params, 'roleId', currentConfig.integrateId); + // 鉴权模式使用 selectedRoleId,兼容模式使用 integrateId + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + setIfPresent(params, 'roleId', roleId); setIfPresent(params, 'accountId', currentConfig.userId); setIfPresent(params, 'categoryId', categoryId !== null && categoryId !== void 0 ? categoryId : currentConfig.categoryId); return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`); @@ -541,8 +557,13 @@ var ChatbotSDK = (function () { externalSignal.addEventListener('abort', () => controller.abort(), { once: true }); } } + // 注入 SDK Token 认证头(仅对 /ai/ 路径注入,避免将 SDK Token 发送到管理接口) + const headers = Object.assign({}, options.headers || {}); + if (currentConfig && currentConfig.token && url.includes('/ai/')) { + headers['Authorization'] = `Bearer ${currentConfig.token}`; + } try { - const response = await fetch(url, Object.assign(Object.assign({}, options), { signal: controller.signal, mode: 'cors', credentials: 'include' })); + const response = await fetch(url, Object.assign(Object.assign({}, options), { headers, signal: controller.signal, mode: 'cors', credentials: 'include' })); return response; } catch (err) { @@ -824,8 +845,9 @@ var ChatbotSDK = (function () { const params = new URLSearchParams(); if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.userId) params.set('accountId', currentConfig.userId); - if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.integrateId) - params.set('roleId', currentConfig.integrateId); + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + if (roleId) + params.set('roleId', roleId); const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`); try { const response = await safeFetch(url); @@ -850,8 +872,9 @@ var ChatbotSDK = (function () { const params = new URLSearchParams(); if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.userId) params.set('accountId', currentConfig.userId); - if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.integrateId) - params.set('roleId', currentConfig.integrateId); + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + if (roleId) + params.set('roleId', roleId); const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`); try { const response = await safeFetch(url, { method: 'DELETE' }); @@ -874,8 +897,9 @@ var ChatbotSDK = (function () { const params = new URLSearchParams(); if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.userId) params.set('accountId', currentConfig.userId); - if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.integrateId) - params.set('roleId', currentConfig.integrateId); + const roleId = currentConfig.token ? selectedRoleId : currentConfig.integrateId; + if (roleId) + params.set('roleId', roleId); return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`); } // ==================== chatId 自动初始化 ==================== @@ -900,7 +924,8 @@ var ChatbotSDK = (function () { } // 2. 查询后端会话列表 try { - const result = await fetchConversationList(1, 5, currentConfig.userId, currentConfig.integrateId); + const roleId = currentConfig.token ? (selectedRoleId || currentConfig.integrateId) : currentConfig.integrateId; + const result = await fetchConversationList(1, 5, currentConfig.userId, roleId); if (result.list.length > 0) { // 使用最新会话的 conversationId 作为 chatId const latestConv = result.list[0]; @@ -1330,6 +1355,26 @@ var ChatbotSDK = (function () { 70% { box-shadow: 0 0 0 6px rgba(110, 231, 183, 0); } 100% { box-shadow: 0 0 0 0 rgba(110, 231, 183, 0); } } +.csk-role-selector { + margin-left: 8px; + padding: 2px 6px; + font-size: 11px; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 6px; + background: rgba(255,255,255,0.15); + color: #fff; + cursor: pointer; + outline: none; + max-width: 120px; + appearance: auto; +} +.csk-role-selector:focus { + border-color: rgba(255,255,255,0.6); +} +.csk-role-selector option { + color: #333; + background: #fff; +} .csk-header__actions { display: flex; align-items: center; @@ -2517,6 +2562,8 @@ var ChatbotSDK = (function () { actions.appendChild(closeBtn); header.appendChild(headerLeft); header.appendChild(actions); + // 渲染角色选择器(鉴权模式下,≥2 角色时显示下拉框) + renderRoleSelector(header); // === 消息区 === const messagesContainer = document.createElement('div'); messagesContainer.id = 'csk-messages'; @@ -4265,7 +4312,8 @@ var ChatbotSDK = (function () { return; listEl.innerHTML = `
加载中...
`; try { - const result = await fetchConversationList(1, 50, config$1.userId, config$1.integrateId); + const roleId = config$1.token ? (selectedRoleId || config$1.integrateId) : config$1.integrateId; + const result = await fetchConversationList(1, 50, config$1.userId, roleId); historyItems = result.list.map(c => ({ id: c.conversationId || '', chatId: c.conversationId || '', @@ -4367,12 +4415,62 @@ var ChatbotSDK = (function () { let launcherPos = null; /** 提示气泡元素 */ let teaserEl = null; + /** SDK 鉴权:当前选中的角色 ID(从 roles 列表中选择) */ + let selectedRoleId = null; /** 提示气泡定时器 */ let teaserTimer = null; /** 未读徽章元素(挂在 launcher 内部) */ let badgeEl = null; /** 音频上下文(提示音用) */ let audioCtx = null; + + // ==================== 角色选择器 ==================== + /** + * 渲染角色选择器下拉框(挂在 header 区域) + * - 只有 1 个角色时:自动选定,不渲染下拉框 + * - ≥2 个角色时:渲染下拉框,支持切换 + * - 从 localStorage 恢复上次选择 + */ + function renderRoleSelector(headerEl) { + if (!currentConfig || !currentConfig.token || !currentConfig.roles) { + return; + } + const roles = currentConfig.roles; + if (roles.length === 0) { + return; + } + if (roles.length === 1) { + // 只有一个角色:自动选定,不渲染下拉框 + selectedRoleId = String(roles[0].id); + return; + } + // ≥2 个角色:渲染下拉框 + const select = document.createElement('select'); + select.className = 'csk-role-selector'; + roles.forEach(role => { + const option = document.createElement('option'); + option.value = String(role.id); + option.textContent = role.name; + select.appendChild(option); + }); + // 从 localStorage 恢复上次选择 + const storageKey = `csk_role_${currentConfig.integrateId}_${currentConfig.userId || 'default'}`; + const savedRoleId = localStorage.getItem(storageKey); + if (savedRoleId && roles.some(r => String(r.id) === savedRoleId)) { + select.value = savedRoleId; + selectedRoleId = savedRoleId; + } else { + selectedRoleId = String(roles[0].id); + } + select.addEventListener('change', () => { + selectedRoleId = select.value; + localStorage.setItem(storageKey, select.value); + logger.info(`切换角色 roleId=${selectedRoleId}`); + }); + // 挂到 header 的左侧区域后面 + headerEl.appendChild(select); + } + // ==================== 公开 API ==================== /** 初始化 SDK */ function init(rawConfig) { @@ -4662,6 +4760,7 @@ var ChatbotSDK = (function () { launcherDragCleanup = null; } launcherPos = null; + selectedRoleId = null; removeStyles(); document.removeEventListener('keydown', onKeyDown); window.removeEventListener('resize', onResize); diff --git a/src/main/resources/static/sdk/test.html b/src/main/resources/static/sdk/test.html index 4630b2f..eb78b90 100644 --- a/src/main/resources/static/sdk/test.html +++ b/src/main/resources/static/sdk/test.html @@ -111,6 +111,24 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S chatId → 自动管理(从 /conversation/list 获取或自动生成) + +
+
🔐 SDK Token 鉴权
+
+ +
+ + +
+
调用 POST /open-api/auth/token 换取 SDK Token
+
+
+ + +
Token 有效期默认 2 小时,过期需重新获取
+
+
+
@@ -296,7 +314,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S } function getCfg() { - return { + const token = getEl('cfg-token').value.trim() || undefined; + const cfg = { integrateId: getEl('cfg-iid').value, // 对应后端 roleId requestDomain: getEl('cfg-domain').value || window.location.origin, userId: getEl('cfg-uid').value || undefined, // 对应后端 accountId @@ -312,11 +331,73 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S showClear: true, launcherTheme: getEl('cfg-theme').value || undefined, }; + // 有 Token 时传入鉴权参数 + if (token) { + cfg.token = token; + // roles 从 Token 换取接口获取(缓存在 window.__sdkRoles) + if (window.__sdkRoles) cfg.roles = window.__sdkRoles; + } + return cfg; } + + /** + * 用 API Key 换取 SDK Token + */ + window.fetchToken = async function() { + const apiKey = getEl('cfg-apikey').value.trim(); + if (!apiKey) { alert('请先输入 API Key'); return; } + const domain = getEl('cfg-domain').value || window.location.origin; + const btn = getEl('btn-fetch-token'); + btn.textContent = '获取中...'; + btn.disabled = true; + try { + const res = await fetch(domain + '/open-api/auth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey + }, + body: JSON.stringify({ ttl: 7200 }) + }); + const data = await res.json(); + if (data.success && data.token) { + getEl('cfg-token').value = data.token; + // 缓存 roles 供 SDK 使用 + window.__sdkRoles = data.roles; + // 自动填充第一个角色的 ID + if (data.roles && data.roles.length > 0) { + getEl('cfg-iid').value = data.roles[0].id; + } + btn.textContent = '✅ 已获取'; + btn.style.background = '#059669'; + setTimeout(() => { btn.textContent = '获取 Token'; btn.style.background = ''; btn.disabled = false; }, 2000); + } else { + alert('换取失败: ' + (data.message || '未知错误')); + btn.textContent = '获取 Token'; + btn.disabled = false; + } + } catch (e) { + alert('请求失败: ' + e.message); + btn.textContent = '获取 Token'; + btn.disabled = false; + } + }; function sleep(ms){return new Promise(r=>setTimeout(r,ms));} function assert(c,m){if(!c)throw new Error(m);} function assertExists(s,m){const el=document.getElementById(s)||document.querySelector(s);assert(!!el,m||s+' 不存在');return el;} + /** + * 带 SDK Token 的 fetch 封装(/ai/ 路径自动注入 Authorization) + */ + function authFetch(url, options) { + const token = getEl('cfg-token').value.trim(); + const opts = Object.assign({}, options); + if (token && url.includes('/ai/')) { + opts.headers = Object.assign({}, opts.headers || {}, { 'Authorization': 'Bearer ' + token }); + } + return fetch(url, opts); + } + // ==================== 接入代码生成 ==================== // 当前代码展示 Tab:'snippet'(Script 片段)| 'fullpage'(完整 HTML 页面) let codeTab = 'snippet'; @@ -344,6 +425,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S // 必传参数 params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)'); params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名'); + // Token 鉴权(推荐) + params.push(' token: ' + "'<从后端获取的 SDK Token>', // 推荐:SDK JWT Token(POST /open-api/auth/token 换取)"); // 可选参数:仅输出与默认值不同的项 if (cfg.userId) params.push(' userId: ' + JSON.stringify(cfg.userId) + ', // 可选:外部用户ID(对应后端 accountId)'); if (cfg.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(cfg.title) + ', // 弹窗标题'); @@ -386,7 +469,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S // 语法高亮:先转义 HTML,再用单遍正则匹配 HTML 注释 / 字符串 / 行注释 / 数字 / 对象 key function highlightCode(code) { const esc = code.replace(/&/g, '&').replace(//g, '>'); - const re = /(<!--[\s\S]*?-->)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|showAdminPanel|launcherIcon|launcherTheme)(?=\s*:)/g; + const re = /(<!--[\s\S]*?-->)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|showAdminPanel|launcherIcon|launcherTheme|token|roles)(?=\s*:)/g; return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => { if (htmlCmt) return '' + htmlCmt + ''; if (str) return '' + str + ''; @@ -468,18 +551,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S async function test11(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(500);ChatbotSDK.open();await sleep(300);const input=getEl('csk-input');if(!input){log('⚠ 输入框不存在','log-line--warn');m('skip','输入框不存在');return;}input.value='你好';input.dispatchEvent(new Event('input'));await sleep(100);const sendBtn=getEl('csk-send-btn');if(sendBtn)sendBtn.click();log('✅ 点击发送按钮','log-line--info');await sleep(8000);const aiMsgs=document.querySelectorAll('.csk-msg--ai .csk-msg__bubble');if(aiMsgs.length>0){const lastAi=aiMsgs[aiMsgs.length-1];log('AI 回复: '+(lastAi.textContent||'').substring(0,100),'log-line--pass');m('pass','通过(有AI回复)');}else{log('⚠ 等待 AI 回复超时','log-line--warn');m('skip','AI 未回复');}} - async function test12(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent('你好')+'&chatId=verify_test&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('❌ fetch 失败','log-line--fail');throw new Error('后端未运行');}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(r.ok,'HTTP '+r.status);const text=await r.text();assert(text.length>3,'回复过短');log('AI 回复: '+text.substring(0,100),'log-line--pass');log('参数: chatId=verify_test roleId='+cfg.integrateId,'log-line--info');m('pass','通过 ('+el+'ms)');} + async function test12(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent('你好')+'&chatId=verify_test&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('❌ fetch 失败','log-line--fail');throw new Error('后端未运行');}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(r.ok,'HTTP '+r.status);const text=await r.text();assert(text.length>3,'回复过短');log('AI 回复: '+text.substring(0,100),'log-line--pass');log('参数: chatId=verify_test roleId='+cfg.integrateId,'log-line--info');m('pass','通过 ('+el+'ms)');} - async function test13(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sse?message='+encodeURIComponent('你好')+'&chatId=verify_sse&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await fetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('❌ SSE 失败','log-line--fail');throw new Error('SSE 失败');}assert(r.ok,'HTTP '+r.status);const reader=r.body.getReader();const decoder=new TextDecoder();let chunks=0,total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});chunks++;}}finally{reader.releaseLock();}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(total.length>3,'SSE 回复过短');log('✅ SSE '+chunks+' chunks, '+total.length+' chars','log-line--pass');m('pass','通过 ('+el+'ms)');} + async function test13(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sse?message='+encodeURIComponent('你好')+'&chatId=verify_sse&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('❌ SSE 失败','log-line--fail');throw new Error('SSE 失败');}assert(r.ok,'HTTP '+r.status);const reader=r.body.getReader();const decoder=new TextDecoder();let chunks=0,total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});chunks++;}}finally{reader.releaseLock();}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(total.length>3,'SSE 回复过短');log('✅ SSE '+chunks+' chunks, '+total.length+' chars','log-line--pass');m('pass','通过 ('+el+'ms)');} // ==================== P1 ==================== async function test14(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);const s=document.querySelector('style[data-csk-sdk]');const css=s?s.textContent:'';assert(css.includes('csk-md-code-block'),'代码块样式');log('✅ 代码块样式','log-line--pass');assert(css.includes('csk-md-link'),'链接样式');log('✅ 链接样式','log-line--pass');m('pass','通过');} async function test15(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:true}));await sleep(300);const sel=document.getElementById('csk-category-select');assert(!!sel,'分类下拉框未创建');log('✅ 分类下拉框已创建','log-line--pass');ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:false}));await sleep(300);assert(!document.getElementById('csk-category-select'),'关闭后不应创建');log('✅ showCategorySwitch=false','log-line--pass');m('pass','通过');} - async function test16(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/rag/sse?message='+encodeURIComponent('你好')+'&chatId=verify_rag&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;log('请求 RAG SSE...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('⚠ RAG SSE 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const reader=r.body.getReader();const decoder=new TextDecoder();let total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});}}finally{reader.releaseLock();}assert(total.length>3,'RAG 回复过短');log('✅ RAG SSE 回复 '+total.length+' chars','log-line--pass');m('pass','通过');} + async function test16(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/rag/sse?message='+encodeURIComponent('你好')+'&chatId=verify_rag&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;log('请求 RAG SSE...','log-line--info');let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('⚠ RAG SSE 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const reader=r.body.getReader();const decoder=new TextDecoder();let total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});}}finally{reader.releaseLock();}assert(total.length>3,'RAG 回复过短');log('✅ RAG SSE 回复 '+total.length+' chars','log-line--pass');m('pass','通过');} - async function test17(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/rag/sources?message='+encodeURIComponent('请假')+'&chatId=verify_sources&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const json=await r.json();log('返回 success='+json.success+' data='+(json.data?json.data.length:0)+' 条','log-line--info');assert(json.success!==undefined,'应返回 success 字段');log('✅ RAG 引用来源接口可用','log-line--pass');m('pass','通过');} + async function test17(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/rag/sources?message='+encodeURIComponent('请假')+'&chatId=verify_sources&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const json=await r.json();log('返回 success='+json.success+' data='+(json.data?json.data.length:0)+' 条','log-line--info');assert(json.success!==undefined,'应返回 success 字段');log('✅ RAG 引用来源接口可用','log-line--pass');m('pass','通过');} async function test18(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/category/tree';apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 分类树 '+(json.data?json.data.length:0)+' 个根节点','log-line--pass');m('pass','通过');} @@ -493,7 +576,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S async function test22(log,m){const cfg=getCfg();if(cfg.userId){const url=cfg.requestDomain+'/conversation/list?page=1&size=10&accountId='+encodeURIComponent(cfg.userId)+'&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (accountId='+cfg.userId+', roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 按外部用户+角色过滤 total='+json.total,'log-line--pass');m('pass','通过');}else{log('⚠ 未设置 userId,跳过','log-line--warn');m('skip','未设置 userId');}} // ========== 参数映射验证 ========== - async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');} + async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');} window.runAll = async function() { passCount=0;failCount=0;skipCount=0;apiCalls=0;apiDurations=[]; @@ -535,14 +618,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S setEl('footer-time',new Date().toLocaleTimeString()); if(typeof window.ChatbotSDK!=='undefined'){const tag=getEl('tag-sdk');tag.className='tag tag--pass';tag.textContent='✅ SDK 已加载';} // 监听配置表单变化,实时刷新接入代码 - ['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-stream','cfg-locale','cfg-cat','cfg-debug','cfg-theme'].forEach(function(id){ + ['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-stream','cfg-locale','cfg-cat','cfg-debug','cfg-theme','cfg-apikey','cfg-token'].forEach(function(id){ const el=getEl(id); if(!el)return; el.addEventListener('input', generateCode); el.addEventListener('change', generateCode); }); generateCode(); - fetch(window.location.origin+'/ai/assistant_app/chat/sync?message=test&chatId=__probe__&roleId=1',{signal:AbortSignal.timeout(5000)}).then(r=>{const tag=getEl('tag-api');if(r.ok||r.status<500){tag.className='tag tag--pass';tag.textContent='✅ 后端连通';setEl('footer-info','ChatbotSDK v1.2.0 | 后端:在线 ✅');}}).catch(()=>{const tag=getEl('tag-api');tag.className='tag tag--fail';tag.textContent='⚠ 后端离线';}); + // 后端连通性探测(使用 Token 或直接请求) + const probeToken = getEl('cfg-token').value.trim(); + const probeHeaders = probeToken ? { 'Authorization': 'Bearer ' + probeToken } : {}; + fetch(window.location.origin+'/ai/assistant_app/chat/sync?message=test&chatId=__probe__&roleId=1',{signal:AbortSignal.timeout(5000),headers:probeHeaders}).then(r=>{const tag=getEl('tag-api');if(r.ok||r.status<500){tag.className='tag tag--pass';tag.textContent='✅ 后端连通';setEl('footer-info','ChatbotSDK v1.2.0 | 后端:在线 ✅');}}).catch(()=>{const tag=getEl('tag-api');tag.className='tag tag--fail';tag.textContent='⚠ 后端离线';}); })(); })();