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
T
xcs 253c6c3afa 文档:补 412 precondition-failed 状态码说明,修正 Keycloak 前置编号
评审逐个核六条会返 412 的路由(流程包写回/启用、活动包安装两审、决议登记、
汇报提交、入站通道),发现 §0「状态码怎么读」表漏了 412 —— 这六条路由全在本文
自己的 §6 总表里,漏了会让照文档接的人撞上一个查不到含义的状态码。补一行说清
412 不是权限/参数问题,是「此刻办不了」。
另修一处编号错误:示例来源注明里把 e2e 服务账号 client(bpos-e2e)前置写成了
「前置 1」,按 spec 编号应为「前置 4」(前置 1 是本服务 client api-gateway)——
前端对接说明那份原本就写对,这次只改 API 文档这一处。
2026-08-25 19:01:27 +08:00

17 KiB

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)
412 precondition-failed 入参合法、权限也够,但这件事此刻办不了:流程包写回生效版本 / 启用时闭合校验没过、活动包安装两审(静态审计 + 回放)未过、决议已登记过不重复起单、汇报已提交过、入站邮件通道已停用。detail 常带闭合报告或安装审计结论
400 validation-failed 入参不合 schema,detail 是 zod issues

1. 身份

GET /api/me —— access: any

我是谁、能进哪几个端。ends 为空也 200(前端据此摆「未授权」页)。

出参 Principal(§7):

{
  "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 头的路。

{ "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

{
  "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 服务账号,只读)。无请求体。

{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14 }

没配 provisioning 时如实回(不是一排 0):

{ "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 做。

{
  "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

{
  "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 同名)

// 三个端
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):

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>" }

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 序列换成真实响应。