补上收银台深链 /pay/<checkoutToken> 的前缀路由(Nginx 三模板 + Pingora + 门禁)
- 现状:只把 `/pay`、`/profile/payment` 加进 allowlist 只能让 check:nginx-spa-routes 变绿; payment.rs 生成的 checkoutUrl 是 `/pay/<checkoutToken>`,深链仍落默认 location 的 try_files → 404。同一批漂移里 check:pingora-route-parity 也是红的(Pingora MAIN_SPA_PATHS 缺 /pay、/profile/payment),只是被 lint 链里先失败的门禁掩盖,修一条要跑到链尾 - 真相源:src/routing/activeAppPageRoutes.ts 新增 APP_PREFIX_ROUTE_ENTRIES ('/pay' → payment-checkout),resolveSelectionStageFromPath 改用它 - 门禁:scripts/check-nginx-spa-routes.mjs 要求三份模板都有锚定前缀 location `location ~* "^/pay/[^/]+/?$"`(裸前缀仍由精确 location 负责;前缀 location 必须镜像精确 location 的维护闸与 try_files 回退);新增 scripts/check-nginx-spa-routes.test.mjs 正/反用例 (把前缀写成精确匹配或过宽裸前缀都会红),由 npm run check:nginx-spa-routes 一起执行; check-pingora-route-parity 新增 MAIN_SPA_PREFIX_PATHS 与前端前缀路由的逐条比对 - 模板:deploy/nginx/genarrative.conf、deploy/nginx/genarrative-dev-http.conf、 deploy/container/nginx.conf 各加一条锚定前缀 location - Pingora:MAIN_SPA_PATHS 补 /pay、/profile/payment;新增 MAIN_SPA_PREFIX_PATHS 与 is_main_spa_prefix_path(大小写不敏感,只认「前缀 + 恰好一段」),矩阵新增 pay_checkout_spa_fallback 用例,并给网关补一条前缀正/反单测 - 文档:Pingora 试点文档的路由表与门禁说明、deploy/nginx/README 与本地开发/生产运维文档 同步前缀路由口径与线上 curl 复验方式 - 本地实跑:node --test scripts/check-nginx-spa-routes.test.mjs(4 passed)、 node scripts/check-nginx-spa-routes.mjs(OK,14 SPA routes / 1 prefix routes / 3 templates)、 npm run check:pingora-route-parity(OK,25 routes)、 cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix(2 passed)
This commit is contained in:
@@ -160,6 +160,11 @@ http {
|
||||
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# 收银台深链 `/pay/<checkoutToken>`:只放行裸前缀会让真实收银台链接落到默认 location 变 404。
|
||||
location ~* "^/pay/[^/]+/?$" {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
|
||||
@@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \
|
||||
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
|
||||
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
|
||||
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
|
||||
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
|
||||
@@ -215,6 +215,18 @@ server {
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# 收银台深链 `/pay/<checkoutToken>`:token 由前端从最后一个路径段读取(payment.rs 生成该链接),
|
||||
# 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。
|
||||
location ~* "^/pay/[^/]+/?$" {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
|
||||
@@ -243,6 +243,18 @@ server {
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# 收银台深链 `/pay/<checkoutToken>`:token 由前端从最后一个路径段读取(payment.rs 生成该链接),
|
||||
# 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。
|
||||
location ~* "^/pay/[^/]+/?$" {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
|
||||
@@ -311,6 +311,20 @@
|
||||
},
|
||||
"docs": ["主站 SPA allowlist", "失败回退 `/index.html`"]
|
||||
},
|
||||
{
|
||||
"id": "pay_checkout_spa_fallback",
|
||||
"samplePath": "/pay/checkout-token",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "spa_fallback"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location ~* \"^/pay/[^/]+/?$\""],
|
||||
"development": ["location ~* \"^/pay/[^/]+/?$\""]
|
||||
},
|
||||
"docs": ["收银台深链 `/pay/<checkoutToken>`", "前缀 + 恰好一个路径段"]
|
||||
},
|
||||
{
|
||||
"id": "games_spa_fallback",
|
||||
"samplePath": "/games/detail",
|
||||
|
||||
@@ -63,7 +63,7 @@ npm run check:pingora-release-readiness
|
||||
|
||||
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
|
||||
|
||||
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。
|
||||
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/<checkoutToken>` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。
|
||||
|
||||
`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。
|
||||
|
||||
@@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
|
||||
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
|
||||
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
|
||||
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
|
||||
| 主站 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 内。 |
|
||||
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/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 内。 |
|
||||
| 主站 SPA 前缀路由 | 收银台深链 `/pay/<checkoutToken>` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 |
|
||||
| 其它 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` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。
|
||||
|
||||
@@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||
- 门禁:
|
||||
|
||||
```bash
|
||||
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由)
|
||||
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由)
|
||||
npm run check:nginx-spa-routes
|
||||
```
|
||||
|
||||
线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/<checkoutToken>` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。
|
||||
|
||||
本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games/<gameId>/` 验证:入口文档 200 `text/html`、`/games/<gameId>/assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。
|
||||
|
||||
#### 游戏分发可观测事件
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@
|
||||
"check:production-api-deploy": "node scripts/check-production-api-deploy.mjs",
|
||||
"check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs",
|
||||
"check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs",
|
||||
"check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs",
|
||||
"check:nginx-spa-routes": "node --test scripts/check-nginx-spa-routes.test.mjs && node scripts/check-nginx-spa-routes.mjs",
|
||||
"check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs",
|
||||
"check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs",
|
||||
"check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts';
|
||||
const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx';
|
||||
const APP_PREFIX_ROUTE_ENTRIES_PATTERN =
|
||||
/const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u;
|
||||
const COMPATIBILITY_ROUTES = [];
|
||||
const NGINX_PATHS = [
|
||||
'deploy/nginx/genarrative.conf',
|
||||
@@ -86,7 +90,92 @@ function compareRouteSets(actualRoutes, expectedRoutes, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateNginxRoutes(nginxPath, expectedRoutes) {
|
||||
/**
|
||||
* 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。
|
||||
* 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。
|
||||
*/
|
||||
export function buildPrefixRouteNginxPattern(prefix) {
|
||||
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
return `^${escapedPrefix}/[^/]+/?$`;
|
||||
}
|
||||
|
||||
/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */
|
||||
export function collectPrefixRoutePatternFailures(pattern, prefix) {
|
||||
const failures = [];
|
||||
const expected = buildPrefixRouteNginxPattern(prefix);
|
||||
if (pattern !== expected) {
|
||||
failures.push(
|
||||
`前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`,
|
||||
);
|
||||
return failures;
|
||||
}
|
||||
const matcher = new RegExp(pattern, 'iu');
|
||||
for (const sample of [
|
||||
`${prefix}/checkout-token`,
|
||||
`${prefix.toUpperCase()}/CheckOutToken/`,
|
||||
]) {
|
||||
if (!matcher.test(sample)) {
|
||||
failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`);
|
||||
}
|
||||
}
|
||||
for (const sample of [
|
||||
prefix,
|
||||
`${prefix}/`,
|
||||
`${prefix}/two/segments`,
|
||||
`${prefix}suffix/segment`,
|
||||
]) {
|
||||
if (matcher.test(sample)) {
|
||||
failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function collectExpectedPrefixRoutes() {
|
||||
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
|
||||
const entries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
APP_PREFIX_ROUTE_ENTRIES_PATTERN,
|
||||
`${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`,
|
||||
);
|
||||
const routes = Array.from(
|
||||
entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu),
|
||||
(match) => ({ path: match[1], stage: match[2] }),
|
||||
);
|
||||
const uniqueRoutes = new Map(routes.map((route) => [route.path, route]));
|
||||
for (const route of uniqueRoutes.values()) {
|
||||
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) {
|
||||
fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`);
|
||||
}
|
||||
}
|
||||
return [...uniqueRoutes.values()].sort((left, right) =>
|
||||
left.path.localeCompare(right.path),
|
||||
);
|
||||
}
|
||||
|
||||
function findRegexLocationBody(block, pattern) {
|
||||
for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) {
|
||||
if (match[1] !== pattern) {
|
||||
continue;
|
||||
}
|
||||
const openBrace = match.index + match[0].length - 1;
|
||||
let depth = 0;
|
||||
for (let index = openBrace; index < block.length; index += 1) {
|
||||
if (block[index] === '{') {
|
||||
depth += 1;
|
||||
} else if (block[index] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return block.slice(openBrace + 1, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) {
|
||||
const source = readFileSync(nginxPath, 'utf8');
|
||||
const blockStart = source.indexOf(SPA_BLOCK_START);
|
||||
const blockEnd = source.indexOf(SPA_BLOCK_END);
|
||||
@@ -140,6 +229,44 @@ function validateNginxRoutes(nginxPath, expectedRoutes) {
|
||||
}
|
||||
}
|
||||
|
||||
// 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认
|
||||
// location 变成 404(例如收银台 `/pay/<checkoutToken>`)。
|
||||
const exactLocationBody = findRegexLocationBody(block, nginxPattern);
|
||||
const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }';
|
||||
for (const { path: prefix } of prefixRoutes) {
|
||||
if (!expectedRoutes.includes(prefix)) {
|
||||
fail(
|
||||
`${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const expectedPattern = buildPrefixRouteNginxPattern(prefix);
|
||||
const prefixLocationBody = findRegexLocationBody(block, expectedPattern);
|
||||
if (prefixLocationBody === null) {
|
||||
fail(
|
||||
`${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const failure of collectPrefixRoutePatternFailures(
|
||||
expectedPattern,
|
||||
prefix,
|
||||
)) {
|
||||
fail(`${nginxPath} ${failure}`);
|
||||
}
|
||||
if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) {
|
||||
fail(`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`);
|
||||
}
|
||||
if (
|
||||
exactLocationBody?.includes(maintenanceGuard) &&
|
||||
!prefixLocationBody.includes(maintenanceGuard)
|
||||
) {
|
||||
fail(
|
||||
`${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
|
||||
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
|
||||
fail(
|
||||
@@ -218,20 +345,27 @@ function validateMaintenanceInternalBypass() {
|
||||
}
|
||||
|
||||
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
|
||||
export const expectedPrefixRoutes = collectExpectedPrefixRoutes();
|
||||
|
||||
for (const nginxPath of NGINX_PATHS) {
|
||||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
|
||||
}
|
||||
validateMaintenanceInternalBypass();
|
||||
const isMainModule =
|
||||
process.argv[1] &&
|
||||
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:nginx-spa-routes] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
if (isMainModule) {
|
||||
for (const nginxPath of NGINX_PATHS) {
|
||||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
validateMaintenanceInternalBypass();
|
||||
|
||||
console.log(
|
||||
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`,
|
||||
);
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:nginx-spa-routes] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
buildPrefixRouteNginxPattern,
|
||||
collectExpectedPrefixRoutes,
|
||||
collectPrefixRoutePatternFailures,
|
||||
} from './check-nginx-spa-routes.mjs';
|
||||
|
||||
test('前缀路由按「前缀 + 恰好一个路径段 + 可省略尾部斜杠」锚定,裸前缀交给精确 location', () => {
|
||||
const pattern = buildPrefixRouteNginxPattern('/pay');
|
||||
assert.equal(pattern, '^/pay/[^/]+/?$');
|
||||
assert.deepEqual(collectPrefixRoutePatternFailures(pattern, '/pay'), []);
|
||||
|
||||
const matcher = new RegExp(pattern, 'iu');
|
||||
assert.ok(matcher.test('/pay/checkout-token'));
|
||||
assert.ok(matcher.test('/PAY/CheckOutToken/'));
|
||||
assert.ok(!matcher.test('/pay'));
|
||||
assert.ok(!matcher.test('/pay/'));
|
||||
assert.ok(!matcher.test('/pay/two/segments'));
|
||||
assert.ok(!matcher.test('/paycheckout/token'));
|
||||
assert.ok(!matcher.test('/payment/token'));
|
||||
});
|
||||
|
||||
test('把前缀路由写回精确匹配(只放行裸前缀)会被门禁拒绝', () => {
|
||||
const failures = collectPrefixRoutePatternFailures('^/pay/?$', '/pay');
|
||||
assert.equal(failures.length, 1);
|
||||
assert.match(failures[0], /必须锚定为 \^\/pay\/\[\^\/\]\+\/\?\$/u);
|
||||
});
|
||||
|
||||
test('过宽的裸前缀形状(会吞掉同名邻居路径)也会被门禁拒绝', () => {
|
||||
const failures = collectPrefixRoutePatternFailures('^/pay', '/pay');
|
||||
assert.equal(failures.length, 1);
|
||||
assert.match(failures[0], /必须锚定为/u);
|
||||
});
|
||||
|
||||
test('前缀路由真相源来自前端路由表且当前包含 /pay', () => {
|
||||
const prefixRoutes = collectExpectedPrefixRoutes();
|
||||
assert.ok(
|
||||
prefixRoutes.some((route) => route.path === '/pay'),
|
||||
'APP_PREFIX_ROUTE_ENTRIES 必须声明 /pay',
|
||||
);
|
||||
for (const route of prefixRoutes) {
|
||||
assert.match(route.path, /^\//u);
|
||||
assert.notEqual(route.stage.trim(), '');
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs';
|
||||
import {
|
||||
expectedMainSpaRoutes,
|
||||
expectedPrefixRoutes,
|
||||
} from './check-nginx-spa-routes.mjs';
|
||||
|
||||
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
|
||||
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
|
||||
@@ -289,10 +292,34 @@ function validateRustMainSpaRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateRustMainSpaPrefixPaths() {
|
||||
const prefixBlock = pingoraGatewaySource.match(
|
||||
/const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
|
||||
);
|
||||
if (!prefixBlock) {
|
||||
fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。');
|
||||
return;
|
||||
}
|
||||
const rustPrefixes = Array.from(
|
||||
prefixBlock[1].matchAll(/"([^"]+)"/gu),
|
||||
(match) => match[1],
|
||||
);
|
||||
const expected = expectedPrefixRoutes.map((route) => route.path);
|
||||
const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix));
|
||||
const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix));
|
||||
if (missing.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
validateMatrixShape();
|
||||
validateRustTestUsesMatrix();
|
||||
validateNginxLocationsAreCovered();
|
||||
validateRustMainSpaRoutes();
|
||||
validateRustMainSpaPrefixPaths();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:pingora-route-parity] FAILED');
|
||||
|
||||
@@ -72,10 +72,17 @@ const MAIN_SPA_PATHS: &[&str] = &[
|
||||
"/games/mine",
|
||||
"/games/play",
|
||||
"/games/publish",
|
||||
"/pay",
|
||||
"/profile",
|
||||
"/profile/payment",
|
||||
"/project",
|
||||
];
|
||||
|
||||
// 带动态段的前缀路由,必须与前端 `APP_PREFIX_ROUTE_ENTRIES` 以及三份 nginx 模板的
|
||||
// `location ~* "^/pay/[^/]+/?$"` 同口径:只放行「前缀 + 恰好一个路径段」,裸前缀由
|
||||
// `MAIN_SPA_PATHS` 负责;`npm run check:pingora-route-parity` 会逐条比对这份 allowlist。
|
||||
const MAIN_SPA_PREFIX_PATHS: &[&str] = &["/pay"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayConfig {
|
||||
listen_addr: String,
|
||||
@@ -1955,6 +1962,21 @@ fn is_main_spa_path(path: &str) -> bool {
|
||||
MAIN_SPA_PATHS
|
||||
.iter()
|
||||
.any(|candidate| normalized.eq_ignore_ascii_case(candidate))
|
||||
|| is_main_spa_prefix_path(normalized)
|
||||
}
|
||||
|
||||
/// 前缀路由(带动态段):`<前缀>/<恰好一个路径段>`,大小写不敏感(与 nginx `location ~*` 同口径)。
|
||||
/// 裸前缀、多段路径和前缀同名邻居(如 `/payment/x`)都不算命中。
|
||||
fn is_main_spa_prefix_path(normalized: &str) -> bool {
|
||||
let mut segments = normalized.trim_start_matches('/').split('/');
|
||||
let (Some(first), Some(second), None) = (segments.next(), segments.next(), segments.next())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!second.is_empty()
|
||||
&& MAIN_SPA_PREFIX_PATHS
|
||||
.iter()
|
||||
.any(|prefix| prefix.trim_start_matches('/').eq_ignore_ascii_case(first))
|
||||
}
|
||||
|
||||
fn is_maintenance_page_asset(path: &str) -> bool {
|
||||
@@ -3310,6 +3332,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pay_checkout_deep_link_uses_main_spa_prefix_route() {
|
||||
let spa_fallback = RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
mode: StaticMode::SpaFallback,
|
||||
});
|
||||
// 裸前缀由 MAIN_SPA_PATHS 精确命中,收银台深链 `/pay/<checkoutToken>` 由前缀路由命中,
|
||||
// 两者都与 Nginx 的 `location ~* "^/pay/[^/]+/?$"` 同口径(大小写不敏感)。
|
||||
for path in [
|
||||
"/pay",
|
||||
"/pay/",
|
||||
"/PAY",
|
||||
"/pay/checkout-token",
|
||||
"/PAY/CheckOutToken/",
|
||||
] {
|
||||
assert_eq!(classify_path(path), spa_fallback, "path: {path}");
|
||||
}
|
||||
// 多段路径与前缀同名邻居不允许被吞进 SPA fallback。
|
||||
for path in ["/pay/two/segments", "/payment/token", "/paycheckout/token"] {
|
||||
assert_eq!(
|
||||
classify_path(path),
|
||||
RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
mode: StaticMode::Exact,
|
||||
}),
|
||||
"path: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_configured_body_limit_to_generic_api_routes_only() {
|
||||
let mut generic_api = classify_path("/api/assets/history");
|
||||
|
||||
@@ -18,6 +18,16 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['game-publish', '/games/publish'],
|
||||
] as const satisfies readonly (readonly [SelectionStage, string])[];
|
||||
|
||||
/**
|
||||
* 带动态段、需要整体回退 index.html 的对外路由前缀 → 归属 stage。
|
||||
* 例如收银台深链 `/pay/<checkoutToken>`(后端 `payment.rs` 用 `format!("/pay/{}", token)` 生成,
|
||||
* 前端从最后一个路径段读 token)。Nginx 必须按「前缀 + 恰好一个路径段」的锚定形状放行:
|
||||
* 只放行裸前缀会让真实收银台链接落到默认 location 变成 404,放行过宽又会把未知路径吞掉。
|
||||
*/
|
||||
export const APP_PREFIX_ROUTE_ENTRIES = [
|
||||
['/pay', 'payment-checkout'],
|
||||
] as const satisfies readonly (readonly [string, SelectionStage])[];
|
||||
|
||||
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
|
||||
Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record<SelectionStage, string>;
|
||||
|
||||
@@ -41,10 +51,13 @@ export function normalizeAppPath(pathname: string) {
|
||||
export function resolveSelectionStageFromPath(
|
||||
pathname: string,
|
||||
): SelectionStage {
|
||||
if (normalizeAppPath(pathname).startsWith('/pay/')) {
|
||||
return 'payment-checkout';
|
||||
const normalizedPath = normalizeAppPath(pathname);
|
||||
for (const [prefix, stage] of APP_PREFIX_ROUTE_ENTRIES) {
|
||||
if (normalizedPath.startsWith(`${prefix}/`)) {
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform';
|
||||
return ROUTE_STAGE_BY_PATH.get(normalizedPath) ?? 'platform';
|
||||
}
|
||||
|
||||
export function resolveInitialSelectionStageFromPath(
|
||||
|
||||
Reference in New Issue
Block a user