# API 文档 —— 鉴权、人员与端(ADR-012) > 契约的唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);本文是给前端 / 联调 / 运维读的人话版。 > 两者不一致时以契约为准并回来改本文(实施计划 Task 12 会用契约把 §6 的总表重新生成一遍核对)。 > 设计出处:`docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`;前端怎么接:`docs/前端对接说明-统一鉴权中心.md`。 ## 0. 通则 | 项 | 值 | |---|---| | Base | 同源 `/api/*`(开发期 Vite 把 `/api` 反代到 `:8787`) | | 认证 | `Authorization: Bearer `;RS256;`iss` = `https://keycloak.akhr-ai.com/realms/corp`;`aud` 须含本服务 clientId —— **本部署是 `task-dispatcher`**(后端环境变量 `KEYCLOAK_CLIENT_ID`) | | 票据 | **GET** 且没带头时认 `?ticket=<60 秒票据>`(取自 `GET /api/auth/ticket`);其它方法不认 | | HEAD | 按 GET 判(框架把 HEAD 改写成 GET 再分发)—— 端归属、票据两处都与同路径的 GET 一模一样 | | 请求体 | JSON,`content-type: application/json` | | 错误体 | `{ "code": string, "message": string, "detail"?: unknown }`;`code` ∈ `unauthenticated` `forbidden` `not-found` `validation-failed` `conflict` `precondition-failed` `internal` `gone` | | 端归属 | 每条路由有 `access`:`public`(不要凭据)/ `any`(登录即可)/ `design` / `ops` / `workbench` / `design\|ops`(能进其中任一端即可) | 状态码怎么读: | 状态 | code | 何时 | |---|---|---| | 401 | `unauthenticated` | 缺 token / 过期 / 签名不对 / aud 或 iss 不对 / 票据坏了(message 逐一说清) | | 403 | `forbidden` | 账号已在鉴权中心停用;或这条路由要求的端不在你的 `ends` 里(`detail.need` / `detail.ends`) | | 404 | `not-found` | 无此路由(**先 401 后 404**:没登录的人探不到路由表)/ 对象不存在 | | 409 | `conflict` | 角色 → 端改完没人能进运营中心;**这次名册同步会让没人能进运营中心**(整体拒绝,一条都不写);删 LLM 实例还有活动引用 | | 410 | `gone` | 路由随统一鉴权中心退役(§5) | | 412 | `precondition-failed` | 入参合法、权限也够,但**这件事此刻办不了**:流程包写回生效版本 / 启用时闭合校验没过、活动包安装两审(静态审计 + 回放)未过、决议已登记过不重复起单、汇报已提交过、入站邮件通道已停用。`detail` 常带闭合报告或安装审计结论 | | 400 | `validation-failed` | 入参不合 schema,`detail` 是 zod issues | ## 1. 身份 ### `GET /api/me` —— access: `any` 我是谁、能进哪几个端。**`ends` 为空也 200**(前端据此摆「未授权」页)。 出参 `Principal`(§7): ```json { "staff": { "id": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44", "account": "chaihao", "name": "柴浩", "email": "chaihao@akhr-ai.com", "positionId": "pm", "state": "active", "roles": ["admin"] }, "position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" }, "roles": ["admin"], "ends": ["design", "ops", "workbench"] } ``` 副作用:第一次见到这个 `sub` 会在名册里建档(岗位 `NONE` 未分配);账号 / 姓名 / 邮箱 / 角色变了会写回。 ### `GET /api/auth/ticket` —— access: `any` 换一枚 60 秒票据,给 `EventSource` 与 `` 这两条带不了 Authorization 头的路。 ```json { "ticket": "v1.OGYzYzFhMmItNmMxZS00YjNhLTlmMGUtMmQ1YzdhMWI5ZTQ0.1756112460.k5vJ…", "expiresAt": "2026-08-25T09:01:00.000Z" } ``` - 只在 GET 的 `?ticket=` 上认;票据里没有权限,后端按它对应的 sub 回库现算(停用 / 改角色即时生效)。 - 同时带头与票据时以头为准。 - 过期或坏了 → 401 `票据无效或已过期 —— 重新取一张 GET /api/auth/ticket`。 ## 2. 实时推送 ### `GET /api/stream?ticket=…` —— access: `any`(SSE) 事件:`ready`(连上)、`touch`(哪个目标动了;载荷形状不变,见 `apps/web/src/app/subscribe.ts`)、`ping`(20 秒心跳)。 连接只在建立那一刻验一次票据;断线重连要重新取票据(浏览器自动重连用旧 URL 会吃 401 且不再重试)。 ## 3. 员工与岗位(运营中心 · 员工) ### `GET /api/gov/staff` —— access: `design|ops` ```json { "staff": [ { "id": "8f3c1a2b-…", "account": "chaihao", "name": "柴浩", "email": "chaihao@akhr-ai.com", "positionId": "pm", "state": "active", "roles": ["admin"], "position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" } } ], "positions": [ { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" }, { "id": "bd", "code": "BD", "name": "商务", "note": "RFQ 受理、成本定价、报价议价、情报归档" }, { "id": "fae", "code": "FAE", "name": "现场应用", "note": "替代选型" }, { "id": "prm", "code": "PRM", "name": "采购/寻源", "note": "渠道寻源" }, { "id": "op", "code": "OP", "name": "运营", "note": "运营支持" }, { "id": "test", "code": "TEST", "name": "测试", "note": "测试账号专用岗;整条流经工作组绑定落到本人" }, { "id": "none", "code": "NONE", "name": "未分配", "note": "未指派岗位;流程包不得声明它" } ] } ``` `state` 只由名册同步置 `disabled`(Keycloak 停用或删除),有效 token 不改它;`roles` 是签发时的快照。 测试台选负责人也用这条,所以设计端也能读。 ### `POST /api/gov/staff/sync` —— access: `ops` 从 Keycloak Admin API 同步名册(provisioning 服务账号,只读)。无请求体。 ```json { "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14, "failed": [] } ``` 三个计数说的是**真落进库里的条数**。单条写失败不让整次同步停在半路:它不进计数,而是点名进 `failed` (`reason` 照抄原话,不改写成「操作失败」): ```json { "configured": true, "inserted": 0, "updated": 3, "disabled": 1, "total": 14, "failed": [{ "account": "bob", "reason": "duplicate key value violates unique constraint \"staff_account_key\"" }] } ``` 没配 provisioning 时如实回(**不是**一排 0): ```json { "configured": false, "message": "未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档" } ``` 规则:远端没有的本地人 → `disabled`;远端 `enabled=false` → `disabled`;远端又启用 → `active`;字段没变的不写。 「缺席即停用」有三道下限保护(全员停用之后,这个按钮自己要 `ops`,那条恢复路会自锁): 1. 远端**一个人都没有**时不据此停任何人 —— 那更像取数出了问题(realm 配错、代理 200 空体、 `view-users` 被改窄),不像全员离职; 2. `JCAI_TEST_ACCOUNTS` 里的服务账号缺席不算离职 —— Admin 的 `GET /users` 本来就不返回服务账号用户; 3. 落库之前先算一遍「这一次同步之后还有谁能进运营中心」,算下来没有人 → **409 整体拒绝,一条都不写**: ```json { "code": "conflict", "message": "这次同步会让没有人能进运营中心,已拒绝(一条都没写)—— 先核对 provisioning 指的是不是同一个 realm,以及那个服务账号还有没有 view-users。" } ``` ### `PUT /api/gov/staff/:staffId/position` —— access: `ops` body `{ "positionId": "bd" }` → 出参 = 那一行 `staff` 连同 `position`(形状同 §3 列表里的一项)。 指回 `NONE` 的 id 等于取消指派。404:员工或岗位不存在。 ### `GET /api/gov/staff/:staffId/in-flight` —— access: `ops` 此人身上还挂着什么(只读;原「停用前置检查」)。停用本身在 Keycloak 做。 ```json { "staffId": "8f3c1a2b-…", "blocked": true, "openTasks": [{ "taskId": "t-01J…", "title": "客户RFQ受理 · Itelma" }], "openCards": [{ "cardId": "c-01J…", "title": "受理需求审定" }], "openApprovals": [] } ``` ### 工作组(不变):`GET /api/gov/work-groups` · `PUT /api/gov/work-groups` —— access: `ops` - GET → `{ "groups": [{ "id", "name", "ownerId", "members": { "BD": "", … }, "ownerName" }] }` - PUT body `{ "ownerId", "name", "members": { "<岗位 code>": "" } }` → `{ "group": {…} }`;一人一组,按 `ownerId` 落。 ## 4. 角色 → 端(运营中心) 一行一个 Keycloak realm 角色;一个人的 `ends` = 他的角色命中行的并集。缺省两行:`admin → design, ops, workbench`;`user → workbench`。 新角色在 Keycloak 建;这边 `knownRoles` 把名册上见过的角色列出来供配置 —— 没配置前它等于「无端可进」。 ### `GET /api/gov/access/roles` —— access: `ops` ```json { "roles": [ { "role": "admin", "ends": ["design", "ops", "workbench"] }, { "role": "user", "ends": ["workbench"] } ], "knownRoles": ["admin", "designer", "user"] } ``` ### `PUT /api/gov/access/roles/:role` —— access: `ops` body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role": "designer", "ends": ["design"] }`。 **409** `conflict`:改完之后没有任何在册活跃员工能进运营中心 —— 那张表就再也没人改得了,拒绝。名册为空(全新安装)不算锁死。 ### `DELETE /api/gov/access/roles/:role` —— access: `ops` → `{ "ok": true }`;同样的 409 保护。 ## 5. 退役路由(一律 410 `gone`,契约留一轮下个迭代删) | 退役 | message 里指的路 | |---|---| | `POST /api/login` | 登录由 Keycloak 完成,请求带 Authorization: Bearer | | `POST /api/logout` | 登出在前端丢弃 token,或调 Keycloak 的 end-session | | `GET /api/session` | 改用 `GET /api/me` | | `POST /api/gov/staff` | 首次登录自动建档,或 `POST /api/gov/staff/sync` | | `GET /api/gov/staff/:staffId/disable-check` | `GET /api/gov/staff/:staffId/in-flight` | | `POST /api/gov/staff/:staffId/disable` | 在 Keycloak 停用后 `POST /api/gov/staff/sync` | | `GET /api/gov/positions/:positionId/grants` | `GET /api/gov/access/roles` | | `POST /api/gov/positions/:positionId/grants` | `PUT /api/gov/access/roles/:role` | ## 6. 全部路由的端归属(总表) 规则:**写归所属端;被两端消费的读标两端**。 | access | 方法 | 路径 | |---|---|---| | `public` | GET | `/api/health` | | `public` | POST | `/api/ingest/channel/:channelId/mail`(外界边界:模拟入站邮件) | | `public`(410) | — | §5 的八条 | | `any` | GET | `/api/me` | | `any` | GET | `/api/auth/ticket` | | `any` | GET | `/api/stream`(SSE) | | `workbench` | GET | `/api/desk/tasks` | | `workbench` | GET | `/api/desk/tasks/:taskId` | | `workbench` | POST | `/api/desk/work-orders/reply` | | `workbench` | POST | `/api/desk/chat-cards/reply` | | `workbench` | POST | `/api/desk/comms/:commId/read` | | `workbench` | POST | `/api/desk/tasks/:taskId/table` | | `workbench` | GET | `/api/desk/tasks/:taskId/file`(`?address=&key=&raw=1`;可用票据) | | `workbench` | POST | `/api/desk/tasks/:taskId/say` | | `workbench` | GET | `/api/desk/messages` | | `workbench` | POST | `/api/desk/messages/:messageId/read` | | `workbench` | POST | `/api/desk/messages/:messageId/answer` | | `workbench` | POST | `/api/desk/trigger-pending/:id/confirm` | | `workbench` | GET | `/api/minutes` | | `workbench` | POST | `/api/minutes` | | `workbench` | POST | `/api/minutes/:minuteId/resolutions` | | `workbench` | POST | `/api/minutes/resolutions/:resolutionId/register` | | `workbench` | GET | `/api/reports/:period` | | `workbench` | GET | `/api/reports` | | `workbench` | POST | `/api/reports/:reportId/submit` | | `workbench` | GET | `/api/approvals` | | `workbench` | POST | `/api/approvals/:workflowId/:nodeId/decide` | | `workbench` | GET | `/api/triggers/registration` | | `workbench` | POST | `/api/triggers/:triggerId/register` | | `design` | PUT | `/api/gov/process` | | `design` | POST | `/api/gov/process/:id/:version/state` | | `design` | DELETE | `/api/gov/process/:id/:version` | | `design` | DELETE | `/api/gov/process/:id` | | `design` | POST | `/api/gov/process/:id/:version/edit` | | `design` | POST | `/api/gov/process/validate` | | `design` | GET | `/api/gov/approval-templates` | | `design` | PUT | `/api/gov/approval-templates` | | `design` | DELETE | `/api/gov/approval-templates/:id` | | `design` | PUT | `/api/gov/triggers`(定义归设计) | | `design` | GET | `/api/gov/activities` | | `design` | POST | `/api/gov/activities` | | `design` | GET | `/api/gov/tools` | | `design` | POST | `/api/gov/tools` | | `design` | GET | `/api/gov/memory/:activityId` | | `design` | GET | `/api/gov/llm/providers` | | `design` | GET | `/api/gov/llm` | | `design` | POST | `/api/gov/llm` | | `design` | DELETE | `/api/gov/llm/:id` | | `design` | POST | `/api/gov/llm/probe` | | `design` | POST | `/api/gov/llm/:id/probe` | | `design` | POST | `/api/gov/testbed/run` | | `design` | POST | `/api/gov/testbed/run-activity` | | `design` | POST | `/api/gov/testbed/stop` | | `design` | GET | `/api/gov/testbed/current` | | `design` | GET | `/api/gov/testbed/activity/:taskId` | | `design` | GET | `/api/gov/testbed/logs` | | `design` | GET | `/api/gov/testbed/logs/:id` | | `design` | DELETE | `/api/gov/testbed/logs/:id` | | `design` | DELETE | `/api/gov/testbed/logs` | | `design` | POST | `/api/gov/upload` | | `design` | GET | `/api/gov/upload/:id` | | `design` | GET | `/api/gov/workflows/:id/trace` | | `ops` | GET | `/api/gov/tasks` | | `ops` | POST | `/api/gov/tasks/:taskId/control` | | `ops` | POST | `/api/gov/tasks/:taskId/resume` | | `ops` | POST | `/api/gov/tasks/:taskId/reassign` | | `ops` | GET | `/api/gov/tasks/:taskId/log` | | `ops` | GET | `/api/gov/approvals` | | `ops` | GET | `/api/gov/metrics` | | `ops` | POST | `/api/gov/triggers/:id/toggle`(启停归运营) | | `ops` | POST | `/api/gov/triggers/:id/confirm` | | `ops` | GET | `/api/gov/packages/:id/owners` | | `ops` | PUT | `/api/gov/packages/:id/owners/:nodeId` | | `ops` | GET | `/api/gov/work-groups` | | `ops` | PUT | `/api/gov/work-groups` | | `ops` | POST | `/api/gov/staff/sync` | | `ops` | PUT | `/api/gov/staff/:staffId/position` | | `ops` | GET | `/api/gov/staff/:staffId/in-flight` | | `ops` | GET | `/api/gov/access/roles` | | `ops` | PUT | `/api/gov/access/roles/:role` | | `ops` | DELETE | `/api/gov/access/roles/:role` | | `ops` | POST | `/api/gov/mail/accounts` | | `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) | | `ops` | PUT | `/api/gov/mail/accounts/:accountId` | | `ops` | DELETE | `/api/gov/mail/accounts/:accountId` | | `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` | | `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` | | `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` | | `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` | | `ops` | GET | `/api/gov/mail/accounts` | | `ops` | GET | `/api/gov/mail/accounts/:accountId` | | `ops` | GET | `/api/gov/mail/inbound` | | `ops` | GET | `/api/gov/mail/inbound/:mailId` | | `ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) | | `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) | | `ops` | GET | `/api/gov/mail/outbound` | | `design\|ops` | GET | `/api/gov/staff`(测试台选负责人) | | `design\|ops` | GET | `/api/gov/process` | | `design\|ops` | GET | `/api/gov/triggers` | | `design\|ops` | GET | `/api/gov/channels` | | `design\|ops` | GET | `/api/gov/data/objects` | | `design\|ops` | GET | `/api/gov/data/by-activity` | | `design\|ops` | GET | `/api/gov/data/:domain/:object` | | `design\|ops` | GET | `/api/gov/workflows/:id` | 计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 34 + 两端 8 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。 ## 7. Schema 附录(与 `packages/contracts/src/org.ts` 同名) ```ts // 三个端 type Surface = 'design' | 'ops' | 'workbench'; // 员工 —— id 就是 Keycloak sub type Staff = { id: string; // Keycloak sub(UUID) account: string; // preferred_username,只作显示 name: string; // name claim,缺则 = account email?: string; positionId: string; // 未指派 = 哨兵岗 NONE 的 id state: 'active' | 'disabled'; roles: string[]; // realm 角色快照(噪声角色已滤) }; type Position = { id: string; code: string; name: string; note?: string }; // GET /api/me type Principal = { staff: Staff; position: Position; roles: string[]; ends: Surface[] }; // 角色 → 端 type RoleAccess = { role: string; ends: Surface[] }; // POST /api/gov/staff/sync type RosterSyncOutcome = | { configured: false; message: string } | { configured: true; inserted: number; // 三个计数 = 真落进库里的条数,不是打算写几条 updated: number; disabled: number; total: number; // 远端这一次回了几个人 failed: Array<{ account: string; reason: string }>; // 逐条落库时失败的人,原话照抄 }; // GET /api/gov/staff/:id/in-flight type InFlightWork = { staffId: string; blocked: boolean; openTasks: Array<{ taskId: string; title: string }>; openCards: Array<{ cardId: string; title: string }>; openApprovals: Array<{ approvalId: string; title: string }>; }; // 工作组(ADR-011,不变) type WorkGroup = { id: string; name: string; ownerId: string; members: Record }; type ApiError = { code: 'unauthenticated' | 'forbidden' | 'not-found' | 'validation-failed' | 'conflict' | 'precondition-failed' | 'internal' | 'gone'; message: string; detail?: unknown }; ``` ## 8. curl 实录 用服务账号拿 token(自动化的正道,手册 §10;该 client 需 `serviceAccountsEnabled`,且 audience mapper **指向本服务 clientId** —— 服务账号 token 默认不含自己的 clientId,漏了这条就会 401 并点名让你加 mapper): ```bash TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \ -d grant_type=client_credentials -d client_id=task-dispatcher -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token) # 我是谁 —— 下面是 2026-08-26 的实测响应(服务账号首次调用即被 JIT 建档,落「未分配」岗) curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq . # { # "staff": { "id": "49a85fc7-020c-4098-ba38-af7b138129ea", # "account": "service-account-task-dispatcher", # "name": "service-account-task-dispatcher", # "positionId": "3e36f2dd-982b-4412-bb1a-4c12a93c053d", # "state": "active", "roles": ["service", "user"] }, # "position": { "id": "3e36f2dd-…", "code": "NONE", "name": "未分配", # "note": "未指派岗位;流程包不得声明它" }, # "roles": ["service", "user"], # "ends": ["workbench"] ← service 没配进「角色→端」表,只有 user 生效 # } # 没带 token → 401 curl -s http://localhost:8787/api/me | jq . # { "code": "unauthenticated", "message": "缺少 Bearer token" } # 端不够 → 403(实测响应) curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/tasks | jq . # { "code": "forbidden", "message": "进入「运营中心」需要授权;你的角色(service, user)可进:工作台", "detail": { "need": ["ops"], "ends": ["workbench"] } } # aud 不对 → 401,话直接点到要改哪儿(实测响应:mapper 补上之前就是这个) # { "code": "unauthenticated", "message": "token 的 aud 不含 \"task-dispatcher\" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper" } # 票据 + SSE # 实测:{ "ticket": "v1.NDlhODVmYzctMDIwYy00MDk4LWJhMzgtYWY3YjEzODEyOWVh.1787712844.x6HoZb7E…", # "expiresAt": "2026-08-26T02:54:04.000Z" } ← 中段是 base64url 的 sub,末段是 HMAC TICKET=$(curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq -r .ticket) curl -s -N "http://localhost:8787/api/stream?ticket=$TICKET" # event: ready # data: 1 # 角色 → 端 curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq . curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"ends":["design"]}' http://localhost:8787/api/gov/access/roles/designer | jq . # 名册同步 / 指派岗位 curl -s -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/staff/sync | jq . curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"positionId":"bd"}' http://localhost:8787/api/gov/staff/8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44/position | jq . # 退役路由 → 410 curl -s -X POST http://localhost:8787/api/login | jq . # { "code": "gone", "message": "已随统一鉴权中心退役:登录由 Keycloak 完成,请求带 Authorization: Bearer " } ``` Task 12 已用契约(`packages/contracts/src/api/*.ts` + `org.ts`)核过本文:§6 总表逐行比对生成表、§7 字段名逐个核对 schema —— 全部一致,零改动。 **2026-08-26 补:已在真环境跑通,§8 的响应示例换成了实测响应。** 当时的部署事实: `KEYCLOAK_CLIENT_ID=task-dispatcher`;`task-dispatcher` / `web-portal` / `web-console` 三个 client 都挂了指向 `task-dispatcher` 的 audience mapper;realm 里 8 个用户,5 个持 `admin`(→ 三端)、3 个持 `user`(→ 工作台)。 实测验过:验签 · claims 归一(噪声角色已滤)· JIT 建档 · 角色→端 · 端判定(工作台 200 / 运营中心 403)· 票据签发与 SSE 连通 · 票据在 POST 上被拒 · `HEAD` 与 `GET` 判定一致 · 退役路由 410。 仍未实测的只有一件:**用真人 token 走一遍**(要前端接入,或临时开 Direct Access Grants,手册 §6.1)。