From 5fee115f103a8a17d429985a8769b7e224550686 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:24:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Pingora=20=E5=8F=91?= =?UTF-8?q?=E8=A1=8C=E7=BD=91=E5=85=B3=E8=B7=AF=E7=94=B1=E5=B9=B6=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E4=B8=A4=E5=A4=84=E8=B7=AF=E7=94=B1=E6=BC=82=E7=A7=BB?= =?UTF-8?q?=E5=BE=85=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RouteDecision::ReleaseGateway:/games/game_<32 位小写十六进制 id>/… 重写上游路径到 /api/game-distribution/releases/、清空 Cookie、不进 SPA fallback、不套接流保护分组、不受维护闸拦截,与 nginx 同名 location 同口径(只认小写 32 位十六进制) - 路由矩阵新增 games_release_gateway 用例(含 upstreamPath 期望值)与对应断言;parity 门禁支持 release_gateway 并把 games_spa_fallback、games_release_gateway 列入必查清单 - check:pingora-gateway-smoke 新增三条运行时断言:重写到发行网关且上游拿不到 Cookie、/games/detail 仍走 SPA、/games/game/index.html 仍是真实 404 - 文档:Pingora 试点文档补「平台同源发行入口」语义;pitfalls 记录 SPA allowlist 三处真相源与「发行入口不是 SPA」;按 docs/project-memory/README.md 删除两份已关闭待办,关闭记录写入开放事项索引第五节 - 验证:网关 39 passed、check:pingora-route-parity OK(23 路由)、check:nginx-spa-routes OK(12 路由 / 3 模板)、check:pingora-gateway-smoke 通过、cargo fmt --all --check、check:production-ops、encoding / doc-index / diff 检查全绿;变异验证:矩阵 upstreamPath 写错或拿掉 /games/detail 都会立刻变红 --- deploy/pingora/nginx-route-parity.matrix.json | 21 ++++ docs/project-memory/shared-memory/pitfalls.md | 7 ++ ...…办】Pingora网关缺发行网关路由-2026-09-29.md | 28 ----- ...站SPA路由白名单与前端路由源不一致-2026-09-24.md | 54 --------- .../【待办】计划收口剩余项索引-2026-09-24.md | 5 +- ...开发运维】Pingora独立网关试点-2026-06-11.md | 2 + scripts/check-pingora-gateway-smoke.mjs | 49 ++++++++ scripts/check-pingora-route-parity.mjs | 12 ++ server-rs/crates/pingora-gateway/src/main.rs | 108 ++++++++++++++++++ 9 files changed, 202 insertions(+), 84 deletions(-) delete mode 100644 docs/project-memory/todos/【待办】Pingora网关缺发行网关路由-2026-09-29.md delete mode 100644 docs/project-memory/todos/【待办】主站SPA路由白名单与前端路由源不一致-2026-09-24.md diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index be62874e2..35769edce 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -317,6 +317,27 @@ }, "docs": ["主站 SPA allowlist", "游戏目录 / 详情 / 游玩 / 我的 / 发布深链"] }, + { + "id": "games_release_gateway", + "samplePath": "/games/game_0123456789abcdef0123456789abcdef/index.html", + "expect": { + "kind": "release_gateway", + "upstreamPath": "/api/game-distribution/releases/game_0123456789abcdef0123456789abcdef/index.html" + }, + "nginx": { + "production": [ + "location ~ \"^/games/(?game_[0-9a-f]{32})(?/.*)?$\"", + "proxy_set_header Cookie \"\";", + "proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path;" + ], + "development": [ + "location ~ \"^/games/(?game_[0-9a-f]{32})(?/.*)?$\"", + "proxy_set_header Cookie \"\";", + "proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path;" + ] + }, + "docs": ["/games/game_<32 位十六进制 id>/…", "平台同源发行入口"] + }, { "id": "web_spa_case_trailing_slash", "samplePath": "/PROJECT/", diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 86995991e..fc8e2da7b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -17,6 +17,13 @@ - **不要踩的坑**:`ss -t` 在没有状态过滤时不显示 LISTEN,连接被瞬拒时也抓不到 TCP 连接,不能用它判断 agent 是否在线;要看 `journalctl -u jenkins-agent@`、`ss -tlnp` 的端口和 Jenkins 工作区归属。停 JNLP 单元前先确认控制器 `slaveAgentPort=-1` 且 SSH launcher 仍在跑,避免把唯一通道停掉。 - **关联**:`/etc/systemd/system/jenkins-agent@.service`、`/etc/jenkins-agent/*.env`、`scripts/deploy/install-jenkins-inbound-agent.sh`。 +## 2026-09-29 主站 SPA allowlist 有三处真相源,只改一处就会让深链 404(含 Pingora) + +- **事实**:主站 SPA 路由要三处同批更新才自洽——① 前端路由源 `src/routing/activeAppPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `src/routing/activeAppRoutes.tsx`;② Nginx 三份模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)里 `# BEGIN GENARRATIVE MAIN SPA ROUTES` 的精确 allowlist;③ Pingora 网关 `server-rs/crates/pingora-gateway/src/main.rs` 的 `MAIN_SPA_PATHS`(精确匹配、大小写不敏感、允许一个尾部斜杠)。 +- **代价**:2026-08-26 加 `/components`、`/design-system` 时只加了前端路由,两个门禁红了一个月(生产深链会 404 而不是 `index.html`);2026-09-28(`87e52860a`「游戏发行入口改为平台同源路径」)补了 nginx 侧的 5 条 `/games*`,却漏了 Pingora 侧,`check:pingora-route-parity` 继续红到 2026-09-29 才补齐(`MAIN_SPA_PATHS` 5→12 条)。 +- **判据/入口**:`npm run check:nginx-spa-routes`(12 条路由 × 3 份模板;断言未知路径只读真实静态文件并 404、大小写与一个尾部斜杠容忍)与 `npm run check:pingora-route-parity`(同一套路由 + `MAIN_SPA_PATHS` 逐条相等)。后者还读 `deploy/pingora/nginx-route-parity.matrix.json`:新增路由要同时补矩阵用例,矩阵里的 `docs` 片段会去 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 里找,文档没写同样判红。 +- **别踩**:`/games/game_<32 位小写十六进制 id>/…` **不是** SPA 深链,而是发行网关路由(Nginx 代理到 `/api/game-distribution/releases/` 并 `proxy_set_header Cookie ""`),Pingora 侧对应 `RouteDecision::ReleaseGateway`(重写上游路径、清空 Cookie、不进 SPA fallback、不套 `limit_conn`/`limit_req`、不受维护闸拦截)。把它写进 `MAIN_SPA_PATHS`,或让 SPA 正则吞掉它,都会破坏在线游玩入口。`check:pingora-gateway-smoke` 已覆盖「重写到发行网关 + 清空 Cookie + 形状不符仍 404」;本机跑它要先设 `OPENSSL_CONF`(见本文件另一条),且改过网关源码后**不能**加 `--skip-build`(会拿旧二进制得到假 404)。 + ## AGC 版本探测必须显式提供隔离用户目录 清空子进程环境后缺少 `USERPROFILE/HOME` 会让 npm 依赖 Windows 后备用户查询,部分机器报 `uv_os_homedir` / `ENOMEM`。正常机器探测成功不能证明环境完整,需同时验证子进程环境。版本探测使用临时用户目录和空 npm 配置,详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“Web 环境版本探测的用户目录隔离”。 diff --git a/docs/project-memory/todos/【待办】Pingora网关缺发行网关路由-2026-09-29.md b/docs/project-memory/todos/【待办】Pingora网关缺发行网关路由-2026-09-29.md deleted file mode 100644 index e88ee3945..000000000 --- a/docs/project-memory/todos/【待办】Pingora网关缺发行网关路由-2026-09-29.md +++ /dev/null @@ -1,28 +0,0 @@ -# 【待办】Pingora 网关缺"发行网关"路由(Nginx 已有) - -更新时间:`2026-09-29` - -状态:开放。这是修「主站 SPA allowlist 漂移」时**顺手查出来的另一处漂移**:三份 Nginx 模板都把 `/games/game_<32 位十六进制 id>/…` 映射到发行网关,Pingora 网关没有对应分支。两个路由 parity 门禁现在都是绿的(`check:nginx-spa-routes` OK 12 路由、`check:pingora-route-parity` OK 22 路由),所以这条**不是**它们在报的问题,而是路由对照矩阵本身没覆盖的空白。 - -## 现象(2026-09-29 只读核对) - -- Nginx:`deploy/nginx/genarrative.conf:205`、`deploy/nginx/genarrative-dev-http.conf:193`、`deploy/container/nginx.conf:150` 都有 - `location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"`, - 内部 `proxy_set_header Cookie "";` + `proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path;`。 -- Pingora:`server-rs/crates/pingora-gateway/src/main.rs` 的 `classify_path` 没有任何 `/games/...` 分支(`MAIN_SPA_PATHS` 里也**不应该**有它),于是落到末尾的 `Static { root: Web, mode: Exact }` —— 真机上就是 404。 -- 矩阵:`deploy/pingora/nginx-route-parity.matrix.json` 没有这条用例,所以 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 覆盖不到。 - -## 影响 - -Pingora 目前只监听 `127.0.0.1:18081`(shadow / direct 试点),公网入口仍是 Nginx,**当前没有用户可见故障**。但试点文档写明「切流前必须与 Nginx 逐条对齐」;一旦把公网入口切到 Pingora,**所有已公开游戏的在线游玩入口 `/games/game_/` 会 404**,而目录与详情仍然 200 —— 必须从详情页点「立即玩」才会暴露,属于很难第一时间发现的缺口。 - -## 修法建议 - -1. 网关加一个独立的"发行网关代理"决策(不要塞进 SPA allowlist):匹配 `^/games/game_[0-9a-f]{32}(?:/.*)?$`,代理到 api 上游的 `/api/game-distribution/releases/`,转发时**清空 Cookie**(与 Nginx 同口径:发行内容不读账号凭证),其余响应头(CSP / nosniff / CORP)仍由 api-server 出。 -2. 矩阵补 `games_release_gateway` 用例并把三份模板的同名片段写进 `nginx` 字段;因为这是「路径重写 + 清 Cookie」,需要给矩阵加一种表达(新增 `expect.kind`,或给 `proxy` 增加可选的重写/头字段),让 `check:pingora-route-parity` 能逐条比对。 -3. 验证:`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` + `check:pingora-gateway-smoke`(本机需先设 `OPENSSL_CONF`,见 pitfalls);再加一条反例断言 `/games/not-a-game-id/` 仍是真实 404。 - -## 关闭条件 - -- 矩阵里有该路由的用例,`npm run check:pingora-route-parity` 与 `npm run check:nginx-spa-routes` 都绿,`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言通过。 -- `check:pingora-gateway-smoke` 在真实网关进程上证明 `/games/game_<32hex>/…` 被代理到发行网关、且上游拿不到 Cookie;`/games/not-a-game-id/` 仍返回 404。 diff --git a/docs/project-memory/todos/【待办】主站SPA路由白名单与前端路由源不一致-2026-09-24.md b/docs/project-memory/todos/【待办】主站SPA路由白名单与前端路由源不一致-2026-09-24.md deleted file mode 100644 index 83d217d6e..000000000 --- a/docs/project-memory/todos/【待办】主站SPA路由白名单与前端路由源不一致-2026-09-24.md +++ /dev/null @@ -1,54 +0,0 @@ -# 【待办】主站 SPA allowlist 与前端路由源不一致 - -更新时间:`2026-09-24` - -状态:**已关闭(2026-09-29)**。两个门禁都已转绿,关闭证据见文末「关闭记录(2026-09-29)」。 - -## 现象(2026-09-24 实跑) - -- `npm run check:nginx-spa-routes` → **exit 1**:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 三份模板的 SPA allowlist 原本都缺 `/components`、`/design-system`、`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`。 -- `npm run check:pingora-route-parity` → 同样失败:它 `import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'`,而后者在模块级就执行整套校验。 -- 前端事实源:`src/routing/activeAppPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES`(含 5 条 games)与 `src/routing/activeAppRoutes.tsx` 的 `/components`、`/design-system`。 - -## 影响 - -生产模板里未列入 allowlist 的路径落到 `location /` 的 `try_files $uri $uri/ =404`,因此这些 SPA 深链在公网返回 404,而不是渲染 index.html: - -- `/components`(兼容别名 `/design-system`):共享组件展示页,技术文档明确它「不经过账号 Gate」,用于网站与客户端接入前的视觉回归和人工验收。 -- `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`:游戏分发系列的目录、详情、游玩、我的与发布页。 - -## 历史(为什么红了这么久) - -- `c5200c7d4`(2026-08-26,PR #202)新增 `/components`、`/design-system` 路由与展示页,nginx 模板没有同步,门禁从那时起就是红的。 -- `39aed2e48`(2026-09-20)曾把这两条连同 games 路由一起补进三份模板的同一行正则。 -- `429f991bd`(2026-09-20,回滚误入 master 的游戏分发 WIP)把整行改回 `creation|editor/canvas|profile|project`,两条展示页路由被连带移除。 -- decision-log 2026-09-23「游戏发行包上限提升到 200 MiB」条目已把这处失败记为「改动前同样失败,与本次口径无关」。 -- `.gitea/workflows/project-ci.yml` 没有运行 `check:nginx-spa-routes`,根 `npm run lint` 也不含它,所以这盏红灯长期只在人工执行时可见。 - -## 本轮处理(2026-09-24) - -- 三份模板的 SPA 正则加回 `components|design-system`(一行 × 3 文件),展示页深链在模板层面恢复:`location ~* "^/(?:components|creation|design-system|editor/canvas|profile|project)/?$"`。 -- 复跑 `npm run check:nginx-spa-routes`:失败清单从 7 条降到 5 条,只剩 `/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`。 -- 相关门禁未受影响:`npm run check:release-origin-config`、`npm run check:production-ops`、`npm run check:maintenance-page`、`npm run check:preview-deployer` 全部通过。 - -## 剩余修法(游戏部分) - -把 5 条 games 路由加回三条正则,并同步 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 的 SPA allowlist 表(必要时还有 `deploy/pingora/nginx-route-parity.matrix.json`),门禁才会真正转绿。该部分属游戏分发系列,按用户本轮要求不推进,随阶段 A 验收结论一并处理。 - -## 关闭条件 - -1. `npm run check:nginx-spa-routes` 与 `npm run check:pingora-route-parity` 通过,三份模板的 allowlist 集合与前端路由源逐条一致。 -2. 大小写与尾部斜杠容忍、`/creation/not-exist` 等未知路径仍返回 404 的判据不被放宽。 -3. 游戏部分补齐前,本文件保持打开,不能把「门禁仍红」当成已收口。 - -## 关闭记录(2026-09-29) - -三条关闭条件逐条复验通过: - -1. **两个门禁都绿**:`npm run check:nginx-spa-routes` → `OK (12 SPA routes, 3 Nginx templates)`;`npm run check:pingora-route-parity` → `OK (22 routes)`。nginx 侧那 5 条 `/games*` 是随 `87e52860a`(游戏发行入口改为平台同源路径)补进三份模板的;这轮把**漏掉的 Pingora 侧**补齐——`server-rs/crates/pingora-gateway/src/main.rs` 的 `MAIN_SPA_PATHS` 从 5 条补到 12 条(`/components`、`/design-system`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish` 与原有 5 条),并同步 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 的 SPA allowlist 表。 -2. **判据没有被放宽**:`check-nginx-spa-routes.mjs` 仍在断言大小写不敏感、允许一个尾部斜杠、`/creation/not-exist` 等未知路径必须只读真实静态文件并返回 404;`is_main_spa_path()` 仍是精确(大小写不敏感 + 去一个尾部斜杠)匹配,不做前缀匹配,所以 `/games/game_<32hex>/` 不会被误当成 SPA。 -3. **运行时口径**:`dev.genarrative.world` 与 `genarrative.world` 的 `/games`、`/games/detail?id=1`、`/games/play?id=1`、`/games/mine`、`/games/publish` 实测都返回 200 + SPA 外壳(`陶泥儿 Genarrative|美术编辑器与项目工作台`)。 - -验证证据:`deploy/pingora/nginx-route-parity.matrix.json` 新增 `games_spa_fallback`(`samplePath=/games/detail`,期望 `static/web/spa_fallback`),`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` → **1 passed**;**变异验证**:临时从 `MAIN_SPA_PATHS` 拿掉 `"/games/detail"` 后该用例立刻失败(`route parity static mode mismatch: games_spa_fallback /games/detail,left: Some("exact") right: Some("spa_fallback")`),`check:pingora-route-parity` 同时报出缺路由。网关全量单测 `cargo test -p pingora-gateway` → **38 passed**;`npm run check:production-ops` 通过;`cargo fmt --all -- --check` 通过。 - -**顺带发现并另立待办**:三份 Nginx 模板都有 `/games/game_<32hex>/…` → 发行网关的代理(清 Cookie),Pingora 没有对应分支,切流后会 404。见 [`【待办】Pingora网关缺发行网关路由-2026-09-29.md`](【待办】Pingora网关缺发行网关路由-2026-09-29.md)。 diff --git a/docs/project-memory/todos/【待办】计划收口剩余项索引-2026-09-24.md b/docs/project-memory/todos/【待办】计划收口剩余项索引-2026-09-24.md index c6f980b69..9ffcecece 100644 --- a/docs/project-memory/todos/【待办】计划收口剩余项索引-2026-09-24.md +++ b/docs/project-memory/todos/【待办】计划收口剩余项索引-2026-09-24.md @@ -46,11 +46,12 @@ | `【待办】引用输入区后续收口-2026-09-24.md` | CSS 类名改名一条已按现状关闭(接受不改名);剩「归一化撞名是否提示」与真机手感验收 | 产品决定 + 真机 | | `【待办】画布验收后续修复-2026-09-18.md` | 三批修复与三项画布后续需求已落地本地;动画生成失败的真实复现、多选卡顿的现场 Profiler、以及全部真机观感仍未取证 | 真实客户端 + 现场 | | `【目标编辑器适配】Unity与Unreal项目识别与导入-2026-09-11.md` | 明确「暂不实现」的新功能 | 排期决定 | -| `【待办】Pingora网关缺发行网关路由-2026-09-29.md`(新增) | 三份 Nginx 模板都有 `/games/game_<32hex>/…` → 发行网关(清 Cookie),Pingora 的 `classify_path` 没有对应分支,切流后会 404;两个路由 parity 门禁与矩阵都覆盖不到这一条。当前 Pingora 只在 `127.0.0.1:18081` shadow,无用户可见故障 | 归 Pingora 试点切流前的对齐清单 | ## 五、已收口口径(供复核) -- **已关闭(2026-09-29)**:`【待办】主站SPA路由白名单与前端路由源不一致-2026-09-24.md`。nginx 侧 5 条 `/games*` 随 `87e52860a` 补齐;这轮补上漏掉的 Pingora 侧(`MAIN_SPA_PATHS` 5→12 条 + 矩阵新增 `games_spa_fallback` 用例 + 文档表同步)。`check:nginx-spa-routes` OK(12 路由 / 3 模板)、`check:pingora-route-parity` OK(22 路由)、`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 1 passed(变异验证:拿掉 `/games/detail` 立刻变红)、网关全量 38 passed;dev 与生产上 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish` 实测 200 + SPA 外壳。该文件已在正文写明关闭记录,闭项从第四节移到本条。 +- **已关闭(2026-09-29)两处路由漂移**,按 `docs/project-memory/README.md`「todos 只留真实开放事项」的规则,两份待办文件已删除,结论与证据保留在这里和权威文档里: + 1. **主站 SPA allowlist 与前端路由源不一致**:nginx 侧 5 条 `/games*` 随 `87e52860a` 补齐;本轮补上漏掉的 Pingora 侧——`MAIN_SPA_PATHS` 5→12 条、矩阵新增 `games_spa_fallback`(`/games/detail`)用例、Pingora 试点文档表同步。`check:nginx-spa-routes` OK(12 路由 / 3 模板)、`check:pingora-route-parity` OK(23 路由)、`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 1 passed(变异验证:拿掉 `/games/detail` 或改错矩阵期望立刻变红)、网关全量 39 passed;dev 与生产上 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish` 实测 200 + SPA 外壳。 + 2. **Pingora 缺发行网关路由**:三份 nginx 模板早就把 `/games/game_<32 位十六进制 id>/…` 代理到发行网关(清 Cookie),Pingora 没有对应分支,切流后「立即玩」会 404。本轮实现 `RouteDecision::ReleaseGateway`(重写上游路径到 `/api/game-distribution/releases/`、清空 Cookie、不进 SPA fallback、不套接流保护、不受维护闸拦截),矩阵补 `games_release_gateway`(含 `upstreamPath` 期望)用例;`check:pingora-gateway-smoke` 新增三条断言(重写+清 Cookie、`/games/detail` 仍走 SPA、`/games/game/index.html` 仍真实 404)并通过。口径与三处真相源已写入 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 与 `pitfalls.md`。 - 74 份计划里 63 份为 `implemented-awaiting-runtime-acceptance`(含用括号写清边界的那批,含 `- Status:` 与表格两种写法);其余 11 份全部落在本文件一、二、三节里——这三节另外还列了 10 份状态已是 `implemented-awaiting-runtime-acceptance`、但仍有条目未勾选或未验收的计划,所以三节合计 21 份,不要与本条那 11 份混算。 - 未勾选复选框从 85 条降到 43 条(2026-09-24 复算口径:`- [ ]` 行首复选框,74 份计划合计):本轮把已有本地证据的条目逐条勾选,并在每份计划里写了本轮的勾选依据或复验记录——多数是「逐条勾选依据(2026-09-24)」章节,另有「本轮复验(2026-09-24)」「本轮复核(2026-09-24)」写法,`【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 则把自动化证据直接写在条目里(`【自动化:…】`);仍勾不动的条目都能在依据里读到具体原因。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index a5a83e367..d962aabb3 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -539,6 +539,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。 +**平台同源发行入口**(`/games/game_<32 位十六进制 id>/…`)与 SPA allowlist 是两条不同的路由:Nginx 用 `location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"` 把它代理到 api-server 的发行网关(`proxy_set_header Cookie ""` + `proxy_pass .../api/game-distribution/releases/$game_id$game_path`),Pingora 侧对应 `RouteDecision::ReleaseGateway`:走 api 上游,但把上游路径重写成 `/api/game-distribution/releases/`(与 Nginx 的 `proxy_pass` 同口径,原来的 query 不再拼接)、清空 `Cookie`,并按 Nginx 该 location 的语义既不进 SPA fallback、也不套用 `limit_conn` / `limit_req` 分组、不受维护闸拦截。只认小写、固定 32 位十六进制 id;`/games/detail` 这类 SPA 深链与 `/games/game/...` 这类形状不符的路径都不会被吞进发行网关。该口径由矩阵的 `games_release_gateway` 用例(含 `upstreamPath` 期望值)与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 固定。 + 维护模式下,公网 API-like 路由返回 JSON `503`;公网 Web 静态路由先读取 `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE` 指向的 release 外运行态公告,缺失时回退 `GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT/maintenance.html`,两者都不存在时返回纯文本 `503`。版本化默认页不得包含日期或具体时段,临时公告由 `maintenance-on.sh --page-file` 安装并在 `maintenance-off.sh` 时清理。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。 代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。 静态 `Range` 只支持单段 bytes range;多段 range 暂按完整文件返回,避免在正式替换前引入 multipart 响应面。`If-None-Match` / `If-Modified-Since` 优先于 `Range` 判定,命中时仍返回 `304`;`If-Range` 日期匹配时继续返回 `206`,日期旧于文件或弱 ETag 校验器时回完整 `200`;`206` / `304` / `416` 不做 gzip 压缩,避免 `Content-Range` 语义被响应体改写破坏。Gateway smoke 会用固定 `X-Request-Id` 对账静态 `304`、`405`、`206`、`416` 的 Pingora access log 行,确认本地响应状态也进入正式切换证据链。 diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index 44978b284..5ba25f392 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -1011,6 +1011,55 @@ async function runSmokeCases( `API 上游 X-Real-IP 未使用 TCP 对端 IP:${apiPayload.realIp}`, ); + // 平台同源发行入口:/games/game_<32 位小写十六进制 id>/… 重写到发行网关并清空 Cookie, + // 形状不符的路径不许被吞进发行网关(SPA 深链仍是 SPA,`/games/game/...` 仍是真实 404)。 + const releaseGameId = 'game_0123456789abcdef0123456789abcdef'; + const releaseUpstreamPath = `/api/game-distribution/releases/${releaseGameId}/index.html`; + const releaseBeforeCount = api.state.requests.length; + const releaseResponse = await expectHttp( + baseUrl, + `/games/${releaseGameId}/index.html`, + 200, + '"upstream":"api"', + '平台同源发行入口转发到发行网关', + { + headers: { + 'X-Request-Id': 'smoke-release-request-id', + Host: 'example.test', + Cookie: 'session=smoke-must-not-reach-release-gateway', + }, + }, + ); + const releasePayload = JSON.parse(releaseResponse.body); + ensure( + releasePayload.url === releaseUpstreamPath, + `发行入口上游路径没有重写:${releasePayload.url}`, + ); + const releaseUpstreamRequests = api.state.requests.slice(releaseBeforeCount); + ensure( + releaseUpstreamRequests.length === 1 && + releaseUpstreamRequests[0].url === releaseUpstreamPath, + `发行入口上游请求不符:${describeRequests(releaseUpstreamRequests)}`, + ); + ensure( + releaseUpstreamRequests[0]?.headers.cookie === undefined, + `发行入口没有清空 Cookie:${describeRequests(releaseUpstreamRequests)}`, + ); + await expectHttp( + baseUrl, + '/games/detail', + 200, + 'site-shell', + '发行入口形状不符时 SPA 深链照常回退', + ); + await expectHttp( + baseUrl, + '/games/game/index.html', + 404, + '', + '发行入口形状不符时仍是真实 404', + ); + await expectHttp( baseUrl, '/api/upload', diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index f08b34514..34f0c16d5 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -14,6 +14,7 @@ const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs'; const VALID_KINDS = new Set([ 'proxy', 'static', + 'release_gateway', 'redirect_permanent', 'shadow_probe', 'not_found', @@ -39,6 +40,8 @@ const REQUIRED_ROUTE_IDS = [ 'generated_assets_forbidden', 'web_spa_fallback', 'profile_spa_fallback', + 'games_spa_fallback', + 'games_release_gateway', 'web_spa_case_trailing_slash', 'web_unknown_path_exact', 'creation_unknown_path_exact', @@ -108,6 +111,15 @@ function validateExpectation(route) { fail(`${context} 非 proxy 路由不能配置 protectionClass。`); } + if (expect.kind !== 'release_gateway' && hasOwn(expect, 'upstreamPath')) { + fail(`${context} 只有 release_gateway 路由才能配置 upstreamPath。`); + } + + if (expect.kind === 'release_gateway') { + requireString(expect.upstreamPath, `${context} upstreamPath`); + return; + } + if (expect.kind === 'static') { if (!VALID_STATIC_ROOTS.has(expect.root)) { fail(`${context} static root 不支持: ${expect.root}`); diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 4172b143e..50e5bf74b 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -849,6 +849,12 @@ enum RouteDecision { target: ProxyTarget, body_limit: Option, }, + /// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>(/)?`。 + /// 与 Nginx 的同名 location 同口径:走 api 上游,但在代理阶段把路径重写成 + /// `/api/game-distribution/releases/` 并清空 Cookie。 + ReleaseGateway { + upstream_path: String, + }, Local(LocalResponse), } @@ -879,6 +885,8 @@ impl RouteDecision { fn proxy_target(&self) -> Option { match self { RouteDecision::Proxy { target, .. } => Some(*target), + // 发行入口同样代理到 api 上游,只有路径与请求头在代理阶段被重写。 + RouteDecision::ReleaseGateway { .. } => Some(ProxyTarget::Api), RouteDecision::Local(_) => None, } } @@ -886,6 +894,8 @@ impl RouteDecision { fn body_limit(&self) -> Option { match self { RouteDecision::Proxy { body_limit, .. } => *body_limit, + // 发行入口只服务静态资源读取,Nginx 侧也没有请求体上限指令。 + RouteDecision::ReleaseGateway { .. } => None, RouteDecision::Local(_) => None, } } @@ -1182,6 +1192,7 @@ impl ProxyHttp for GenarrativeGateway { match &ctx.route { RouteDecision::Proxy { .. } => Ok(false), + RouteDecision::ReleaseGateway { .. } => Ok(false), RouteDecision::Local(LocalResponse::RedirectPermanent { location }) => { respond_redirect(session, location).await?; Ok(true) @@ -1296,6 +1307,14 @@ impl ProxyHttp for GenarrativeGateway { upstream_request.insert_header("X-Forwarded-For", forwarded_for.as_str())?; } + // 中文注释:平台同源发行入口按 Nginx 的 proxy_pass 口径重写路径——换成 + // /api/game-distribution/releases/,原来的 query 不再拼接; + // 同时清空 Cookie,发行内容不读账号凭证。 + if let RouteDecision::ReleaseGateway { upstream_path } = &ctx.route { + upstream_request.set_raw_path(upstream_path.as_bytes())?; + upstream_request.remove_header("cookie"); + } + // 中文注释:SpacetimeDB 订阅走 WebSocket Upgrade;普通 API 连接头保持干净,贴近当前 Nginx 模板。 if ctx.route.proxy_target() == Some(ProxyTarget::Spacetime) && is_upgrade_request(session) { upstream_request.insert_header("Connection", "Upgrade")?; @@ -1623,6 +1642,31 @@ fn is_generic_api_proxy_path(path: &str) -> bool { path == "/api" || path.starts_with("/api/") } +/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>` 可选跟一段 `/…` 资源路径。 +/// +/// 与 Nginx 的 `location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"` 同口径: +/// 只认小写十六进制、固定 32 位,不做大小写放宽;命中后上游路径是 +/// `/api/game-distribution/releases/`,其余返回 `None` 交给后面的 SPA / 静态分支。 +fn release_gateway_upstream_path(path: &str) -> Option { + let rest = path.strip_prefix("/games/")?; + let (game_id, asset_path) = match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, ""), + }; + let hex = game_id.strip_prefix("game_")?; + if hex.len() != 32 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return None; + } + + Some(format!( + "/api/game-distribution/releases/{game_id}{asset_path}" + )) +} + fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option { match route { RouteDecision::Proxy { @@ -1641,6 +1685,8 @@ fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option None, + // 发行入口在 Nginx 侧没有任何 limit_conn / limit_req 指令,保持同口径。 + RouteDecision::ReleaseGateway { .. } => None, RouteDecision::Local(_) => None, } } @@ -1868,6 +1914,10 @@ fn classify_path(path: &str) -> RouteDecision { return RouteDecision::Local(LocalResponse::NotFound); } + if let Some(upstream_path) = release_gateway_upstream_path(path) { + return RouteDecision::ReleaseGateway { upstream_path }; + } + if path.starts_with("/admin/assets/") || path.starts_with("/assets/") { return RouteDecision::Local(LocalResponse::Static { root: StaticRoot::Web, @@ -3065,6 +3115,7 @@ mod tests { mode: Option, location: Option, protection_class: Option, + upstream_path: Option, } #[derive(Deserialize)] @@ -3108,6 +3159,15 @@ mod tests { case.sample_path ); } + ("release_gateway", RouteDecision::ReleaseGateway { upstream_path }) => { + assert_eq!( + Some(upstream_path.as_str()), + case.expect.upstream_path.as_deref(), + "route parity release gateway upstream path mismatch: {} {}", + case.id, + case.sample_path + ); + } ( "redirect_permanent", RouteDecision::Local(LocalResponse::RedirectPermanent { location }), @@ -3872,6 +3932,54 @@ mod tests { } } + #[test] + fn classifies_platform_same_origin_release_paths() { + let game_id = "game_0123456789abcdef0123456789abcdef"; + assert_eq!( + release_gateway_upstream_path(&format!("/games/{game_id}/index.html")), + Some(format!( + "/api/game-distribution/releases/{game_id}/index.html" + )) + ); + // 不带资源路径(尾斜杠缺失)时与 Nginx 一样仍然命中,只是上游没有 asset 段。 + assert_eq!( + release_gateway_upstream_path(&format!("/games/{game_id}")), + Some(format!("/api/game-distribution/releases/{game_id}")) + ); + assert!(matches!( + classify_path(&format!("/games/{game_id}/assets/app.js")), + RouteDecision::ReleaseGateway { .. } + )); + + // 大小写、位数、字符集、以及「像但不是」的路径都不许被吞进发行网关。 + for path in [ + "/games/detail", + "/games/mine", + "/games/game_0123456789ABCDEF0123456789ABCDEF/index.html", + "/games/game_0123456789abcdef0123456789abcde/index.html", + "/games/game_0123456789abcdef0123456789abcdef0/index.html", + "/games/game_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz/index.html", + "/games/game/index.html", + ] { + assert_eq!(release_gateway_upstream_path(path), None, "path: {path}"); + } + // 这些路径仍然按 SPA / 精确静态分类,不受发行入口影响。 + assert_eq!( + classify_path("/games/detail"), + RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::SpaFallback, + }) + ); + assert_eq!( + classify_path("/games/game/index.html"), + RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::Exact, + }) + ); + } + #[test] fn protection_rejects_when_concurrency_is_exhausted() { let config = ProtectionConfig {