This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/API文档-鉴权与人员.md
xcsandClaude Fable 5 0923fbfe4d feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params /
summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts`
(与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema);
迁移 0035_activity_ops;《API文档-活动模块》按契约回写。
设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*`
整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。
2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:15 +08:00

443 lines
22 KiB
Markdown

# 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 —— **本部署是 `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` 与 `<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, "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": "<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/activity-packages`(活动库:包版本 / 资产版本;2026-08-27 从 `/api/gov/activities` 改名让位) |
| `design` | POST | `/api/gov/activity-packages`(装入活动包;同上改名) |
| `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` |
| `ops` | GET | `/api/gov/activities`(运营中心 · 活动模块 L1;见《API文档-活动模块》) |
| `ops` | GET | `/api/gov/activities/:activityId` |
| `ops` | GET | `/api/gov/activities/:activityId/view`(text/html,可用票据) |
| `ops` | GET | `/api/gov/activities/:activityId/instances` |
| `ops` | GET | `/api/gov/activities/:activityId/launch-params` |
| `ops` | POST | `/api/gov/activities/:activityId/instances`(启动 = 创建实例,幂等键) |
| `ops` | GET | `/api/gov/activities/:activityId/instances/:instanceId` |
| `ops` | GET | `/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index`(可用票据;`?raw=1` 直出字节) |
| `ops` | POST | `/api/gov/activities/:activityId/disable` |
| `ops` | POST | `/api/gov/activities/:activityId/activate` |
| `ops` | POST | `/api/gov/activities/:activityId/instances/:instanceId/terminate` |
| `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<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`,且 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 <token>" }
```
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)。