docs: 开发环境册与产品需求 stub(上游仓库结构对表)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lovefyf
2026-08-14 04:34:31 +08:00
co-authored by Claude Fable 5
parent 5ebef45b1d
commit 7bb0589ce4
2 changed files with 278 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
# BPOS-Service-Gateway 产品需求(能力面 stub)
> **本文是能力面 stub,字段级契约以协议册与 `src/contracts` 为准。**
>
> 上游《BPOS仓库结构》约定"各服务的产品需求文档随各自服务仓 `docs/` 维护",本文是本仓那一份。
---
## 1. 一句话定位
> **API 网关**:会话鉴权 · 按域路由(task/data/agent)· SSE 推送中心 · 上传转对象存储。
> 前端唯一入口,8800,**无自有库**。
(照录《BPOS仓库结构》`docs/proto-v13` 分支 §1 表格中本仓那一行;该行标注
【提案 · 待架构负责人确认】——网关落仓在 §3"待定项"里尚未定稿。)
---
## 2. 能力面清单
每一项都对得上仓内**已存在**的路由或模块。
### 2.1 会话鉴权
- `requireAuth` / `requireRealmRole` / `requireService`,JWKS 远端验签(与 Task/Data 同一份实现,同一性文件)。
- 鉴权失败分类**只此一份**:token 坏 → 401;**JWKS 拉不动 → 503**(不是 401)。
SSE 是长连,客户端拿到 401 会清 token 去重登录,而重登录打的还是同一个 Keycloak
——把"鉴权服务暂不可达"报成 401 会制造一场无解的重登录风暴。
- `GET /health` **公开**(不过 `requireAuth`),汇总 `nats` 依赖状态;无自有库故无 `db` 探活。
### 2.2 按域路由(代理)
`/api/task/*` → `TASK_SERVICE_URL`、`/api/data/*` → `DATA_SERVICE_URL`、
`/api/agent/*` → `AGENT_SERVICE_URL`。全方法透传(`r.all`)。
**挂载顺序 load-bearing**:`/api/stream` 与 `/api/upload` 必须先于 `/api` 代理挂载,
否则被代理的 catch-all 吞掉。
三条安全约束(每条都是踩过的):
| 约束 | 做法 | 不这么做的后果 |
|---|---|---|
| **主机不可位移** | 在 `new URL(target)` 上**赋 `pathname`**,绝不 `new URL(路径, target)` | 剥前缀后若余下 `//evil.example.com/x` 这种协议相对路径,URL 解析只保留 scheme 而把 host 整个换掉——调用方的 Authorization 与请求体发去攻击者主机,响应还原样回传(SSRF + 令牌外泄) |
| **内部头剥离** | 转发前删掉 `x-user-id`、`x-user-roles`、`x-internal-token`、`host`、`content-length` | 外部调用方自带 `x-user-id` 就能冒充任意身份——下游若信这类头即越权 |
| **响应头剥离** | 回传前删掉 `content-encoding`、`content-length`、`transfer-encoding` | undici 已按 `content-encoding` 解过压、body 是明文,原样回传就是自相矛盾的响应(客户端二次解压失败或按错长度截断) |
**Authorization 原样带上**(下游自己再验一遍——纵深防御)。
下游连不上 → 统一形状的 **502** 并点名是哪个下游。
**traceparent**:调用方没带就在这里补发一条(网关是外部请求的第一跳),已有的**绝不覆盖**
(那会把上游已开好的链从中间截断)。
- 落点:`src/modules/proxy/`。
### 2.3 SSE 推送中心
`GET /api/stream`(建流)+ `POST /api/stream/subscribe`(改订阅集)。
**token 取法**:EventSource 带不了 `Authorization` 头,故 `?access_token=` 与头两收
(降级轮询共用同一条口)。
**帧序与编号**:
1. `hello`(带 `connectionId`)**必须先于回放**——客户端拿到 id 之前收到的事件无从对账
(它还不知道该拿哪个 id 去 `/subscribe`);
2. 每条推送帧带 **`id: <seq>`**,`seq` 是 hub 内单调递增的全局序号;
3. `event:` 是主题名,`data:` 是原始载荷。
**Last-Event-ID 回放**:客户端重连带 `Last-Event-ID` 头(只认纯数字),
hub 在 **512 条**的环形窗口里补发 `seq > lastEventId` 且**本连接可见**的事件。
> **诚实的局限**:EventSource 重连不携带订阅状态,新连接的 `topics` 是空集,
> 所以实际只补得到 `touch.system` 与 `touch.staff.<sub>`。任务域的补发要求客户端收到 `hello`
> 后先重新 `/subscribe`——而那时这一跳已经过去。真正的任务域断点续传要把订阅集做成
> 服务端持久态(按 `staffSub` 记住上次订阅),**尚未做**。
**心跳**:每 **25s** 发一帧 `event: ping`,`data: {}`。
流的寿命挂在一个可被两端 resolve 的 promise 上:客户端 abort、服务端 shutdown 都能收流
(否则心跳定时器与整个闭包会一直漏着不回收)。
**可订阅主题与授权**(默认拒;可订阅集 = **有授权规则**的集合):
| 主题形状 | 授权判据 |
|---|---|
| `touch.task.<taskId>` | 问任务服务 `GET /task/:id/access?staff=<sub>`(负责人/审批人/admin) |
| `stream.task.<taskId>` | 同上;额外**按需**建 NATS 订阅,最后一个订阅者退订即撤 |
| `touch.workflow.<workflowId>` | 问任务服务 `GET /workflow/:id/access?staff=<sub>`——判据是该实例**定义里**的节点归属(参与人 `ownerSub` / 审批人 `approverSub`),与单张单的归属不是一回事,故是**另一口**而非复用 |
| `touch.system` | 人人可见(无需订阅) |
| `touch.staff.<sub>` | 只对本人可见(无需订阅) |
`admin` 旁路两条 access 询问(既是规则,也免掉白跑的跨服务往返)。
两次询问都走**服务账号 token**(不是转发用户 token),3s 超时,**非 200 一律当无权**
——推送面失败关闭:宁可少订一条,不可把别人的任务事件扇给不该看的人;但非 200 会打日志,
否则"人人被拒"无从诊断。
`/subscribe` 的三条守则:
1. **形状预检先于任何变更**:`add` 里有一个不可订阅的主题就整单 422、零副作用
(边订边查会把合法的那条真订上、还白跑一次跨服务往返,客户端以为全失败而服务端留了半截订阅);
2. **`add`/`remove` 必须是数组**,不是就 422(类型断言不是校验:字符串走到 `.find` 是 TypeError→500,
请求写错却报成服务端故障,而 500 在网关这层还会被当成可重试);
3. **只能操作自己名下的连接**:`hub.subscribe` 拿的是**连接的** `staffSub` 授权,
不校验归属的话任何登录用户只要知道别人的 `connectionId` 就能改人家的订阅集,
还能拿 `results` 当权限探测器。`admin` 例外。
运行时状态(连接不存在/被拒)逐条落 `results` 回 200,客户端据此决定重连还是申请权限。
- 落点:`src/modules/sse/`(`hub.ts`、`routes.ts`、`task-access.ts`、`workflow-access.ts`)。
### 2.4 上传转对象存储
- `POST /api/upload`(`user`/`admin`/`service`):**8MB** 上限,
`bodyLimit` 先看 `content-length`、没有就边读边计数、一超限立刻断流
——413 必须在收完 body 之前定,1GB 的 POST 不会先整个躺进内存再挨拒。
返回 `{ ref }`(201)。`x-filename` 可选;记 `ownerSub`(只落盘,读侧鉴权尚未做)。
- `GET /api/upload/:ref`:回原始字节,**一律 `Content-Disposition: attachment`**(不内联)
+ `X-Content-Type-Options: nosniff`——`content-type` 是上传方自报的,原样回显+内联渲染
就是网关源上的存储型 XSS。文件名走 **RFC 5987** `filename*=UTF-8''…`
(`filename="…"` 只认 ASCII,中文名塞 percent-escape 会原样显示成 `%E4%B8%AD`)。
- 存储是 **FS 适配器**(`OBJECT_STORE_DIR`,缺省 `./.objectstore`);
`objectstore.ts` / `fs-store.ts` 与 Task 仓 `channel/` 下那两份是同一性文件(逐字节相同)。
- 落点:`src/modules/upload/`。
---
## 3. 明确不做(边界)
- **不持久化**:无自有库、无 `db:migrate`、无 outbox、无 relay、无 consumer、无 durable。
- **不做业务判断**:可见性一律**问任务服务**,网关不自己算归属。
- **不建流、不发布业务事件**:只 core NATS 订阅 `touch.>` 与按需的 `stream.task.*`。
- **上传读侧的归属鉴权**:`GET /api/upload/:ref` 现在只做验签+realm 角色校验
(`user`/`admin`/`service`),**不校验对象归属**——拿到 `ref` 的任何登录用户都读得到。
`ownerSub` 已随上传落盘,归属规则待后续。
- **SSE 任务域断点续传**:见 §2.3 的局限说明,待订阅集服务端持久化。
- **不做本地设施兜底**:见《开发环境册》§2.1。
---
## 4. 依据链
| 依据 | 位置 | 用来定什么 |
|---|---|---|
| 《系统架构设计.html》v2 §07「两端与实时推送」 | `BPOS-Document-Share/系统架构设计.html` | 网关的会话鉴权、按域路由、SSE 扇出 |
| 《系统架构设计.html》v2 §06「消息总线:四类消息,五项保障」 | 同上 | 总线的能力要求(BUS-*)——本仓只涉及 BUS-08 尽力而为推送 |
| 《系统架构设计.html》v2 §10「持久层与文档体系」 | 同上 | 文档与契约的家:谁维护什么、放在哪 |
| 《BPOS服务消息协议》SVC 分节 | `BPOS-Document-Share/BPOS服务消息协议.md` | `/task/:id/access`、`/workflow/:id/access` 等服务间同步接口 |
| 《BPOS仓库结构》§1 / §3 | `BPOS-Document-Share/BPOS仓库结构.md`(`docs/proto-v13` 分支) | 本仓定位一行(提案待确认)与"网关落仓"待定项 |
| 本仓 `src/contracts/envelope.ts` | 本仓 | 信封结构(同一性文件,真源在 BPOS-Document-Share) |
仓内既有记录:`docs/keycloak-setup-log.md`。
验收演练的一手记录在姊妹仓:`BPOS-Service-Task/tools/n2-acceptance.md`、`n3-acceptance.md`、
`BPOS-Service-Data/tools/n1-acceptance.md`。