72 changed files with 7232 additions and 1219 deletions
-
19CLAUDE.md
-
472SDK-INTEGRATION.md
-
2client/README.md
-
963client/dist/chatbot-sdk.js
-
2client/dist/chatbot-sdk.js.map
-
2client/dist/chatbot-sdk.min.js
-
2client/dist/chatbot-sdk.min.js.map
-
101client/src/api.ts
-
107client/src/chat.ts
-
126client/src/config.ts
-
179client/src/dom.ts
-
6client/src/i18n.ts
-
55client/src/index.ts
-
453client/src/styles.ts
-
21client/src/types.ts
-
9client/src/utils.ts
-
BINio/modelcontextprotocol/client/McpSyncClient.class
-
6pom.xml
-
223src/main/java/com/wok/supportbot/app/AssistantApp.java
-
16src/main/java/com/wok/supportbot/config/ChatModelFactory.java
-
203src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
80src/main/java/com/wok/supportbot/config/FileStorageConfig.java
-
660src/main/java/com/wok/supportbot/config/McpClientManager.java
-
24src/main/java/com/wok/supportbot/config/McpRequestInterceptor.java
-
24src/main/java/com/wok/supportbot/config/McpWebMvcConfig.java
-
17src/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
-
36src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
-
36src/main/java/com/wok/supportbot/controller/DocumentController.java
-
348src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
-
118src/main/java/com/wok/supportbot/converter/MessageConverter.java
-
12src/main/java/com/wok/supportbot/dao/McpServerConfigMapper.java
-
35src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java
-
37src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java
-
41src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java
-
4src/main/java/com/wok/supportbot/entity/ApiKey.java
-
6src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
-
104src/main/java/com/wok/supportbot/entity/McpServerConfig.java
-
265src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
-
111src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java
-
73src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java
-
2src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java
-
30src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
-
4src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
-
124src/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
-
82src/main/java/com/wok/supportbot/service/ApiKeyService.java
-
143src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
-
124src/main/java/com/wok/supportbot/service/DocumentService.java
-
3src/main/java/com/wok/supportbot/service/FaqService.java
-
320src/main/java/com/wok/supportbot/service/McpServerConfigService.java
-
5src/main/resources/application-dev.yml
-
9src/main/resources/application-prod.yml
-
9src/main/resources/application.yml
-
94src/main/resources/init-database.sql
-
129src/main/resources/static/components/ApiKeyManager.js
-
41src/main/resources/static/components/ChatPanel.js
-
9src/main/resources/static/components/DocList.js
-
524src/main/resources/static/components/McpServerManager.js
-
29src/main/resources/static/components/RoleManager.js
-
9src/main/resources/static/css/main.css
-
134src/main/resources/static/js/api.js
-
33src/main/resources/static/js/app.js
-
5src/main/resources/static/js/store.js
-
61src/main/resources/static/js/utils.js
-
963src/main/resources/static/sdk/chatbot-sdk.js
-
2src/main/resources/static/sdk/chatbot-sdk.js.map
-
2src/main/resources/static/sdk/chatbot-sdk.min.js
-
2src/main/resources/static/sdk/chatbot-sdk.min.js.map
-
221src/main/resources/static/sdk/test.html
@ -0,0 +1,472 @@ |
|||
# 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) |
|||
- [5.5 角色切换](#55-角色切换) |
|||
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: [ // 可用角色列表(传多个时 SDK header 显示角色切换下拉框) |
|||
{ 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 换取步骤。由于不传 `roles`,SDK 不会显示角色选择器,用户只能使用初始指定的单一角色。 |
|||
|
|||
### 5.5 角色切换 |
|||
|
|||
当 `roles` 数组长度 > 1 时,SDK 会在聊天窗口头部自动显示角色选择下拉框,用户可随时切换当前使用的客服角色。 |
|||
|
|||
**触发条件:** |
|||
- `ChatbotSDK.init()` 时传入的 `roles` 数组包含 2 个及以上角色 |
|||
|
|||
**切换行为:** |
|||
1. 自动保存当前对话到 localStorage(与旧角色关联) |
|||
2. 清空当前消息列表 |
|||
3. 生成新的 `chatId`(与新角色关联) |
|||
4. 尝试从 localStorage 恢复新角色的缓存消息 |
|||
5. 异步从后端加载新角色的历史会话 |
|||
|
|||
**示例:** |
|||
|
|||
```javascript |
|||
// 1. 后端换取 Token,获取可用角色列表 |
|||
const data = await fetch('/api/chatbot/token').then(r => r.json()); |
|||
|
|||
// 2. 初始化 SDK,传入角色列表 |
|||
ChatbotSDK.init({ |
|||
integrateId: data.roles[0].id, // 默认使用第一个角色 |
|||
requestDomain: 'https://your-domain.com', |
|||
token: data.token, |
|||
roles: data.roles, // 传入多个角色 → header 显示角色选择器 |
|||
userId: 'current_user_id' |
|||
}); |
|||
``` |
|||
|
|||
**安全说明:** |
|||
- 角色切换由 SDK 前端发起,后端 `SdkAuthFilter` 会校验 JWT Token 中的 `rids` claim,确保用户只能切换到被授权的角色 |
|||
- 切换角色不需要重新换取 Token,JWT 中已包含允许的角色 ID 列表 |
|||
|
|||
--- |
|||
|
|||
## 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}]`,长度 > 1 时 header 显示切换下拉框 | |
|||
| `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> |
|||
``` |
|||
963
client/dist/chatbot-sdk.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
client/dist/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,80 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import jakarta.annotation.PostConstruct; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.io.File; |
|||
import java.nio.file.Path; |
|||
import java.nio.file.Paths; |
|||
|
|||
/** |
|||
* 文件存储配置 |
|||
* 管理上传文件的本地存储路径 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class FileStorageConfig { |
|||
|
|||
@Value("${knowledge.storage.local-path:./uploads}") |
|||
private String localPath; |
|||
|
|||
private Path storageRootPath; |
|||
|
|||
@PostConstruct |
|||
public void init() { |
|||
storageRootPath = Paths.get(localPath).toAbsolutePath().normalize(); |
|||
File rootDir = storageRootPath.toFile(); |
|||
if (!rootDir.exists()) { |
|||
boolean created = rootDir.mkdirs(); |
|||
if (created) { |
|||
log.info("创建文件存储根目录: {}", storageRootPath); |
|||
} else { |
|||
log.warn("创建文件存储根目录失败: {}", storageRootPath); |
|||
} |
|||
} |
|||
log.info("文件存储路径: {}", storageRootPath); |
|||
} |
|||
|
|||
/** |
|||
* 获取存储根路径 |
|||
*/ |
|||
public Path getStorageRootPath() { |
|||
return storageRootPath; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定文件的完整路径 |
|||
* |
|||
* @param relativePath 相对路径(如 "2024/01/xxx.docx") |
|||
*/ |
|||
public Path getFilePath(String relativePath) { |
|||
return storageRootPath.resolve(relativePath).normalize(); |
|||
} |
|||
|
|||
/** |
|||
* 生成按年月组织的相对路径 |
|||
* |
|||
* @param originalFilename 原始文件名 |
|||
* @return 相对路径,如 "2024/01/uuid.docx" |
|||
*/ |
|||
public String generateRelativePath(String originalFilename) { |
|||
String extension = getExtension(originalFilename); |
|||
String uniqueName = java.util.UUID.randomUUID().toString() + extension; |
|||
|
|||
// 按年月分目录 |
|||
java.time.LocalDate now = java.time.LocalDate.now(); |
|||
return String.format("%d/%02d/%s", now.getYear(), now.getMonthValue(), uniqueName); |
|||
} |
|||
|
|||
/** |
|||
* 获取文件扩展名(含点号) |
|||
*/ |
|||
private String getExtension(String filename) { |
|||
if (filename == null || !filename.contains(".")) { |
|||
return ""; |
|||
} |
|||
return filename.substring(filename.lastIndexOf(".")).toLowerCase(); |
|||
} |
|||
} |
|||
@ -0,0 +1,660 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import com.wok.supportbot.service.McpServerConfigService; |
|||
import io.modelcontextprotocol.client.McpSyncClient; |
|||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; |
|||
import io.modelcontextprotocol.client.transport.ServerParameters; |
|||
import io.modelcontextprotocol.client.transport.StdioClientTransport; |
|||
import io.modelcontextprotocol.spec.McpSchema; |
|||
import jakarta.annotation.PostConstruct; |
|||
import jakarta.annotation.PreDestroy; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.model.ModelOptionsUtils; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.scheduling.annotation.Scheduled; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.time.Duration; |
|||
import java.time.Instant; |
|||
import java.util.*; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
|
|||
/** |
|||
* MCP 客户端生命周期管理器 |
|||
* 负责 MCP Client 的创建、缓存、刷新和销毁。 |
|||
* <p> |
|||
* 支持两种传输模式: |
|||
* - SSE(Server-Sent Events):通过 HttpClientSseClientTransport 连接远程 MCP Server |
|||
* - stdio(标准输入输出):通过 StdioClientTransport 启动本地 MCP Server 进程 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class McpClientManager { |
|||
|
|||
@Autowired |
|||
private McpServerConfigService mcpServerConfigService; |
|||
|
|||
/** |
|||
* 客户端缓存:key = 配置ID 的字符串形式,value = MCP Client 实例 |
|||
* volatile 保证 refreshAll() 双缓冲切换时的可见性 |
|||
*/ |
|||
private volatile ConcurrentHashMap<String, McpSyncClient> clientCache = new ConcurrentHashMap<>(); |
|||
|
|||
/** |
|||
* 不可用配置集合:记录已知不存在或未启用的配置ID,避免重复查询 DB |
|||
* 配置被启用或新建时需同步移除此集合中的对应条目 |
|||
*/ |
|||
private final Set<String> unavailableConfigs = ConcurrentHashMap.newKeySet(); |
|||
|
|||
/** |
|||
* 健康状态缓存:key = 配置ID 的字符串形式,value = 健康检查结果 |
|||
*/ |
|||
private final ConcurrentHashMap<String, HealthStatus> healthCache = new ConcurrentHashMap<>(); |
|||
|
|||
/** |
|||
* MCP Server 健康状态记录 |
|||
*/ |
|||
public record HealthStatus( |
|||
/** 状态:ONLINE / OFFLINE / UNKNOWN */ |
|||
String status, |
|||
/** 响应延迟(毫秒) */ |
|||
long latencyMs, |
|||
/** 最后检查时间 */ |
|||
Instant lastCheckTime, |
|||
/** 错误信息(健康时为 null) */ |
|||
String errorMessage |
|||
) { |
|||
public static HealthStatus online(long latencyMs) { |
|||
return new HealthStatus("ONLINE", latencyMs, Instant.now(), null); |
|||
} |
|||
|
|||
public static HealthStatus offline(long latencyMs, String errorMessage) { |
|||
return new HealthStatus("OFFLINE", latencyMs, Instant.now(), errorMessage); |
|||
} |
|||
|
|||
public static HealthStatus unknown(String errorMessage) { |
|||
return new HealthStatus("UNKNOWN", 0, Instant.now(), errorMessage); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* MCP 客户端请求超时时间,支持 Duration 格式(如 30s、PT30S、60s) |
|||
*/ |
|||
@Value("${mcp.client.request-timeout:30s}") |
|||
private String requestTimeoutStr; |
|||
|
|||
/** |
|||
* MCP 客户端初始化超时时间,支持 Duration 格式(如 15s、PT15S、30s) |
|||
*/ |
|||
@Value("${mcp.client.init-timeout:15s}") |
|||
private String initTimeoutStr; |
|||
|
|||
/** |
|||
* 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接 |
|||
* 确保第一次对话请求时 clientCache 已就绪,MCP 工具可以被注册到 ChatClient |
|||
*/ |
|||
@PostConstruct |
|||
public void init() { |
|||
log.info("McpClientManager 初始化,开始加载已启用的 MCP Server 配置..."); |
|||
refreshAll(); |
|||
} |
|||
|
|||
/** |
|||
* 刷新所有 MCP 客户端连接(双缓冲策略) |
|||
* 先在新 Map 中构建所有客户端,再原子切换引用,最后关闭旧客户端。 |
|||
* 避免清空与重建之间的请求全部失败。 |
|||
*/ |
|||
public void refreshAll() { |
|||
log.info("开始刷新所有 MCP 客户端连接..."); |
|||
|
|||
// 获取所有启用的配置(一次查询代替两次按类型过滤) |
|||
List<McpServerConfig> allActiveConfigs = mcpServerConfigService.listAllActiveConfigs(); |
|||
|
|||
// 第一步:在全新 Map 中构建所有客户端(不影响现有缓存) |
|||
ConcurrentHashMap<String, McpSyncClient> newCache = new ConcurrentHashMap<>(); |
|||
for (McpServerConfig config : allActiveConfigs) { |
|||
try { |
|||
McpSyncClient client = createClientDirectly(config); |
|||
if (client != null) { |
|||
newCache.put(config.getId().toString(), client); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("创建客户端失败: id={}, name={}, transportType={}, error={}", |
|||
config.getId(), config.getName(), config.getTransportType(), e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
// 第二步:原子切换引用(volatile 保证其他线程立即可见) |
|||
ConcurrentHashMap<String, McpSyncClient> oldCache = clientCache; |
|||
clientCache = newCache; |
|||
unavailableConfigs.clear(); |
|||
|
|||
// 第三步:关闭旧缓存中的客户端(不影响新请求) |
|||
for (Map.Entry<String, McpSyncClient> entry : oldCache.entrySet()) { |
|||
try { |
|||
McpSyncClient client = entry.getValue(); |
|||
if (client != null) { |
|||
client.close(); |
|||
log.debug("已关闭旧 MCP 客户端: configId={}", entry.getKey()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("关闭旧 MCP 客户端失败: configId={}, error={}", entry.getKey(), e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
log.info("MCP 客户端刷新完成,当前缓存数量: {}", clientCache.size()); |
|||
} |
|||
|
|||
/** |
|||
* 获取指定配置的客户端(懒加载) |
|||
* 使用 computeIfAbsent 保证同一配置只创建一次客户端,避免并发竞态。 |
|||
* 如果缓存中不存在且不在不可用集合中,从 DB 读取配置后原子创建并缓存。 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return MCP Client 实例,不存在或未启用返回 null |
|||
*/ |
|||
public McpSyncClient getClient(Long configId) { |
|||
String key = configId.toString(); |
|||
|
|||
// 快速路径:缓存命中 |
|||
McpSyncClient cached = clientCache.get(key); |
|||
if (cached != null) { |
|||
return cached; |
|||
} |
|||
|
|||
// 快速路径:已知不可用的配置,直接跳过 |
|||
if (unavailableConfigs.contains(key)) { |
|||
return null; |
|||
} |
|||
|
|||
// 缓存未命中,使用 computeIfAbsent 保证同一 key 只创建一次客户端 |
|||
// ConcurrentHashMap.computeIfAbsent 对同一 key 加锁,避免并发线程重复创建 |
|||
// 注意:mapping function 不能返回 null(会抛 NPE),因此不可用的情况记录到 unavailableConfigs |
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null) { |
|||
log.warn("MCP 配置不存在: id={}", configId); |
|||
unavailableConfigs.add(key); |
|||
return null; |
|||
} |
|||
if (!Boolean.TRUE.equals(config.getIsActive())) { |
|||
log.warn("MCP 配置未启用,跳过创建客户端: id={}, name={}", configId, config.getName()); |
|||
unavailableConfigs.add(key); |
|||
return null; |
|||
} |
|||
|
|||
// 配置存在且启用,通过 computeIfAbsent 原子创建(防止并发重复创建) |
|||
return clientCache.computeIfAbsent(key, k -> createClientDirectly(config)); |
|||
} |
|||
|
|||
/** |
|||
* 获取所有已启用的 MCP 工具描述 |
|||
* 遍历所有已缓存的客户端,调用 listTools() 获取真实的工具列表 |
|||
* |
|||
* @return 工具描述列表,每个元素包含 config_id、name、transport_type、description、tools |
|||
*/ |
|||
public List<Map<String, Object>> listAvailableTools() { |
|||
List<Map<String, Object>> tools = new ArrayList<>(); |
|||
|
|||
log.info("🔍 listAvailableTools: clientCache 大小 = {}", clientCache.size()); |
|||
|
|||
if (clientCache.isEmpty()) { |
|||
log.warn("⚠️ clientCache 为空!MCP Server 可能未配置或连接失败。"); |
|||
log.warn(" 请检查:1) 是否添加了 MCP Server 配置 2) 配置是否已启用 3) 启动日志是否有连接错误"); |
|||
return tools; |
|||
} |
|||
|
|||
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) { |
|||
Long configId; |
|||
try { |
|||
configId = Long.parseLong(entry.getKey()); |
|||
} catch (NumberFormatException e) { |
|||
log.warn("缓存 key 格式异常: {}", entry.getKey()); |
|||
continue; |
|||
} |
|||
|
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null || !Boolean.TRUE.equals(config.getIsActive())) { |
|||
continue; |
|||
} |
|||
|
|||
Map<String, Object> toolInfo = new LinkedHashMap<>(); |
|||
toolInfo.put("config_id", config.getId().toString()); |
|||
toolInfo.put("name", config.getName()); |
|||
toolInfo.put("transport_type", config.getTransportType()); |
|||
toolInfo.put("description", config.getDescription()); |
|||
|
|||
// 从 MCP Client 获取真实的工具列表 |
|||
List<Map<String, Object>> toolList = new ArrayList<>(); |
|||
try { |
|||
McpSyncClient client = entry.getValue(); |
|||
McpSchema.ListToolsResult listToolsResult = client.listTools(); |
|||
if (listToolsResult != null && listToolsResult.tools() != null) { |
|||
log.info(" 📦 MCP Server [{}] 暴露了 {} 个工具", config.getName(), listToolsResult.tools().size()); |
|||
for (McpSchema.Tool tool : listToolsResult.tools()) { |
|||
Map<String, Object> toolMeta = new LinkedHashMap<>(); |
|||
toolMeta.put("name", tool.name()); |
|||
toolMeta.put("description", tool.description()); |
|||
// JsonSchema 对象序列化为 JSON 字符串,供 McpToolCallback 构建 ToolDefinition 使用 |
|||
if (tool.inputSchema() != null) { |
|||
toolMeta.put("inputSchema", ModelOptionsUtils.toJsonString(tool.inputSchema())); |
|||
} else { |
|||
toolMeta.put("inputSchema", "{}"); |
|||
} |
|||
toolList.add(toolMeta); |
|||
} |
|||
} |
|||
log.info(" 📦 MCP Server [{}] 获取到 {} 个工具", config.getName(), toolList.size()); |
|||
if (toolList.isEmpty()) { |
|||
log.warn(" ⚠️ MCP Server [{}] 未暴露任何工具,AI 模型将无法调用", config.getName()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("获取 MCP 工具列表失败: serverId={}, name={}, error={}", |
|||
configId, config.getName(), e.getMessage()); |
|||
} |
|||
|
|||
toolInfo.put("tools", toolList); |
|||
tools.add(toolInfo); |
|||
} |
|||
|
|||
return tools; |
|||
} |
|||
|
|||
/** |
|||
* 定时健康检查:每 5 分钟执行一次 |
|||
* 遍历所有缓存的客户端,尝试 listTools() 探测连通性,记录状态到 healthCache |
|||
*/ |
|||
@Scheduled(fixedDelay = 300000) |
|||
public void healthCheck() { |
|||
log.info("开始 MCP Server 定时健康检查,当前缓存数量: {}", clientCache.size()); |
|||
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) { |
|||
String key = entry.getKey(); |
|||
checkSingleClient(key, entry.getValue()); |
|||
} |
|||
log.info("MCP Server 定时健康检查完成"); |
|||
} |
|||
|
|||
/** |
|||
* 手动触发单个配置的健康检查 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return 该配置的最新健康状态 |
|||
*/ |
|||
public HealthStatus checkHealthForConfig(Long configId) { |
|||
String key = configId.toString(); |
|||
McpSyncClient client = clientCache.get(key); |
|||
if (client == null) { |
|||
HealthStatus status = HealthStatus.unknown("客户端未缓存或配置不存在"); |
|||
healthCache.put(key, status); |
|||
return status; |
|||
} |
|||
return checkSingleClient(key, client); |
|||
} |
|||
|
|||
/** |
|||
* 对单个客户端执行健康探测 |
|||
* 通过调用 listTools() 判断服务端是否存活 |
|||
* |
|||
* @param key 缓存 key(配置ID 字符串) |
|||
* @param client MCP 客户端实例 |
|||
* @return 健康状态 |
|||
*/ |
|||
private HealthStatus checkSingleClient(String key, McpSyncClient client) { |
|||
long startTime = System.currentTimeMillis(); |
|||
try { |
|||
McpSchema.ListToolsResult result = client.listTools(); |
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
int toolsCount = (result != null && result.tools() != null) ? result.tools().size() : 0; |
|||
HealthStatus status = HealthStatus.online(latencyMs); |
|||
healthCache.put(key, status); |
|||
log.debug("MCP Server 健康检查通过: configId={}, latencyMs={}, toolsCount={}", key, latencyMs, toolsCount); |
|||
return status; |
|||
} catch (Exception e) { |
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage()); |
|||
healthCache.put(key, status); |
|||
log.warn("MCP Server 健康检查失败: configId={}, latencyMs={}, error={}", key, latencyMs, e.getMessage()); |
|||
return status; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定配置的健康状态 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return 健康状态,未检查过返回 UNKNOWN |
|||
*/ |
|||
public HealthStatus getHealthStatus(Long configId) { |
|||
HealthStatus status = healthCache.get(configId.toString()); |
|||
if (status == null) { |
|||
return HealthStatus.unknown("尚未执行健康检查"); |
|||
} |
|||
return status; |
|||
} |
|||
|
|||
/** |
|||
* 获取所有已缓存配置的健康状态 |
|||
* |
|||
* @return Map:key = 配置ID 字符串,value = 健康状态 |
|||
*/ |
|||
public Map<String, HealthStatus> getAllHealthStatus() { |
|||
Map<String, HealthStatus> result = new LinkedHashMap<>(); |
|||
// 遍历所有缓存的客户端,未在 healthCache 中的标记为 UNKNOWN |
|||
for (String key : clientCache.keySet()) { |
|||
HealthStatus status = healthCache.get(key); |
|||
if (status == null) { |
|||
status = HealthStatus.unknown("尚未执行健康检查"); |
|||
} |
|||
result.put(key, status); |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
/** |
|||
* 关闭所有 MCP 客户端连接 |
|||
* 在应用关闭或手动刷新时调用 |
|||
*/ |
|||
@PreDestroy |
|||
public void shutdown() { |
|||
log.info("关闭所有 MCP 客户端连接,当前数量: {}", clientCache.size()); |
|||
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) { |
|||
try { |
|||
McpSyncClient client = entry.getValue(); |
|||
if (client != null) { |
|||
client.close(); |
|||
log.debug("已关闭 MCP 客户端: configId={}", entry.getKey()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("关闭 MCP 客户端失败: configId={}, error={}", entry.getKey(), e.getMessage()); |
|||
} |
|||
} |
|||
clientCache.clear(); |
|||
unavailableConfigs.clear(); |
|||
healthCache.clear(); |
|||
log.info("所有 MCP 客户端连接已关闭"); |
|||
} |
|||
|
|||
/** |
|||
* 根据配置创建 MCP 客户端(不负责缓存,由调用方决定是否缓存) |
|||
* <p> |
|||
* SSE 模式:使用 HttpClientSseClientTransport 连接远程 MCP Server |
|||
* stdio 模式:使用 StdioClientTransport 启动本地 MCP Server 进程 |
|||
* |
|||
* @param config MCP Server 配置 |
|||
* @return 创建并初始化后的客户端实例,失败返回 null |
|||
*/ |
|||
private McpSyncClient createClientDirectly(McpServerConfig config) { |
|||
try { |
|||
McpSyncClient client; |
|||
|
|||
if ("sse".equals(config.getTransportType())) { |
|||
// 创建 SSE 传输层:连接远程 MCP Server |
|||
// HttpClientSseClientTransport 会自动将 /sse 作为 SSE 端点附加到 baseUri |
|||
String serverUrl = config.getServerUrl().trim(); |
|||
// 如果 URL 以 /sse 结尾,去掉后缀作为 baseUri(transport 层会自动附加) |
|||
String baseUri = serverUrl.replaceAll("/sse/?$", ""); |
|||
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUri).build(); |
|||
|
|||
client = buildSyncClient(transport); |
|||
log.info("正在初始化 SSE MCP 客户端: id={}, url={}", config.getId(), baseUri); |
|||
|
|||
} else if ("stdio".equals(config.getTransportType())) { |
|||
// 创建 stdio 传输层:启动本地 MCP Server 进程 |
|||
String command = config.getCommand().trim(); |
|||
|
|||
// 解析参数列表(逗号分隔) |
|||
List<String> argsList = new ArrayList<>(); |
|||
if (config.getArgs() != null && !config.getArgs().trim().isEmpty()) { |
|||
argsList = Arrays.asList(config.getArgs().split("\\s*,\\s*")); |
|||
} |
|||
|
|||
// 构建 ServerParameters |
|||
ServerParameters.Builder paramsBuilder = ServerParameters.builder(command); |
|||
if (!argsList.isEmpty()) { |
|||
paramsBuilder.args(argsList); |
|||
} |
|||
|
|||
// 环境变量(Map<String, Object> -> Map<String, String>) |
|||
if (config.getEnvVars() != null && !config.getEnvVars().isEmpty()) { |
|||
Map<String, String> envStr = new HashMap<>(); |
|||
config.getEnvVars().forEach((k, v) -> envStr.put(k, v != null ? v.toString() : "")); |
|||
paramsBuilder.env(envStr); |
|||
} |
|||
|
|||
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build()); |
|||
client = buildSyncClient(transport); |
|||
log.info("正在初始化 stdio MCP 客户端: id={}, command={}", config.getId(), command); |
|||
|
|||
} else { |
|||
log.error("不支持的传输类型: {}", config.getTransportType()); |
|||
return null; |
|||
} |
|||
|
|||
// 初始化连接(握手协议) |
|||
client.initialize(); |
|||
log.info("MCP 客户端初始化成功: id={}, name={}, transportType={}", |
|||
config.getId(), config.getName(), config.getTransportType()); |
|||
|
|||
return client; |
|||
|
|||
} catch (Exception e) { |
|||
log.error("创建 MCP 客户端失败: id={}, name={}, transportType={}, error={}", |
|||
config.getId(), config.getName(), config.getTransportType(), e.getMessage(), e); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 构建 McpSyncClient 实例 |
|||
* 统一设置客户端信息和超时参数,超时时间从配置文件读取 |
|||
* |
|||
* @param transport 传输层实例(SSE 或 stdio) |
|||
* @return 构建的 McpSyncClient |
|||
*/ |
|||
private McpSyncClient buildSyncClient(io.modelcontextprotocol.spec.McpClientTransport transport) { |
|||
Duration requestTimeout = parseDuration(requestTimeoutStr, Duration.ofSeconds(30)); |
|||
Duration initTimeout = parseDuration(initTimeoutStr, Duration.ofSeconds(15)); |
|||
|
|||
return io.modelcontextprotocol.client.McpClient.sync(transport) |
|||
.clientInfo(new McpSchema.Implementation("supportbot-mcp-client", "1.0.0")) |
|||
.requestTimeout(requestTimeout) |
|||
.initializationTimeout(initTimeout) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 解析 Duration 字符串,兼容 ISO-8601 格式(PT30S)和简化格式(30s、60s) |
|||
* 解析失败时返回默认值 |
|||
* |
|||
* @param durationStr Duration 字符串 |
|||
* @param defaultValue 解析失败时的默认值 |
|||
* @return 解析后的 Duration |
|||
*/ |
|||
private Duration parseDuration(String durationStr, Duration defaultValue) { |
|||
if (durationStr == null || durationStr.isBlank()) { |
|||
return defaultValue; |
|||
} |
|||
try { |
|||
// 先尝试 ISO-8601 格式(如 PT30S、PT1M30S) |
|||
return Duration.parse(durationStr.trim()); |
|||
} catch (Exception e) { |
|||
// 尝试简化格式:纯数字秒(如 "30")或带 "s" 后缀(如 "30s") |
|||
String trimmed = durationStr.trim().toLowerCase(); |
|||
String numStr = trimmed.replaceAll("[^0-9.]+$", ""); |
|||
if (!numStr.isEmpty()) { |
|||
try { |
|||
long seconds = Long.parseLong(numStr); |
|||
return Duration.ofSeconds(seconds); |
|||
} catch (NumberFormatException ignored) { |
|||
// 继续尝试小数 |
|||
} |
|||
} |
|||
log.warn("无法解析 Duration 配置 '{}',使用默认值: {}", durationStr, defaultValue); |
|||
return defaultValue; |
|||
} |
|||
} |
|||
|
|||
// ==================== 增量缓存管理(供 Controller 层 CRUD 操作使用) ==================== |
|||
|
|||
/** |
|||
* 新增配置时:创建客户端并加入缓存 |
|||
* 仅对新建的单个配置创建连接,不影响其他已有连接。 |
|||
* |
|||
* @param configId 新建配置的ID |
|||
*/ |
|||
public void addClient(Long configId) { |
|||
String key = configId.toString(); |
|||
// 从不可用集合中移除(新建的配置可能之前被标记为不可用) |
|||
unavailableConfigs.remove(key); |
|||
|
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null || !Boolean.TRUE.equals(config.getIsActive())) { |
|||
return; |
|||
} |
|||
McpSyncClient client = createClientDirectly(config); |
|||
if (client != null) { |
|||
clientCache.put(key, client); |
|||
log.info("增量添加 MCP 客户端: id={}, name={}", configId, config.getName()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新配置时:关闭旧客户端,重建新客户端 |
|||
* 仅对更新的单个配置重建连接,不影响其他已有连接。 |
|||
* |
|||
* @param configId 更新的配置ID |
|||
*/ |
|||
public void rebuildClient(Long configId) { |
|||
String key = configId.toString(); |
|||
// 先移除旧客户端 |
|||
removeClient(configId); |
|||
|
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null) { |
|||
unavailableConfigs.add(key); |
|||
return; |
|||
} |
|||
if (!Boolean.TRUE.equals(config.getIsActive())) { |
|||
unavailableConfigs.add(key); |
|||
return; |
|||
} |
|||
|
|||
unavailableConfigs.remove(key); |
|||
McpSyncClient client = createClientDirectly(config); |
|||
if (client != null) { |
|||
clientCache.put(key, client); |
|||
log.info("增量重建 MCP 客户端: id={}, name={}", configId, config.getName()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 禁用配置时:关闭并移除客户端,标记为不可用 |
|||
* |
|||
* @param configId 禁用的配置ID |
|||
*/ |
|||
public void disableClient(Long configId) { |
|||
removeClient(configId); |
|||
unavailableConfigs.add(configId.toString()); |
|||
} |
|||
|
|||
/** |
|||
* 移除指定配置的客户端缓存 |
|||
* 配置删除或禁用时调用 |
|||
* |
|||
* @param configId 配置ID |
|||
*/ |
|||
public void removeClient(Long configId) { |
|||
String key = configId.toString(); |
|||
McpSyncClient removed = clientCache.remove(key); |
|||
if (removed != null) { |
|||
try { |
|||
removed.close(); |
|||
} catch (Exception e) { |
|||
log.error("关闭被移除的 MCP 客户端失败: configId={}, error={}", configId, e.getMessage()); |
|||
} |
|||
log.info("已移除并关闭 MCP 客户端缓存: configId={}", configId); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 测试指定配置的 MCP Server 连接 |
|||
* 创建临时客户端 -> initialize -> listTools -> close,不影响正常缓存 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return 测试结果 Map:success / latencyMs / toolsCount / errorMessage |
|||
*/ |
|||
public Map<String, Object> testConnection(Long configId) { |
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null) { |
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", false); |
|||
result.put("latencyMs", 0); |
|||
result.put("toolsCount", 0); |
|||
result.put("errorMessage", "配置不存在: id=" + configId); |
|||
return result; |
|||
} |
|||
|
|||
McpSyncClient tempClient = null; |
|||
long startTime = System.currentTimeMillis(); |
|||
try { |
|||
// 创建临时客户端(不加入缓存),复用 createClientDirectly 的传输层构建逻辑 |
|||
tempClient = createClientDirectly(config); |
|||
if (tempClient == null) { |
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", false); |
|||
result.put("latencyMs", System.currentTimeMillis() - startTime); |
|||
result.put("toolsCount", 0); |
|||
result.put("errorMessage", "客户端创建失败,请检查配置"); |
|||
return result; |
|||
} |
|||
|
|||
// 获取工具列表 |
|||
McpSchema.ListToolsResult listToolsResult = tempClient.listTools(); |
|||
int toolsCount = (listToolsResult != null && listToolsResult.tools() != null) |
|||
? listToolsResult.tools().size() : 0; |
|||
|
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
log.info("MCP 连接测试成功: id={}, name={}, latencyMs={}, toolsCount={}", |
|||
configId, config.getName(), latencyMs, toolsCount); |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", true); |
|||
result.put("latencyMs", latencyMs); |
|||
result.put("toolsCount", toolsCount); |
|||
result.put("errorMessage", null); |
|||
return result; |
|||
|
|||
} catch (Exception e) { |
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
log.warn("MCP 连接测试失败: id={}, name={}, latencyMs={}, error={}", |
|||
configId, config.getName(), latencyMs, e.getMessage()); |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", false); |
|||
result.put("latencyMs", latencyMs); |
|||
result.put("toolsCount", 0); |
|||
result.put("errorMessage", e.getMessage()); |
|||
return result; |
|||
|
|||
} finally { |
|||
// 确保关闭临时客户端 |
|||
if (tempClient != null) { |
|||
try { |
|||
tempClient.close(); |
|||
} catch (Exception e) { |
|||
log.debug("关闭临时测试客户端失败: configId={}, error={}", configId, e.getMessage()); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取当前缓存的客户端数量 |
|||
* |
|||
* @return 缓存数量 |
|||
*/ |
|||
public int getCacheSize() { |
|||
return clientCache.size(); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import com.wok.supportbot.mcp.McpToolCallback; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.servlet.http.HttpServletResponse; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.servlet.HandlerInterceptor; |
|||
|
|||
/** |
|||
* MCP 请求拦截器 |
|||
* 在每个 HTTP 请求开始时重置 McpToolCallback 的 ThreadLocal 调用轮次计数器, |
|||
* 防止 Tomcat 线程池复用导致计数器累积,避免内存泄漏和限流失效。 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class McpRequestInterceptor implements HandlerInterceptor { |
|||
|
|||
@Override |
|||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { |
|||
McpToolCallback.resetCallRounds(); |
|||
return true; |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; |
|||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; |
|||
|
|||
/** |
|||
* MCP 拦截器注册配置 |
|||
* 将 McpRequestInterceptor 注册到所有请求路径, |
|||
* 确保每次请求开始时重置 MCP 工具调用轮次计数器。 |
|||
*/ |
|||
@Configuration |
|||
public class McpWebMvcConfig implements WebMvcConfigurer { |
|||
|
|||
@Autowired |
|||
private McpRequestInterceptor mcpRequestInterceptor; |
|||
|
|||
@Override |
|||
public void addInterceptors(InterceptorRegistry registry) { |
|||
registry.addInterceptor(mcpRequestInterceptor) |
|||
.addPathPatterns("/**"); |
|||
} |
|||
} |
|||
@ -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,348 @@ |
|||
package com.wok.supportbot.controller; |
|||
|
|||
import com.wok.supportbot.config.McpClientManager; |
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import com.wok.supportbot.service.McpServerConfigService; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP Server 配置管理控制器 |
|||
* 提供 MCP Server 配置的增删改查、启用/禁用、连接测试、缓存刷新等 API |
|||
*/ |
|||
@RestController |
|||
@Slf4j |
|||
public class McpServerConfigController { |
|||
|
|||
@Autowired |
|||
private McpServerConfigService mcpServerConfigService; |
|||
|
|||
@Autowired |
|||
private McpClientManager mcpClientManager; |
|||
|
|||
// ==================== 分页列表 ==================== |
|||
|
|||
/** |
|||
* 获取 MCP Server 配置列表(分页) |
|||
* |
|||
* @param page 页码(默认1) |
|||
* @param size 每页大小(默认10) |
|||
* @return 分页配置列表 |
|||
*/ |
|||
@GetMapping("/mcp-server/list") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> listConfigs( |
|||
@RequestParam(defaultValue = "1") int page, |
|||
@RequestParam(defaultValue = "10") int size) { |
|||
try { |
|||
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size); |
|||
Map<String, Object> data = new LinkedHashMap<>(); |
|||
data.put("success", true); |
|||
data.put("data", result.get("records")); |
|||
data.put("total", result.get("total")); |
|||
data.put("page", result.get("page")); |
|||
data.put("size", result.get("size")); |
|||
data.put("pages", result.get("pages")); |
|||
return ResponseEntity.ok(data); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 单条详情 ==================== |
|||
|
|||
/** |
|||
* 获取单条 MCP Server 配置详情 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 配置详情 |
|||
*/ |
|||
@GetMapping("/mcp-server/{id}") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> getConfigDetail(@PathVariable("id") Long id) { |
|||
try { |
|||
Map<String, Object> config = mcpServerConfigService.getConfigDetail(id); |
|||
if (config == null) { |
|||
return ResponseEntity.status(404).body(Map.of( |
|||
"success", false, |
|||
"message", "配置不存在" |
|||
)); |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", config |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 新建配置 ==================== |
|||
|
|||
/** |
|||
* 新建 MCP Server 配置 |
|||
* |
|||
* @param config 配置对象 |
|||
* @return 创建结果 |
|||
*/ |
|||
@PostMapping("/mcp-server") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> createConfig(@RequestBody McpServerConfig config) { |
|||
try { |
|||
Map<String, Object> created = mcpServerConfigService.createConfig(config); |
|||
// 增量操作:仅为新建配置创建客户端,不影响其他已有连接 |
|||
Object idObj = created.get("id"); |
|||
if (idObj != null) { |
|||
try { |
|||
mcpClientManager.addClient(Long.parseLong(idObj.toString())); |
|||
} catch (Exception e) { |
|||
log.warn("新建配置后创建客户端失败(不影响配置保存): {}", e.getMessage()); |
|||
} |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", created, |
|||
"message", "配置创建成功" |
|||
)); |
|||
} catch (IllegalArgumentException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "创建失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 更新配置 ==================== |
|||
|
|||
/** |
|||
* 更新 MCP Server 配置 |
|||
* |
|||
* @param id 配置ID |
|||
* @param config 更新内容 |
|||
* @return 更新结果 |
|||
*/ |
|||
@PutMapping("/mcp-server/{id}") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> updateConfig( |
|||
@PathVariable("id") Long id, |
|||
@RequestBody McpServerConfig config) { |
|||
try { |
|||
Map<String, Object> updated = mcpServerConfigService.updateConfig(id, config); |
|||
// 增量操作:仅重建更新的配置客户端,不影响其他已有连接 |
|||
mcpClientManager.rebuildClient(id); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", updated, |
|||
"message", "配置更新成功" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "更新失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除配置 ==================== |
|||
|
|||
/** |
|||
* 删除 MCP Server 配置(逻辑删除) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 删除结果 |
|||
*/ |
|||
@DeleteMapping("/mcp-server/{id}") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> deleteConfig(@PathVariable("id") Long id) { |
|||
try { |
|||
mcpServerConfigService.deleteConfig(id); |
|||
// 增量操作:仅移除被删除的配置客户端,不影响其他已有连接 |
|||
mcpClientManager.removeClient(id); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "配置删除成功" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "删除失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 启用/禁用 ==================== |
|||
|
|||
/** |
|||
* 切换配置启用/禁用状态 |
|||
* |
|||
* @param id 配置ID |
|||
* @param body 请求体:{active: true/false} |
|||
* @return 操作结果 |
|||
*/ |
|||
@PutMapping("/mcp-server/{id}/toggle") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> toggleActive( |
|||
@PathVariable("id") Long id, |
|||
@RequestBody Map<String, Boolean> body) { |
|||
try { |
|||
Boolean active = body.get("active"); |
|||
if (active == null) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", "active 参数不能为空" |
|||
)); |
|||
} |
|||
mcpServerConfigService.toggleActive(id, active); |
|||
// 增量操作:启用时重建客户端,禁用时移除客户端 |
|||
if (active) { |
|||
mcpClientManager.rebuildClient(id); |
|||
} else { |
|||
mcpClientManager.disableClient(id); |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", active ? "配置已启用" : "配置已禁用" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "操作失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 测试连接 ==================== |
|||
|
|||
/** |
|||
* 测试 MCP Server 连接 |
|||
* 创建临时客户端进行握手 + 工具列表查询,不影响正常缓存 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 测试结果(success / latencyMs / toolsCount / errorMessage) |
|||
*/ |
|||
@PostMapping("/mcp-server/{id}/test") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> testConnection(@PathVariable("id") Long id) { |
|||
try { |
|||
Map<String, Object> result = mcpClientManager.testConnection(id); |
|||
return ResponseEntity.ok(result); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "测试失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 刷新连接 ==================== |
|||
|
|||
/** |
|||
* 刷新所有 MCP 客户端连接 |
|||
* 清空现有缓存并重新建立所有已启用配置的客户端连接 |
|||
* |
|||
* @return 刷新结果 |
|||
*/ |
|||
@PostMapping("/mcp-server/refresh") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> refreshAll() { |
|||
try { |
|||
mcpClientManager.refreshAll(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "MCP 客户端连接已刷新" |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "刷新失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 健康检查 ==================== |
|||
|
|||
/** |
|||
* 获取所有 MCP Server 的健康状态 |
|||
* 返回各配置的在线/离线状态、延迟、最后检查时间等信息 |
|||
* |
|||
* @return 所有 MCP Server 的健康状态 |
|||
*/ |
|||
@GetMapping("/mcp-server/health") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> getAllHealthStatus() { |
|||
try { |
|||
Map<String, McpClientManager.HealthStatus> healthMap = mcpClientManager.getAllHealthStatus(); |
|||
Map<String, Object> data = new LinkedHashMap<>(); |
|||
data.put("success", true); |
|||
data.put("data", healthMap); |
|||
return ResponseEntity.ok(data); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "获取健康状态失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 手动触发单个 MCP Server 的健康检查 |
|||
* 对指定配置的客户端执行 listTools 探测,返回最新健康状态 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 该配置的健康状态 |
|||
*/ |
|||
@PostMapping("/mcp-server/{id}/health-check") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> checkSingleHealth(@PathVariable("id") Long id) { |
|||
try { |
|||
McpClientManager.HealthStatus status = mcpClientManager.checkHealthForConfig(id); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", Map.of( |
|||
"configId", id.toString(), |
|||
"status", status.status(), |
|||
"latencyMs", status.latencyMs(), |
|||
"lastCheckTime", status.lastCheckTime().toString(), |
|||
"errorMessage", status.errorMessage() != null ? status.errorMessage() : "" |
|||
) |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "健康检查失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.wok.supportbot.dao; |
|||
|
|||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import org.apache.ibatis.annotations.Mapper; |
|||
|
|||
/** |
|||
* MCP Server 配置 Mapper - 继承 MyBatis Plus BaseMapper,自带 CRUD |
|||
*/ |
|||
@Mapper |
|||
public interface McpServerConfigMapper extends BaseMapper<McpServerConfig> { |
|||
} |
|||
@ -0,0 +1,104 @@ |
|||
package com.wok.supportbot.entity; |
|||
|
|||
import com.baomidou.mybatisplus.annotation.*; |
|||
import com.fasterxml.jackson.databind.annotation.JsonSerialize; |
|||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; |
|||
import com.wok.supportbot.handler.PostgresJsonTypeHandler; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.io.Serial; |
|||
import java.io.Serializable; |
|||
import java.util.Date; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP Server 配置表 - 管理外部 MCP Server 连接,支持 stdio 和 sse 两种传输模式 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
@TableName(value = "mcp_server_config", autoResultMap = true) |
|||
public class McpServerConfig implements Serializable { |
|||
|
|||
@Serial |
|||
@TableField(exist = false) |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** |
|||
* 主键ID(雪花算法) |
|||
*/ |
|||
@TableId(value = "id", type = IdType.ASSIGN_ID) |
|||
@JsonSerialize(using = ToStringSerializer.class) |
|||
private Long id; |
|||
|
|||
/** |
|||
* 配置名称(如"天气查询服务") |
|||
*/ |
|||
@TableField("name") |
|||
private String name; |
|||
|
|||
/** |
|||
* 传输类型: stdio(标准输入输出)/ sse(Server-Sent Events) |
|||
*/ |
|||
@TableField("transport_type") |
|||
private String transportType; |
|||
|
|||
/** |
|||
* SSE 模式的 MCP Server URL |
|||
*/ |
|||
@TableField("server_url") |
|||
private String serverUrl; |
|||
|
|||
/** |
|||
* stdio 模式的启动命令 |
|||
*/ |
|||
@TableField("command") |
|||
private String command; |
|||
|
|||
/** |
|||
* stdio 模式的命令参数(多个用逗号分隔) |
|||
*/ |
|||
@TableField("args") |
|||
private String args; |
|||
|
|||
/** |
|||
* 环境变量(JSONB,键值对形式) |
|||
*/ |
|||
@TableField(value = "env_vars", typeHandler = PostgresJsonTypeHandler.class) |
|||
private Map<String, Object> envVars; |
|||
|
|||
/** |
|||
* 描述说明 |
|||
*/ |
|||
@TableField("description") |
|||
private String description; |
|||
|
|||
/** |
|||
* 是否启用 |
|||
*/ |
|||
@TableField("is_active") |
|||
private Boolean isActive; |
|||
|
|||
/** |
|||
* 创建时间 |
|||
*/ |
|||
@TableField(value = "create_time", fill = FieldFill.INSERT) |
|||
private Date createTime; |
|||
|
|||
/** |
|||
* 更新时间 |
|||
*/ |
|||
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) |
|||
private Date updateTime; |
|||
|
|||
/** |
|||
* 删除标志 - false:未删除, true:已删除(逻辑删除) |
|||
*/ |
|||
@TableField("is_delete") |
|||
@TableLogic |
|||
private boolean isDelete; |
|||
} |
|||
@ -0,0 +1,265 @@ |
|||
package com.wok.supportbot.mcp; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.wok.supportbot.config.McpClientManager; |
|||
import io.modelcontextprotocol.client.McpSyncClient; |
|||
import io.modelcontextprotocol.spec.McpSchema; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.model.ModelOptionsUtils; |
|||
import org.springframework.ai.tool.ToolCallback; |
|||
import org.springframework.ai.tool.definition.DefaultToolDefinition; |
|||
import org.springframework.ai.tool.definition.ToolDefinition; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP Tool -> Spring AI ToolCallback 适配器 |
|||
* 将单个 MCP Server 暴露的工具适配为 Spring AI 可调用的 ToolCallback。 |
|||
* <p> |
|||
* 工作流程: |
|||
* 1. ChatClient 在对话时通过 getToolDefinition() 获取工具元数据(名称、描述、参数 Schema) |
|||
* 2. 当 AI 模型决定调用该工具时,Spring AI 框架自动调用 call() 方法 |
|||
* 3. call() 内部通过 McpClientManager 获取对应的 MCP Client,转发调用到远程 MCP Server |
|||
* 4. 工具调用事件通过 ThreadLocal 收集器记录,供 SSE 流式输出使用 |
|||
*/ |
|||
@Slf4j |
|||
public class McpToolCallback implements ToolCallback { |
|||
|
|||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
|||
|
|||
// ==================== 工具调用事件收集 ==================== |
|||
|
|||
/** |
|||
* 工具调用事件记录 |
|||
*/ |
|||
public record ToolCallEvent(String tool, String input, String result, long latencyMs, boolean error) {} |
|||
|
|||
/** |
|||
* 线程级事件收集器:在同一请求线程中收集所有工具调用事件 |
|||
* SSE 流式输出完成后,从这里取出事件发送给前端 |
|||
*/ |
|||
private static final ThreadLocal<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new); |
|||
|
|||
/** |
|||
* 获取当前线程收集的所有工具调用事件,并清空收集器 |
|||
*/ |
|||
public static List<ToolCallEvent> drainEvents() { |
|||
List<ToolCallEvent> events = EVENTS.get(); |
|||
List<ToolCallEvent> copy = new ArrayList<>(events); |
|||
events.clear(); |
|||
return copy; |
|||
} |
|||
|
|||
/** |
|||
* 重置事件收集器(每个新请求开始时调用) |
|||
*/ |
|||
public static void resetEvents() { |
|||
EVENTS.remove(); |
|||
} |
|||
|
|||
/** |
|||
* MCP Server 配置 ID(用于路由调用到正确的 MCP Client) |
|||
*/ |
|||
private final Long mcpServerConfigId; |
|||
|
|||
/** |
|||
* MCP Client Manager(用于获取实际的 MCP Client 实例) |
|||
*/ |
|||
private final McpClientManager mcpClientManager; |
|||
|
|||
/** |
|||
* 工具名称(已加 mcp_ 前缀,避免与内置工具冲突) |
|||
*/ |
|||
private final String toolName; |
|||
|
|||
/** |
|||
* MCP Server 上的原始工具名称(无前缀,用于发送给 MCP Server 调用) |
|||
*/ |
|||
private final String originalToolName; |
|||
|
|||
/** |
|||
* 工具描述(供 AI 模型理解工具用途) |
|||
*/ |
|||
private final String toolDescription; |
|||
|
|||
/** |
|||
* 工具输入参数的 JSON Schema(供 AI 模型生成合法的调用参数) |
|||
*/ |
|||
private final String inputSchema; |
|||
|
|||
/** |
|||
* 单次对话请求中最大允许的调用轮次,防止无限循环调用 |
|||
*/ |
|||
private final int maxCallRounds; |
|||
|
|||
/** |
|||
* 线程级调用轮次计数器,每个请求线程独立计数 |
|||
*/ |
|||
private static final ThreadLocal<Integer> CALL_ROUNDS = ThreadLocal.withInitial(() -> 0); |
|||
|
|||
/** |
|||
* 构造 MCP 工具适配器(含最大调用轮次限制) |
|||
* |
|||
* @param mcpServerConfigId MCP Server 配置 ID |
|||
* @param toolName 工具名称(含 mcp_ 前缀,用于 Spring AI 注册) |
|||
* @param originalToolName MCP Server 上的原始工具名称(无前缀,用于实际调用) |
|||
* @param toolDescription 工具描述 |
|||
* @param inputSchema 输入参数的 JSON Schema |
|||
* @param mcpClientManager MCP 客户端管理器 |
|||
* @param maxCallRounds 单次请求中最大允许的调用轮次 |
|||
*/ |
|||
public McpToolCallback(Long mcpServerConfigId, String toolName, String originalToolName, |
|||
String toolDescription, String inputSchema, McpClientManager mcpClientManager, |
|||
int maxCallRounds) { |
|||
this.mcpServerConfigId = mcpServerConfigId; |
|||
this.toolName = toolName; |
|||
this.originalToolName = originalToolName; |
|||
this.toolDescription = toolDescription; |
|||
this.inputSchema = inputSchema; |
|||
this.mcpClientManager = mcpClientManager; |
|||
this.maxCallRounds = maxCallRounds; |
|||
} |
|||
|
|||
/** |
|||
* 构造 MCP 工具适配器(使用默认最大调用轮次 5) |
|||
*/ |
|||
public McpToolCallback(Long mcpServerConfigId, String toolName, String originalToolName, |
|||
String toolDescription, String inputSchema, McpClientManager mcpClientManager) { |
|||
this(mcpServerConfigId, toolName, originalToolName, toolDescription, inputSchema, mcpClientManager, 5); |
|||
} |
|||
|
|||
/** |
|||
* 兼容旧构造方式(toolName 直接用于调用,不加前缀区分) |
|||
*/ |
|||
public McpToolCallback(Long mcpServerConfigId, String toolName, String toolDescription, |
|||
String inputSchema, McpClientManager mcpClientManager) { |
|||
this(mcpServerConfigId, toolName, toolName, toolDescription, inputSchema, mcpClientManager, 5); |
|||
} |
|||
|
|||
/** |
|||
* 返回工具定义,供 Spring AI 框架注册到 ChatClient |
|||
* AI 模型通过此定义了解工具的名称、用途和参数格式 |
|||
*/ |
|||
@Override |
|||
public ToolDefinition getToolDefinition() { |
|||
return DefaultToolDefinition.builder() |
|||
.name(toolName) |
|||
.description(toolDescription) |
|||
.inputSchema(inputSchema) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 执行工具调用 |
|||
* 当 AI 模型决定调用此工具时,Spring AI 框架自动调用此方法。 |
|||
* 内部通过 McpClientManager 获取对应的 MCP Client 实例, |
|||
* 将调用转发到远程 MCP Server 并返回结果。 |
|||
* <p> |
|||
* 包含调用轮次限制:同一请求线程中,所有 MCP 工具的累计调用次数 |
|||
* 超过 maxCallRounds 时,返回错误 JSON 防止无限循环。 |
|||
* |
|||
* @param toolInput JSON 格式的工具输入参数 |
|||
* @return 工具执行结果(JSON 字符串形式) |
|||
*/ |
|||
@Override |
|||
public String call(String toolInput) { |
|||
// 检查调用轮次是否超限 |
|||
int currentRound = CALL_ROUNDS.get(); |
|||
if (currentRound >= maxCallRounds) { |
|||
log.warn("MCP 工具调用轮次超限: tool={}, currentRound={}, maxRounds={}", |
|||
originalToolName, currentRound, maxCallRounds); |
|||
return "{\"error\": \"工具调用轮次已达上限 (" + maxCallRounds + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}"; |
|||
} |
|||
CALL_ROUNDS.set(currentRound + 1); |
|||
|
|||
log.info("MCP 工具调用: serverId={}, tool={}, input={}, round={}/{}", |
|||
mcpServerConfigId, originalToolName, toolInput, currentRound + 1, maxCallRounds); |
|||
long startTime = System.currentTimeMillis(); |
|||
|
|||
try { |
|||
// 获取 MCP Client 实例 |
|||
McpSyncClient client = mcpClientManager.getClient(mcpServerConfigId); |
|||
if (client == null) { |
|||
log.error("MCP 客户端不可用: serverId={}, tool={}", mcpServerConfigId, originalToolName); |
|||
return "{\"error\": \"MCP Server (id=" + mcpServerConfigId + ") 客户端未就绪,请检查配置\"}"; |
|||
} |
|||
|
|||
// 解析工具输入参数:JSON 字符串 -> Map |
|||
Map<String, Object> arguments = OBJECT_MAPPER.readValue(toolInput, new TypeReference<>() {}); |
|||
|
|||
// 构建 MCP 调用请求并执行 |
|||
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(originalToolName, arguments); |
|||
McpSchema.CallToolResult result = client.callTool(request); |
|||
|
|||
long latency = System.currentTimeMillis() - startTime; |
|||
log.info("MCP 工具调用完成: tool={}, latency={}ms, isError={}", |
|||
originalToolName, latency, result.isError()); |
|||
|
|||
// 收集工具调用事件(供 SSE 流式输出使用) |
|||
String resultStr = result.content() != null ? String.valueOf(result.content()) : ""; |
|||
boolean isError = result.isError() != null && result.isError(); |
|||
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, resultStr, latency, isError)); |
|||
|
|||
// 检查是否为错误结果 |
|||
if (result.isError() != null && result.isError()) { |
|||
log.error("MCP 工具返回错误: tool={}, content={}", originalToolName, result.content()); |
|||
return "{\"error\": \"工具执行返回错误: " + |
|||
escapeJson(String.valueOf(result.content())) + "\"}"; |
|||
} |
|||
|
|||
// 将 MCP Content 列表序列化为 JSON 字符串返回给 AI 模型 |
|||
// 与官方 SyncMcpToolCallback 保持一致,使用 ModelOptionsUtils 序列化 |
|||
String resultJson = ModelOptionsUtils.toJsonString(result.content()); |
|||
log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, resultJson); |
|||
return resultJson; |
|||
|
|||
} catch (Exception e) { |
|||
long latency = System.currentTimeMillis() - startTime; |
|||
log.error("MCP 工具调用失败: tool={}, latency={}ms, error={}", originalToolName, latency, e.getMessage()); |
|||
// 记录失败事件 |
|||
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true)); |
|||
return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}"; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 转义字符串中的特殊字符,避免破坏 JSON 格式 |
|||
*/ |
|||
private String escapeJson(String text) { |
|||
if (text == null) return "未知错误"; |
|||
return text.replace("\\", "\\\\").replace("\"", "'").replace("\n", "\\n").replace("\r", "\\r"); |
|||
} |
|||
|
|||
// ==================== Getter 方法 ==================== |
|||
|
|||
public Long getMcpServerConfigId() { |
|||
return mcpServerConfigId; |
|||
} |
|||
|
|||
public String getToolName() { |
|||
return toolName; |
|||
} |
|||
|
|||
public String getOriginalToolName() { |
|||
return originalToolName; |
|||
} |
|||
|
|||
public String getToolDescription() { |
|||
return toolDescription; |
|||
} |
|||
|
|||
public String getInputSchema() { |
|||
return inputSchema; |
|||
} |
|||
|
|||
/** |
|||
* 重置当前线程的调用轮次计数器 |
|||
* 应在每次新的对话请求开始时调用,确保轮次计数从零开始 |
|||
*/ |
|||
public static void resetCallRounds() { |
|||
CALL_ROUNDS.remove(); |
|||
} |
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
package com.wok.supportbot.mcp; |
|||
|
|||
import com.wok.supportbot.config.McpClientManager; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.tool.ToolCallback; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP 工具批量适配器 |
|||
* 从 McpClientManager 获取所有已启用的 MCP 工具描述, |
|||
* 批量转换为 Spring AI ToolCallback 列表,供 ChatClient 注册。 |
|||
* <p> |
|||
* 使用方式: |
|||
* <pre> |
|||
* // 注入后在 ChatClient 构建时注册所有 MCP 工具 |
|||
* ChatClient.builder(chatModel) |
|||
* .defaultTools(mcpToolCallbackAdapter.getAllToolCallbacksAsArray()) |
|||
* .defaultAdvisors(...) |
|||
* .build(); |
|||
* </pre> |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class McpToolCallbackAdapter { |
|||
|
|||
private final McpClientManager mcpClientManager; |
|||
|
|||
/** |
|||
* 单次对话请求中最大允许的 MCP 工具调用轮次,防止无限循环调用 |
|||
*/ |
|||
@Value("${mcp.tools.max-call-rounds:5}") |
|||
private int maxCallRounds; |
|||
|
|||
public McpToolCallbackAdapter(McpClientManager mcpClientManager) { |
|||
this.mcpClientManager = mcpClientManager; |
|||
} |
|||
|
|||
/** |
|||
* 获取所有 MCP 工具的 ToolCallback 列表 |
|||
* 遍历所有已启用的 MCP Server,将其暴露的工具转换为 ToolCallback |
|||
* |
|||
* @return ToolCallback 列表(可能为空,但不会为 null) |
|||
*/ |
|||
public List<ToolCallback> getAllToolCallbacks() { |
|||
List<ToolCallback> callbacks = new ArrayList<>(); |
|||
List<Map<String, Object>> tools = mcpClientManager.listAvailableTools(); |
|||
|
|||
for (Map<String, Object> toolInfo : tools) { |
|||
String configId = (String) toolInfo.get("config_id"); |
|||
String serverName = (String) toolInfo.get("name"); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
List<Map<String, Object>> toolList = (List<Map<String, Object>>) toolInfo.get("tools"); |
|||
if (toolList == null || toolList.isEmpty()) { |
|||
log.debug("MCP Server [{}] 暂无可用工具", serverName); |
|||
continue; |
|||
} |
|||
|
|||
for (Map<String, Object> tool : toolList) { |
|||
String toolName = (String) tool.get("name"); |
|||
String description = (String) tool.get("description"); |
|||
String inputSchema = (String) tool.get("inputSchema"); |
|||
|
|||
// 工具名加 mcp_ 前缀,避免与内置工具冲突 |
|||
String prefixedName = "mcp_" + toolName; |
|||
McpToolCallback callback = new McpToolCallback( |
|||
Long.parseLong(configId), prefixedName, toolName, description, inputSchema, |
|||
mcpClientManager, maxCallRounds); |
|||
callbacks.add(callback); |
|||
log.debug("注册 MCP 工具: server={}, tool={}", serverName, prefixedName); |
|||
} |
|||
} |
|||
|
|||
log.info("MCP 工具适配完成,共注册 {} 个工具", callbacks.size()); |
|||
return callbacks; |
|||
} |
|||
|
|||
/** |
|||
* 获取 MCP 工具的 ToolCallback 数组(供 ChatClient.defaultTools() 使用) |
|||
* ChatClient.defaultTools() 接受 ToolCallback[] 参数,此方法提供便捷转换 |
|||
* |
|||
* @return ToolCallback 数组 |
|||
*/ |
|||
public ToolCallback[] getAllToolCallbacksAsArray() { |
|||
return getAllToolCallbacks().toArray(new ToolCallback[0]); |
|||
} |
|||
|
|||
/** |
|||
* 获取指定工具名列表的 ToolCallback 数组(按角色权限过滤 MCP 工具) |
|||
* |
|||
* @param allowedTools 允许的工具名列表;["*"] 表示全部允许,其他为具体工具名列表 |
|||
* @return 过滤后的 ToolCallback 数组(可能为空数组) |
|||
*/ |
|||
public ToolCallback[] getFilteredToolCallbacks(List<String> allowedTools) { |
|||
if (allowedTools == null || allowedTools.isEmpty()) { |
|||
return new ToolCallback[0]; |
|||
} |
|||
List<ToolCallback> all = getAllToolCallbacks(); |
|||
if (allowedTools.contains("*")) { |
|||
return all.toArray(new ToolCallback[0]); |
|||
} |
|||
return all.stream() |
|||
.filter(cb -> allowedTools.contains(cb.getToolDefinition().name())) |
|||
.toArray(ToolCallback[]::new); |
|||
} |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.wok.supportbot.mcp; |
|||
|
|||
import org.springframework.http.codec.ServerSentEvent; |
|||
|
|||
/** |
|||
* SSE 事件构建器 |
|||
* 用于在 MCP 工具调用流程中构建标准 SSE 事件, |
|||
* 让前端能区分文本内容与工具调用状态。 |
|||
* |
|||
* 事件类型: |
|||
* - message: 普通文本内容 |
|||
* - tool_call_start: 工具调用开始 |
|||
* - tool_call_result: 工具调用结果返回 |
|||
* - error: 错误信息 |
|||
*/ |
|||
public class SseEventBuilder { |
|||
|
|||
/** |
|||
* 构建普通文本消息事件 |
|||
*/ |
|||
public static ServerSentEvent<String> messageEvent(String data) { |
|||
return ServerSentEvent.<String>builder() |
|||
.event("message") |
|||
.data(data) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 构建工具调用开始事件 |
|||
*/ |
|||
public static ServerSentEvent<String> toolCallStartEvent(String toolName, String input) { |
|||
String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}", |
|||
escapeJson(toolName), escapeJson(input)); |
|||
return ServerSentEvent.<String>builder() |
|||
.event("tool_call_start") |
|||
.data(json) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 构建工具调用结果事件 |
|||
*/ |
|||
public static ServerSentEvent<String> toolCallResultEvent(String toolName, String result, long latencyMs) { |
|||
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}", |
|||
escapeJson(toolName), escapeJson(result), latencyMs); |
|||
return ServerSentEvent.<String>builder() |
|||
.event("tool_call_result") |
|||
.data(json) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 构建错误事件 |
|||
*/ |
|||
public static ServerSentEvent<String> errorEvent(String message) { |
|||
return ServerSentEvent.<String>builder() |
|||
.event("error") |
|||
.data("{\"message\":\"" + escapeJson(message) + "\"}") |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* JSON 字符串转义 |
|||
*/ |
|||
private static String escapeJson(String s) { |
|||
if (s == null) return ""; |
|||
return s.replace("\\", "\\\\") |
|||
.replace("\"", "\\\"") |
|||
.replace("\n", "\\n") |
|||
.replace("\r", "\\r") |
|||
.replace("\t", "\\t"); |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
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(); |
|||
// 放行 CORS 预检请求(OPTIONS 请求不需要 Token 鉴权) |
|||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { |
|||
return true; |
|||
} |
|||
// 仅拦截 /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 保持一致的格式) |
|||
* 同时添加 CORS 头,避免浏览器将鉴权失败误报为跨域错误 |
|||
*/ |
|||
private void writeError(HttpServletResponse response, int status, String message) throws IOException { |
|||
response.setStatus(status); |
|||
response.setContentType("application/json;charset=UTF-8"); |
|||
// 添加 CORS 头,使浏览器能正确读取错误响应 |
|||
response.setHeader("Access-Control-Allow-Origin", "*"); |
|||
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); |
|||
response.setHeader("Access-Control-Allow-Headers", "*"); |
|||
response.setHeader("Access-Control-Allow-Credentials", "true"); |
|||
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)); |
|||
} |
|||
} |
|||
@ -0,0 +1,320 @@ |
|||
package com.wok.supportbot.service; |
|||
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.wok.supportbot.dao.McpServerConfigMapper; |
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.*; |
|||
|
|||
/** |
|||
* MCP Server 配置管理服务 |
|||
* 提供 CRUD、启用/禁用、JSONB 字段显式持久化等功能 |
|||
*/ |
|||
@Service |
|||
@Slf4j |
|||
public class McpServerConfigService { |
|||
|
|||
@Autowired |
|||
private McpServerConfigMapper mcpServerConfigMapper; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
/** 复用的 JSON 序列化器,避免每次持久化都新建实例 */ |
|||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
|||
|
|||
// ==================== 分页列表 ==================== |
|||
|
|||
/** |
|||
* 分页查询 MCP Server 配置列表 |
|||
* |
|||
* @param page 页码(从1开始) |
|||
* @param size 每页大小 |
|||
* @return 分页结果(records/total/page/size/pages) |
|||
*/ |
|||
public Map<String, Object> listConfigs(int page, int size) { |
|||
// 查询总数 |
|||
LambdaQueryWrapper<McpServerConfig> countWrapper = new LambdaQueryWrapper<>(); |
|||
Long total = mcpServerConfigMapper.selectCount(countWrapper); |
|||
if (total == null) total = 0L; |
|||
|
|||
// 查询列表(带排序和分页) |
|||
LambdaQueryWrapper<McpServerConfig> listWrapper = new LambdaQueryWrapper<>(); |
|||
listWrapper.orderByDesc(McpServerConfig::getCreateTime); |
|||
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size)); |
|||
List<McpServerConfig> records = mcpServerConfigMapper.selectList(listWrapper); |
|||
|
|||
// 格式化返回结果 |
|||
List<Map<String, Object>> formattedRecords = new ArrayList<>(); |
|||
for (McpServerConfig record : records) { |
|||
formattedRecords.add(formatConfig(record)); |
|||
} |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("records", formattedRecords); |
|||
result.put("total", total); |
|||
result.put("page", page); |
|||
result.put("size", size); |
|||
result.put("pages", (total + size - 1) / size); |
|||
return result; |
|||
} |
|||
|
|||
// ==================== 详情 ==================== |
|||
|
|||
/** |
|||
* 获取单条配置详情 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 格式化的配置详情,不存在返回 null |
|||
*/ |
|||
public Map<String, Object> getConfigDetail(Long id) { |
|||
McpServerConfig config = mcpServerConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
return null; |
|||
} |
|||
return formatConfig(config); |
|||
} |
|||
|
|||
// ==================== 新建配置 ==================== |
|||
|
|||
/** |
|||
* 新建 MCP Server 配置 |
|||
* |
|||
* @param config 配置对象 |
|||
* @return 保存后的配置(格式化) |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Map<String, Object> createConfig(McpServerConfig config) { |
|||
// 校验必填字段 |
|||
validateRequired(config); |
|||
|
|||
mcpServerConfigMapper.insert(config); |
|||
// JSONB 字段显式持久化,避免 MyBatis Plus typeHandler 不触发的问题 |
|||
persistEnvVars(config.getId(), config.getEnvVars()); |
|||
|
|||
log.info("新建 MCP Server 配置: name={}, transportType={}", config.getName(), config.getTransportType()); |
|||
return formatConfig(mcpServerConfigMapper.selectById(config.getId())); |
|||
} |
|||
|
|||
// ==================== 更新配置 ==================== |
|||
|
|||
/** |
|||
* 更新 MCP Server 配置 |
|||
* 使用 LambdaUpdateWrapper 显式设置所有字段(含 null), |
|||
* 避免 updateById 默认跳过 null 字段导致无法清空可选字段。 |
|||
* |
|||
* @param id 配置ID |
|||
* @param config 更新内容 |
|||
* @return 更新后的配置(格式化) |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Map<String, Object> updateConfig(Long id, McpServerConfig config) { |
|||
McpServerConfig existing = mcpServerConfigMapper.selectById(id); |
|||
if (existing == null) { |
|||
throw new RuntimeException("配置不存在: id=" + id); |
|||
} |
|||
|
|||
// 使用 LambdaUpdateWrapper 显式设置所有字段,null 也会被写入(清空语义) |
|||
LambdaUpdateWrapper<McpServerConfig> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(McpServerConfig::getId, id) |
|||
.set(McpServerConfig::getName, config.getName()) |
|||
.set(McpServerConfig::getTransportType, config.getTransportType()) |
|||
.set(McpServerConfig::getServerUrl, config.getServerUrl()) |
|||
.set(McpServerConfig::getCommand, config.getCommand()) |
|||
.set(McpServerConfig::getArgs, config.getArgs()) |
|||
.set(McpServerConfig::getDescription, config.getDescription()); |
|||
// isActive 仅在前端显式传入时更新,null 表示不修改 |
|||
if (config.getIsActive() != null) { |
|||
updateWrapper.set(McpServerConfig::getIsActive, config.getIsActive()); |
|||
} |
|||
mcpServerConfigMapper.update(null, updateWrapper); |
|||
|
|||
// JSONB 字段显式持久化(LambdaUpdateWrapper 不支持 JSONB 类型) |
|||
// null 或空 Map 均执行持久化(清空语义),与 LambdaUpdateWrapper 行为一致 |
|||
persistEnvVars(id, config.getEnvVars()); |
|||
|
|||
log.info("更新 MCP Server 配置: id={}", id); |
|||
return formatConfig(mcpServerConfigMapper.selectById(id)); |
|||
} |
|||
|
|||
// ==================== 删除配置 ==================== |
|||
|
|||
/** |
|||
* 逻辑删除 MCP Server 配置 |
|||
* |
|||
* @param id 配置ID |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void deleteConfig(Long id) { |
|||
McpServerConfig config = mcpServerConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
throw new RuntimeException("配置不存在: id=" + id); |
|||
} |
|||
mcpServerConfigMapper.deleteById(id); |
|||
log.info("逻辑删除 MCP Server 配置: id={}, name={}", id, config.getName()); |
|||
} |
|||
|
|||
// ==================== 启用/禁用 ==================== |
|||
|
|||
/** |
|||
* 切换配置启用状态 |
|||
* |
|||
* @param id 配置ID |
|||
* @param active 是否启用 |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void toggleActive(Long id, boolean active) { |
|||
McpServerConfig config = mcpServerConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
throw new RuntimeException("配置不存在: id=" + id); |
|||
} |
|||
|
|||
LambdaUpdateWrapper<McpServerConfig> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(McpServerConfig::getId, id) |
|||
.set(McpServerConfig::getIsActive, active); |
|||
mcpServerConfigMapper.update(null, updateWrapper); |
|||
|
|||
log.info("切换 MCP Server 配置状态: id={}, name={}, active={}", id, config.getName(), active); |
|||
} |
|||
|
|||
// ==================== 活跃配置查询 ==================== |
|||
|
|||
/** |
|||
* 获取所有启用的 SSE 类型配置 |
|||
* 供 McpClientManager 建立 SSE 连接使用 |
|||
* |
|||
* @return SSE 类型的活跃配置列表 |
|||
*/ |
|||
public List<McpServerConfig> listActiveSseConfigs() { |
|||
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(McpServerConfig::getIsActive, true) |
|||
.eq(McpServerConfig::getTransportType, "sse"); |
|||
return mcpServerConfigMapper.selectList(wrapper); |
|||
} |
|||
|
|||
/** |
|||
* 获取所有启用的 stdio 类型配置 |
|||
* 供 McpClientManager 启动 stdio 进程使用 |
|||
* |
|||
* @return stdio 类型的活跃配置列表 |
|||
*/ |
|||
public List<McpServerConfig> listActiveStdioConfigs() { |
|||
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(McpServerConfig::getIsActive, true) |
|||
.eq(McpServerConfig::getTransportType, "stdio"); |
|||
return mcpServerConfigMapper.selectList(wrapper); |
|||
} |
|||
|
|||
/** |
|||
* 根据 ID 获取配置(含完整字段,仅供内部使用) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 原始配置对象,不存在返回 null |
|||
*/ |
|||
public McpServerConfig getConfigById(Long id) { |
|||
return mcpServerConfigMapper.selectById(id); |
|||
} |
|||
|
|||
/** |
|||
* 获取所有启用的配置(不区分传输类型) |
|||
* 供 McpClientManager.refreshAll() 一次查询获取所有活跃配置 |
|||
* |
|||
* @return 所有启用的配置列表 |
|||
*/ |
|||
public List<McpServerConfig> listAllActiveConfigs() { |
|||
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(McpServerConfig::getIsActive, true); |
|||
return mcpServerConfigMapper.selectList(wrapper); |
|||
} |
|||
|
|||
// ==================== 工具方法 ==================== |
|||
|
|||
/** |
|||
* 校验必填字段 |
|||
* |
|||
* @param config 配置对象 |
|||
*/ |
|||
private void validateRequired(McpServerConfig config) { |
|||
if (config.getName() == null || config.getName().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("配置名称不能为空"); |
|||
} |
|||
if (config.getTransportType() == null || config.getTransportType().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("传输类型不能为空"); |
|||
} |
|||
// 根据传输类型校验对应必填字段 |
|||
switch (config.getTransportType()) { |
|||
case "sse": |
|||
if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("SSE 模式下服务地址不能为空"); |
|||
} |
|||
break; |
|||
case "stdio": |
|||
if (config.getCommand() == null || config.getCommand().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("stdio 模式下启动命令不能为空"); |
|||
} |
|||
break; |
|||
default: |
|||
throw new IllegalArgumentException("不支持的传输类型: " + config.getTransportType()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 格式化配置为前端返回格式 |
|||
* 雪花 ID 转字符串、时间格式化 |
|||
* |
|||
* @param config 原始配置 |
|||
* @return 格式化后的 Map |
|||
*/ |
|||
private Map<String, Object> formatConfig(McpServerConfig config) { |
|||
Map<String, Object> formatted = new LinkedHashMap<>(); |
|||
// 雪花 ID 必须转为字符串,避免 JS 精度丢失 |
|||
formatted.put("id", config.getId().toString()); |
|||
formatted.put("name", config.getName()); |
|||
formatted.put("transport_type", config.getTransportType()); |
|||
formatted.put("server_url", config.getServerUrl()); |
|||
formatted.put("command", config.getCommand()); |
|||
formatted.put("args", config.getArgs()); |
|||
formatted.put("env_vars", config.getEnvVars()); |
|||
formatted.put("description", config.getDescription()); |
|||
formatted.put("is_active", config.getIsActive()); |
|||
formatted.put("create_time", config.getCreateTime()); |
|||
formatted.put("update_time", config.getUpdateTime()); |
|||
return formatted; |
|||
} |
|||
|
|||
/** |
|||
* 显式持久化 envVars JSONB 字段 |
|||
* MyBatis Plus 的 insert/updateById 对带 typeHandler 的 JSONB 字段可能不触发写入, |
|||
* 使用 JdbcTemplate 显式更新保证数据可靠落库。 |
|||
* |
|||
* @param id 配置ID |
|||
* @param envVars 环境变量 Map |
|||
*/ |
|||
private void persistEnvVars(Long id, Map<String, Object> envVars) { |
|||
if (id == null) { |
|||
return; |
|||
} |
|||
try { |
|||
String json; |
|||
if (envVars == null || envVars.isEmpty()) { |
|||
json = "{}"; |
|||
} else { |
|||
json = OBJECT_MAPPER.writeValueAsString(envVars); |
|||
} |
|||
jdbcTemplate.update( |
|||
"UPDATE mcp_server_config SET env_vars = ?::jsonb WHERE id = ?", |
|||
json, id); |
|||
log.debug("持久化 envVars: id={}, envVars={}", id, json); |
|||
} catch (Exception e) { |
|||
log.error("持久化 envVars 失败: id={}, error={}", id, e.getMessage()); |
|||
throw new RuntimeException("持久化环境变量失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,524 @@ |
|||
/** |
|||
* MCP 服务管理组件 |
|||
* 支持 SSE / stdio 两种传输类型的 MCP Server 配置管理 |
|||
* 功能:列表展示、新建/编辑/删除、启用/禁用切换、测试连接、刷新连接、分页 |
|||
*/ |
|||
import { ref, computed, onMounted, onUnmounted } from 'vue' |
|||
import * as api from '../js/api.js' |
|||
import { toast, formatDate } from '../js/utils.js' |
|||
|
|||
// 传输类型选项
|
|||
const TRANSPORT_OPTIONS = [ |
|||
{ value: 'sse', label: 'SSE(HTTP 服务)' }, |
|||
{ value: 'stdio', label: 'Stdio(本地进程)' } |
|||
] |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>MCP 服务管理</h2> |
|||
<p style="font-size:13px;color:var(--sub);margin:-4px 0 16px;"> |
|||
管理 MCP (Model Context Protocol) 服务端配置,支持 SSE 和 Stdio 两种传输方式 |
|||
</p> |
|||
|
|||
<!-- 操作栏 --> |
|||
<div class="input-row" style="flex-wrap:wrap;margin-bottom:16px;"> |
|||
<button class="btn btn-primary btn-sm" @click="openAddModal">新建配置</button> |
|||
<button class="btn btn-outline btn-sm" @click="refreshConnections" :disabled="refreshLoading"> |
|||
{{ refreshLoading ? '刷新中...' : '刷新连接' }} |
|||
</button> |
|||
<button class="btn btn-outline btn-sm" @click="load(currentPage)">刷新列表</button> |
|||
<span style="margin-left:auto;font-size:12px;color:var(--sub);"> |
|||
共 {{ total }} 条配置 |
|||
</span> |
|||
</div> |
|||
|
|||
<!-- 列表表格 --> |
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>名称</th> |
|||
<th>传输类型</th> |
|||
<th>URL / 命令</th> |
|||
<th>启用状态</th> |
|||
<th>描述</th> |
|||
<th>创建时间</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="servers.length === 0"> |
|||
<td colspan="7" style="text-align:center;color:var(--sub);">暂无 MCP 服务配置</td> |
|||
</tr> |
|||
<tr v-for="s in servers" :key="s.id"> |
|||
<td><strong>{{ s.name || '-' }}</strong></td> |
|||
<td> |
|||
<span class="badge" :class="s.transport_type === 'sse' ? 'badge-get' : ''"> |
|||
{{ s.transport_type === 'sse' ? 'SSE' : 'Stdio' }} |
|||
</span> |
|||
</td> |
|||
<td style="font-size:12px;color:#6b7280;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" |
|||
:title="getDisplayUrl(s)"> |
|||
{{ getDisplayUrl(s) || '-' }} |
|||
</td> |
|||
<td> |
|||
<label class="toggle-switch" :title="s.is_active ? '已启用' : '已禁用'"> |
|||
<input type="checkbox" :checked="s.is_active" @change="toggleActive(s)"> |
|||
<span class="toggle-slider"></span> |
|||
</label> |
|||
</td> |
|||
<td style="font-size:12px;color:#6b7280;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" |
|||
:title="s.description"> |
|||
{{ s.description || '-' }} |
|||
</td> |
|||
<td style="font-size:12px;color:#6b7280;">{{ formatDate(s.create_time) }}</td> |
|||
<td style="white-space:nowrap;"> |
|||
<button class="btn btn-sm btn-outline" @click="testConnection(s)" :disabled="testLoading[s.id]" title="测试连接"> |
|||
{{ testLoading[s.id] ? '测试中...' : '测试' }} |
|||
</button> |
|||
<button class="btn btn-sm btn-outline" @click="openEditModal(s)" title="编辑">编辑</button> |
|||
<button class="btn btn-sm btn-danger" @click="remove(s.id, s.name)" title="删除">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- 分页 --> |
|||
<div class="pagination" v-if="totalPages > 1"> |
|||
<button :disabled="currentPage <= 1" @click="load(currentPage - 1)">上一页</button> |
|||
<template v-for="i in totalPages" :key="i"> |
|||
<button v-if="i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)" |
|||
:class="{ active: i === currentPage }" @click="load(i)">{{ i }}</button> |
|||
<span v-else-if="i === currentPage - 3 || i === currentPage + 3" style="padding:6px;">...</span> |
|||
</template> |
|||
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button> |
|||
</div> |
|||
|
|||
<!-- 测试结果提示 --> |
|||
<div v-if="testResult.visible" class="test-result-toast" |
|||
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', |
|||
background: testResult.success ? '#16a34a' : '#dc2626' }"> |
|||
<div style="font-weight:600;">{{ testResult.success ? '连接成功' : '连接失败' }}</div> |
|||
<div v-if="testResult.message" style="font-size:12px;margin-top:4px;opacity:0.9;">{{ testResult.message }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 新建/编辑弹窗 --> |
|||
<div class="modal-overlay" :class="{ active: showModal }"> |
|||
<div class="modal-box" style="max-width:600px;"> |
|||
<button class="modal-close" @click="closeModal">×</button> |
|||
<h2 style="margin-bottom:16px;">{{ modalMode === 'add' ? '新建 MCP 服务配置' : '编辑 MCP 服务配置' }}</h2> |
|||
|
|||
<div style="display:flex;flex-direction:column;gap:16px;"> |
|||
<!-- 名称 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
服务名称 <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<input type="text" class="input" v-model.trim="form.name" placeholder="如:天气查询服务"> |
|||
</div> |
|||
|
|||
<!-- 传输类型 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;"> |
|||
传输类型 <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<div style="display:flex;gap:8px;"> |
|||
<button v-for="opt in transportOptions" :key="opt.value" |
|||
type="button" |
|||
@click="form.transportType = opt.value" |
|||
style="padding:6px 16px;border-radius:6px;font-size:13px;cursor:pointer;transition:all 0.15s;" |
|||
:style="form.transportType === opt.value |
|||
? 'background:#2563eb;color:white;border:2px solid #2563eb;font-weight:600;' |
|||
: 'background:white;color:#374151;border:2px solid #e5e7eb;font-weight:400;'"> |
|||
{{ opt.label }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- SSE 模式:URL --> |
|||
<div v-if="form.transportType === 'sse'"> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
Server URL <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<input type="text" class="input" v-model.trim="form.url" placeholder="如:http://localhost:3001/sse"> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
MCP 服务的 SSE 端点地址 |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Stdio 模式:Command + Args --> |
|||
<template v-if="form.transportType === 'stdio'"> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
Command <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<input type="text" class="input" v-model.trim="form.command" placeholder="如:npx, python, node"> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
启动 MCP 服务的可执行命令 |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
Args(命令参数,每行一个) |
|||
</label> |
|||
<textarea class="input" v-model="form.argsText" rows="3" |
|||
placeholder="-m mcp-server-fetch --verbose"></textarea> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
每行一个参数,留空则不传参 |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<!-- 环境变量 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
环境变量(可选) |
|||
</label> |
|||
<textarea class="input" v-model="form.envText" rows="2" |
|||
placeholder="KEY1=value1 KEY2=value2"></textarea> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
格式:KEY=value,每行一组,留空则不设置 |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 描述 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label> |
|||
<textarea class="input" v-model.trim="form.description" rows="2" placeholder="可选,填写服务用途说明"></textarea> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 按钮 --> |
|||
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;"> |
|||
<button class="btn btn-outline" @click="closeModal">取消</button> |
|||
<button class="btn btn-primary" @click="save" :disabled="saving"> |
|||
{{ saving ? '保存中...' : '保存' }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
|
|||
setup() { |
|||
// ==================== 列表状态 ====================
|
|||
const servers = ref([]) |
|||
const currentPage = ref(1) |
|||
const totalPages = ref(1) |
|||
const total = ref(0) |
|||
const pageSize = 10 |
|||
|
|||
// ==================== 弹窗状态 ====================
|
|||
const showModal = ref(false) |
|||
const modalMode = ref('add') // 'add' | 'edit'
|
|||
const editId = ref(null) |
|||
const saving = ref(false) |
|||
|
|||
// 表单数据
|
|||
const form = ref(createEmptyForm()) |
|||
|
|||
function createEmptyForm() { |
|||
return { |
|||
name: '', |
|||
transportType: 'sse', |
|||
url: '', |
|||
command: '', |
|||
argsText: '', |
|||
envText: '', |
|||
description: '' |
|||
} |
|||
} |
|||
|
|||
// ==================== 测试连接状态 ====================
|
|||
const testLoading = ref({}) |
|||
const testResult = ref({ visible: false, success: false, message: '' }) |
|||
let testResultTimer = null |
|||
|
|||
// ==================== 刷新连接状态 ====================
|
|||
const refreshLoading = ref(false) |
|||
|
|||
// ==================== 传输类型选项 ====================
|
|||
const transportOptions = TRANSPORT_OPTIONS |
|||
|
|||
// ==================== 数据加载 ====================
|
|||
|
|||
async function load(p = 1) { |
|||
currentPage.value = p |
|||
try { |
|||
const json = await api.listMcpServers(p, pageSize) |
|||
if (json.success) { |
|||
servers.value = json.data || [] |
|||
total.value = json.total || 0 |
|||
totalPages.value = json.pages || 1 |
|||
} else { |
|||
toast(json.message || '查询失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载列表失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 弹窗操作 ====================
|
|||
|
|||
function openAddModal() { |
|||
modalMode.value = 'add' |
|||
editId.value = null |
|||
form.value = createEmptyForm() |
|||
showModal.value = true |
|||
} |
|||
|
|||
function openEditModal(server) { |
|||
modalMode.value = 'edit' |
|||
editId.value = server.id |
|||
|
|||
// 解析 args 数组为换行文本
|
|||
let argsText = '' |
|||
if (server.args && Array.isArray(server.args)) { |
|||
argsText = server.args.join('\n') |
|||
} else if (typeof server.args === 'string' && server.args) { |
|||
try { |
|||
const parsed = JSON.parse(server.args) |
|||
if (Array.isArray(parsed)) argsText = parsed.join('\n') |
|||
else argsText = server.args |
|||
} catch (e) { |
|||
argsText = server.args |
|||
} |
|||
} |
|||
|
|||
// 解析环境变量对象为换行文本
|
|||
let envText = '' |
|||
if (server.env_vars && typeof server.env_vars === 'object') { |
|||
envText = Object.entries(server.env_vars).map(([k, v]) => k + '=' + v).join('\n') |
|||
} else if (typeof server.env_vars === 'string' && server.env_vars) { |
|||
try { |
|||
const parsed = JSON.parse(server.env_vars) |
|||
if (typeof parsed === 'object' && parsed !== null) { |
|||
envText = Object.entries(parsed).map(([k, v]) => k + '=' + v).join('\n') |
|||
} else { |
|||
envText = server.env_vars |
|||
} |
|||
} catch (e) { |
|||
envText = server.env_vars |
|||
} |
|||
} |
|||
|
|||
form.value = { |
|||
name: server.name || '', |
|||
transportType: server.transport_type || 'sse', |
|||
url: server.server_url || '', |
|||
command: server.command || '', |
|||
argsText: argsText, |
|||
envText: envText, |
|||
description: server.description || '' |
|||
} |
|||
showModal.value = true |
|||
} |
|||
|
|||
function closeModal() { |
|||
showModal.value = false |
|||
} |
|||
|
|||
// ==================== 保存 ====================
|
|||
|
|||
function buildSaveData() { |
|||
const f = form.value |
|||
const data = { |
|||
name: f.name, |
|||
transportType: f.transportType, |
|||
description: f.description |
|||
} |
|||
|
|||
if (f.transportType === 'sse') { |
|||
data.serverUrl = f.url |
|||
} else { |
|||
data.command = f.command |
|||
// 解析 args:按换行拆分,过滤空行
|
|||
if (f.argsText && f.argsText.trim()) { |
|||
data.args = f.argsText.split('\n').map(s => s.trim()).filter(Boolean) |
|||
} else { |
|||
data.args = [] |
|||
} |
|||
} |
|||
|
|||
// 解析环境变量:按换行拆分,解析 KEY=value
|
|||
if (f.envText && f.envText.trim()) { |
|||
const envObj = {} |
|||
f.envText.split('\n').forEach(line => { |
|||
const trimmed = line.trim() |
|||
if (!trimmed) return |
|||
const eqIdx = trimmed.indexOf('=') |
|||
if (eqIdx > 0) { |
|||
envObj[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1) |
|||
} |
|||
}) |
|||
data.envVars = envObj |
|||
} |
|||
|
|||
return data |
|||
} |
|||
|
|||
async function save() { |
|||
const f = form.value |
|||
|
|||
// 校验
|
|||
if (!f.name || !f.name.trim()) { |
|||
toast('请填写服务名称', 'error') |
|||
return |
|||
} |
|||
if (f.transportType === 'sse') { |
|||
if (!f.url || !f.url.trim()) { |
|||
toast('请填写 Server URL', 'error') |
|||
return |
|||
} |
|||
} else { |
|||
if (!f.command || !f.command.trim()) { |
|||
toast('请填写 Command', 'error') |
|||
return |
|||
} |
|||
} |
|||
|
|||
saving.value = true |
|||
try { |
|||
const data = buildSaveData() |
|||
let json |
|||
if (modalMode.value === 'add') { |
|||
json = await api.createMcpServer(data) |
|||
} else { |
|||
json = await api.updateMcpServer(editId.value, data) |
|||
} |
|||
|
|||
if (json.success) { |
|||
toast(modalMode.value === 'add' ? '创建成功' : '更新成功', 'success') |
|||
closeModal() |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('保存失败:' + e.message, 'error') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除 ====================
|
|||
|
|||
async function remove(id, name) { |
|||
if (!confirm('确定删除配置「' + (name || id) + '」?删除后不可恢复。')) return |
|||
try { |
|||
const json = await api.deleteMcpServer(id) |
|||
if (json.success) { |
|||
toast('删除成功', 'success') |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 启用/禁用 ====================
|
|||
|
|||
async function toggleActive(server) { |
|||
const newActive = !server.is_active |
|||
try { |
|||
const json = await api.toggleMcpServer(server.id, newActive) |
|||
if (json.success) { |
|||
toast(newActive ? '已启用' : '已禁用', 'success') |
|||
// 乐观更新本地状态,避免刷新列表
|
|||
server.is_active = newActive |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('操作失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 测试连接 ====================
|
|||
|
|||
async function testConnection(server) { |
|||
testLoading.value = { ...testLoading.value, [server.id]: true } |
|||
try { |
|||
const json = await api.testMcpServer(server.id) |
|||
showTestResult(json) |
|||
} catch (e) { |
|||
showTestResult({ success: false, message: e.message }) |
|||
} finally { |
|||
testLoading.value = { ...testLoading.value, [server.id]: false } |
|||
} |
|||
} |
|||
|
|||
function showTestResult(result) { |
|||
if (testResultTimer) clearTimeout(testResultTimer) |
|||
testResult.value = { |
|||
visible: true, |
|||
success: result.success, |
|||
message: result.message || '' |
|||
} |
|||
testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000) |
|||
} |
|||
|
|||
// ==================== 刷新连接 ====================
|
|||
|
|||
async function refreshConnections() { |
|||
refreshLoading.value = true |
|||
try { |
|||
const json = await api.refreshMcpServers() |
|||
if (json.success) { |
|||
toast('连接已刷新', 'success') |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '刷新失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('刷新失败:' + e.message, 'error') |
|||
} finally { |
|||
refreshLoading.value = false |
|||
} |
|||
} |
|||
|
|||
// ==================== 工具函数 ====================
|
|||
|
|||
function getDisplayUrl(server) { |
|||
if (server.transport_type === 'sse') { |
|||
return server.server_url || '-' |
|||
} |
|||
// stdio 模式:显示 command + args
|
|||
let display = server.command || '' |
|||
if (server.args) { |
|||
// args 可能是字符串或数组
|
|||
const argsStr = Array.isArray(server.args) ? server.args.join(' ') : server.args |
|||
display += ' ' + argsStr |
|||
} |
|||
return display || '-' |
|||
} |
|||
|
|||
// ==================== 生命周期 ====================
|
|||
|
|||
onMounted(() => { |
|||
load() |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
if (testResultTimer) clearTimeout(testResultTimer) |
|||
}) |
|||
|
|||
return { |
|||
// 列表
|
|||
servers, currentPage, totalPages, total, |
|||
// 弹窗
|
|||
showModal, modalMode, form, saving, transportOptions, |
|||
// 测试
|
|||
testLoading, testResult, |
|||
// 刷新
|
|||
refreshLoading, |
|||
// 方法
|
|||
load, openAddModal, openEditModal, closeModal, save, remove, |
|||
toggleActive, testConnection, refreshConnections, getDisplayUrl, formatDate |
|||
} |
|||
} |
|||
} |
|||
963
src/main/resources/static/sdk/chatbot-sdk.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
src/main/resources/static/sdk/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue