用契约(packages/contracts/src/api/*.ts + org.ts)重新生成路由归属总表,与 API 文档 §6 逐行比对 (方法/路径/access 三者):零差异,95 条契约路由与文档现状完全一致,不改路由表。 §7 的六个 schema(Staff/Position/Principal/RoleAccess/RosterSyncOutcome/InFlightWork/WorkGroup/ ApiError)逐个核对字段名与可选性,与 org.ts 一致,不改。前端对接说明 §2 的十个文件指针全部存在。 Keycloak 侧前置(serviceAccountsEnabled 的 client、audience mapper)本轮未就位,示例响应仍按契约 构造 —— 两份文档末尾补记这一状态,留给前置就位后换真实响应。
17 KiB
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 <Keycloak access_token>;RS256;iss = https://keycloak.akhr-ai.com/realms/corp;aud 须含本服务 clientId(KEYCLOAK_CLIENT_ID,建议 api-gateway) |
| 票据 | GET 且没带头时认 ?ticket=<60 秒票据>(取自 GET /api/auth/ticket);其它方法不认 |
| 请求体 | 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) |
| 400 | validation-failed |
入参不合 schema,detail 是 zod issues |
1. 身份
GET /api/me —— access: any
我是谁、能进哪几个端。ends 为空也 200(前端据此摆「未授权」页)。
出参 Principal(§7):
{
"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 与 <a href download> 这两条带不了 Authorization 头的路。
{ "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
{
"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 服务账号,只读)。无请求体。
{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14 }
没配 provisioning 时如实回(不是一排 0):
{ "configured": false, "message": "未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档" }
规则:远端没有的本地人 → disabled;远端 enabled=false → disabled;远端又启用 → active;字段没变的不写。
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 做。
{
"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": "<staffId>", … }, "ownerName" }] } - PUT body
{ "ownerId", "name", "members": { "<岗位 code>": "<staffId>" } }→{ "group": {…} };一人一组,按ownerId落。
4. 角色 → 端(运营中心)
一行一个 Keycloak realm 角色;一个人的 ends = 他的角色命中行的并集。缺省两行:admin → design, ops, workbench;user → workbench。
新角色在 Keycloak 建;这边 knownRoles 把名册上见过的角色列出来供配置 —— 没配置前它等于「无端可进」。
GET /api/gov/access/roles —— access: ops
{
"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 |
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 19 + 两端 8 = 契约 95 条。
7. Schema 附录(与 packages/contracts/src/org.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 };
// 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<string /* 岗位 code */, string /* staffId */> };
type ApiError = { code: 'unauthenticated' | 'forbidden' | 'not-found' | 'validation-failed' | 'conflict' | 'precondition-failed' | 'internal' | 'gone'; message: string; detail?: unknown };
8. curl 实录
用服务账号拿 token(自动化的正道,手册 §10;该 client 需 serviceAccountsEnabled、realm 角色 admin、且 audience mapper 指向本服务 clientId):
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
# 我是谁
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq .
# 没带 token → 401
curl -s http://localhost:8787/api/me | jq .
# { "code": "unauthenticated", "message": "缺少 Bearer token" }
# 端不够 → 403(拿一个只有 user 角色的 token 试)
curl -s -H "Authorization: Bearer $USER_TOKEN" http://localhost:8787/api/gov/tasks | jq .
# { "code": "forbidden", "message": "进入「运营中心」需要授权;你的角色(user)可进:工作台", "detail": { "need": ["ops"], "ends": ["workbench"] } }
# 票据 + SSE
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 <token>" }
Task 12 已用契约(packages/contracts/src/api/*.ts + org.ts)核过本文:§6 总表逐行比对生成表、§7 字段名逐个核对 schema —— 全部一致,零改动。
示例里的 UUID / 岗位 id / 时间戳仍按契约构造 —— 真实响应需要 Keycloak 侧前置 1(建 bpos-e2e 一类 serviceAccountsEnabled 的 client、授 realm 角色、挂 audience mapper)先就位,这是管理员的活,本轮未就位;前置就位后用 §8 给的 curl 序列换成真实响应。