文档:前端对接说明与 API 文档(鉴权 / 人员 / 角色→端);计划加 Task 12 按契约核对两份文档

前端对接说明:要改哪些文件、/api/me 与端、401/403/410 处置、SSE 与下载的 60s 票据、
运营中心员工页的新接口、接入完成判据。API 文档:通则与状态码、每条新接口的出入参与示例、
退役八条的替代、全部 95 条契约路由的端归属总表、schema 附录、curl 实录。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
xcs
2026-08-25 15:25:30 +08:00
co-authored by Claude Fable 5
parent 8ba4ab339e
commit 094386f02c
4 changed files with 647 additions and 1 deletions
+365
View File
@@ -0,0 +1,365 @@
# 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):
```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` 与 `<a href download>` 这两条带不了 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 }
```
没配 provisioning 时如实回(**不是**一排 0):
```json
{ "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 做。
```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": "<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`
```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` |
| `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` 同名)
```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):
```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=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>" }
```
示例里的 UUID / 岗位 id / 时间戳是按契约构造的;实施完成后 Task 12 会用真实响应替换。
@@ -55,6 +55,7 @@
| 数据 | `db/migrations/0032_keycloak_identity.sql` + `meta/_journal.json` | 见 spec §6 | | 数据 | `db/migrations/0032_keycloak_identity.sql` + `meta/_journal.json` | 见 spec §6 |
| 测试 | `org/__tests__/{claims,ends,roster-diff,test-account,keycloak-token-verifier,keycloak-admin-roster,org-service}.test.ts`;`app/__tests__/{route-registry,ticket,auth-middleware,config-keycloak,keycloak-check}.test.ts`;`app/__tests__/route-coverage.it.test.ts`;三份既有 `*.it.test.ts` 改造人 | | | 测试 | `org/__tests__/{claims,ends,roster-diff,test-account,keycloak-token-verifier,keycloak-admin-roster,org-service}.test.ts`;`app/__tests__/{route-registry,ticket,auth-middleware,config-keycloak,keycloak-check}.test.ts`;`app/__tests__/route-coverage.it.test.ts`;三份既有 `*.it.test.ts` 改造人 | |
| 工具 / 文档 | `tools/e2e-a2.mjs`;`docs/adr/ADR-012-keycloak-identity.md`;`docs/编码规范.md`;`docs/debt.md`;`CLAUDE.md`;`api-report/` | | | 工具 / 文档 | `tools/e2e-a2.mjs`;`docs/adr/ADR-012-keycloak-identity.md`;`docs/编码规范.md`;`docs/debt.md`;`CLAUDE.md`;`api-report/` | |
| 对外文档(已随设计写好,Task 12 核对) | `docs/前端对接说明-统一鉴权中心.md`;`docs/API文档-鉴权与人员.md` | 前端要改什么、后端给了什么;接口人话版 + 全部路由归属总表 |
--- ---
@@ -4857,6 +4858,13 @@ git commit -m "e2e:改用 Keycloak 服务账号 client_credentials 拿 token,4
绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。 绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
``` ```
「文档权威地图」表在 `docs/debt.md` 那一行之前加两行:
```markdown
| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 |
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
```
现状快照列表末尾(`## 文档权威地图` 之前)加一条: 现状快照列表末尾(`## 文档权威地图` 之前)加一条:
```markdown ```markdown
@@ -4885,9 +4893,83 @@ git commit -m "文档:ADR-012 统一鉴权中心;编码规范鉴权定调改为
--- ---
### Task 12: 前端对接说明与 API 文档 —— 与真实契约核对
两份文档是设计阶段按 spec 写好的(`docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md`),
实现落地后要用**契约本身**把它们核一遍:路由归属总表由契约重新生成、schema 字段名逐个对、示例尽量换成真实响应。
**Files:**
- Modify: `docs/API文档-鉴权与人员.md`(§6 总表整段重生成;§7 字段核对;§8 示例)
- Modify: `docs/前端对接说明-统一鉴权中心.md`(§2 文件指针、§7 接口名核对)
**Interfaces:**
- Consumes:`packages/contracts`(Task 1 之后每条路由都有 `access`)、`GET /api/me` 等已实现的路由(Task 8)。
- [ ] **Step 1: 用契约重新生成路由归属总表**
```bash
node --experimental-strip-types --input-type=module -e "
const m = await import('./packages/contracts/src/index.ts');
const isRoute = (v) => v !== null && typeof v === 'object' && typeof v.method === 'string' && typeof v.path === 'string' && 'access' in v && 'output' in v;
const order = ['public', 'any', 'workbench', 'design', 'ops', 'design|ops'];
const rows = Object.entries(m).filter(([, v]) => isRoute(v)).map(([name, r]) => ({ access: Array.isArray(r.access) ? r.access.join('|') : r.access, method: r.method, path: r.path, name }));
rows.sort((a, b) => order.indexOf(a.access) - order.indexOf(b.access) || a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
console.log('| access | 方法 | 路径 |');
console.log('|---|---|---|');
for (const r of rows) console.log('| \`' + r.access.replace('|', '\\\\|') + '\` | ' + r.method + ' | \`' + r.path + '\` |');
console.log('\n计数:' + rows.length + ' 条契约路由');
" > /tmp/route-access-table.md
wc -l /tmp/route-access-table.md
```
Expected:打印 95 行路由 + 计数 `95 条契约路由`。若 `node --experimental-strip-types` 在本机版本上不认 `satisfies`(Node < 22.7),改用 `pnpm vitest run` 跑一个临时测试文件把同一段代码 `console.log` 出来,跑完删掉临时文件。
- [ ] **Step 2: 对 §6 总表**
把生成表与 `docs/API文档-鉴权与人员.md` §6 逐行比:方法 / 路径 / access 三者任一不同 → 以生成表为准改文档;
文档里手写的备注(「定义归设计」「可用票据」等)保留。SSE `GET /api/stream` 不在契约里(`EXTRA_ROUTES`),文档单列一行。
最后一行「计数」按生成结果改。
- [ ] **Step 3: 对 §7 字段名**
```bash
grep -n "^export const \(staffSchema\|principalSchema\|roleAccessSchema\|rosterSyncOutcomeSchema\|inFlightWorkSchema\|workGroupSchema\|positionSchema\)" packages/contracts/src/org.ts
grep -n "API_ERROR_CODES" -A 10 packages/contracts/src/api/http.ts | head -12
```
逐个 schema 打开看字段名与可选性,与文档 §7 的 TS 形状对齐(字段多了补、少了删、可选性以契约为准)。
- [ ] **Step 4: 示例换成真实响应(Keycloak 侧前置 1 与 4 就位时做;未就位就跳过并在文档末尾注明「示例按契约构造」)**
```bash
export KEYCLOAK_URL=https://keycloak.akhr-ai.com KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=api-gateway
pnpm dev:api & # 另开终端亦可;启动日志须见「✓ Keycloak 连通」
TOKEN=$(curl -s -X POST "$KEYCLOAK_URL/realms/corp/protocol/openid-connect/token" -d grant_type=client_credentials -d client_id="$KEYCLOAK_E2E_CLIENT_ID" -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq .
curl -s http://localhost:8787/api/me | jq .
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq .
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq .
curl -s -X POST http://localhost:8787/api/login | jq .
```
把上面五个响应原样贴进文档 §1 / §4 / §5 / §8(sub、邮箱按真实值;secret 一个字都别贴)。
- [ ] **Step 5: 核前端对接说明里的文件指针**
```bash
for f in shared/typed-fetch.ts app/main.tsx features/auth/internal/LoginPage.tsx shared/gov/surfaces.ts app/gov-shell.tsx app/app-shell.tsx app/subscribe.ts features/desk-task/internal/ViewRegion.tsx features/desk-task/internal/FileViewer.tsx shared/gov/gov-api.ts; do test -f "apps/web/src/$f" && echo "ok $f" || echo "MISSING $f"; done
```
Expected:全部 `ok`;有 `MISSING` 就改文档 §2 的指针(web 本轮没动,理论上不会)。§7 里提到的接口名与 §6 总表逐个对一遍。
- [ ] **Step 6: 提交**
```bash
git add 'docs/API文档-鉴权与人员.md' 'docs/前端对接说明-统一鉴权中心.md'
git commit -m "文档:前端对接说明与 API 文档按真实契约核对(路由归属总表由契约生成,示例换真实响应)"
```
---
## 自审记录(写完计划后对着 spec 过的一遍) ## 自审记录(写完计划后对着 spec 过的一遍)
- **spec 覆盖**:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。 - **spec 覆盖**:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11;用户追加的前端对接说明与 API 文档(设计阶段已写)→ Task 12 用契约核对。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。
- **类型一致性**:`OrgService` 的方法名在 Task 8 A2(契约)、A7(实现)、B3–B9(调用方)一致:`authenticate` `principalOf` `registerIdentity` `listStaff` `getStaff` `staffOfPosition` `setPosition` `syncRoster` `inFlightOf` `listRoleAccess` `setRoleAccess` `deleteRoleAccess` `staffWithEnd` `saveWorkGroup` `workGroupOf` `bindingOf` `ensureSeeded`;`Authenticator`(Task 7)是它的子集;`StaffRepository` 无 `findByAccount`(A3 / A6 / A7 / A1 的内存实现一致);`createContainer(config, overrides)`(B3)与 Task 9 的 it-test 调用一致;`EXTRA_ROUTES` / `appRouteRegistry`(B4)与 Task 9 覆盖测试一致。 - **类型一致性**:`OrgService` 的方法名在 Task 8 A2(契约)、A7(实现)、B3–B9(调用方)一致:`authenticate` `principalOf` `registerIdentity` `listStaff` `getStaff` `staffOfPosition` `setPosition` `syncRoster` `inFlightOf` `listRoleAccess` `setRoleAccess` `deleteRoleAccess` `staffWithEnd` `saveWorkGroup` `workGroupOf` `bindingOf` `ensureSeeded`;`Authenticator`(Task 7)是它的子集;`StaffRepository` 无 `findByAccount`(A3 / A6 / A7 / A1 的内存实现一致);`createContainer(config, overrides)`(B3)与 Task 9 的 it-test 调用一致;`EXTRA_ROUTES` / `appRouteRegistry`(B4)与 Task 9 覆盖测试一致。
- **占位扫描**:无 TBD / TODO;每个改动步骤都给了代码或精确的替换文本;脚本化改写(B7 / B8)带断言与验收 grep。 - **占位扫描**:无 TBD / TODO;每个改动步骤都给了代码或精确的替换文本;脚本化改写(B7 / B8)带断言与验收 grep。
- **已知取舍**:Task 8 是一次提交(中间态 typecheck 必红,不拆);it-tests 与路由覆盖测试需要 PG,没库时自报跳过。 - **已知取舍**:Task 8 是一次提交(中间态 typecheck 必红,不拆);it-tests 与路由覆盖测试需要 PG,没库时自报跳过。
@@ -417,6 +417,7 @@ c.set('principal', principal) → next
| `docs/debt.md` | 鉴权段重写(见 §13) | | `docs/debt.md` | 鉴权段重写(见 §13) |
| `CLAUDE.md` | 铁规矩 5 环境变量列表加 `KEYCLOAK_*`;铁规矩 6 「AI 账号(密码…)」改「AI 服务账号(client_credentials)」;现状快照加一条 | | `CLAUDE.md` | 铁规矩 5 环境变量列表加 `KEYCLOAK_*`;铁规矩 6 「AI 账号(密码…)」改「AI 服务账号(client_credentials)」;现状快照加一条 |
| `api-report/` | `pnpm api:check --update` | | `api-report/` | `pnpm api:check --update` |
| `docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md` | 设计阶段按本 spec 写好(用户 2026-08-25 追加);实现落地后用契约重新生成路由归属总表核对(计划 Task 12) |
## 12. 测试策略 ## 12. 测试策略
@@ -0,0 +1,198 @@
# 统一鉴权中心(Keycloak)—— 前端对接说明
> 后端已按 ADR-012 改成**资源服务器**:不再有登录口、会话 cookie 与岗位授权;
> 每个请求带 `Authorization: Bearer <access_token>`,后端本地验签、按「角色 → 端」判权。
> 这份说明只讲**前端要改什么、后端给了什么**。浏览器怎么登录、怎么取 / 刷 token,
> 照公司统一的《Keycloak 前端接入手册》,本文不重复。
> 接口的字段与状态码以 `docs/API文档-鉴权与人员.md` 与 `packages/contracts/src/api/*.ts` 为准。
## 0. 一分钟看懂
- 后端**不做登录**。前端(公司已在 Keycloak `corp` realm 建好两个前端 client:`web-portal` 用户端
`jcai-bpos.akhr-ai.com` / `web-console` 管理端 `jcai-bpos-opt.akhr-ai.com`,PKCE)自己拿 token。
- token 的 `aud` 必须含本后端的 clientId(后端环境变量 `KEYCLOAK_CLIENT_ID`,建议 `api-gateway`);
后端**不看 `azp`**,用哪个前端 client 取的 token 都行 —— 没有 aud 会全线 401,后端的 401 message 会点名让管理员加 audience mapper。
- 每个请求头带 `Authorization: Bearer …`。`GET /api/me` 回答两件事:我是谁、我能进哪几个端(`ends`)。
- **权限只到端**:`design`(设计中心 `/design`)/ `ops`(运营中心 `/ops`)/ `workbench`(工作台 `/`)。
入口亮不亮、页面能不能进,只看 `ends`;进去之后哪个接口能调,后端按每条路由的归属自己判(§6 总表)。
- 两条带不了 Authorization 头的路(`EventSource` 与 `<a href download>`)用 **60 秒票据**:
`GET /api/auth/ticket` → 把 `ticket` 放到那条 GET 的 `?ticket=` 上。
- 401 = 该重新登录(或刷 token);403 = 没权限,重登没用;410 = 这条路由退役了,看 §7 的替代。
## 1. 请求链
```
浏览器(SPA,PKCE)──登录/刷新 token──▶ https://keycloak.akhr-ai.com/realms/corp
│
│ Authorization: Bearer <access_token>(aud ∋ api-gateway)
▼
apps/api 鉴权中间件(唯一一处)
① 路由归属:契约里每条路由自带 access(public / any / 端)
② 验签:RS256 · iss · aud · 30s 容差(本地,不回源)
③ 名册即时更新:第一次见到这个 sub 就建档(岗位「未分配」)
④ 角色 × 角色→端表 → ends;端不够 → 403
⑤ 路由拿到 principal(staff / position / roles / ends)
```
## 2. 改动清单(按 `apps/web` 的文件)
| 文件 | 现在 | 改成 |
|---|---|---|
| `shared/typed-fetch.ts` | `credentials: 'same-origin'`,靠 cookie | 每个请求加 `Authorization: Bearer <token>`;401 → 刷一次 token 重试,仍 401 → 落登录;403 → 抛给页面提示(带 `detail.need` / `detail.ends`),**不要**跳登录 |
| `app/main.tsx`(`Gate`) | `call(sessionRoute)`,401 → `LoginPage` | 没 token → 走手册的登录跳转;有 token → `call(meRoute)`;`authed` = `me` 200;401 仍落登录 |
| `features/auth/internal/LoginPage.tsx` | 账号 + 口令表单 `POST /api/login` | 删除表单;换成「跳转到统一登录」(手册的 `login()`);`/api/login` 现回 410 |
| `shared/gov/surfaces.ts` | `canSeeSurface(session, surface)` 读 `session.grants` × `SURFACE_FNS` | `canSeeSurface(me, surface) = me.ends.includes(surface)`;`SURFACE_FNS` 删除 |
| `app/gov-shell.tsx` / `app/app-shell.tsx` | `session.data?.staff.name`;登出 `call(logoutRoute)` | `me.data?.staff.name`;登出 = 丢掉本地 token + Keycloak end-session(手册);`/api/logout` 现回 410 |
| `app/subscribe.ts` | `new EventSource('/api/stream')` | 先 `GET /api/auth/ticket`,再 `new EventSource('/api/stream?ticket=…')`;断线重连要**重新取票据**(§5) |
| `features/desk-task/internal/ViewRegion.tsx` + `FileViewer.tsx` | `<a href="/api/desk/tasks/:id/file?…&raw=1" download>` | 点击前现取票据拼到 `&ticket=`,或 `fetch`(带头)→ `blob:` URL(§5) |
| `features/gov-staff/*` + `shared/gov/gov-api.ts` | 建人 / 停用 / 停用前置检查 / 岗位授权 | 四条已退役(410);换成:同步名册、指派岗位、在途工作、角色 → 端(§7);`useGovGrants` 删 |
| 类型清理 | `Session` `sessionSchema` `grant*` `GovernanceFunction*` `disableCheckSchema` | 这轮契约里标了 `@deprecated`,web 编译照过;前端接入完成时一并从 `@contracts` 删(§12.4 三步之第三步) |
不用动的:`ui-kit` 的 `ViewContainer`、各 feature 的业务调用(路由契约的路径 / 参数 / 出参一个没改,只是每条多了 `access`)、
Vite 的 `/api` 反代、SSE 事件的形状(`ready` / `touch` / `ping`)。
## 3. `GET /api/me` 与「端」
```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"]
}
```
- `staff.id` 就是 Keycloak 的 `sub`。所有原来拿 `staff.id` 的地方(负责人、卡的收件人、工作组成员)语义不变,值变成 UUID。
- `staff.roles` 是 realm 角色(签发时的快照;`default-roles-corp` / `offline_access` / `uma_authorization` 已滤掉)。
- `ends` 是**并集**:角色命中「角色 → 端」表的每一行取并集;顺序固定 `design, ops, workbench`。
- `ends` 可以是 `[]`(角色在表里一行都没命中)—— `/api/me` 仍 200,别的路由 403。
这时候页面该说「你的角色(xxx)还没有被授权进入任何一端,请联系运营中心」,不要落登录页。
- 岗位 `position.code` 有两个特殊值:`NONE`(未分配,新人第一次登录就是它)、`TEST`(测试账号)。
403 的 `detail` 告诉你差的是哪个端、他能进哪几个:
```json
{
"code": "forbidden",
"message": "进入「运营中心」需要授权;你的角色(user)可进:工作台",
"detail": { "need": ["ops"], "ends": ["workbench"] }
}
```
## 4. token 怎么带、失败怎么处理
| 状态 | code | 意思 | 前端动作 |
|---|---|---|---|
| 401 | `unauthenticated` | 没带 token / 过期 / 签名不对 / aud 不含本服务(message 会说清哪一种) | 先刷一次 token 重试;仍 401 → 清本地 token,走登录 |
| 403 | `forbidden` | token 对,但账号已在鉴权中心停用,或这条路由的端不在 `ends` 里 | 提示,不重登;停用的 message 会说「已停用」 |
| 410 | `gone` | 路由随统一鉴权中心退役 | 改调 message 里点名的替代路由(§7) |
| 409 | `conflict` | 角色 → 端改完会没人能进运营中心(锁死保护) | 把 message 原样给用户看 |
细节:
- access token 默认 5 分钟;刷新由前端管(手册),后端不参与。后端验签有 30 秒时钟容差。
- 后端每个请求都验签,是纯本地运算(公钥缓存 10 分钟),不用担心「带 token 会慢」。
- 撤了角色,旧 token 到期前仍按旧角色算(最坏 5 分钟);账号停用经运营中心「同步名册」后即时 403。
- 不要把 token 放进 URL(票据除外,见 §5)、不要放进 localStorage 以外的地方由后端读 —— 后端只认 `Authorization` 头。
## 5. 票据:SSE 与下载
`EventSource` 与 `<a href download>` 带不了请求头,所以后端给了一条**只对 GET 有效**的路:
```
GET /api/auth/ticket Authorization: Bearer …
→ 200 { "ticket": "v1.OGYzYzFhMmI….1756112460.k5v…", "expiresAt": "2026-08-25T09:01:00.000Z" }
```
- 票据 60 秒有效、只在 **GET** 的 `?ticket=` 上认;POST / PUT / DELETE 带了也当没带(401)。
- 票据里没有权限:后端拿它对应的 sub 回库现算,停用 / 改角色即时生效。
- 同时带了 `Authorization` 头和票据时,以头为准。
### SSE
```ts
// app/subscribe.ts 的改法(示意)
async function openStream(onTouch: (t: Touch) => void, onOpen: () => void): Promise<() => void> {
const { ticket } = await call<{ ticket: string; expiresAt: string }>(authTicketRoute);
const source = new EventSource(`/api/stream?ticket=${encodeURIComponent(ticket)}`);
source.addEventListener('touch', (e) => onTouch(JSON.parse((e as MessageEvent<string>).data)));
source.addEventListener('open', onOpen);
// 票据只活 60 秒:断线后浏览器自动重连用的还是旧 URL,后端会 401,
// 而 EventSource 收到非 200 就不再自动重连 —— 所以 error 时要自己关掉、重新取票据、重建
source.addEventListener('error', () => {
source.close();
setTimeout(() => void openStream(onTouch, onOpen), 1000);
});
return () => source.close();
}
```
### 下载原件
两种都行,任选其一:
1. 点击前现取票据:`href = \`/api/desk/tasks/${id}/file?address=…&key=…&raw=1&ticket=${encodeURIComponent(ticket)}\``
—— 票据 60 秒,**别在渲染时预取**,要在 `onClick` 里取完再 `window.location.assign(href)`(或动态创建 `<a>` 点一下)。
2. `fetch(href, { headers: { authorization } })` → `blob()` → `URL.createObjectURL` → `<a download>` 点一下 → `revokeObjectURL`。
不用票据,但大文件会先进内存。
`FileViewer` 里「预览」那条路本来就是 `fetch`(经 `typed-fetch`),带头即可;只有「下载原件」那枚 `<a download>` 需要上面两选一。
## 6. 谁能进哪个端:入口与页面
- 入口亮灯:`canSeeSurface(me, 'design')` / `'ops'`;工作台入口同理看 `'workbench'`(从前工作台是默认进得去的,现在也要看 `ends`)。
- 深链保护:进入 `/design` 或 `/ops` 时若 `ends` 不含该端,落到能进的第一个端;一个都不能进 → §3 的「未授权」页。
- 页面内的按钮**不用**再按功能藏:后端不再有「task:read / task:govern」这种粒度,同一端内的接口对能进该端的人全开。
某些**读**接口两端都能调(`GET /api/gov/staff` 等八条,见 API 文档 §6),
写接口只归一端(触发器的启停 / 节点负责人归运营;定义类归设计)。
## 7. 退役路由与替代
| 退役(410) | 替代 |
|---|---|
| `POST /api/login` | 手册的登录跳转(Keycloak) |
| `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/:id/disable-check` | `GET /api/gov/staff/:id/in-flight`(只读:此人身上还挂着什么) |
| `POST /api/gov/staff/:id/disable` | 在 Keycloak 停用账号 → `POST /api/gov/staff/sync` |
| `GET|POST /api/gov/positions/:id/grants` | `GET|PUT|DELETE /api/gov/access/roles[/:role]`(角色 → 端) |
运营中心 · 员工 那一页建议长这样:
- **名册**:`GET /api/gov/staff` 照旧(每人多了 `email` / `roles`;`positions` 里多了 `NONE` 未分配、`TEST` 测试);
「同步名册」按钮 → `POST /api/gov/staff/sync`,`configured:false` 就把 `message` 原样摆出来(别摆一排 0);
每人一个岗位下拉 → `PUT /api/gov/staff/:id/position`;「在途工作」→ `GET /api/gov/staff/:id/in-flight`。
- **角色 → 端**:`GET /api/gov/access/roles` 出 `roles`(已配置的行)与 `knownRoles`(名册上见过的 ∪ 已配置的);
每行三个勾(设计中心 / 运营中心 / 工作台)→ `PUT /api/gov/access/roles/:role` body `{ ends }`;
删行 → `DELETE`;409 时把 message 摆出来(「改完没人能进运营中心」)。
没在表里的 `knownRoles` 项要能一键「加进来」(等于 PUT 一行)。
- **工作组**:`GET|PUT /api/gov/work-groups` 不变。
## 8. 本地开发
- 后端启动必须配 `KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID`(缺一拒绝启动,会打印缺哪个);
可选 `KEYCLOAK_PROVISIONING_CLIENT_ID/_SECRET`(同步名册)、`JCAI_TEST_ACCOUNTS`(测试账号用户名)。
- Vite 的 `/api` 反代不变;浏览器直连 `keycloak.akhr-ai.com` 取 token,与后端无关(跨域由 Keycloak 的 Web Origins 管,手册有写)。
- 想在没前端的情况下打接口:用服务账号拿 token(见 API 文档 §8 的 curl),或让管理员临时给 `web-portal` 开 Direct Access Grants 用密码取一枚(手册 §6.1,用完关掉)。
- 试跑 / e2e 用的「AI 账号」现在是 Keycloak 里的服务账号(`bpos-e2e`),工作台测试时用它的 token。
## 9. 接入完成的判据
- [ ] 未登录打开任一页 → 跳统一登录;登录回来 `GET /api/me` 200,首页按 `ends` 落到能进的端
- [ ] `ends = []` 的账号看到「未授权」页,不是登录页,也不是白屏
- [ ] 三个入口的亮灯与 `ends` 一致;深链到没权限的端会被挡回
- [ ] token 过期(等 5 分钟)后操作不报错:刷新后重试成功
- [ ] SSE 连上、断线 61 秒后能自动重连(新票据)
- [ ] 「下载原件」拿到的是真字节(`.docx` 能被 Word 打开)
- [ ] 运营中心 · 员工:同步名册 / 指派岗位 / 角色 → 端三样能用;409 与 `configured:false` 的话原样可见
- [ ] 登出后再访问 → 401 → 登录页;旧 token 不再被任何地方发出
- [ ] `apps/web` 里不再 import `loginRoute` / `logoutRoute` / `sessionRoute` / `Session` / `grantSchema` / `SURFACE_FNS`