文档:两份前端文档按真环境核对 —— clientId 实测为 task-dispatcher,示例换成实测响应

设计阶段写的两份对外文档,Task 12 只按契约核过,没按真环境核过。这次跑通之后逐条对:
① clientId 写的是「建议 api-gateway」——实际这个 realm 里 api-gateway 的 mapper 对我们不适用,
   本部署是 task-dispatcher(两个前端 client 都已挂好指向它的 audience mapper,实测确认);
② e2e 账号写的是 bpos-e2e,实际用的是 task-dispatcher 的服务账号,且它只有 service+user
   两个角色 → 只进得了工作台,要跑测试台得另配;
③ 「ends 为空」原本像个假想边界,实际 service 角色就是——没配进「角色→端」表的角色一律不加端;
④ §8 的响应示例从「按契约构造」换成实测响应(/api/me 的 JIT 建档结果、403 的真 message、
   aud 不对时的 401、票据的真形状);
⑤ 两处「核对状态」改写:前置已就位、整条链真环境验过,并写明仍未实测的那一件——用真人 token 走一遍。
This commit is contained in:
xcs
2026-08-26 10:55:58 +08:00
parent f70de44338
commit 0f1e53d114
2 changed files with 43 additions and 13 deletions
+30 -8
View File
@@ -9,7 +9,7 @@
| 项 | 值 |
|---|---|
| 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`) |
| 认证 | `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` |
@@ -367,24 +367,41 @@ type ApiError = { code: 'unauthenticated' | 'forbidden' | 'not-found' | 'validat
## 8. curl 实录
用服务账号拿 token(自动化的正道,手册 §10;该 client 需 `serviceAccountsEnabled`、realm 角色 `admin`、且 audience mapper 指向本服务 clientId):
用服务账号拿 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=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_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(拿一个只有 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"] } }
# 端不够 → 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
@@ -406,4 +423,9 @@ curl -s -X POST http://localhost:8787/api/login | jq .
```
Task 12 已用契约(`packages/contracts/src/api/*.ts` + `org.ts`)核过本文:§6 总表逐行比对生成表、§7 字段名逐个核对 schema —— 全部一致,零改动。
示例里的 UUID / 岗位 id / 时间戳仍按契约构造 —— 真实响应需要 Keycloak 侧前置 4(e2e / 试跑服务账号 client `bpos-e2e`:`serviceAccountsEnabled`、realm 角色 `admin`、挂本服务 audience mapper)先就位,这是管理员的活,本轮未就位;前置就位后用 §8 给的 curl 序列换成真实响应。
**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)。
+13 -5
View File
@@ -10,10 +10,13 @@
- 后端**不做登录**。前端(公司已在 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`);
- token 的 `aud` 必须含本后端的 clientId —— **本部署是 `task-dispatcher`**(后端环境变量 `KEYCLOAK_CLIENT_ID`);
`web-portal` 与 `web-console` 都已挂好指向它的 audience mapper(2026-08-26 实测确认),前端不用为此做任何事;
后端**不看 `azp`**,用哪个前端 client 取的 token 都行 —— 没有 aud 会全线 401,后端的 401 message 会点名让管理员加 audience mapper。
- 每个请求头带 `Authorization: Bearer …`。`GET /api/me` 回答两件事:我是谁、我能进哪几个端(`ends`)。
- **权限只到端**:`design`(设计中心 `/design`)/ `ops`(运营中心 `/ops`)/ `workbench`(工作台 `/`)。
当前 realm 里的角色不止 `admin` / `user` 两种(服务账号还持有 `service`),而**没配进「角色 → 端」表的角色一律不加端** ——
所以 `ends: []` 不是假想的边界,是真会遇到的(§3 那一段说了怎么呈现)。
入口亮不亮、页面能不能进,只看 `ends`;进去之后哪个接口能调,后端按每条路由的归属自己判(§6 总表)。
- 两条带不了 Authorization 头的路(`EventSource` 与 `<a href download>`)用 **60 秒票据**:
`GET /api/auth/ticket` → 把 `ticket` 放到那条 GET 的 `?ticket=` 上。
@@ -24,7 +27,7 @@
```
浏览器(SPA,PKCE)──登录/刷新 token──▶ https://keycloak.akhr-ai.com/realms/corp
│
│ Authorization: Bearer <access_token>(aud ∋ api-gateway)
│ Authorization: Bearer <access_token>(aud ∋ task-dispatcher)
▼
apps/api 鉴权中间件(唯一一处)
① 路由归属:契约里每条路由自带 access(public / any / 端)
@@ -183,7 +186,9 @@ async function openStream(onTouch: (t: Touch) => void, onOpen: () => void): Prom
可选 `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。
- 试跑 / e2e 用的是 **`task-dispatcher` 的服务账号**(`service-account-task-dispatcher`)。
注意它当前只有 `service` + `user` 两个 realm 角色 → 只进得了工作台;要跑测试台(设计端)得在运营中心
给 `service` 配上 design,或给该账号加 `admin`。
## 9. 接入完成的判据
@@ -201,5 +206,8 @@ async function openStream(onTouch: (t: Touch) => void, onOpen: () => void): Prom
Task 12 已用契约把本文核过一遍:§2 的十个文件指针全部存在(`apps/web` 本轮未动,指针原样有效);
§7 的退役路由与替代逐条对过 `docs/API文档-鉴权与人员.md` §6 的总表,一致。
本文里出现的响应示例(`GET /api/me`、票据、403 detail)与 API 文档同源,同样按契约构造 ——
换真实响应要 Keycloak 侧前置 1 与 4(服务账号 client、audience mapper)先就位,本轮未就位,留给前置就位后去 API 文档 §8 换。
**2026-08-26 补:整条链已在真环境跑通并实测。** Keycloak 侧前置已就位(`task-dispatcher` 及两个前端 client
的 audience mapper 齐备),后端以 `KEYCLOAK_CLIENT_ID=task-dispatcher` 启动、自检通过。实测验过的行为:
验签 → claims 归一 → 首次登录 JIT 建档(落 `NONE / 未分配` 岗)→ 角色→端 → 端判定;工作台 200、运营中心 403(带 `detail`);
票据签发后 `?ticket=` 真连上 SSE、同一张票据用在 POST 上被拒;`HEAD` 与 `GET` 判定一致。
本文与 API 文档 §8 的响应示例现在都是**实测响应**,不再是按契约构造的样例。