Files
Genarrative/scripts/check-pingora-route-parity.mjs
T
lhk229 576d2709e0
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m10s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 7m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m8s
Project CI / Repository checks (pull_request) Successful in 5m55s
Project CI / Native shell tests (pull_request) Successful in 8m10s
合并最新主分支并保留美术包合同修复
合并 origin/master 的 9f4c7d763 到当前修复分支
合并双方技能包内容,将版本递增至 2026-08-26.44
保留双方共享记忆条目以及本分支的 API 说明、提示词和实际切片处理修复
验证图集回归 26 项、Rust 技能包 7 项、提示词 24 项和技能包脚本 3 项通过
验证三个变更技能的通用校验、技能包指纹、文档索引、编码与差异检查通过
保留提交钩子对 Pingora 路由校验脚本的纯格式调整
2026-10-06 10:21:25 +01:00

467 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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',
'play_session_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',
'play_sessions_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 (expect.kind === 'play_session_gateway') {
// 播放会话入口与通用 `/api` 同口径(同样吃 api 限流),但路径不重写、也不引入新的头部语义;
// 显式声明 protectionClass 是为了让下面「必须清空 Cookie」的断言有对比基准。
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
fail(
`${context} play_session_gateway protectionClass 不支持: ${expect.protectionClass}`,
);
}
if (hasOwn(expect, 'upstreamPath')) {
fail(
`${context} play_session_gateway 不做路径重写,不能配置 upstreamPath。`,
);
}
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)',
'("play_session_gateway", RouteDecision::PlaySessionGateway)',
]) {
if (!pingoraGatewaySource.includes(fragment)) {
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
}
}
}
// 播放会话前缀的 Pingora 侧判定:必须独立成 `PlaySessionGateway`,在通用 `/api` 分支之前命中,
// 并通过 `route_clears_cookie` 在上游代理阶段清空 Cookie。把前缀合并回通用 `/api` 分支(或删掉
// 清 Cookie 的处理)都会让这条断言失败。
function validateRustPlaySessionGatewayIsolation() {
for (const fragment of [
'fn is_play_session_proxy_path(path: &str) -> bool',
'"/api/game-distribution/play-sessions/"',
'fn route_clears_cookie(route: &RouteDecision) -> bool',
'upstream_request.remove_header("cookie");',
]) {
if (!pingoraGatewaySource.includes(fragment)) {
fail(`Pingora Rust 缺少播放会话 Cookie 隔离实现: ${fragment}`);
}
}
const classifyBlock = pingoraGatewaySource.match(
/fn classify_path\(path: &str\) -> RouteDecision \{([\s\S]*?)\n\}/u,
);
if (!classifyBlock) {
fail('Pingora Rust 缺少 classify_path 路由判定函数。');
return;
}
const playSessionIndex = classifyBlock[1].indexOf(
'if is_play_session_proxy_path(path) {',
);
const genericApiIndex = classifyBlock[1].indexOf(
'path == "/api" || path.starts_with("/api/")',
);
if (playSessionIndex < 0) {
fail(
'Pingora classify_path 缺少播放会话前缀判定(必须在通用 /api 分支之前命中)。',
);
return;
}
if (genericApiIndex < 0) {
fail('Pingora classify_path 缺少通用 /api 代理分支。');
return;
}
if (playSessionIndex > genericApiIndex) {
fail(
'Pingora classify_path 的播放会话前缀判定必须排在通用 /api 分支之前。',
);
}
if (!pingoraGatewaySource.includes('if route_clears_cookie(&ctx.route) {')) {
fail(
'Pingora 上游代理阶段必须按 route_clears_cookie 清空 Cookie(发行入口 / 播放会话入口同口径)。',
);
}
}
// 平台内容网关(发行入口 / 播放会话入口)必须在边缘清空 Cookie:api-server 侧对带平台
// refresh Cookie 的请求返回 403(纵深防御保留),Cookie 一旦被转发,sandbox iframe 与包内
// 每个相对资源都会 403,付费游戏实际不可玩(2026-10-05 就是这样被发现的)。
// 这条同时挡住「把播放会话前缀合并回通用 `/api` 规则」:通用规则必须继续转发 Cookie
// (`/api/auth/*` 依赖 refresh cookie),合并后要么清空 Cookie 的片段消失、要么落到通用 location。
function validateContentGatewayCookieIsolation() {
const clearCookieFragment = 'proxy_set_header Cookie "";';
const genericApiLocation = 'location ~ ^/api(?:/|$)';
const contentGatewayKinds = new Set([
'release_gateway',
'play_session_gateway',
]);
for (const route of matrix.routes) {
if (!contentGatewayKinds.has(route.expect?.kind)) {
continue;
}
for (const environment of ['production', 'development']) {
const fragments = route.nginx?.[environment] ?? [];
if (!fragments.includes(clearCookieFragment)) {
fail(
`route ${route.id} 的 ${environment} Nginx 片段必须显式清空 Cookie:${clearCookieFragment}`,
);
}
const mergedIntoTemplate = fragments.some((fragment) =>
fragment.includes(genericApiLocation),
);
if (mergedIntoTemplate) {
fail(
`route ${route.id} 的 ${environment} Nginx 片段不能复用通用 ${genericApiLocation}(通用规则会转发 Cookie)。`,
);
}
}
}
// 播放会话前缀必须排在自己的 `^~` 前缀 location 上,并且在模板里排在通用 `/api` location 之前;
// nginx 的 `^~` 前缀优先于正则 location,但顺序仍按任务要求固定,便于人工核对。
const playSessionLocation =
'location ^~ /api/game-distribution/play-sessions/';
for (const environment of ['production', 'development']) {
const source = files[environment];
const playSessionIndex = source.indexOf(playSessionLocation);
if (playSessionIndex < 0) {
fail(
`${environment} Nginx 模板缺少播放会话前缀 location: ${playSessionLocation}`,
);
continue;
}
const genericApiIndex = source.indexOf(genericApiLocation);
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
fail(
`${environment} Nginx 模板的播放会话前缀 location 必须排在通用 ${genericApiLocation} 之前。`,
);
}
}
}
// 反向覆盖:模板里出现的每条 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();
validateRustPlaySessionGatewayIsolation();
validateContentGatewayCookieIsolation();
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)`);