Archived
文档:前端对接说明与 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:
@@ -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 会用真实响应替换。
|
||||
Reference in New Issue
Block a user