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`。
+127
View File
@@ -0,0 +1,127 @@
# BPOS-Service-Gateway 开发环境册
> 简版。与姊妹仓《BPOS-Service-Task/docs/开发环境册.md》共享设施与纪律段落,本册只写本仓差异与要点。
>
> **凭证纪律**:本册只写环境变量的**键名**,绝不落主机、IP、账号、口令、密钥值。
> 真值去共享设施的凭证发放渠道要,写进本机 `.env`(该文件永不入 git)。
---
## 1. 本仓是什么
API 网关(`bpos-service-gateway`):前端唯一入口。
会话鉴权 · 按域路由(task/data/agent)· SSE 推送中心 · 上传转对象存储。
**8800,无自有库**——不持久化,故无 `db:migrate`、无 outbox、无 relay、无 consumer。
NATS 只用连通性(订阅 `touch.>` 与按需的 `stream.task.*`)。
> 落仓状态:上游《BPOS仓库结构》原六仓拓扑未含网关,§3"待定项"记为待定;
> `docs/proto-v13` 分支上已有【提案】"已建独立仓 BPOS-Service-Gateway",**待架构负责人确认**。
---
## 2. 共享设施与配置
### 2.1 一条硬性纪律:不许自建本地设施
**严禁在本机启动 Docker / 容器 / 任何本地替代基础设施。** NATS 在共享开发机上,
Keycloak 是共享站点。设施不可达时如实降级或停下,**不起本地兜底**:
NATS 不可达 → SSE 推送停摆、`/health` 照实报 fail,但代理/上传/健康检查照常服务
(`src/app/server.ts` 明确不因此拖垮 HTTP 面)。交付如实标注 `SKIPPED-INFRA-DOWN` 或报 BLOCKED。
### 2.2 环境变量(只写键名)
`.env.example` 是权威清单。
| 键名 | 作用 | 缺省与后果 |
|---|---|---|
| `PORT` | HTTP 监听端口 | 缺省 8800 |
| `NATS_URL` | 共享 NATS | `src/shared/config.ts` 有开发期缺省,部署一律显式配 |
| `KEYCLOAK_URL` / `KEYCLOAK_REALM` | 验签用的站点与 realm | 用户 token 与服务 token 同一 realm |
| `KEYCLOAK_CLIENT_ID` | 本仓 client,缺省 `api-gateway` | |
| `KEYCLOAK_CLIENT_SECRET` | **须为 `api-gateway` 服务账号的密钥** | 只从环境读、无缺省。配错/配成别的 client 的密钥=换来别人的服务账号 token(或直接被拒),SSE 的订阅授权全线失败并"人人被拒"。**值绝不写进任何文档或提交**,从 Keycloak 该 client 的 Credentials 页取 |
| `TASK_SERVICE_URL` | 代理与订阅授权的目标 | 缺省本机 8801 |
| `DATA_SERVICE_URL` | 代理目标 | 缺省本机 8802 |
| `AGENT_SERVICE_URL` | 代理目标 | 缺省本机 8901(开发期即 Task 仓的 fake-agent 桩) |
| `OBJECT_STORE_MODE` / `OBJECT_STORE_DIR` | 上传落盘(FS 适配器) | 缺省目录 `./.objectstore`,已在 `.gitignore` |
**为什么这里必须是 `api-gateway` 自己的密钥**:SSE 订阅授权要拿服务账号 token 去问任务服务
`GET /task/:id/access` 与 `GET /workflow/:id/access`,后者在任务侧是
`requireService(['api-gateway'])`——只认网关。`src/shared/service-token.ts` 用
`config.keycloak.clientId` + `KEYCLOAK_CLIENT_SECRET` 换 `client_credentials` token,
两者必须是**同一个 client** 的。相关的 realm 角色与 audience mapper 配置记录见
`docs/keycloak-setup-log.md`(该文只记对象名与操作结果,同样不含密钥值)。
`.env` / `.env.*` 已在 `.gitignore`(`!.env.example` 例外)。收尾核验里
`git log --all --oneline --diff-filter=A -- .env | wc -l` 必须为 `0`。
---
## 3. 端口与运行
```bash
pnpm i # 装依赖
pnpm dev # tsx watch,读 .env,起在 :8800
pnpm gates # typecheck + lint + test
```
**无 `db:migrate`**(本仓无自有库)。
启动顺序:建 SseHub → 建 app → 起 HTTP → 连 NATS(通则 `hub.attachNats`,不通只告警)。
`SIGINT`/`SIGTERM`:**先收 SSE 长连**再 `server.close()`——那些响应永远不结束,
不主动关就会一直等;3s 兜底强退。
跨仓端口速查:Gateway 8800(无库)、Task 8801(`jx_dispatch`)、Data 8802(`jx_business`)、
fake-agent 桩 8901(在 Task 仓 `tools/fake-agent.ts`,Activity 仓交付真桩后退役)。
---
## 4. 总线用法:只订阅,不建流
本仓**不建流、不发布业务事件、无 outbox、无 relay、无 durable**。
只用 core NATS 订阅两类推送主题(BUS-08 尽力而为,本就不落 JetStream):
- `touch.>`:连上就订,进程内按连接可见性扇出;
- `stream.task.<id>`:**按需**订——有连接订阅了才建 NATS 订阅,最后一个连接退订就撤掉。
三条 JetStream 流(`BPOS_EVT` / `BPOS_CMD` / `BPOS_DLQ`)由 Task / Data 两仓引导,与本仓无关。
---
## 5. 测试纪律
本仓无库,共享库那套 `zz_test_` 前缀 / `afterAll` 谓词清理 / LIKE 下划线转义 / 生产扫描排除
的纪律**在本仓无直接落点**,但两条相关的仍适用:
1. **集成用例打的是共享 Keycloak 与共享 NATS**,不许起本地替身;不可达就如实跳过或失败。
2. **`test/support/token.ts` 是三仓同一性文件**(逐字节相同)——改它要同步三仓并重跑同一性脚本。
3. 用例造出来的 taskId / workflowId 一律用 `zz_test_` 前缀:它们会经服务账号 token 打到
任务服务的 `/access` 端点上,落在**共用**的 `jx_dispatch` 库前面,前缀是那边扫描排除的抓手。
---
## 6. 门禁与验收
```bash
pnpm typecheck # tsc --noEmit
pnpm lint # eslint src test
pnpm vitest run # 全量
pnpm gates # 上面三条
```
跨仓同一性由 Task 仓的脚本统一跑(三仓需为同级目录):
```bash
node ../BPOS-Service-Task/scripts/check-identity.mjs # 期望 16/16
```
本仓参与其中 11 条:`keycloak-auth.ts`、`bus/nats.ts`、`contracts/envelope.ts`、`app/health.ts`、
`test/support/token.ts`、`vitest.config.ts`(三仓)+ `shared/errors.ts`、`shared/service-token.ts`、
`shared/trace.ts`(与 Task 两仓)+ `upload/objectstore.ts`、`upload/fs-store.ts`
(与 Task 的 `channel/objectstore.ts`、`channel/fs-store.ts` 同内容、不同路径)。
**改这些文件必须同步复制到相关仓,再重跑脚本。**
仓内既有记录:`docs/keycloak-setup-log.md`(realm 角色 `service`、两个服务账号的 audience mapper、
共享 client 影响面、解码真 token 的验证结果)。
验收演练的一手记录在姊妹仓:`BPOS-Service-Task/tools/n2-acceptance.md`(touch 经 SSE 到浏览器)、
`n3-acceptance.md`(编排全链)、`BPOS-Service-Data/tools/n1-acceptance.md`(含网关这条腿的 aud 边界)。