From 38eb5b69ed53a67c3a23fb78992060216e6cd641 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:29:41 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=99=E8=B7=AF=E7=94=B1=20parity=20?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E8=A1=A5=E5=8F=8D=E5=90=91=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=8C=E6=A8=A1=E6=9D=BF=E9=87=8C=E7=9A=84=20location=20?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E8=A2=AB=E7=9F=A9=E9=98=B5=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-pingora-route-parity 新增 validateNginxLocationsAreCovered:两份 Nginx 模板里出现的每条 location 都必须被矩阵某条用例的 nginx 片段(location 前缀)声明,否则判红 - 这条针对的正是 2026-09-29 发行网关路由那类漂移:此前只做正向检查(矩阵片段必须存在于模板),模板单方面加路由时门禁一直是绿的 - 矩阵新增 web_root_spa(samplePath=/ → static/web/spa_fallback,片段 location = / + try_files /index.html =404;)并把三个新 id 列入必查清单;Pingora 试点文档补根路径回退与反向覆盖说明 - 验证:check:pingora-route-parity OK(24 路由)、check:nginx-spa-routes OK(12 路由 / 3 模板)、cargo test -p pingora-gateway 39 passed、check:production-ops、prettier / eslint / encoding / doc-index / diff 全绿;变异验证:往生产模板插一条无矩阵声明的 location → 只有反向覆盖报「production 模板的 location 没有被矩阵覆盖」 --- deploy/pingora/nginx-route-parity.matrix.json | 14 +++++++++++ docs/project-memory/shared-memory/pitfalls.md | 2 +- ...开发运维】Pingora独立网关试点-2026-06-11.md | 4 +++- scripts/check-pingora-route-parity.mjs | 24 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 35769edce..1d3f8b993 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -257,6 +257,20 @@ }, "docs": ["`/generated-*`"] }, + { + "id": "web_root_spa", + "samplePath": "/", + "expect": { + "kind": "static", + "root": "web", + "mode": "spa_fallback" + }, + "nginx": { + "production": ["location = /", "try_files /index.html =404;"], + "development": ["location = /", "try_files /index.html =404;"] + }, + "docs": ["主站 SPA allowlist", "根路径 `/` 精确回退 `/index.html`"] + }, { "id": "web_spa_fallback", "samplePath": "/project", diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fc8e2da7b..cd6a3e63e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -21,7 +21,7 @@ - **事实**:主站 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` 里找,文档没写同样判红。 +- **判据/入口**:`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` 里找,文档没写同样判红。2026-09-29 起它还做**反向覆盖**:两份 Nginx 模板里出现的每条 `location` 都必须被矩阵某条用例声明(变异验证:往生产模板插一条无矩阵声明的 `location` 即报「没有被矩阵覆盖」),所以「模板改了、矩阵和 Pingora 没跟上」这类漂移不会再漏过。 - **别踩**:`/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 版本探测必须显式提供隔离用户目录 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index d962aabb3..75f67d8e2 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -537,7 +537,9 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | | 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 | -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,见下一节的对照说明。 +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` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。 + +`npm run check:pingora-route-parity` 同时对两份 Nginx 模板做**反向覆盖**检查:模板里出现的每条 `location` 都必须被矩阵某条用例的 `nginx` 片段声明,否则失败。这条是 2026-09-29 补的——此前只做正向检查(矩阵片段必须存在于模板),于是「模板加/改了路由、矩阵与 Pingora 没跟上」这类漂移(发行网关路由就是这么漏的)不会被门禁发现。 **平台同源发行入口**(`/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` 固定。 diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 34f0c16d5..1a083564b 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -42,6 +42,7 @@ const REQUIRED_ROUTE_IDS = [ 'profile_spa_fallback', 'games_spa_fallback', 'games_release_gateway', + 'web_root_spa', 'web_spa_case_trailing_slash', 'web_unknown_path_exact', 'creation_unknown_path_exact', @@ -241,6 +242,28 @@ function validateRustTestUsesMatrix() { } } +// 反向覆盖:模板里出现的每条 location 都必须被矩阵某条用例声明过。 +// 只做正向检查(矩阵片段存在于模板)会漏掉「Nginx 模板加/改了路由、矩阵与 Pingora 没跟上」—— +// 2026-09-29 的发行网关路由就是这样:Nginx 有三份、Pingora 和矩阵都没有,门禁一直是绿的。 +function validateNginxLocationsAreCovered() { + for (const environment of ['production', 'development']) { + const source = files[environment]; + const locationFragments = matrix.routes + .flatMap((route) => route.nginx?.[environment] ?? []) + .map((fragment) => fragment.trim()) + .filter((fragment) => fragment.startsWith('location')); + for (const match of source.matchAll(/^[ \t]*location\b[^\n]*/gmu)) { + const line = match[0].trim().replace(/\s*\{\s*$/u, ''); + if (line.startsWith('#')) { + continue; + } + if (!locationFragments.some((fragment) => line.startsWith(fragment))) { + fail(`${environment} 模板的 location 没有被矩阵覆盖: ${line}`); + } + } + } +} + function validateRustMainSpaRoutes() { const routeBlock = pingoraGatewaySource.match( /const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u, @@ -268,6 +291,7 @@ function validateRustMainSpaRoutes() { validateMatrixShape(); validateRustTestUsesMatrix(); +validateNginxLocationsAreCovered(); validateRustMainSpaRoutes(); if (failures.length > 0) {