diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index ee1861c..0c7d4a0 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -212,7 +212,7 @@ claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \ ## Open API(服务间接口,/api/open/**) -供其他系统在服务端到服务端场景下获取用户 Token。鉴权为静态服务间密钥(header `X-Open-Key`,dev 明文配置在 `application-dev.yml` 的 `app.open.api-key`;配置为空 = 接口关闭)。 +供其他系统在服务端到服务端场景下获取用户或服务 Token。鉴权使用 header `X-Open-Key`:生产优先在管理后台「服务密钥」页面创建并绑定作用域,明文只展示一次、数据库仅存 SHA-256 哈希;原有 `app.open.api-key` / `app.open.clients` 环境配置继续兼容。 **`POST /api/open/token/ensure`** —— 按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create): @@ -228,12 +228,13 @@ curl -X POST http://localhost:8088/api/open/token/ensure \ - 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。 - 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。 - 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。 +- 管理后台支持服务密钥列表、创建、编辑作用域、吊销/启用和轮换;吊销或轮换后旧密钥立即无法通过 `/api/open/**` 鉴权。 **签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。 ## codegen(run-once) -表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。 +表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user, app_open_client, app_open_client_scope`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。 ```bash # 生成(依赖已下载后执行;连 dev 库) diff --git a/xiaoqu-agent-service/doc/agent-mcp-service-design.md b/xiaoqu-agent-service/doc/agent-mcp-service-design.md index ea9415e..716985f 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-service-design.md +++ b/xiaoqu-agent-service/doc/agent-mcp-service-design.md @@ -151,7 +151,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤ 供其他业务系统/智能体平台在服务端到服务端场景下获取用户 Token,路径 `/api/open/**`,与 ak 体系、后台 session 体系完全分离。 -- **鉴权**:静态服务间密钥,header `X-Open-Key`,配置项 `app.open.api-key`(dev 明文写 `application-dev.yml`;配置为空 = 接口关闭)。常量时间比较防时序侧信道。 +- **鉴权**:服务间密钥放在 header `X-Open-Key`。生产密钥由现有管理后台创建,数据库只存 SHA-256 哈希并绑定作用域;旧 `app.open.api-key` / `app.open.clients` 配置保留兼容,其中环境变量明文使用常量时间比较。 - **`POST /api/open/token/ensure`**(get-or-create): - 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。 - 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。 @@ -162,7 +162,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤ - **服务 Token(principal_type=service,REQ-20260911-0014)**: - `agent_api_key.principal_type` 区分 `user`(用户委托,绑定 `user_id`,行级权限跟随人员)与 `service`(租户服务,`user_id` 为 NULL,`subject_name` 标识服务主体,行级权限不适用,数据范围 = 绑定作用域全量)。 - ensure 入参补 `principalType` / `serviceName`:`principalType=service` 时按 `serviceName + scopeCode` get-or-create 服务 Token,语义与 user 路径一致(存在解密回传、无密文自动轮换、不存在签发永久 Token)。 - - **服务密钥 → 数据域绑定**:`app.open.clients` 配置密钥清单(key/name/scopes),鉴权时按密钥确定可操作的作用域,越域 403;旧配置 `app.open.api-key` 保留为不限域密钥,仅存量 user ensure 可用,**不可签发 service Token**。 + - **服务密钥 → 数据域绑定**:管理后台「服务密钥」页面创建调用方并勾选作用域,鉴权时按密钥确定可操作的数据域,越域 403;密钥明文仅在创建/轮换时展示,吊销/轮换立即生效。`app.open.clients` 作为环境变量兼容通道;旧 `app.open.api-key` 保留为不限域密钥,仅存量 user ensure 可用,**不可签发 service Token**。 - 吊销:后台 `PATCH /api/admin/tokens/{id}/status` 置 0,`AkAuthFilter` 现有 status 校验天然生效,吊销后立即 401;轮换:`POST /api/admin/tokens/{id}/rotate` 禁用旧把并按原主体/作用域/过期时间签新把,明文仅本次响应返回。 - **人员停用失效**:`AuthService` 校验 user 类型 Token 时检查 m0.user `soft_delete`(注意 `work_status` 是考勤状态,不是在职状态),人员状态本地缓存 TTL 60s(→ Redis 热缓存 → m0 库三级),停用最长 60s 后 ak 调用返回 401/UNAUTHENTICATED。 - **SYNC 工具白名单**:个人 Token 禁调 SYNC 工具不新造拦截代码,靠 `token_scope` 白名单——SYNC 工具(`*.changes.list`,后续需求 5628 注册)只绑进 `sync` 作用域,个人 Token 不勾该作用域即天然 PERMISSION_DENIED;`sync` 作用域由 `sql/seed-dev.sql` 预建。ensure 侧同时做入口约束:`principalType=service` 只允许 `scopeCode=sync`,user ensure 不允许 `sync`。 @@ -203,6 +203,8 @@ xiaoqu-agent-service/ | `tool` | name、category、description、kind(read/write)、status | 工具注册表,启动时同步;category 代码声明 | | `audit_log` | user_id、token_id、tool_name、params_digest、result_status、cost_ms、client_ip、client_type | 审计;client_type 区分 mcp-kimi/mcp-claude/cli | | `admin_user` | account、password_hash | 后台管理员 | +| `app_open_client` | name、key_prefix、key_hash、status、last_used_time | 管理后台创建的 Open API 服务密钥调用方(不存明文) | +| `app_open_client_scope` | client_id、scope_id | 服务密钥-作用域关系 | 均带标准审计五件套(create/modify id/time、soft_delete)。 @@ -220,7 +222,7 @@ xiaoqu-agent-service/ | P1 骨架 | 工程初始化、版本组合验证、三数据源连通、codegen 跑通 | demo 工具经 MCP 调通 | | P2 权限 | ak 鉴权 Filter、scope 校验、数据权限引擎、审计 | 不同权限组的人查询结果隔离正确 | | P3 首批工具 | 代理商查询 + 客户(物业)查询(定义见 §9.1)+ tool 表同步 | Kimi/Claude 双端实测 | -| P4 管理后台 | 用户/token、工具、作用域、审计四页面(Vue3 内嵌) | 管理员全流程自助配置 | +| P4 管理后台 | 用户/token、服务密钥、工具、作用域、审计五页面(Vue3 内嵌) | 管理员全流程自助配置 | | P5 加固 | 限流、ES 接入(按需)、写工具评审开放、CLI(Go 单二进制) | 生产上线 | ### 9.1 首期工具定义(P3,均已确认) diff --git a/xiaoqu-agent-service/doc/crm-integration-guide.md b/xiaoqu-agent-service/doc/crm-integration-guide.md index 8457175..5ab885d 100644 --- a/xiaoqu-agent-service/doc/crm-integration-guide.md +++ b/xiaoqu-agent-service/doc/crm-integration-guide.md @@ -13,11 +13,22 @@ | 步骤 | 操作方 | 说明 | |---|---|---| -| 1 | 小趣管理员 | 为 CRM 开通服务密钥(X-Open-Key,一系统一密钥) | -| 2 | 小趣管理员 | 给 CRM 的服务主体绑定 `sync` 作用域(含 `integration.context.get` + 三个 changes 工具) | -| 3 | CRM 后端 | 调 `POST /api/open/token/ensure`(principal=服务主体)拿**长期服务 Token**(`xqak-...`),安全保管 | +| 1 | 小趣管理员 | 登录 `https://agent.ququclean.com/`,进入「服务密钥」→「创建服务密钥」 | +| 2 | 小趣管理员 | 调用方名称填 `crm`,勾选 `sync` 作用域,保存并立即复制只展示一次的 `X-Open-Key` | +| 3 | CRM 后端 | 用下面的请求换取**长期服务 Token**(`xqak-...`),安全保管 | -完成后 CRM 持有两样东西:**服务 Token**(后台扫描用)、**服务密钥**(给销售换个人 Token 用)。 +```bash +curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \ + -H 'X-Open-Key: <管理后台创建后显示的服务密钥>' \ + -H 'Content-Type: application/json' \ + -d '{"scopeCode":"sync","principalType":"service","serviceName":"crm"}' +``` + +响应的 `token` 就是后续放进 MCP `Authorization: Bearer ` 的服务 Token。`X-Open-Key` 不是 MCP Token,不能直接放进 Authorization 头。 + +完成后 CRM 持有两样东西:**服务 Token**(后台扫描用)、**服务密钥**(重新 ensure 获取 Token 或给销售换个人 Token 用)。若还需要给销售换 QUERY 个人 Token,创建/编辑服务密钥时还要勾选对应 QUERY 作用域;只绑定 `sync` 的密钥不能跨域签发个人 Token。 + +密钥泄露时,管理员在同一页面点「轮换」(旧密钥立即失效)或「吊销」。数据库只保存密钥哈希,关闭创建/轮换结果弹窗后无法找回明文。 ## 2. 每次调用前的身份自检 diff --git a/xiaoqu-agent-service/front/src/App.vue b/xiaoqu-agent-service/front/src/App.vue index ff50594..4ca28d8 100644 --- a/xiaoqu-agent-service/front/src/App.vue +++ b/xiaoqu-agent-service/front/src/App.vue @@ -5,6 +5,7 @@ import { api, setCsrf } from './api' const menus = [ { id: 'tokens', label: '用户与 Token', icon: '👥' }, + { id: 'clients', label: '服务密钥', icon: '🔐' }, { id: 'tools', label: '工具管理', icon: '🔧' }, { id: 'scopes', label: '作用域管理', icon: '🧩' }, { id: 'audit', label: '审计日志', icon: '📋' }, @@ -20,6 +21,7 @@ const userPage = reactive({ page: 1, pageSize: 20, total: 0 }) const selectedUser = ref(null) const permission = ref(null) const tokens = ref([]) +const openClients = ref([]) const scopes = ref([]) const tools = ref([]) const audits = ref([]) @@ -28,6 +30,9 @@ const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '' const tokenDialog = ref(false) const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null }) const issuedTokens = ref([]) +const clientDialog = ref(false) +const clientForm = reactive({ id: null, name: '', memo: '', scopeIds: [] }) +const issuedOpenKey = ref(null) const scopeDialog = ref(false) const scopeForm = reactive({ id: null, name: '', code: '', memo: '' }) const scopeToolsDialog = ref(false) @@ -41,6 +46,20 @@ const groupedTools = computed(() => tools.value.reduce((groups, tool) => { ;(groups[category] ||= []).push(tool) return groups }, {})) +const issuedKeyCurl = computed(() => { + if (!issuedOpenKey.value) return '' + const payload = JSON.stringify({ + scopeCode: 'sync', principalType: 'service', serviceName: issuedOpenKey.value.client.name, + }) + return [ + "curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \\", + ` -H 'X-Open-Key: ${issuedOpenKey.value.plaintext}' \\`, + " -H 'Content-Type: application/json' \\", + ` -d ${shellQuote(payload)}`, + ].join('\n') +}) + +function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'` } function qs(values) { const params = new URLSearchParams() @@ -76,6 +95,7 @@ async function logout() { async function switchMenu(id) { current.value = id if (id === 'tokens') await loadUsers() + if (id === 'clients') await loadOpenClients() if (id === 'tools') await loadTools() if (id === 'scopes') await loadScopes() if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })]) @@ -146,6 +166,54 @@ async function deleteToken(token) { }).catch(() => {}) } +async function loadOpenClients() { + ;[openClients.value, scopes.value] = await Promise.all([api('/open-clients'), api('/scopes')]) +} + +function openClient(client = null) { + Object.assign(clientForm, client ? { + id: client.id, name: client.name, memo: client.memo || '', scopeIds: [...client.scopeIds], + } : { id: null, name: '', memo: '', scopeIds: [] }) + clientDialog.value = true +} + +async function saveClient() { + if (!clientForm.name.trim()) return ElMessage.warning('请填写调用方名称') + if (!clientForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域') + await run(async () => { + const body = JSON.stringify({ name: clientForm.name, memo: clientForm.memo, scopeIds: clientForm.scopeIds }) + if (clientForm.id) await api(`/open-clients/${clientForm.id}`, { method: 'PUT', body }) + else issuedOpenKey.value = await api('/open-clients', { method: 'POST', body }) + clientDialog.value = false + await loadOpenClients() + ElMessage.success(clientForm.id ? '服务密钥配置已保存' : '服务密钥已创建') + }).catch(() => {}) +} + +async function setClientStatus(client) { + if (client.status === 1) { + try { await ElMessageBox.confirm(`吊销后 ${client.name} 将立即无法用此密钥换取 Token,确认继续?`, '吊销服务密钥', { type: 'warning' }) } + catch { return } + } + await run(async () => { + await api(`/open-clients/${client.id}/status`, { + method: 'PATCH', body: JSON.stringify({ status: client.status === 1 ? 0 : 1 }), + }) + await loadOpenClients() + ElMessage.success(client.status === 1 ? '服务密钥已吊销' : '服务密钥已启用') + }).catch(() => {}) +} + +async function rotateClient(client) { + try { await ElMessageBox.confirm(`轮换后 ${client.name} 的旧密钥立即失效,确认继续?`, '轮换服务密钥', { type: 'warning' }) } + catch { return } + await run(async () => { + issuedOpenKey.value = await api(`/open-clients/${client.id}/rotate`, { method: 'POST' }) + await loadOpenClients() + ElMessage.success('服务密钥已轮换') + }).catch(() => {}) +} + async function loadTools() { tools.value = await api(`/tools${qs(toolFilter)}`) } @@ -212,6 +280,7 @@ async function copyText(value) { await navigator.clipboard.writeText(value); ElM async function copyAllTokens() { await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n')) } +function clearIssuedOpenKey() { issuedOpenKey.value = null } onMounted(async () => { try { @@ -242,6 +311,7 @@ onMounted(async () => {