26 changed files with 1557 additions and 77 deletions
-
19CLAUDE.md
-
437SDK-INTEGRATION.md
-
88src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
3src/main/java/com/wok/supportbot/controller/AiController.java
-
59src/main/java/com/wok/supportbot/controller/ApiKeyController.java
-
127src/main/java/com/wok/supportbot/controller/AuthController.java
-
3src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
-
4src/main/java/com/wok/supportbot/entity/ApiKey.java
-
2src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java
-
12src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
-
4src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
-
114src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
-
149src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
-
5src/main/java/com/wok/supportbot/security/SecurityConfig.java
-
80src/main/java/com/wok/supportbot/service/ApiKeyService.java
-
55src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
-
2src/main/java/com/wok/supportbot/service/DocumentService.java
-
3src/main/java/com/wok/supportbot/service/FaqService.java
-
5src/main/resources/application.yml
-
27src/main/resources/init-database.sql
-
122src/main/resources/static/components/ApiKeyManager.js
-
24src/main/resources/static/js/api.js
-
26src/main/resources/static/js/app.js
-
1src/main/resources/static/js/store.js
-
127src/main/resources/static/sdk/chatbot-sdk.js
-
104src/main/resources/static/sdk/test.html
@ -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> |
||||
|
``` |
||||
@ -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 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) { |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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: 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<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 中提取 apiKeyId(subject) |
||||
|
*/ |
||||
|
public String getApiKeyId(Claims claims) { |
||||
|
return claims.getSubject(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 从 Claims 中提取允许的角色 ID 集合 |
||||
|
* JWT 中 rids 为 List<Integer>,需转换为 Set<Long> |
||||
|
*/ |
||||
|
@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)); |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue