Browse Source

、API Key 绑定客服角色、编写 SDK 集成文档

dev-mcp
wanghanlin 2 weeks ago
parent
commit
14c5ccf6ab
  1. 19
      CLAUDE.md
  2. 437
      SDK-INTEGRATION.md
  3. 88
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  4. 3
      src/main/java/com/wok/supportbot/controller/AiController.java
  5. 59
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  6. 127
      src/main/java/com/wok/supportbot/controller/AuthController.java
  7. 3
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  8. 4
      src/main/java/com/wok/supportbot/entity/ApiKey.java
  9. 2
      src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java
  10. 12
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  11. 4
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  12. 114
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  13. 149
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  14. 5
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  15. 80
      src/main/java/com/wok/supportbot/service/ApiKeyService.java
  16. 55
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  17. 2
      src/main/java/com/wok/supportbot/service/DocumentService.java
  18. 3
      src/main/java/com/wok/supportbot/service/FaqService.java
  19. 5
      src/main/resources/application.yml
  20. 27
      src/main/resources/init-database.sql
  21. 122
      src/main/resources/static/components/ApiKeyManager.js
  22. 24
      src/main/resources/static/js/api.js
  23. 26
      src/main/resources/static/js/app.js
  24. 1
      src/main/resources/static/js/store.js
  25. 127
      src/main/resources/static/sdk/chatbot-sdk.js
  26. 104
      src/main/resources/static/sdk/test.html

19
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 阶段新增功能

437
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
<script src="https://your-domain.com/sdk/chatbot-sdk.js"></script>
```
### 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
<!-- ==================== 第三方前端示例 ==================== -->
<script src="https://your-ai-domain.com/sdk/chatbot-sdk.js"></script>
<script>
// 从自己后端获取 Token
fetch('/api/chatbot/token')
.then(r => r.json())
.then(data => {
if (data.success) {
ChatbotSDK.init({
integrateId: data.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-ai-domain.com',
token: data.token,
roles: data.roles,
userId: 'current_user_id',
title: '在线客服',
theme: 'light',
quickReplies: ['如何退款?', '查看订单', '联系人工']
});
}
});
</script>
```

88
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);
}
}
}

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

59
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<Long> 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<Map<String, Object>> updateRoles(
@PathVariable Long id,
@RequestBody Map<String, Object> body) {
try {
List<Long> 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<Long> 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();
}
}

127
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 + 角色列表
* <p>
* 路径在 /open-api/ 请求首先由 ApiKeyAuthFilter 校验 X-API-Key
* 校验通过后 ApiKey 信息存入 request.attribute("apiKey")本控制器直接使用
* <p>
* 角色来源: 优先使用 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 换取接口
* <p>
* 前置条件: ApiKeyAuthFilter 已校验 X-API-Key存入 request.attribute("apiKey")
* <p>
* 返回: 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<Long> boundRoleIds = apiKeyService.parseRoleIds(apiKey);
// 3. 查询角色有绑定则用绑定列表否则返回所有启用角色向后兼容
List<RoleBrief> 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<Long> 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 JWTsubject = 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) {
}
}

3
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<Map<String, Object>> listRoles() {
return ResponseEntity.ok(Map.of(
"success", true,
"data", customerServiceRoleService.listRoles()
"data", customerServiceRoleService.listRoles(false)
));
}

4
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;

2
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

12
src/main/java/com/wok/supportbot/security/JwtAuthFilter.java

@ -33,9 +33,12 @@ 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)) {
if (StringUtils.hasText(token)) {
boolean valid = jwtTokenProvider.validateToken(token);
if (valid) {
String username = jwtTokenProvider.getUsernameFromToken(token);
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
@ -47,6 +50,13 @@ public class JwtAuthFilter extends OncePerRequestFilter {
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);

4
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;
}

114
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
* <p>
* 优先级高于 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<Long> 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<String, Object> body = Map.of(
"success", false,
"message", message,
"code", status
);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}

149
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
* <p>
* Token 中包含:
* - sub: apiKeyIdAPI 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<Long> 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<Long> 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 中提取 apiKeyIdsubject
*/
public String getApiKeyId(Claims claims) {
return claims.getSubject();
}
/**
* Claims 中提取允许的角色 ID 集合
* JWT rids List&lt;Integer&gt;需转换为 Set&lt;Long&gt;
*/
@SuppressWarnings("unchecked")
public Set<Long> 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));
}
}

5
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()

80
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<ApiKey> records = apiKeyMapper.selectList(listWrapper);
// 补充读取 JSONB role_ids 字段MyBatis Plus 无法正确读取 JSONB
if (!records.isEmpty()) {
Map<Long, String> 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<String, Object> 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 兜底
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<Long> roleIds) {
ApiKey existing = apiKeyMapper.selectById(id);
if (existing != null) {
existing.setCurrentCalls(
(existing.getCurrentCalls() != null ? existing.getCurrentCalls() : 0L) + 1
);
apiKeyMapper.updateById(existing);
if (existing == null) {
throw new IllegalArgumentException("API Key 不存在,ID:" + id);
}
String json;
try {
json = objectMapper.writeValueAsString(roleIds != null ? roleIds : List.of());
} catch (Exception e) {
log.warn("自增 API Key 调用次数失败: id={}", id, 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<Long> 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<List<Long>>() {});
} catch (Exception e) {
log.warn("解析 role_ids 失败: {}", apiKey.getRoleIds(), e);
return List.of();
}
}
}

55
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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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
@ -214,6 +219,46 @@ public class CustomerServiceRoleService {
}
}
/**
* 精简角色信息Token 换取接口用
*/
public record RoleBrief(Long id, String roleKey, String name) {}
/**
* 查询所有启用的客服角色精简字段
*
* @return 精简角色信息列表
*/
public List<RoleBrief> 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<RoleBrief> listRolesByIds(List<Long> 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 工具权限
*/

2
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()

3
src/main/java/com/wok/supportbot/service/FaqService.java

@ -259,7 +259,8 @@ public class FaqService {
Map<String, Object> stats = new HashMap<>();
// FAQ
Long totalCount = faqMapper.selectCount(new QueryWrapper<>());
QueryWrapper<KnowledgeFaq> totalWrapper = new QueryWrapper<>();
Long totalCount = faqMapper.selectCount(totalWrapper);
stats.put("totalCount", totalCount);
// 启用数

5
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

27
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,
@ -504,6 +505,7 @@ CREATE TABLE IF NOT EXISTS knowledge_faq (
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 知识库表(用于意图路由后的精准问答匹配)';
@ -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 张表及对应索引。

122
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 {
<th style="text-align:left;padding:8px;">Key</th>
<th style="text-align:center;padding:8px;">频率限制</th>
<th style="text-align:center;padding:8px;">调用次数</th>
<th style="text-align:center;padding:8px;">绑定角色</th>
<th style="text-align:center;padding:8px;">过期时间</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="7" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="keys.length === 0"><td colspan="7" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr>
<tr v-if="loading"><td colspan="8" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="keys.length === 0"><td colspan="8" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr>
<tr v-for="k in keys" :key="k.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;">
<div style="font-weight:500;">{{ k.name || '未命名' }}</div>
@ -50,6 +51,10 @@ export default {
<span v-if="k.maxCalls" style="color:#999;">/ {{ k.maxCalls }}</span>
<span v-else style="color:#999;">不限</span>
</td>
<td style="text-align:center;padding:8px;font-size:12px;">
<span v-if="getBoundRoleCount(k.roleIds) > 0" style="color:var(--primary);">{{ getBoundRoleCount(k.roleIds) }} 个角色</span>
<span v-else style="color:#999;">不限全部</span>
</td>
<td style="text-align:center;padding:8px;font-size:12px;">
{{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }}
</td>
@ -59,6 +64,7 @@ export default {
</span>
</td>
<td style="text-align:right;padding:8px;">
<button @click="openRoleDialog(k)" style="padding:3px 8px;background:none;border:1px solid var(--primary);color:var(--primary);border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">绑定角色</button>
<button v-if="k.enabled" @click="doRevoke(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">吊销</button>
<button v-else @click="doEnable(k.id)" style="padding:3px 8px;background:none;border:1px solid #28a745;color:#28a745;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">启用</button>
<button @click="doDelete(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button>
@ -100,6 +106,16 @@ export default {
<label style="display:block;margin-bottom:4px;font-size:13px;">过期时间可选</label>
<input v-model="form.expireTime" type="datetime-local" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">绑定客服角色可选不选则不限制</label>
<div v-if="allRoles.length === 0" style="font-size:12px;color:#999;padding:4px 0;">暂无可用角色</div>
<div v-for="role in allRoles" :key="role.id" style="display:inline-flex;align-items:center;gap:4px;margin:2px 8px 2px 0;font-size:13px;">
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" :value="Number(role.id)" v-model="form.roleIds" />
{{ role.name }} <span style="color:#999;font-size:11px;">({{ role.role_key }})</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showCreateDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doCreate" :disabled="!form.name" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">创建</button>
@ -121,6 +137,36 @@ export default {
</div>
</div>
</div>
<!-- 角色绑定弹窗 -->
<div v-if="showRoleDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:8px;">绑定客服角色 {{ editingKeyName }}</h4>
<p style="color:#666;font-size:13px;margin-bottom:16px;">勾选允许此 API Key 使用的客服角色不勾选任何角色则不限制可使用所有角色</p>
<div v-if="roleLoading" style="text-align:center;padding:20px;color:#999;">加载角色列表...</div>
<div v-else-if="allRoles.length === 0" style="text-align:center;padding:20px;color:#999;">暂无可用角色请先创建客服角色</div>
<div v-else>
<div v-for="role in allRoles" :key="role.id"
style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;cursor:pointer;"
@click="toggleRole(Number(role.id))">
<input type="checkbox" :checked="selectedRoleIds.includes(Number(role.id))" @click.stop style="cursor:pointer;" />
<div style="flex:1;">
<div style="font-weight:500;font-size:14px;">{{ role.name }}</div>
<div style="font-size:12px;color:#999;">{{ role.role_key }}<span v-if="role.description"> · {{ role.description }}</span></div>
</div>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;">
<span style="font-size:12px;color:#999;">已选 {{ selectedRoleIds.length }} 个角色{{ selectedRoleIds.length === 0 ? '(不限制)' : '' }}</span>
<div style="display:flex;gap:8px;">
<button @click="showRoleDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doSaveRoles" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
</div>
</div>
`,
@ -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
}
}
}

24
src/main/resources/static/js/api.js

@ -3,15 +3,19 @@
* 封装所有后端接口调用组件只需调用函数处理业务逻辑
* P1: 所有请求自动附加 Bearer Token401 时触发未登录事件
*/
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 })
}

26
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() })
}
})
})

1
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':
// 新页面无额外数据预加载

127
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 = `<div class="csk-history-panel__loading">加载中...</div>`;
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);

104
src/main/resources/static/sdk/test.html

@ -111,6 +111,24 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成)
</div>
<!-- SDK Token 鉴权 -->
<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;padding:12px;margin-bottom:14px;">
<div style="font-size:12px;font-weight:600;color:#166534;margin-bottom:8px;">🔐 SDK Token 鉴权</div>
<div class="fg" style="margin-bottom:8px">
<label>API Key(用于换取 Token)</label>
<div style="display:flex;gap:6px">
<input type="text" id="cfg-apikey" placeholder="sk_xxxx..." style="flex:1">
<button class="btn btn-primary btn-sm" onclick="fetchToken()" id="btn-fetch-token">获取 Token</button>
</div>
<div class="hint">调用 POST /open-api/auth/token 换取 SDK Token</div>
</div>
<div class="fg" style="margin-bottom:0">
<label>SDK Token(自动填充或手动粘贴)</label>
<input type="text" id="cfg-token" placeholder="eyJhbGciOi...(获取后自动填充)">
<div class="hint">Token 有效期默认 2 小时,过期需重新获取</div>
</div>
</div>
<div class="fg">
<label>integrateId → roleId(必传,客服角色 ID)</label>
<input type="number" id="cfg-iid" value="1" placeholder="客服角色 ID(数字)">
@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\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 = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\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 '<span class="cm">' + htmlCmt + '</span>';
if (str) return '<span class="st">' + str + '</span>';
@ -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='⚠ 后端离线';});
})();
})();
</script>

Loading…
Cancel
Save