ad0430fcd2
- 现状:只把 `/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)
333 lines
10 KiB
JavaScript
333 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
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';
|
|
const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf';
|
|
const PINGORA_DOC_PATH =
|
|
'docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md';
|
|
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',
|
|
]);
|
|
const VALID_PROXY_TARGETS = new Set(['api', 'spacetime']);
|
|
const VALID_STATIC_ROOTS = new Set(['web', 'acme']);
|
|
const VALID_STATIC_MODES = new Set(['exact', 'spa_fallback']);
|
|
const VALID_PROTECTION_CLASSES = new Set(['admin_api', 'api', 'spacetime']);
|
|
const REQUIRED_ROUTE_IDS = [
|
|
'acme_challenge',
|
|
'shadow_probe',
|
|
'admin_redirect',
|
|
'admin_api_proxy',
|
|
'admin_assets',
|
|
'admin_spa_fallback',
|
|
'web_assets',
|
|
'generic_api_proxy',
|
|
'spacetime_subscribe',
|
|
'spacetime_identity',
|
|
'v1_forbidden',
|
|
'healthz_forbidden',
|
|
'readyz_forbidden',
|
|
'generated_assets_forbidden',
|
|
'web_spa_fallback',
|
|
'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',
|
|
'runtime_unknown_path_exact',
|
|
'puzzle_unknown_path_exact',
|
|
];
|
|
|
|
const files = {
|
|
production: readFileSync(PRODUCTION_NGINX_PATH, 'utf8'),
|
|
development: readFileSync(DEVELOPMENT_NGINX_PATH, 'utf8'),
|
|
};
|
|
const docs = readFileSync(PINGORA_DOC_PATH, 'utf8');
|
|
const pingoraGatewaySource = readFileSync(PINGORA_GATEWAY_SOURCE, 'utf8');
|
|
const matrix = JSON.parse(readFileSync(MATRIX_PATH, 'utf8'));
|
|
const failures = [];
|
|
|
|
function fail(message) {
|
|
failures.push(message);
|
|
}
|
|
|
|
function hasOwn(object, key) {
|
|
return Object.prototype.hasOwnProperty.call(object, key);
|
|
}
|
|
|
|
function requireString(value, context) {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
fail(`${context} 必须是非空字符串。`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function validateExpectation(route) {
|
|
const context = `${MATRIX_PATH} route ${route.id}`;
|
|
const expect = route.expect;
|
|
if (!expect || typeof expect !== 'object' || Array.isArray(expect)) {
|
|
fail(`${context} 缺少 expect 对象。`);
|
|
return;
|
|
}
|
|
|
|
if (!VALID_KINDS.has(expect.kind)) {
|
|
fail(`${context} expect.kind 不支持: ${expect.kind}`);
|
|
return;
|
|
}
|
|
|
|
if (expect.kind === 'proxy') {
|
|
if (!VALID_PROXY_TARGETS.has(expect.target)) {
|
|
fail(`${context} proxy target 不支持: ${expect.target}`);
|
|
}
|
|
if (
|
|
hasOwn(expect, 'bodyLimit') &&
|
|
expect.bodyLimit !== null &&
|
|
expect.bodyLimit !== 'default' &&
|
|
(!Number.isInteger(expect.bodyLimit) || expect.bodyLimit < 1)
|
|
) {
|
|
fail(`${context} bodyLimit 必须是 null、default 或正整数。`);
|
|
}
|
|
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
|
|
fail(
|
|
`${context} proxy protectionClass 不支持: ${expect.protectionClass}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (hasOwn(expect, 'protectionClass')) {
|
|
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}`);
|
|
}
|
|
if (!VALID_STATIC_MODES.has(expect.mode)) {
|
|
fail(`${context} static mode 不支持: ${expect.mode}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
expect.kind === 'redirect_permanent' &&
|
|
!requireString(expect.location, `${context} redirect location`)
|
|
) {
|
|
fail(`${context} redirect_permanent 必须配置 location。`);
|
|
}
|
|
}
|
|
|
|
function validateNginxFragments(route) {
|
|
for (const environment of ['production', 'development']) {
|
|
const fragments = route.nginx?.[environment];
|
|
if (fragments === undefined) {
|
|
if (environment === 'production' && route.id !== 'shadow_probe') {
|
|
fail(`${MATRIX_PATH} route ${route.id} 缺少 production Nginx 片段。`);
|
|
}
|
|
continue;
|
|
}
|
|
if (!Array.isArray(fragments) || fragments.length === 0) {
|
|
fail(
|
|
`${MATRIX_PATH} route ${route.id} 的 ${environment} Nginx 片段不能为空。`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
for (const fragment of fragments) {
|
|
if (!requireString(fragment, `${route.id} ${environment} Nginx 片段`)) {
|
|
continue;
|
|
}
|
|
if (!files[environment].includes(fragment)) {
|
|
fail(
|
|
`${environment} Nginx 模板缺少 route ${route.id} 片段: ${fragment}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateDocFragments(route) {
|
|
if (!Array.isArray(route.docs) || route.docs.length === 0) {
|
|
fail(`${MATRIX_PATH} route ${route.id} 缺少 docs 片段。`);
|
|
return;
|
|
}
|
|
|
|
for (const fragment of route.docs) {
|
|
if (!requireString(fragment, `${route.id} docs 片段`)) {
|
|
continue;
|
|
}
|
|
if (!docs.includes(fragment)) {
|
|
fail(`Pingora 试点文档缺少 route ${route.id} 片段: ${fragment}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateMatrixShape() {
|
|
if (matrix.version !== 1) {
|
|
fail(`${MATRIX_PATH} version 必须为 1。`);
|
|
}
|
|
if (!Array.isArray(matrix.routes) || matrix.routes.length === 0) {
|
|
fail(`${MATRIX_PATH} routes 不能为空。`);
|
|
return;
|
|
}
|
|
|
|
const ids = new Set();
|
|
const samplePaths = new Set();
|
|
for (const route of matrix.routes) {
|
|
if (!requireString(route.id, `${MATRIX_PATH} route.id`)) {
|
|
continue;
|
|
}
|
|
if (ids.has(route.id)) {
|
|
fail(`${MATRIX_PATH} route id 重复: ${route.id}`);
|
|
}
|
|
ids.add(route.id);
|
|
|
|
if (!requireString(route.samplePath, `${route.id} samplePath`)) {
|
|
continue;
|
|
}
|
|
if (!route.samplePath.startsWith('/')) {
|
|
fail(`${MATRIX_PATH} route ${route.id} samplePath 必须以 / 开头。`);
|
|
}
|
|
if (samplePaths.has(route.samplePath)) {
|
|
fail(`${MATRIX_PATH} samplePath 重复: ${route.samplePath}`);
|
|
}
|
|
samplePaths.add(route.samplePath);
|
|
|
|
validateExpectation(route);
|
|
validateNginxFragments(route);
|
|
validateDocFragments(route);
|
|
}
|
|
|
|
for (const routeId of REQUIRED_ROUTE_IDS) {
|
|
if (!ids.has(routeId)) {
|
|
fail(`${MATRIX_PATH} 缺少必需 route id: ${routeId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateRustTestUsesMatrix() {
|
|
for (const fragment of [
|
|
'include_str!("../../../../deploy/pingora/nginx-route-parity.matrix.json")',
|
|
'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)',
|
|
'protection_class_for_route(&route, &case.sample_path)',
|
|
'fn matches_nginx_route_parity_matrix()',
|
|
'fn is_main_spa_path(path: &str)',
|
|
"path.strip_suffix('/')",
|
|
'normalized.eq_ignore_ascii_case(candidate)',
|
|
]) {
|
|
if (!pingoraGatewaySource.includes(fragment)) {
|
|
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 反向覆盖:模板里出现的每条 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,
|
|
);
|
|
if (!routeBlock) {
|
|
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
|
|
return;
|
|
}
|
|
|
|
const rustRoutes = Array.from(
|
|
routeBlock[1].matchAll(/"([^"]+)"/gu),
|
|
(match) => match[1],
|
|
).sort();
|
|
const expected = new Set(expectedMainSpaRoutes);
|
|
const actual = new Set(rustRoutes);
|
|
const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route));
|
|
const extra = rustRoutes.filter((route) => !expected.has(route));
|
|
if (missing.length > 0) {
|
|
fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`);
|
|
}
|
|
if (extra.length > 0) {
|
|
fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
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');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`[check:pingora-route-parity] OK (${matrix.routes.length} routes)`);
|