文档:前端对接说明与 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
@@ -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`