补上收银台深链 /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:
2026-10-04 03:25:54 +08:00
parent 9e2cea5b72
commit ad0430fcd2
13 changed files with 342 additions and 23 deletions
+148 -14
View File
@@ -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)`,
);
}
+47
View File
@@ -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(), '');
}
});
+28 -1
View File
@@ -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');