Compare commits

...
21 changed files with 721 additions and 504 deletions
+6 -3
View File
@@ -132,6 +132,8 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本机客户端配置(如 `.kimi-code/mcp.json`,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
手工插入的 ak 没有 `key_cipher` 密文:走 `/mcp` 正常(只校验哈希),但被 `ensure` 命中时会触发自动轮换(旧把禁用、签发新把)——这是预期行为。
```bash
AK="xqak_$(openssl rand -hex 24)" # 生成随机 ak,明文只贴进本机客户端配置
printf %s "$AK" | shasum -a 256 # 输出的哈希写入 agent_api_key.key_hash
@@ -211,9 +213,10 @@ curl -X POST http://localhost:8088/api/open/token/ensure \
```
- 入参:`mobile`、`email` 至少一个(手机号优先);`scopeCode` 必填。人员只认企业账号(enterprise_type 0/1),查不到 → 404。
- 已存在有效 Token → `{"created":false,"token":null,"keyPrefix":"xqak_xxx…",...}`(服务端只存哈希,**明文不可回吐**,丢了只能后台重新签发)。
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文,仅此一次)",...}`。
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api)。
- 已存在有效 Token → `{"created":false,"token":"xqak_...(解密既有密文得到的明文)",...}`(**支持二次获取**:服务端保存 SHA-256 哈希 + AES-GCM 密文,密钥由 `app.token-cipher-key` 派生)。
- 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。
**签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。
@@ -8,7 +8,7 @@
## 1. 背景与目标
现有 SaaS(xiaoqu-franchisee)是面向 PC 后台人工操作的系统,接口鉴权(uuid + 短信登录态 + 签名)不适合机器调用。本方案新建一套**独立系统**,将仓库、客户等业务数据以 **MCP 服务**形式暴露给智能体,同时提供 CLI 兜底与 Web 管理后台。
现有 SaaS(xiaoqu-franchisee)是面向 PC 后台人工操作的系统,接口鉴权(uuid + 短信登录态 + 签名)不适合机器调用。本方案新建一套**独立系统**,将仓库、客户等业务数据以 **MCP 服务**形式暴露给 xiaoqu-ai Android/iOS/Web、xiaoqu-operations-ai 及外部 MCP 客户端,同时提供 CLI 兜底与 Web 管理后台。
设计约束(讨论中明确):
@@ -20,11 +20,11 @@
## 2. 总体架构
```
Kimi Code ──┐
├─ MCP (Streamable HTTP, Bearer ak) ─┐
Claude ─────┤ │
│ ▼
xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌─────────────────────────┐
Kimi/Claude/xq CLI ── MCP (Bearer ak) ──────────┐
xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
运营助手 Android/iOS ─────► ops-assistant-service┤
▼
┌─────────────────────────┐
│ xiaoqu-agent-service │
│ (单 jar, Spring Boot 3) │
│ │
@@ -44,7 +44,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
└──────────┘ └────────────┘
```
关键分层:**业务工具只写一遍**,MCP 端点(Kimi/Claude/CLI 同为 MCP 客户端)、后台页面共享同一套鉴权、权限引擎与数据访问层。
关键分层:**业务工具只写一遍**。Kimi/Claude/CLI 和两个产品的服务端 Agent 是 MCP Client;Android/iOS/Web 业务客户端只访问本产品 Gateway/Agent API,不直接持有服务间密钥或 MCP Token。所有 MCP 调用共享同一套鉴权、权限引擎与数据访问层。
## 3. 权限模型(三层)
@@ -123,7 +123,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
```
- Claude Code:`claude mcp add --transport http xiaoqu https://mcp.<域名>/mcp --header "Authorization: Bearer <ak>"`。
- ak 明文只出现在本机客户端配置与签发时刻;服务端只存 SHA-256 哈希,含 ak 的客户端配置文件不得提交版本库。
- ak 明文只出现在服务端签发/ensure 响应和运行时;服务端保存 SHA-256 哈希及 AES-GCM 加密密文(二次获取用),含 ak 的客户端配置文件不得提交版本库。
### 5.2 Web 管理后台(四个页面)
@@ -155,9 +155,10 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
- **`POST /api/open/token/ensure`**(get-or-create):
- 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。
- 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。
- 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,只回 `keyPrefix`/`expireTime` 等元信息。**明文不可回吐**:服务端只存 SHA-256,这是安全红线,调用方丢了明文只能走后台重新签发。
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文,仅此一次。
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏)。
- 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,**解密 `key_cipher` 回传明文**(二次获取,2026-09-07 决策:服务端保存 SHA-256 哈希 + AES-GCM 加密密文,密钥由配置 `app.token-cipher-key` 派生)。
- 已存在但**无密文**(V3 之前的历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文;解密失败 → 500,不回退。
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文。
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏,不记录明文)。
## 6. 技术栈与工程结构
@@ -186,7 +187,7 @@ xiaoqu-agent-service/
| 表 | 关键字段 | 说明 |
|---|---|---|
| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user |
| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、key_cipher(AES-GCM 密文)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user |
| `scope` | name、code、memo、status | 作用域 |
| `scope_tool` | scope_id、tool_id | 作用域-工具 |
| `token_scope` | token_id、scope_id | token-作用域 |
@@ -199,7 +200,7 @@ xiaoqu-agent-service/
## 8. 安全基线
- dev/内网测试阶段允许裸跑 HTTP(客户端 URL 用 `http://`);**对外/生产必须 HTTPS**(Nginx 终结 TLS,ak 在 HTTP 下明文可被抓包)
- ak 只存哈希;审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库)
- ak 存 SHA-256 哈希 + AES-GCM 加密密文(不存明文;密钥独立配置 `app.token-cipher-key`);审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库)
- 数据访问全部只读账号;scope 最小化发放,新工具默认不可用
- 可选:Nginx IP 白名单(客户端出口固定时)
@@ -240,7 +241,7 @@ xiaoqu-agent-service/
- 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖
- 前后端一体打包单 jar → 可逆决定,将来可拆
- 一作用域一 Token(2026-09-04)→ 收回某能力不需整把吊销;数据模型不变(token_scope 每把只写一行)
- Open API 已存在 Token 只回元信息不回明文 → 服务端只存哈希,明文不可回吐是安全红线
- ensure 支持二次获取明文(2026-09-07,推翻此前"明文不可回吐"红线):服务端存 SHA-256 哈希 + AES-GCM 密文(`app.token-cipher-key` 派生密钥);已存在 Token 解密回传明文,历史无密文 Token 自动轮换 → 调用方丢了明文无需人工重新签发;代价是拿到 `X-Open-Key` 者可取回任意用户 ak 明文,密钥必须仅限受信任后端保管
- Open API 鉴权用配置文件静态密钥(dev)→ 服务间调用方少,落库+轮换留给生产阶段
- 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度
@@ -266,4 +267,3 @@ xiaoqu-agent-service/
- 合并回 main 后用 `xiaoqu-agent-service/deploy/deploy.sh restart` 完成构建+重启,接口实测通过再 `git push`。
- 后台列表页统一服务端分页(page 从 1 开始,pageSize 默认 20、上限 100);MCP 工具 `rows` 同样上限 100,超出截断并在 `notice` 字段提示。
- **新增/修改 MCP 工具必须同步更新 `doc/agent-mcp-tools-reference.md`**(入参、出参、权限语义、示例),工具对外契约以该文档为准。
@@ -8,7 +8,7 @@
| 项 | 说明 |
|---|---|
| 端点 | `POST http://<host>:8088/mcp`(MCP Streamable HTTP) |
| 认证 | Header `Authorization: Bearer xqak_xxx`,ak 在管理后台「用户与 Token」签发,服务端只存 SHA-256 |
| 认证 | Header `Authorization: Bearer xqak_xxx`,Token 由管理后台或受信任 Open API 获取;服务端保存 SHA-256 及加密密文 |
| 工具可见性 | `tools/list` 按 ak 作用域过滤,只返回该 ak 作用域并集内且启用状态的工具(2026-09-04 起,服务端 servlet 层重写响应实现);`tools/call` 另有强制校验兜底,越权返回可读拒绝信息并落审计(denied) |
| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 |
| 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) |
@@ -0,0 +1,153 @@
# xiaoqu-ai 接入 xiaoqu-mcp 详细设计
## 1. 目标与边界
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的 xiaoqu-ai 落地设计。
xiaoqu-ai 的 iOS、Android、Web 共用一套服务端 MCP 接入,不在三个客户端分别实现 MCP Client:
```text
iOS / Android / Web
│ 产品登录 Token
▼
xiaoqu-ai Gateway / Agent Runtime
│ 用户 MCP Token(仅服务端持有)
▼
xiaoqu-agent-service /mcp
```
项目目录:
- iOS、Web、Gateway、Agent Service:`/Users/tonylai/xiaoquai/xiaoqu-ai`
- Android:`/Users/tonylai/jiaxk/xiaoqu-ai-android`
## 2. 当前代码依据
| 端/服务 | 当前入口 | 结论 |
|---|---|---|
| iOS 销售助手 | `ios/Sources/XiaoquCRM/Features/SalesAgent/SalesAgentClient.swift` | 继续调用 `/api/v1/sales-agent/*` REST/SSE |
| iOS 管理助手 | `ios/Sources/XiaoquCRM/Features/AI/Networking/ManagerAgentClient.swift` | 继续调用服务端 Agent,不新增 MCP Token |
| Android | `app/src/main/java/com/xiaoquai/crm/data/remote/WebSocketManager.kt`、`ApiService.kt` | 继续走 Gateway WebSocket/REST,`TokenManager` 只保存产品 Token |
| Web | `web/src/hooks/use-agent-chat.ts` | 继续调用 `/api/v1/agents/{id}/invoke` |
| Gateway | `gateway/internal/agent/tools.go`、`gateway/internal/handler/runtime_tool_invoke_handlers.go` | 已有统一 ToolExecutor、数据范围校验和运行时工具入口 |
| Agent Service | `agent-service/agent_service/tools/runtime_gateway_tools.py` | 已使用当前用户 JWT调用 Gateway runtime catalog/tool invoke |
iOS 的 `ClawBridge.swift` 是独立端侧 Agent 模式,不属于本次服务端 xiaoqu-mcp 接入,不修改其 Keychain、MCP 配置或 Token。
## 3. 统一接入点
MCP 适配器放在 Gateway 的工具执行层,三端最终复用同一实现:
```text
Web / Android Agent 请求 ──► Gateway AgentRuntime ─┐
├─► Gateway ToolExecutor
iOS Sales Agent ──► agent-service │ │
└─ runtime_gateway_tools ───────┘ ▼
xiaoqu-mcp
```
这样保留现有客户端和 Agent 编排,只把新的远程工具接入集中在 Gateway。Agent Service 继续转发已验证的用户 JWT,不自行持有第二份 xiaoqu-mcp Token 缓存。
## 4. Gateway 变更
### 4.1 配置
在 Gateway 启动配置中增加:
| 变量 | 默认值 | 说明 |
|---|---|---|
| `XQ_MCP_ENABLED` | `false` | 是否启用 xiaoqu-mcp 工具 |
| `XQ_MCP_URL` | unset | xiaoqu-mcp 内网地址 |
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证 |
| `XQ_MCP_SCOPE_CODE` | unset | xiaoqu-ai 固定 scope;启用前必须在管理后台确认 |
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
启用时 URL、Open Key 和 scope 必填。Open Key 只来自部署 Secret,不写入 YAML、客户端配置或仓库。
### 4.2 最小模块
新增 `gateway/internal/connector/xiaoqumcp/`:
- `token_provider.go`
- 输入 Gateway 已验证的 `tenantID`、`userID`。
- 从用户资料存储取得可信 mobile/email 并调用 `ensure`。
- 按租户、用户和 scope 缓存 Token;401 时只刷新当前用户一次。
- `client.go`
- 管理 MCP JSON-RPC、Session ID、Bearer Header、超时和错误映射。
- 按租户、用户和 scope 隔离 Session。
使用 Go 标准 HTTP Client 和项目现有错误/日志设施,不增加 MCP 框架依赖。
### 4.3 ToolExecutor 接入
在 `gateway/internal/agent/tools.go` 注册稳定的本地工具名,由 Handler 调用 `xiaoqumcp.Client`。不得把远端 `tools/list` 原样注册给模型。
首期只接只读工具。候选映射以 xiaoqu-mcp 当前工具为基础,但必须先为 xiaoqu-ai 创建并审核独立 scope:
| Gateway 本地工具 | MCP 工具 | 状态 |
|---|---|---|
| `xiaoqu.agent_count` | `agent.count` | scope 绑定后启用 |
| `xiaoqu.agent_list` | `agent.list.query` | scope 绑定后启用 |
| `xiaoqu.property_list` | `property.list.query` | scope 绑定后启用 |
每个工具继续使用现有 `RequiredDataScope`、Agent Capability 绑定和参数 schema。MCP 返回结果先做字段白名单投影,再进入模型上下文、卡片渲染和 `chat_tool_logs`。
## 5. 用户与权限流程
```text
产品登录 Token
→ Gateway middleware 得到 userID / tenantID / role
→ Agent Capability + RequiredDataScope 校验
→ 可信用户资料解析 mobile/email
→ token_provider.ensure(scopeCode)
→ mcp_client.tools/call
→ xiaoqu-mcp 再校验 Token scope 与数据权限
```
`agent.InvokeRequest` 已携带 `UserID`、`TenantID`、`UserJWT` 和 `UserRole`。MCP Handler 必须使用这些可信字段,不接受模型参数覆盖身份或 scope。
Agent Service 的 `runtime_gateway_tools.py` 继续转发入站用户 JWT;无 JWT 时保持 fail-closed,不允许自签或使用公共用户身份。
## 6. 客户端影响
| 客户端 | 变更 |
|---|---|
| iOS | 无 MCP 网络层变更;继续消费现有 REST/SSE、确认卡和工具结果 |
| Android | 无 MCP 网络层变更;Gateway Token 仍由现有 `TokenManager` 管理 |
| Web | 无 MCP 网络层变更;继续调用 Agent invoke API 并渲染返回结果 |
如果新工具需要专用展示,只扩展现有服务端响应投影和客户端卡片 schema;不得因此向客户端下发 MCP Token、Session ID 或原始 Header。
## 7. 错误与生命周期
- 用户切换或产品会话失效时,当前用户的 MCP Token 缓存和 Session 一并失效。
- MCP 401:清理当前用户缓存,重新 `ensure` 并重连一次。
- MCP 403:返回权限不足,不刷新 Token。
- MCP 超时或不可用:返回现有稳定工具错误,不回退公共 Token 或数据库直连。
- 只读请求最多重试一次;写工具不在首期范围。
- 日志和 `chat_tool_logs` 只记录工具名、状态、耗时与脱敏错误,不记录 Token、Header、mobile/email 或未投影原始结果。
## 8. 开发顺序
1. 在目标环境创建或确认 xiaoqu-ai 独立 scope,并绑定首期只读工具。
2. 增加 Gateway 配置与启动校验。
3. 实现 `token_provider.go` 和 `client.go`,覆盖 Token、Session、超时和错误映射测试。
4. 在 ToolExecutor 注册固定工具并复用现有 DataScope 校验。
5. 验证 Agent Service runtime gateway 路径仍只转发用户 JWT。
6. 分别用 iOS、Android、Web 完成同用户、跨用户和跨租户联调。
## 9. 项目验收
- 同一用户在 iOS、Android、Web 调用同一工具时,结果契约和数据权限一致。
- 三端请求、存储、日志中均无 MCP Token、Session ID 或 `X-Open-Key`。
- Web/Android Gateway Agent 与 iOS Sales Agent 最终调用同一个 Gateway MCP Handler。
- 用户 A/B、不同租户和 scope 的 Token 缓存与 MCP Session 完全隔离。
- 无本地 Capability、无 RequiredDataScope 或无 MCP scope 时均不能调用。
- MCP 401 只刷新当前用户;MCP 不可用不影响未启用该 Capability 的原有 Agent 功能。
## 10. 当前不做
- 不为 iOS、Android、Web 分别实现 MCP Client。
- 不改造 iOS ClawBridge。
- 不把远端全部工具动态暴露给 Agent。
- 不在首期接入写工具或副作用自动重试。
@@ -1,322 +0,0 @@
# xiaoqu-ai 每用户 MCP Token 接入开发设计文档
## 1. 需求概述
### 1.1 需求信息
| 项目 | 内容 |
|---|---|
| 需求标题 | xiaoqu-ai 每用户独立 MCP Token 接入 |
| 优先级 | high |
| 目标客户端 | xiaoqu-ai iOS ClawMobile |
| 目标服务 | xiaoqu-agent-service `/mcp` |
| 认证要求 | 每个登录用户使用自己的 MCP Token |
| 预估工时 | 12-16h |
### 1.2 成功标准
- 用户 A 的 MCP 请求只能携带用户 A 的 Token,不能使用用户 B 或应用公共 Token。
- 登录、刷新 Token、切换账号、退出登录后,Claw/MCP 会话不会串用旧用户凭证。
- MCP 请求使用 `Authorization: Bearer <token>`,服务端继续执行用户、租户、作用域和审计校验。
- Token 不出现在 prompt、工具参数、普通日志、崩溃信息或 Git 文件中。
- 用户无 MCP Token、Token 过期、Token 被撤销时,客户端显示可恢复错误,不发送无凭证请求。
### 1.3 明确不做
- 不把一个固定 Token 写入 App、公共配置文件或所有用户共用。
- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文只在签发响应中出现一次。
- 不让客户端提交任意 `user_id`、`tenant_id` 来冒充用户身份。
## 2. 现状分析
### 2.1 相关现有代码
| 文件 | 层级 | 当前功能 | 改动类型 |
|---|---|---|---|
| `xiaoqu-ai/ios/Sources/XiaoquCRM/Core/Dependencies/ClawBridge.swift` | iOS 依赖 | 静态初始化 Claw 配置,并创建 MCP Server 列表 | 修改 |
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/ffi.rs` | Rust FFI | 接收 MCP 配置,连接、发现工具、调用工具 | 修改 |
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/mcp_http.rs` | Rust HTTP | JSON-RPC HTTP/SSE MCP 客户端 | 修改 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java` | 服务鉴权 | 仅解析 `Authorization: Bearer <ak>` | 保持兼容 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java` | 服务鉴权 | AK SHA-256 哈希查询、用户加载、过期检查 | 修改/复用 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,明文只返回一次 | 扩展复用 |
### 2.2 当前缺口
1. `ClawBridge.liveValue` 是静态值,Token 只在进程初始化时读取一次。
2. 当前 FFI 将 `apiKey` 发送为 `X-API-Key`,与服务端要求的 Bearer 不一致。
3. iOS FFI 未把配置文件中的 MCP Server 自动接入 `FfiEngineConfig`。
4. 多 MCP Client 共存时,工具调用没有稳定的“工具名 → 所属 Server”映射。
5. ~~当前管理端签发接口需要管理员会话,不适合作为普通用户登录后的自动取 Token 接口。~~ **已解决(2026-09-04)**:xiaoqu-agent-service 已交付 Open API `POST /api/open/token/ensure`(get-or-create,服务间密钥鉴权),见 §4.1。
## 3. 方案设计
### 3.1 认证模型
采用两级凭证:
1. xiaoqu-ai 登录 `accessToken`:证明当前用户登录身份,只用于向业务后端换取 MCP Token。
2. MCP Token(`xqak_*`):绑定 `user_id`,受 MCP scope、租户/行级权限、过期时间和撤销状态约束,只用于 `/mcp`。
客户端不得把 `accessToken` 和 MCP Token 混为一谈。MCP 服务仍只接受 MCP Token。
### 3.2 Token 生命周期
| 场景 | 行为 |
|---|---|
| 登录成功 | 用当前用户登录态换取 MCP Token,按用户保存 |
| MCP Token 过期/401 | 清除当前用户 MCP Token,重新换取一次;同一请求不自动重放工具副作用 |
| accessToken 刷新 | 保留 MCP Token;若服务返回 401,再重新换取 |
| 切换账号 | 先断开旧 MCP Client,再加载新账号 Token 并建立新 Client |
| 退出登录 | 销毁旧 Claw/MCP Client,删除当前账号本地 MCP Token;当前永久 Token 不会自动失效,需要显式调用 revoke |
| 多账号 | Keychain key 必须包含稳定用户 ID,禁止所有账号共用 `mcpToken` |
~~建议 MCP Token 默认有效期 15 分钟,并提供换取接口;长期 AK 仅用于受控开发/管理场景。~~
**2026-09-04 更新(与已交付实现对齐)**:现行模型为**长期有效 + 禁用回收**(与管理后台签发模型一致),不做 15 分钟短周期。`ensure` 语义天然兼容短周期模式(过期 token 不计入"已存在",会触发新建并返回新明文),将来如需缩短有效期,只调整签发时的 `expireTime`,协议不变。
## 4. API 契约
> **2026-09-04 更新**:本节按 xiaoqu-agent-service 已交付的 Open API 重写。核心变化——agent-service 不直接验证 xiaoqu-ai 登录态(它没有 JWT 基础设施,也不懂租户),改为**两段式**:iOS 用登录态调 xiaoqu-ai 业务后端;业务后端拿着登录用户的手机号/邮箱,用服务间密钥调 agent-service 的 `ensure` 接口取 MCP Token。
### 4.1 服务端原语(已交付):POST `/api/open/token/ensure`
描述: 按 手机号/邮箱 + 作用域编码 确保用户有有效 MCP Token(get-or-create)。
认证:
- `X-Open-Key: <服务间静态密钥>`(dev 配置在 `application-dev.yml` 的 `app.open.api-key`,生产走环境变量;配置为空 = 接口关闭)。
- 只允许 xiaoqu-ai 业务后端等服务端调用方持有,**不得下发到 App**。
请求体:
- `mobile`: string — 登录用户手机号;与 `email` 至少传一个,手机号优先。
- `email`: string — 登录用户邮箱。
- `scopeCode`: string (required) — 作用域编码(如 `operation`);iOS 用哪个作用域由双方约定后在此固定。
响应 200:
- `created`: bool — 本次是否新建。
- `token`: string | null — **仅 `created=true` 时返回明文,且只出现这一次**;`created=false` 时恒为 null。
- `keyPrefix`: string — Token 前缀(如 `xqak_abcd1234`),用于标识和对账。
- `expireTime`: datetime | null — 现行为 null(永久,靠禁用回收)。
- `userId` / `userName` / `scopeCode`: 归属信息。
错误(统一 `{"message": "原因"}`):
- `400`: 参数缺失 / 作用域不存在或已停用。
- `401`: `X-Open-Key` 无效。
- `403`: Open API 未启用(服务端未配置密钥)。
- `404`: 人员不存在或非企业账号(只认 `enterprise_type` 0/1 的企业账号)。
服务端处理(已实现):
- 复用 `agent_api_key` 哈希存储与 `token_scope` 绑定;一作用域一 Token。
- 已存在有效 Token(正常且未过期)只回元信息——**明文不可回吐,服务端只存 SHA-256**。
- 不存在则自动创建永久 Token,memo 标记 `open-api 自动签发(<scopeCode>)`。
- 全程写 `audit_log`(`tool_name=open.token.ensure`、`client_type=open-api`,手机号/邮箱脱敏,不记录 Token)。
**关键约束与对策**:`created=false` 时拿不到明文。因此 xiaoqu-ai 业务后端必须在首次 `created=true` 时**自己持久化明文**(按 user_id 关联,加密存储),之后下发给本用户的客户端。
**身份约束**:业务后端不得接受客户端或模型传入的 `user_id`、`mobile`、`email` 作为身份依据。必须先从已验证的 xiaoqu-ai `accessToken` 会话解析用户,再由业务后端查询该用户的手机号/邮箱并调用 `ensure`。`X-Open-Key` 只允许服务端保管。
### 4.1a Token 丢失与主动失效(建议补充)
当前 `ensure` 在 `created=false` 时不会返回明文,无法解决业务后端首次落库失败或 Token 泄露后的换发问题。建议增加两个服务间接口,均使用 `X-Open-Key` 鉴权:
`POST /api/open/token/rotate`
- 按用户和 `scopeCode` 禁用当前有效 Token,创建新 Token。
- 仅本次返回新 Token 明文;旧 Token 立即失效。
- 记录旧 Token、新 Token 前缀、用户和 scope,禁止记录 Token 明文。
- 具备幂等保护,避免并发换发产生多个有效 Token。
`POST /api/open/token/revoke`
- 按用户和 `scopeCode` 禁用当前 Token。
- 不返回 Token 明文;重复调用视为成功。
- 用于退出登录后的主动失效、设备解绑和安全事件处置。
在 `rotate/revoke` 尚未实现前,退出登录只能清理客户端本地凭证,不能声称服务端 Token 已失效;管理员后台禁用仍是唯一服务端回收方式。
### 4.1b xiaoqu-ai 业务后端门面(待 xiaoqu-ai 侧实现)
iOS 面向的接口(建议沿用原设计):POST `/api/v1/mcp/session-token`,`Authorization: Bearer <xiaoqu-access-token>`。业务后端从登录态解析用户 → 服务端查询手机号/邮箱 → 调 `ensure` → 返回 `{token, expires_at, user_id}`。不得使用请求体中的 `user_id/mobile/email` 覆盖登录态身份;客户端身份字段(client、device_id)由业务后端自行记录,agent-service 不感知。
### 4.2 POST `/mcp`
描述: MCP Streamable HTTP JSON-RPC 端点,现有接口保持不变。
请求 Header:
- `Authorization: Bearer <mcp-token>` (required)
- `Content-Type: application/json` (required)
- `Accept: application/json, text/event-stream` (required)
- `Mcp-Session-Id: <id>` (required after initialize response contains it)
请求/响应:
- 保持 MCP `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 顺序。
- `tools/call` 必须继续执行 Token 作用域、用户和租户数据范围校验。
错误:
- `401`: MCP Token 缺失、无效、撤销或过期。
- `200` + MCP `isError=true`: Token 有效但无权调用该工具,客户端展示权限错误,不切换账号重试。
### 4.3 客户端配置文件
配置文件只保存非用户敏感配置:
```json
{
"mcpServers": {
"xiaoqu": {
"type": "http",
"url": "http://<host>:8088/mcp"
}
}
}
```
Token 不写入该文件。客户端运行时读取当前用户 Token,并动态加入 `Authorization` Header。
## 5. 客户端实现设计
### 5.1 ClawBridge
- 将 `ClawBridge` 从静态全局凭证改为按当前用户创建的实例。
- `connectMcp(userID, mcpToken)` 创建只属于当前用户的 FFI Engine。
- `disconnectMcp()` 在切换账号、退出登录和重新换 Token 前调用。
- 不在 `liveValue` 捕获 `accessToken` 或 MCP Token;通过依赖注入传入当前账号凭证。
### 5.2 FFI / HTTP
- `FfiMcpServerConfig` 增加明确的认证模式,或直接将 `apiKey` 改为 `bearerToken`。
- 使用 `Authorization: Bearer <mcpToken>`,禁止发送 `X-API-Key`。
- `initialize` 后保存 `Mcp-Session-Id`,发送 `notifications/initialized`,再调用 `tools/list`。
- 工具发现时保存 `tool_name → server_name` 映射;调用只发送到所属 Server,不按 Client 顺序盲试。
- Token 过期只触发一次重新换取和重新连接;并发请求共享刷新锁,避免同一用户并发签发多个 Token。
### 5.3 Keychain
- Key 名:`mcpToken.<user_id>`,值为 MCP Token;使用 ThisDeviceOnly 存储。
- `device_id` 单独生成并持久化,不从 Token 派生。
- 日志只记录 Token 是否存在、剩余有效期和用户 ID,不记录 Token 内容。
## 6. 数据模型变更
### 6.1 现有表复用
| 表 | 用途 | 变更 |
|---|---|---|
| `agent_api_key` | MCP Token 哈希、用户绑定、状态、过期时间 | 复用;确认 `user_id/status/expire_time/key_hash` 索引 |
| `token_scope` | Token 与作用域绑定 | 复用 |
| `audit_log` | MCP 调用和 Token 生命周期审计 | 扩展事件类型/元数据(若当前字段不足) |
### 6.2 建议新增字段(仅在现有字段不足时)
> 2026-09-04 注:已交付实现暂不新增字段——ensure 自动签发的 Token 用 `memo` 标记(`open-api 自动签发(<scopeCode>)`),`create_id=0` 表示系统签发。若后续需要按来源/设备撤销或统计,再按下表加独立 migration。
| 表 | 字段 | 类型/约束 | 说明 |
|---|---|---|---|
| `agent_api_key` | `source` | `VARCHAR(32) NOT NULL DEFAULT 'admin'` | `admin` / `user_session`,便于撤销和审计 |
| `agent_api_key` | `device_id` | `VARCHAR(128) NULL` | 用户会话 Token 的设备绑定 |
索引:`(user_id, source, status, expire_time)`。新增字段必须通过新的 Flyway migration,禁止修改已执行 migration。
## 7. 变更文件清单
### xiaoqu-agent-service
| 文件/目录 | 层级 | 类型 | 改动说明 |
|---|---|---|---|
| ~~`security/` JWT 验证组件~~ | Security | ~~新增~~ 取消 | agent-service 不验证 xiaoqu-ai 登录态,改由业务后端两段式调用 |
| `open/OpenTokenController.java` 等 | Open API | **已交付**(2026-09-04) | `POST /api/open/token/ensure`,X-Open-Key 鉴权,get-or-create |
| `open/OpenKeyFilter.java` | Filter | **已交付** | 服务间静态密钥,常量时间比较,配置为空即关闭 |
| `admin/AdminTokenService.java` | Service | **已交付** | 一作用域一 Token;`issueSingle`/`findActiveToken` 供 ensure 复用 |
| `common/GlobalExceptionHandler.java` | Handler | **已交付** | 错误统一 `{"message": ...}` |
| `security/AkAuthFilter.java` | Filter | 保持兼容 | `/mcp` Bearer ak 校验不变 |
| `src/test/...open` | Test | **已交付** | ensure 已存在/新建/404/停用作用域/缺参 5 个用例 |
### xiaoqu-ai
| 文件/目录 | 层级 | 类型 | 改动说明 |
|---|---|---|---|
| `ios/.../ClawBridge.swift` | iOS | 修改 | 按登录用户注入 Token,重建/销毁 Claw 实例 |
| `claw-mobile/src/ffi.rs` | Rust FFI | 修改 | Bearer Header、会话通知、Server 路由映射 |
| `claw-mobile/src/mcp_http.rs` | Rust HTTP | 修改 | 通用 Header、Session ID 和 JSON-RPC 初始化流程 |
| `ios/.../KeychainClient.swift` | iOS 存储 | 修改 | 账号隔离的 MCP Token 存取和删除 |
| `ios/.../AuthFeature.swift` | 登录生命周期 | 修改 | 登录/刷新/切换/退出触发 MCP 凭证生命周期 |
| `ios/Tests/...` | Test | 新增 | 用户切换、401 刷新、Token 不串号测试 |
| `claw-code-parity/.../settings*.json` | 配置 | 修改 | 仅保存 URL,不保存用户 Token |
## 8. 开发任务拆分
| # | 任务标题 | 工时 | 依赖 | 完成标准 |
|---|---|---:|---|---|
| 1 | ~~服务端确认 JWT 与租户映射、Token exchange 契约~~ → xiaoqu-ai 业务后端实现门面接口(登录态 → ensure) | 2h | - | 门面 401/403 语义确定;明文按 user_id 加密落库 |
| 2 | ~~服务端实现用户 MCP Token 签发与审计~~ **已交付**(ensure + 审计,2026-09-04) | - | - | 已上线并实测幂等/脱敏/错误语义 |
| 3 | iOS Keychain 账号隔离和认证生命周期接入 | 3h | #1 | 登录/切换/退出无旧 Token 残留 |
| 4 | Rust MCP HTTP Bearer、Session、初始化通知 | 3h | #3 | initialize/list/call 真实握手成功 |
| 5 | MCP 工具到 Server 路由映射 | 2h | #4 | 多 Server 下工具调用不发错目标 |
| 6 | 集成与安全回归测试 | 2-4h | #2-#5 | 用户隔离、401 刷新、权限拒绝和日志脱敏通过 |
实施顺序:#1 → #2;#3 与 #4 可并行;完成后 #5 → #6。
## 9. 测试与验收
### 9.1 服务端
- 用户 A Token 调用 `tools/list` / `tools/call` 成功,审计 `user_id=A`。
- 用户 B Token 调用时只能看到/调用 B 作用域允许的数据。
- A 的 Token 调用不存在或已撤销 Token 返回 401;A 的 Token 调 B 身份参数不得改变审计身份。
- A 无某工具 scope 时返回 MCP `isError=true`,不得执行 Handler。
- 签发响应后数据库和日志中搜索不到 Token 明文。
### 9.2 iOS
- A 登录后抓包确认所有 MCP 请求为 `Authorization: Bearer <A-MCP-Token>`。
- 切换 B 后,旧连接关闭,后续请求只携带 B Token。
- A Token 401 时只刷新 A 的 Token,不影响 B 或其他账号。
- 退出后旧 Claw 不再产生网络请求。
- 配置文件、prompt、工具参数和日志不包含 Token。
### 9.3 联调命令
```bash
# 业务后端侧:用服务间密钥 ensure 用户 MCP Token(示例,密钥与明文不写入仓库)
curl -X POST http://<host>:8088/api/open/token/ensure \
-H 'X-Open-Key: <服务间密钥>' \
-H 'Content-Type: application/json' \
-d '{"mobile":"<登录用户手机号>","scopeCode":"operation"}'
# iOS 侧:走 xiaoqu-ai 业务后门门面(待实现)
curl -X POST http://<xiaoqu-ai-host>/api/v1/mcp/session-token \
-H 'Authorization: Bearer <xiaoqu-access-token>' \
-H 'Content-Type: application/json' \
-d '{"client":"xiaoqu-ai-ios","device_id":"<device-id>"}'
# 使用返回的用户 MCP Token 完成 MCP 握手
curl -X POST http://<host>:8088/mcp \
-H 'Authorization: Bearer <user-mcp-token>' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"xiaoqu-ai","version":"1.0"}}}'
```
## 10. 技术风险与回退
| 风险 | 等级 | 应对 |
|---|---|---|
| accessToken 与 MCP AK 类型混用 | 高 | 强制 Token exchange;服务端只接受明确的 MCP AK |
| 静态 Claw 实例持有旧用户 Token | 高 | 用户切换/退出先销毁实例,增加生命周期测试 |
| 多 MCP Server 工具路由错误 | 高 | 建立工具归属映射,不采用首个 Client 试调用 |
| Token 泄露到日志/崩溃 | 高 | 脱敏中间件、禁止打印 Header、静态扫描和测试 |
| Token exchange 不可用 | 中 | MCP 功能降级为不可用并提示重试;不得使用公共 Token 兜底 |
| 新字段迁移失败 | 中 | 字段变更独立 Flyway migration;回滚只撤销新增字段/索引 |
回退策略:保留现有管理员签发 Token 和 `/mcp` 协议不变;先关闭用户自动换取入口,客户端显示 MCP 不可用。禁止回退到全局共享 Token。
## 11. 设计结论
公共 MCP 配置文件只负责“连哪个服务”;用户 Token 由登录用户上下文动态决定。实现完成后,xiaoqu-ai 的每个用户拥有独立 MCP 权限边界,服务端仍能按用户、租户、作用域和审计链路追踪调用。
@@ -1,146 +0,0 @@
# xiaoqu-operations-ai 接入 xiaoqu-mcp 开发设置
## 1. 目标
让 `ops-assistant-service` 通过服务端 MCP Client 调用
`xiaoqu_mcp_service`,保持现有:
```text
Router → Planner → Executor → Answer
```
每次调用使用当前已验证用户对应的 MCP Token,不把 MCP Token 放进提示词、Planner 输出、工具参数或普通日志。
## 2. 当前代码结论
- `ops-assistant-service` 是 FastAPI + Python 3.12 服务。
- 当前依赖中没有 MCP SDK,也没有 `tools/list` / `tools/call` 客户端。
- `Executor` 目前直接调用本地 capability:`customer_issues.*`、`smartclean.query`。
- 用户身份来自 Gateway JWT,并通过 `ops_identity_mappings` 解析租户、用户和 `scope_tags`。
- 当前最合适的接入点是 Executor 的工具适配层,不新增第二套编排器。
## 3. xiaoqu-mcp 服务契约
### 3.1 MCP 调用
- 地址:`POST <XQ_MCP_URL>/mcp`
- 认证:`Authorization: Bearer <xqak-token>`
- 初始化顺序:`initialize` → `notifications/initialized` → `tools/list` → `tools/call`
- 初始化后保存 `Mcp-Session-Id`。
- `tools/list` 已按 Token scope 过滤,`tools/call` 仍由服务端再次鉴权。
### 3.2 Token 初始化
服务间调用:`POST <XQ_MCP_URL>/api/open/token/ensure`
```http
X-Open-Key: <server-only-key>
Content-Type: application/json
```
请求体包含 `mobile` 或 `email`,以及固定的 `scopeCode`(首期建议 `operation`)。
约束:
- `X-Open-Key` 只能配置在受信任后端,不能下发 App。
- 业务后端必须从已验证用户会话查询手机号/邮箱,不能信任客户端或模型传入的身份字段。
- `created=true` 才返回 Token 明文;业务后端必须立即加密保存。
- `created=false` 不返回明文;Token 丢失前需先实现 rotate,退出登录需实现 revoke 才能立即失效。
## 4. 推荐实现
### 4.1 配置
在 `config.py` 增加以下 `OPS_ASSISTANT_` 配置:
| 变量 | 默认值 | 说明 |
|---|---|---|
| `XQ_MCP_ENABLED` | `false` | 是否启用 MCP capability |
| `XQ_MCP_URL` | unset | 例如 `http://xiaoqu-mcp:8088`,只存地址,不存用户 Token |
| `XQ_MCP_OPEN_KEY` | unset | 仅用于服务端 Token 初始化,使用 `SecretStr` |
| `XQ_MCP_SCOPE_CODE` | `operation` | 固定作用域,禁止由 Planner 或客户端选择 |
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
启用时必须同时提供 URL、Open Key 和 scope;生产环境禁止明文写入仓库。
### 4.2 新增最小模块
建议只增加两个模块:
- `src/ops_assistant_service/mcp_client.py`
- 管理 HTTP 请求、JSON-RPC、Session ID、Bearer Header、超时和错误映射。
- 进程内按 `user_id` 缓存 MCP Session;账号或 Token 变化时销毁。
- `src/ops_assistant_service/mcp_token_provider.py`
- 接收已验证的 `SharedIdentity`。
- 从受信任用户资料源取得 mobile/email。
- 调用 `ensure`,首次创建时加密持久化明文。
- 不接受 `user_id/mobile/email` 作为客户端覆盖参数。
不引入通用 MCP 框架、第二个 Agent 或数据库直连;现有 `httpx` 已足够完成首期只读接入。
### 4.3 Capability 映射
在 `capability_registry.py` 增加固定的本地 capability,禁止把远端 `tools/list` 原样暴露给 Planner:
| 本地 capability | MCP 工具 | 首期权限 |
|---|---|---|
| `xiaoqu.agent_count` | `agent.count` | read |
| `xiaoqu.agent_list` | `agent.list.query` | read |
| `xiaoqu.property_list` | `property.list.query` | read |
Planner 只能选择注册表中的名称和 schema。Executor 在调用前同时检查:
```text
应用允许 ∩ 用户 scope_tags ∩ 租户/数据范围
```
远端返回结果先经过字段白名单投影,再交给 Answer;Token、原始 Header 和未授权字段不得进入 trace。
### 4.4 Executor 接入点
在 `executor.py` 增加一个只读分支:
```text
validate_tool_input
→ 校验本地 capability 和参数白名单
→ 读取 SharedIdentity / DataScope
→ 获取当前用户 MCP Token
→ MCP tools/call
→ 结果投影
→ 现有 execution_trace / Answer 链路
```
现有 `customer_issues.*` 和 `smartclean.query` 保持不变。MCP 不可用时返回稳定的 `TOOL_UNAVAILABLE`,不得回退到公共 Token 或直接访问 xiaoqu-mcp 数据库。
## 5. 生命周期与安全
- 登录用户变化:先关闭旧 Session,再建立新用户 Session。
- MCP 返回 401:清理当前用户 Token,最多执行一次重新获取和重连;不自动重放副作用请求。
- 退出登录:清理本地/服务端 Token 缓存;实现 revoke 后再主动撤销远端 Token。
- 日志只记录用户 ID、工具名、scope 和错误码,不记录 Token、Authorization Header 或手机号/邮箱明文。
- `tools/list` 仅用于校验远端能力;真正可调用集合以本地 allowlist 为准。
## 6. 开发顺序
1. 先在 xiaoqu-ai 业务后端完成登录态到 `ensure` 的门面,并验证 Token 明文加密落库。
2. 增加 `Settings` 配置和启动校验。
3. 实现 `mcp_token_provider.py` 和 `mcp_client.py`,补充 JSON-RPC/Session 单元测试。
4. 增加 3 个只读 capability,并接入 Executor。
5. 增加用户隔离、scope 拒绝、401 重连、Token 脱敏和 MCP 不可用测试。
6. 先在 development/test 环境启用,完成联调后再进入 staging。
## 7. 验收标准
- 用户 A 的请求始终使用 A 的 MCP Token,用户 B 不能复用。
- A 无对应 scope 时,Planner/Executor 均不能调用该 MCP 工具。
- `tools/list`、`tools/call` 使用同一用户 Session 和 Bearer Token。
- 切换用户后旧 Session 不再发出请求。
- MCP 401 只刷新当前用户,且同一请求不重复执行副作用。
- 测试、日志、trace、配置文件和 Git 历史中均无 Token 明文。
## 8. 当前不做
- 不把 MCP Client 放进 iOS/Android 端。
- 不把全部远程工具动态注册给 Planner。
- 不复制 xiaoqu-mcp 数据库或绕过 MCP 权限。
- 不在本次首期引入写工具;写工具需另行设计审批、幂等和补偿协议。
@@ -0,0 +1,137 @@
# xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计
## 1. 设计结论
xiaoqu-ai 与 xiaoqu-operations-ai 共用 xiaoqu-mcp 的服务能力、Token 原语、工具协议、权限模型和审计规则,但保留各自的 Agent Runtime:
```text
xiaoqu-ai iOS / Android / Web
│ 产品登录 Token
▼
xiaoqu-ai Gateway / Agent Runtime ───┐
│ 用户 MCP Token(仅服务端持有)
xiaoqu-operations-ai iOS / Android / Web
│ 产品登录 Token │
▼ │
ops-assistant-service ───────────────┤
▼
xiaoqu-agent-service /mcp
Token + Scope + Tool
行级权限 + Audit
```
共同的是后端协议,不是 Swift、Kotlin 或 Web UI 代码。任何客户端都不实现本次 xiaoqu-mcp Client,不保存用户 MCP Token,也不接触 `X-Open-Key`。
## 2. 范围与文档分工
| 层级 | 项目 | 项目目录 | 文档 |
|---|---|---|---|
| 总体 | xiaoqu-mcp | 当前仓库 `xiaoqu-agent-service` | 本文 |
| 详细设计 | xiaoqu-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/xiaoquai/xiaoqu-ai`、`/Users/tonylai/jiaxk/xiaoqu-ai-android` | [`xiaoqu-ai-mcp-integration-design.md`](./xiaoqu-ai-mcp-integration-design.md) |
| 详细设计 | xiaoqu-operations-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/wangheng/xiaoqu-operations-ai` | [`xiaoqu-operations-ai-mcp-integration-design.md`](./xiaoqu-operations-ai-mcp-integration-design.md) |
本文只定义两个产品必须共同遵守的架构和安全契约。项目代码落点、配置、Capability 映射和项目级验收只写在对应详细设计中。
## 3. 统一职责
| 层 | 职责 |
|---|---|
| iOS / Android / Web | 登录、会话 UI、流式展示和确认交互;只携带产品登录凭证 |
| 产品 Gateway | 验证登录态,建立可信用户、租户和数据范围上下文 |
| 产品 Agent Runtime | 编排 Agent,维护本地 Capability allowlist,获取用户 MCP Token 并调用 MCP |
| xiaoqu-agent-service | 校验 MCP Token,按 scope 过滤工具,执行用户/租户/行级权限并记录审计 |
两个产品可以暴露不同的 Capability 集合,但都必须通过同一套 MCP 鉴权和工具契约执行。运营项目不得直接调用 xiaoqu-ai Agent,也不得复制或直连 xiaoqu-mcp 数据库。
## 4. 认证与 Token 契约
### 4.1 三类凭证
| 凭证 | 使用链路 | 用途 |
|---|---|---|
| 产品登录 Token | 客户端 → 产品 Gateway | 证明当前产品用户身份 |
| `X-Open-Key` | 产品 Agent Runtime → `/api/open/token/ensure` | 服务间获取指定用户的 MCP Token |
| 用户 MCP Token `xqak_*` | 产品 Agent Runtime → `/mcp` | 绑定 MCP 用户、scope、权限和审计 |
`X-Open-Key` 和用户 MCP Token 只能存在于受信任服务端,不得进入客户端、prompt、模型输出、工具参数、普通日志或 Git。
### 4.2 用户映射
1. Gateway 验证产品登录 Token。
2. Agent Runtime 从可信上下文取得产品用户、租户和数据范围。
3. Agent Runtime 从受信任用户资料源取得手机号或邮箱。
4. Agent Runtime 使用服务端固定的 `scopeCode` 调用 `POST /api/open/token/ensure`。
5. xiaoqu-agent-service 返回该 MCP 用户对应的 Token。
6. Agent Runtime 使用该 Token 调用 `/mcp`。
客户端、Planner 和模型不得提供或覆盖 `user_id`、手机号、邮箱、租户、数据范围或 `scopeCode`。每个产品启用前必须核对管理后台中的真实 scope 和工具绑定。
### 4.3 `POST /api/open/token/ensure`
认证:`X-Open-Key: <server-only-key>`。
```json
{
"mobile": "<trusted-user-mobile>",
"email": "<trusted-user-email>",
"scopeCode": "<server-configured-scope>"
}
```
- `mobile`、`email` 至少一个,手机号优先。
- 已有有效 Token:解密 `key_cipher` 并返回同一明文。
- 历史无密文 Token:禁用旧 Token 并自动签发新 Token。
- 不存在有效 Token:创建长期 Token;回收通过禁用实现。
- 密文解密失败必须报错,不得回退哈希值或公共 Token。
错误语义:`400` 参数或 scope 错误,`401` Open Key 无效,`403` Open API 未启用,`404` 用户不存在或不属于允许的企业账号。
## 5. MCP 协议与权限
调用入口:`POST /mcp`。
```http
Authorization: Bearer <user-mcp-token>
Content-Type: application/json
Accept: application/json, text/event-stream
```
调用顺序:`initialize` → `notifications/initialized` → `tools/list` → `tools/call`。初始化响应包含 Session ID 时,后续请求携带 `Mcp-Session-Id`。
最终可执行能力为:
```text
产品 Agent capability allowlist
∩ MCP Token scope
∩ 当前用户权限
∩ 当前租户/数据范围
```
`tools/list` 必须按 Token scope 过滤,`tools/call` 必须再次强制校验。不得仅依赖提示词、本地隐藏菜单或 Planner 输出做授权。
## 6. 生命周期与错误处理
- 缓存键至少包含产品、租户、产品用户 ID 和 scope。
- 登录用户变化或退出登录时,旧用户的 Token 缓存和 MCP Session 必须失效。
- MCP 返回 401 时,只清理当前用户缓存并重新 `ensure` 一次。
- 只读调用可在重新连接后重试一次;有副作用调用不得自动重放。
- MCP 不可用时返回稳定的工具不可用错误,不得回退公共 Token 或数据库直连。
- 日志只记录用户 ID、scope、工具名、状态和耗时,不记录凭证明文或手机号/邮箱明文。
## 7. 联调顺序
| # | 工作 | 完成标准 |
|---|---|---|
| 1 | 确认两个产品的用户映射与实际 scope | scope 已存在并绑定所需工具,不依赖客户端身份字段 |
| 2 | 完成 xiaoqu-ai 服务端接入 | iOS、Android、Web 经同一服务端适配器调用 |
| 3 | 完成 xiaoqu-operations-ai 服务端接入 | 运营 iOS、Android、Web 经 ops-assistant-service 调用 |
| 4 | 跨用户、跨租户和跨产品回归 | 无 Token 泄露、串号、越权或副作用自动重放 |
## 8. 总体验收与回退
- 所有客户端请求只出现产品登录 Token,不出现用户 MCP Token 或 `X-Open-Key`。
- 用户 A/B、不同租户、不同 scope 的 Token 缓存和 MCP Session 完全隔离。
- 绕过 `tools/list` 直接调用 `tools/call` 仍会被服务端拒绝并审计。
- 两个产品的本地 allowlist 可以不同,但同名 MCP 工具的请求和响应契约一致。
- 任一产品回退时,只关闭该 Agent Runtime 的 MCP Capability 开关;客户端继续使用原有 Agent 功能。
- 禁止回退为客户端共享固定 Token、公共服务端 Token 或数据库直连。
@@ -0,0 +1,181 @@
# xiaoqu-operations-ai 接入 xiaoqu-mcp 详细设计
## 1. 目标与边界
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的运营项目落地设计,代码基线为 `/Users/tonylai/wangheng/xiaoqu-operations-ai`。
运营 iOS、Android、Web 共用 `ops-assistant-service` 中的一套 MCP Client:
```text
iOS / Android / Web
│ 产品登录 Token
▼
ops-assistant-service REST
│
▼
Router → Resolver → Planner → Executor → Answer
│
▼
Capability Registry
│ 固定 adapter_id
▼
xiaoqu-mcp Adapter
│ 用户 MCP Token(仅服务端持有)
▼
xiaoqu-agent-service /mcp
```
WebSocket 只负责会话进度和完成通知,不承载 MCP 调用。客户端不新增 MCP 代码,不保存用户 MCP Token、MCP Session ID 或 `X-Open-Key`。
## 2. 当前代码依据
- `web/ops-assistant/src/api.ts` 通过 `/api/v1/ops-assistant/*` REST 提交消息和读取业务数据。
- `web/ops-assistant/src/ConversationPage.tsx` 使用 WebSocket 订阅会话进度,消息提交仍走 REST。
- `services/ops-assistant-service` 是 FastAPI + Python 3.12 服务,现有 `httpx` 足够实现 MCP HTTP Client。
- `worker.py` 同时运行旧流水线和 `contextual_fulfillment_v2`;新流水线包含 Router、Resolver、Planner、Recovery、Executor、Answer。
- `capability_registry.py` 维护静态 `CAPABILITY_REGISTRY`、冻结的 Capability Contract 和 `adapter_id` 白名单。
- 旧 `executor.py` 与新 `contextual_execution.py` 最终都调用 `execute_registered_tool()`。
- 当前 Registry 包含 `customer_issues.*`、`vision_analyze`、`work_items.list`、`read_only_todos.list`、`knowledge_entries.search`,已不包含 `smartclean.query`。
- Gateway JWT 在入口解析为共享身份,再通过 `ops_identity_mappings` 映射为运营 `tenant_id`、`user_id` 和 DataScope;后台 Worker 执行时使用运营 UUID。
因此不在 Web、路由层或某一条流水线中增加专用 MCP 分支。唯一接入点是两套流水线共用的 Capability Adapter 执行边界。
## 3. 设计决策
### 3.1 统一 Adapter
在 `CAPABILITY_REGISTRY` 中注册稳定的本地 Capability 和固定 `adapter_id`,由 `execute_registered_tool()` 将 MCP Adapter ID 路由到 `XiaoquMcpAdapter`。
```text
Planner 选择本地 Capability
→ 校验冻结的 Capability Contract
→ 校验输入 schema、scope_tags、DataScope
→ execute_registered_tool(adapter_id)
→ XiaoquMcpAdapter
→ MCP tools/call
```
不得把远端 `tools/list` 原样注册给 Planner。`tools/list` 只用于启动或联调时验证远端工具是否与本地冻结契约一致。
### 3.2 显式依赖传递
`XiaoquMcpRuntime` 由 `worker.build_pipeline_runtime()` 创建,并通过显式参数传给旧 `run_executor_tick` 和新 `run_contextual_executor_tick`。禁止用模块级全局变量或从请求上下文隐式读取用户。
`PipelineRuntime.close()` 负责关闭 MCP HTTP Client 和清理进程内 Session 缓存。未启用 MCP 时传入 `None`,现有本地 Capability 保持原行为。
## 4. 配置设计
在 `src/ops_assistant_service/config.py` 的 `Settings` 增加以下 `OPS_ASSISTANT_` 配置:
| 变量 | 默认值 | 说明 |
|---|---|---|
| `XQ_MCP_ENABLED` | `false` | 是否启用 MCP Capability |
| `XQ_MCP_URL` | unset | xiaoqu-mcp 内网地址 |
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证,使用 `SecretStr` |
| `XQ_MCP_SCOPE_CODE` | `operation` | 服务端固定 scope,禁止由客户端或模型选择 |
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
启用时 URL、Open Key 和 scope 必填。`operation` 已存在于 xiaoqu-mcp dev 种子;development、test、staging、production 均须分别核对真实 scope 和工具绑定。凭证只由部署 Secret 注入。
## 5. 最小模块
### 5.1 `mcp_token_provider.py`
输入为 Worker 已持有的运营 `tenant_id`、`user_id` 和固定 scope:
1. 从 `ops_identity_mappings` 反查可信的 `shared_tenant_id`、`shared_user_id`。
2. 通过现有只读共享用户 Port 获取权威 mobile;权威数据源存在 email 时可作为备用。
3. 使用固定 `scopeCode` 调用 `/api/open/token/ensure`。
4. 按运营租户、运营用户、共享用户和 scope 缓存 Token。
现有 `SharedUserPort` 尚未暴露 mobile/email,需要最小扩展其只读 DTO 和 PostgreSQL Adapter;不得复制用户表,也不得使用 Web、Planner、工具参数或模型输出中的身份字段。
### 5.2 `mcp_client.py`
使用现有 `httpx.Client` 管理:
- `initialize`、`notifications/initialized`、`tools/list`、`tools/call`。
- Bearer Header、`Mcp-Session-Id`、JSON-RPC ID 和响应校验。
- 连接、读取、写入和总超时。
- 按运营租户、运营用户和 scope 隔离 Session。
- 关闭时释放 HTTP Client,不持久化 MCP Session。
### 5.3 `mcp_adapter.py`
`XiaoquMcpAdapter` 只接收已经验证的 Capability Contract、运营身份和参数:
- 根据固定映射选择远端 MCP 工具。
- 调用 Token Provider 和 MCP Client。
- 校验远端响应并执行字段白名单投影。
- 返回现有 Executor/Answer 可消费的字典,不返回 Token、Header 或原始未授权字段。
不引入通用 MCP 框架或第二套 Agent 编排器。
## 6. Capability 映射
首期只注册只读 Capability:
| 本地 Capability | `adapter_id` | MCP 工具 | 权限 |
|---|---|---|---|
| `xiaoqu.agent_count` | `mcp.xiaoqu.agent_count.v1` | `agent.count` | read |
| `xiaoqu.agent_list` | `mcp.xiaoqu.agent_list.v1` | `agent.list.query` | read |
| `xiaoqu.property_list` | `mcp.xiaoqu.property_list.v1` | `property.list.query` | read |
每项必须在 `CAPABILITY_REGISTRY` 中声明输入/输出 schema、风险、审批、超时重试、审计事件和结果模板,并将精确 `adapter_id` 加入 `EXECUTION_ADAPTER_IDS`。已有本地 Capability 不改名、不改 Adapter、不改变执行语义。
## 7. 身份与权限
最终授权取交集:
```text
本地 Capability allowlist
∩ 当前运营用户 scope_tags / DataScope
∩ MCP Token scope
∩ xiaoqu-mcp 用户、租户和行级数据范围
```
- REST 和 WebSocket 只负责验证产品登录态并建立运营身份。
- Planner 只能选择 Registry 中的 Capability,不决定用户、租户或 scope。
- Worker 必须在每次执行前确认 `ops_identity_mappings` 仍有效,不能仅依赖消息入库时的旧快照。
- `tools/list` 与 `tools/call` 使用同一用户 Token 和 MCP Session;服务端在两个入口都强制鉴权。
## 8. 异步执行与生命周期
Web、iOS、Android 提交消息后获得 `202`;Worker 在后台完成 Agent 和工具执行,再由消息查询或 WebSocket 返回进度与结果。
- `build_pipeline_runtime()` 创建一个进程级 `XiaoquMcpRuntime`,但 Token 和 Session 按用户隔离。
- Worker 重启后 Session 不恢复;首次调用重新 `ensure` 和 `initialize`。
- 用户映射失效、scope 版本变化或 MCP 401 时,只清理该用户缓存。
- MCP 401:重新 `ensure` 和连接一次;只读调用最多重试一次。
- MCP 403:直接返回权限不足,不刷新 Token。
- 超时、协议错误或服务不可用:映射为稳定的 `TOOL_UNAVAILABLE`,并让当前 Execution Attempt 进入可审计失败状态。
- 不自动重放写操作;首期不接写工具。
## 9. 开发顺序
1. 核对各环境 `operation` scope、三项工具绑定和用户映射数据源。
2. 扩展只读 SharedUser Port,完成运营 UUID 到 mobile/email 的可信解析测试。
3. 增加 Settings 配置、启用校验和 `XiaoquMcpRuntime` 生命周期。
4. 实现 Token Provider、MCP Client 和 Adapter 的协议单元测试。
5. 注册三项本地 Capability,并让旧 Executor 与 contextual v2 共用同一 Adapter。
6. 覆盖用户隔离、租户隔离、scope 拒绝、401、403、超时、脱敏和 Worker 重启测试。
7. 分别从 iOS、Android、Web 提交对话请求,验证异步进度和最终结果一致。
## 10. 项目验收
- iOS、Android、Web 均只调用 `ops-assistant-service`,客户端没有 MCP Token、Session ID 或 Open Key。
- Web 消息走 REST,WebSocket 仅订阅进度/完成通知。
- 旧流水线和 `contextual_fulfillment_v2` 对同一 MCP Capability 使用同一个 Adapter。
- 用户 A/B、不同租户和 scope 的 Token 与 MCP Session 不互用。
- Planner 不能选择未注册工具;冻结 Contract、`scope_tags`、DataScope 或 MCP scope 任一不满足即拒绝。
- 现有七项本地 Capability 的契约和行为不受影响。
- MCP 401 只刷新当前用户;MCP 不可用时不回退公共 Token、数据库直连或 xiaoqu-ai Agent。
- 测试、日志、trace、配置和 Git 历史中无凭证明文。
## 11. 当前不做
- 不在运营 iOS、Android、Web 中实现 MCP Client。
- 不动态注册全部远程工具。
- 不恢复或假定已删除的 `smartclean.query`。
- 不复制 xiaoqu-mcp 或共享用户数据库。
- 不在首期引入写工具;写工具需另行审批幂等、确认和补偿协议。
@@ -0,0 +1,3 @@
-- 回滚 V3:删除 key_cipher 列。回滚后 ensure 对存量 Token 无法二次获取明文(回到只发新把模式)。
ALTER TABLE agent_api_key
DROP COLUMN key_cipher;
@@ -10,6 +10,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthService;
import me.iiv.agent.security.TokenCipher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -25,18 +26,21 @@ public class AdminTokenService {
private final AgentApiKeyMapper tokenMapper;
private final TokenScopeMapper relationMapper;
private final AgentScopeMapper scopeMapper;
private final TokenCipher tokenCipher;
public AdminTokenService(UserMapper userMapper, AgentApiKeyMapper tokenMapper,
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper) {
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper,
TokenCipher tokenCipher) {
this.userMapper = userMapper;
this.tokenMapper = tokenMapper;
this.relationMapper = relationMapper;
this.scopeMapper = scopeMapper;
this.tokenCipher = tokenCipher;
}
/**
* 签发 Token:一作用域一 Token。选 N 个作用域生成 N 把,每把独立绑定单个作用域,
* 便于独立吊销、独立审计,明文逐把返回(仅本次可见)。
* 便于独立吊销、独立审计,明文逐把返回;服务端同时保存 SHA-256 哈希和 AES-GCM 密文(供二次获取)。
*/
@Transactional(transactionManager = "svcTransactionManager")
public List<IssuedToken> issue(Long userId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
@@ -56,6 +60,7 @@ public class AdminTokenService {
token.setUserId(userId);
token.setKeyPrefix(plaintext.substring(0, 13));
token.setKeyHash(AuthService.sha256Hex(plaintext));
token.setKeyCipher(tokenCipher.encrypt(plaintext));
token.setMemo(trim(memo, 255));
token.setStatus((byte) 1);
token.setExpireTime(expireTime);
@@ -47,6 +47,11 @@ public class AgentApiKey implements Serializable {
*/
private String keyHash;
/**
* ak 明文 AES-GCM 加密密文(二次获取用),空=历史数据未存
*/
private String keyCipher;
/**
* 用途备注(如 Kimi 用 / Claude 用)
*/
@@ -21,8 +21,8 @@ public class OpenTokenController {
}
/**
* 确保某人员在某作用域下有有效 Token:存在则返回元信息(created=false,无明文),
* 不存在则自动创建永久 Token 并返回明文(created=true,仅此一次可见)。
* 确保某人员在某作用域下有有效 Token:存在则解密回传明文(created=false,二次获取),
* 不存在则自动创建永久 Token 并返回明文(created=true);历史无密文 Token 自动轮换后按创建返回。
*/
@PostMapping("/token/ensure")
public EnsureResult ensure(@RequestBody EnsureRequest body) {
@@ -9,6 +9,7 @@ import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.TokenCipher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -19,8 +20,9 @@ import java.util.Date;
/**
* Open API:按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create)。
* 存在有效 Token 只回元信息(服务端只存哈希,明文不可回吐);
* 不存在则自动创建永久 Token,明文仅本次返回。
* 存在有效 Token 时解密 key_cipher 回传明文(二次获取,2026-09-07 决策);
* 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把);
* 不存在则自动创建永久 Token。两条路径均返回明文。
*/
@Service
public class OpenTokenService {
@@ -33,13 +35,16 @@ public class OpenTokenService {
private final AgentScopeMapper scopeMapper;
private final AdminTokenService tokenService;
private final AuditService auditService;
private final TokenCipher tokenCipher;
public OpenTokenService(UserMapper userMapper, AgentScopeMapper scopeMapper,
AdminTokenService tokenService, AuditService auditService) {
AdminTokenService tokenService, AuditService auditService,
TokenCipher tokenCipher) {
this.userMapper = userMapper;
this.scopeMapper = scopeMapper;
this.tokenService = tokenService;
this.auditService = auditService;
this.tokenCipher = tokenCipher;
}
@Transactional(transactionManager = "svcTransactionManager")
@@ -65,10 +70,22 @@ public class OpenTokenService {
AgentApiKey existing = tokenService.findActiveToken(user.getId(), scope.getId());
if (existing != null) {
audit(user, existing, body, "success", "已存在有效 Token,返回元信息", start);
return new OpenTokenController.EnsureResult(false, null, existing.getKeyPrefix(),
if (StringUtils.hasText(existing.getKeyCipher())) {
String plaintext;
try {
plaintext = tokenCipher.decrypt(existing.getKeyCipher());
} catch (IllegalStateException e) {
audit(user, existing, body, "error", "Token 密文解密失败", start);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Token 密文解密失败");
}
audit(user, existing, body, "success", "已存在有效 Token,解密返回明文", start);
return new OpenTokenController.EnsureResult(false, plaintext, existing.getKeyPrefix(),
existing.getExpireTime(), user.getId(), user.getName(), scope.getCode());
}
// 历史数据无密文:禁用旧把并签发新把(轮换),保证总能拿到明文
tokenService.status(existing.getId(), (byte) 0, SYSTEM_CREATOR_ID);
audit(user, existing, body, "success", "旧 Token 无密文,自动轮换", start);
}
AdminTokenService.IssuedToken issued = tokenService.issueSingle(user.getId(),
"open-api 自动签发(" + scope.getCode() + ")", scope.getId(), null, SYSTEM_CREATOR_ID);
@@ -0,0 +1,78 @@
package me.iiv.agent.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* ak 明文加密器:AES-GCM,密钥由配置 app.token-cipher-key 经 SHA-256 派生(配置任意长度字符串即可)。
* 密文格式:Base64( iv(12B) ‖ cipher+tag ),供 Open API 二次获取明文时解密。
* 配置为空时加解密直接报错(视为功能未开启,不回退到任何弱行为)。
*/
@Component
public class TokenCipher {
private static final int IV_LEN = 12;
private static final int TAG_BITS = 128;
private final byte[] key;
private final SecureRandom random = new SecureRandom();
public TokenCipher(@Value("${app.token-cipher-key:}") String cipherKey) {
if (cipherKey == null || cipherKey.isBlank()) {
this.key = null;
} else {
try {
this.key = MessageDigest.getInstance("SHA-256")
.digest(cipherKey.trim().getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
}
}
public String encrypt(String plaintext) {
byte[] iv = new byte[IV_LEN];
random.nextBytes(iv);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(requireKey(), "AES"),
new GCMParameterSpec(TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] packed = new byte[IV_LEN + encrypted.length];
System.arraycopy(iv, 0, packed, 0, IV_LEN);
System.arraycopy(encrypted, 0, packed, IV_LEN, encrypted.length);
return Base64.getEncoder().encodeToString(packed);
} catch (Exception e) {
throw new IllegalStateException("ak 加密失败", e);
}
}
public String decrypt(String packedBase64) {
try {
byte[] packed = Base64.getDecoder().decode(packedBase64);
byte[] iv = new byte[IV_LEN];
System.arraycopy(packed, 0, iv, 0, IV_LEN);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(requireKey(), "AES"),
new GCMParameterSpec(TAG_BITS, iv));
return new String(cipher.doFinal(packed, IV_LEN, packed.length - IV_LEN), StandardCharsets.UTF_8);
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("ak 密文解密失败", e);
}
}
private byte[] requireKey() {
if (key == null) throw new IllegalStateException("未配置 app.token-cipher-key,ak 加解密不可用");
return key;
}
}
@@ -5,6 +5,9 @@ app:
open:
# Open API(/api/open/**)服务间静态密钥,dev 明文;调用方放 header X-Open-Key
api-key: xqok_4b44012f12b9fa54d6ec11690e6da57563a75c891df60961
# ak 明文 AES-GCM 加密密钥(任意长度字符串,SHA-256 派生为密钥),供 ensure 二次获取明文;
# dev 明文(同库密码惯例),生产必须改用环境变量且与 dev 不同值
token-cipher-key: d70da46f5f0db2016d88d5ee4b1d6af35867b7700b97105fec9b3ad567d474a5
datasource:
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -0,0 +1,3 @@
-- ak 二次获取(2026-09-07):签发时保存 AES-GCM 加密密文,ensure 已存在 Token 时解密回传明文
ALTER TABLE agent_api_key
ADD COLUMN key_cipher VARCHAR(512) NULL COMMENT 'ak 明文 AES-GCM 加密密文(Base64: iv‖cipher‖tag),空=历史数据未存' AFTER key_hash;
@@ -1,2 +1,3 @@
f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql
0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql
@@ -9,6 +9,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthService;
import me.iiv.agent.security.TokenCipher;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -33,6 +34,7 @@ class AdminTokenServiceTest {
@Mock AgentApiKeyMapper tokenMapper;
@Mock TokenScopeMapper tokenScopeMapper;
@Mock AgentScopeMapper scopeMapper;
@Mock TokenCipher tokenCipher;
private User stubUser() {
User user = new User();
@@ -43,11 +45,13 @@ class AdminTokenServiceTest {
}
private AdminTokenService service() {
return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper);
org.mockito.Mockito.lenient().when(tokenCipher.encrypt(any()))
.thenAnswer(invocation -> "cipher:" + invocation.getArgument(0));
return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper, tokenCipher);
}
@Test
void issueReturnsPlaintextOnceAndStoresOnlyHash() {
void issueReturnsPlaintextAndStoresHashAndCipher() {
stubUser();
AgentScope scope = new AgentScope();
scope.setId(3L);
@@ -68,6 +72,7 @@ class AdminTokenServiceTest {
assertTrue(issued.get(0).plaintext().matches("xqak_[0-9a-f]{48}"));
assertEquals(AuthService.sha256Hex(issued.get(0).plaintext()), stored.getKeyHash());
assertNotEquals(issued.get(0).plaintext(), stored.getKeyHash());
assertEquals("cipher:" + issued.get(0).plaintext(), stored.getKeyCipher());
assertEquals(11L, issued.get(0).token().id());
}
@@ -19,7 +19,7 @@ class FlywayMigrationTest {
flyway.migrate();
assertEquals("2", flyway.info().current().getVersion().getVersion());
assertEquals("3", flyway.info().current().getVersion().getVersion());
assertEquals(255, passwordHashLength(dataSource));
}
@@ -28,12 +28,13 @@ class FlywayMigrationTest {
JdbcDataSource dataSource = dataSource("legacy");
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE admin_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, password_hash CHAR(64) NOT NULL)");
statement.execute("CREATE TABLE agent_api_key (id BIGINT AUTO_INCREMENT PRIMARY KEY, key_hash CHAR(64) NOT NULL)");
}
Flyway flyway = flyway(dataSource, true);
flyway.migrate();
assertEquals("2", flyway.info().current().getVersion().getVersion());
assertEquals("3", flyway.info().current().getVersion().getVersion());
assertEquals(255, passwordHashLength(dataSource));
}
@@ -7,6 +7,7 @@ import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.security.TokenCipher;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,6 +38,7 @@ class OpenTokenServiceTest {
@Mock AgentScopeMapper scopeMapper;
@Mock AdminTokenService tokenService;
@Mock AuditService auditService;
@Mock TokenCipher tokenCipher;
OpenTokenService service;
AgentScope scope;
@@ -44,7 +46,7 @@ class OpenTokenServiceTest {
@BeforeEach
void setUp() {
service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService);
service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService, tokenCipher);
scope = new AgentScope();
scope.setId(3L);
scope.setCode("operation");
@@ -62,24 +64,62 @@ class OpenTokenServiceTest {
}
@Test
void existingActiveTokenReturnsMetadataWithoutPlaintext() {
void existingTokenWithCipherReturnsDecryptedPlaintext() {
stubScopeAndUser();
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
existing.setKeyCipher("packed-cipher");
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff");
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
assertFalse(result.created());
assertNull(result.token());
assertEquals("xqak_abcd1234ffff", result.token());
assertEquals("xqak_abcd1234", result.keyPrefix());
assertEquals(7L, result.userId());
org.mockito.Mockito.verify(tokenService, never())
.issueSingle(anyLong(), anyString(), anyLong(), isNull(), anyLong());
}
@Test
void existingTokenWithoutCipherRotatesAndReturnsNewPlaintext() {
stubScopeAndUser();
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1,
null, null, List.of(3L), List.of("运营"));
when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L)))
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "b".repeat(48), view));
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L);
assertTrue(result.created());
assertTrue(result.token().matches("xqak_[0-9a-f]{48}"));
}
@Test
void decryptFailureReturns500() {
stubScopeAndUser();
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
existing.setKeyCipher("bad-cipher");
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
when(tokenCipher.decrypt("bad-cipher")).thenThrow(new IllegalStateException("ak 密文解密失败"));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
assertEquals(500, error.getStatusCode().value());
}
@Test
void missingTokenCreatesPermanentOneAndReturnsPlaintext() {
stubScopeAndUser();
@@ -0,0 +1,50 @@
package me.iiv.agent.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TokenCipherTest {
private final TokenCipher cipher = new TokenCipher("test-cipher-key");
@Test
void encryptDecryptRoundTrip() {
String plaintext = "xqak_25939fab31f812eb4e566197f53f16482d3e1f588b8bcaca";
String packed = cipher.encrypt(plaintext);
assertNotEquals(plaintext, packed);
assertEquals(plaintext, cipher.decrypt(packed));
}
@Test
void samePlaintextEncryptsDifferentlyEachTime() {
String plaintext = "xqak_" + "a".repeat(48);
assertNotEquals(cipher.encrypt(plaintext), cipher.encrypt(plaintext));
}
@Test
void wrongKeyCannotDecrypt() {
String packed = cipher.encrypt("xqak_secret");
TokenCipher other = new TokenCipher("another-key");
assertThrows(IllegalStateException.class, () -> other.decrypt(packed));
}
@Test
void tamperedCipherRejected() {
String packed = cipher.encrypt("xqak_secret");
byte[] raw = java.util.Base64.getDecoder().decode(packed);
raw[raw.length - 1] ^= 0x01;
String tampered = java.util.Base64.getEncoder().encodeToString(raw);
assertThrows(IllegalStateException.class, () -> cipher.decrypt(tampered));
}
@Test
void blankKeyDisablesCipher() {
TokenCipher disabled = new TokenCipher(" ");
assertThrows(IllegalStateException.class, () -> disabled.encrypt("xqak_x"));
assertThrows(IllegalStateException.class, () -> disabled.decrypt("AAAA"));
}
}