Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
#	apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
#	apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	package.json
#	packages/shared/src/contracts/gameDistribution.ts
#	scripts/check-game-distribution-dto-parity.mjs
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/module-game-distribution/src/errors.rs
#	server-rs/crates/module-game-distribution/src/lib.rs
#	server-rs/crates/shared-contracts/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-client/src/active/mapper.rs
#	server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs
#	server-rs/crates/spacetime-client/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/game-distribution/GamePlayPage.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
#	src/services/gameDistributionClient.test.ts
#	src/services/gameDistributionClient.ts
This commit is contained in:
2026-10-06 16:10:52 +08:00
314 changed files with 13094 additions and 2427 deletions
+108 -15
View File
@@ -6,6 +6,8 @@
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
// - 两侧字段的可空性必须一致:TS 允许 `null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时
// TS 必须可空或可省略,避免「Rust 契约写 String、线上实际下发 null」这类漂移。
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
@@ -63,10 +65,7 @@ const PAIRS = [
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
// 共创(Fork)授权与血缘:档位枚举与公开血缘摘要必须与 TS 逐字对齐。
[
'GameDistributionForkAuthorization',
'GameDistributionForkAuthorization',
],
['GameDistributionForkAuthorization', 'GameDistributionForkAuthorization'],
['GameDistributionLineage', 'GameDistributionLineage'],
['GameDistributionLineageNode', 'GameDistributionLineageNode'],
['GameDistributionLineageResponse', 'GameDistributionLineageResponse'],
@@ -102,6 +101,13 @@ const PAIRS = [
'GameDistributionUpsertThemeMemberRequest',
'GameDistributionUpsertThemeMemberRequest',
],
['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'],
['GameDistributionPurchase', 'GameDistributionPurchase'],
['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'],
[
'GameDistributionPlaySessionResponse',
'GameDistributionPlaySessionResponse',
],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
@@ -316,19 +322,57 @@ function rustDefinitions(source) {
while ((match = pattern.exec(source))) {
const { attrs, kind, name, body } = match.groups;
const rename = /rename_all = "(?<style>\w+)"/.exec(attrs)?.groups?.style;
const members =
kind === 'struct'
? [...body.matchAll(/^\s*pub (\w+):/gm)].map((item) =>
renamedMember(item[1], kind, rename),
)
: [...body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)].map((item) =>
renamedMember(item[1], kind, rename),
);
result.set(name, { kind, members });
const members = [];
const optionalFields = [];
if (kind === 'struct') {
for (const item of body.matchAll(/^\s*pub (\w+):\s*(.+?)\s*,?\s*$/gm)) {
const member = renamedMember(item[1], kind, rename);
members.push(member);
// `Option<T>` 是「允许 null」的唯一 Rust 依据。
if (/\bOption</.test(item[2])) optionalFields.push(member);
}
} else {
for (const item of body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)) {
members.push(renamedMember(item[1], kind, rename));
}
}
result.set(name, { kind, members, optionalFields });
}
return result;
}
// 括号是否闭合:跨行的 TS 类型只解析到第一行,无法判断可空性,这类字段跳过比对。
function balancedType(typeText) {
let depth = 0;
for (const char of typeText) {
if (char === '{' || char === '[' || char === '(' || char === '<')
depth += 1;
else if (char === '}' || char === ']' || char === ')' || char === '>')
depth -= 1;
}
return depth === 0;
}
// 只认字段自身类型上的 `| null`;内联对象 / 泛型里的 `| null` 属于嵌套成员,不算。
function topLevelNullable(typeText) {
let depth = 0;
for (let index = 0; index < typeText.length; index += 1) {
const char = typeText[index];
if (char === '{' || char === '[' || char === '(' || char === '<')
depth += 1;
else if (char === '}' || char === ']' || char === ')' || char === '>')
depth -= 1;
else if (
char === '|' &&
depth === 0 &&
/^\s*null\b/.test(typeText.slice(index + 1))
) {
return true;
}
}
return false;
}
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
@@ -337,13 +381,27 @@ function tsDefinitions(source) {
while ((match = objectPattern.exec(source))) {
const members = [];
const required = [];
const nullable = [];
const complete = [];
for (const line of match[2].split('\n')) {
const member = /^ {2}(\w+)(\??):/.exec(line);
const member = /^ {2}(\w+)(\??):\s*(.*)$/.exec(line);
if (!member) continue;
members.push(member[1]);
if (member[2] !== '?') required.push(member[1]);
if (balancedType(member[3])) {
complete.push(member[1]);
if (topLevelNullable(member[3])) nullable.push(member[1]);
}
}
result.set(match[1], { kind: 'struct', members, required });
const optionalFields = members.filter((name) => !required.includes(name));
result.set(match[1], {
kind: 'struct',
members,
required,
nullable,
complete,
optionalFields,
});
}
const unionPattern =
/export type (GameDistribution\w+|AdminGameReview\w*|Creator\w+) =\s*([^;]+);/g;
@@ -556,6 +614,41 @@ for (const [rustName, tsName] of PAIRS) {
}
}
// 可空性一致性:TS 声明 `| null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时 TS 必须可空(`| null`)
// 或可省略(`?`)。只比对两边都能完整解析(非跨行)的字段,避免误报。
for (const [rustName, tsName] of PAIRS) {
const rustDefinition = rust.get(rustName);
const tsDefinition = ts.get(tsName);
if (
!rustDefinition ||
!tsDefinition ||
rustDefinition.kind !== 'struct' ||
tsDefinition.kind !== 'struct'
) {
continue;
}
for (const field of rustDefinition.optionalFields) {
if (!tsDefinition.members.includes(field)) continue;
if (
!tsDefinition.nullable.includes(field) &&
!tsDefinition.optionalFields.includes(field)
) {
failures.push(
`${tsName}.${field} 必须可空(\`| null\`)或可选(\`?\`):${rustName} 的该字段是 Option`,
);
}
}
for (const field of tsDefinition.nullable) {
if (!rustDefinition.members.includes(field)) continue;
if (!tsDefinition.complete.includes(field)) continue;
if (!rustDefinition.optionalFields.includes(field)) {
failures.push(
`${tsName}.${field} 允许 null,但 ${rustName} 的该字段不是 Option,Rust 侧必须同步为可空`,
);
}
}
}
for (const name of rust.keys()) {
if (!mappedRust.has(name)) {
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// 检查游戏买断价上限在四处声明是否同源。
//
// 为什么需要它:`1_000_000` 这个上限同时出现在平台共享 TS 常量、服务端 Rust 常量、
// AGC 壳 Rust 常量,以及网页侧的再导出别名里。任一处被单独改掉,都会变成「前端允许提交、
// 后端拒绝」或反过来「后端允许、前端拦截」的口径分叉,而且只在真实发布时才暴露。
// 因此这里做常量一致性断言:任一处改了就红。
//
// - TS 真相源:`packages/shared/src/components/platformGamePricingModel.ts` 的
// `PLATFORM_GAME_MAX_PRICE_MUD_POINTS`;
// - 服务端 Rust:`server-rs/crates/module-game-distribution/src/domain.rs` 的
// `MAX_GAME_PRICE_MUD_POINTS`;
// - AGC 壳 Rust:`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs`
// 的 `MAX_GAME_PRICE_MUD_POINTS`(原生发布链路本地校验用,必须与后端一致);
// - 网页侧别名:`src/components/game-distribution/gamePublishMetadata.ts` 的
// `MAX_GAME_PRICE_MUD_POINTS` 必须直接引用共享常量,而不是另写一个字面量。
//
// 上限文案(例如 `GameDistributionFieldError::InvalidGamePrice`)由
// `cargo test -p module-game-distribution` 覆盖:那边断言文案插值常量而不是硬编码数字。
import fs from 'node:fs';
const TS_SOURCE = 'packages/shared/src/components/platformGamePricingModel.ts';
const WEB_ALIAS = 'src/components/game-distribution/gamePublishMetadata.ts';
const RUST_SERVER = 'server-rs/crates/module-game-distribution/src/domain.rs';
const RUST_AGC =
'apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs';
const TS_TS_CONSTANT = 'PLATFORM_GAME_MAX_PRICE_MUD_POINTS';
const RUST_CONSTANT = 'MAX_GAME_PRICE_MUD_POINTS';
/** 读文件;缺失即失败:路径改名必须同步本脚本,不能静默跳过检查。 */
function readSource(path) {
if (!fs.existsSync(path)) {
throw new Error(`缺少被检查的源文件:${path}`);
}
return fs.readFileSync(path, 'utf8');
}
/** 取常量声明的字面量;匹配不到时返回 null(调用方给出针对性报错)。 */
function matchLiteral(source, pattern) {
const match = source.match(pattern);
return match ? match[1] : null;
}
/** `1_000_000` 与 `1000000` 是同一个值,先去掉分隔下划线再比较。 */
function digitsOf(literal) {
return literal.replace(/_/gu, '');
}
const declarations = [
{
label: `TS 常量 ${TS_TS_CONSTANT}`,
file: TS_SOURCE,
literal: matchLiteral(
readSource(TS_SOURCE),
new RegExp(`export const ${TS_TS_CONSTANT}\\s*=\\s*([\\d_]+)\\s*;`, 'u'),
),
},
{
label: `服务端 Rust 常量 ${RUST_CONSTANT}`,
file: RUST_SERVER,
literal: matchLiteral(
readSource(RUST_SERVER),
new RegExp(
`pub const ${RUST_CONSTANT}:\\s*u64\\s*=\\s*([\\d_]+)\\s*;`,
'u',
),
),
},
{
label: `AGC 壳 Rust 常量 ${RUST_CONSTANT}`,
file: RUST_AGC,
literal: matchLiteral(
readSource(RUST_AGC),
new RegExp(`const ${RUST_CONSTANT}:\\s*u64\\s*=\\s*([\\d_]+)\\s*;`, 'u'),
),
},
];
const failures = [];
for (const declaration of declarations) {
if (declaration.literal === null) {
failures.push(
`${declaration.label} 在 ${declaration.file} 里找不到形如 ` +
`\`const <名字>: u64 = 1000000;\` / \`export const <名字> = 1000000;\` 的声明`,
);
}
}
// 网页侧别名必须是标识符引用,不能是另一个数字字面量。
const webAliasSource = readSource(WEB_ALIAS);
const webAlias = matchLiteral(
webAliasSource,
new RegExp(`const ${RUST_CONSTANT}\\s*=\\s*([^;]+);`, 'u'),
);
if (webAlias === null) {
failures.push(
`${WEB_ALIAS} 缺少 \`export const ${RUST_CONSTANT} = ...;\` 声明`,
);
} else if (webAlias.trim() !== TS_TS_CONSTANT) {
failures.push(
`${WEB_ALIAS} 的 ${RUST_CONSTANT} 必须直接引用 ${TS_TS_CONSTANT},` +
`当前为:${webAlias.trim()}`,
);
}
if (failures.length === 0) {
const expected = digitsOf(declarations[0].literal);
for (const declaration of declarations.slice(1)) {
if (digitsOf(declaration.literal) !== expected) {
failures.push(
`${declaration.label}(${declaration.file})为 ` +
`${digitsOf(declaration.literal)},与 ${declarations[0].label} 的 ` +
`${expected} 不一致`,
);
}
}
}
if (failures.length > 0) {
console.error('[check:game-distribution-price-limit-parity] 不一致:');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log(
`[check:game-distribution-price-limit-parity] OK:买断价上限 ` +
`${digitsOf(declarations[0].literal)} 在 ${declarations.length} 处声明一致,` +
`且网页侧别名引用共享常量`,
);
File diff suppressed because it is too large Load Diff
+71
View File
@@ -346,6 +346,76 @@ function validateMaintenanceInternalBypass() {
}
}
const PLAY_SESSION_LOCATION =
'location ^~ /api/game-distribution/play-sessions/';
const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)';
/** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */
function findLocationBody(source, locationHeader) {
const start = source.indexOf(locationHeader);
if (start < 0) {
return null;
}
const openBrace = source.indexOf('{', start);
if (openBrace < 0) {
return null;
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBrace + 1, index);
}
}
}
return null;
}
/**
* 付费游戏播放会话前缀(sandbox iframe src 的前缀,包内相对资源沿同一前缀解析)必须有自己的
* `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403,
* Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。
* `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去;
* 前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token,api-server 未注册
* 该路径);`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内并被清 Cookie。
*/
function validatePlaySessionCookieIsolation() {
for (const nginxPath of NGINX_PATHS) {
const source = readFileSync(nginxPath, 'utf8');
const playSessionIndex = source.indexOf(PLAY_SESSION_LOCATION);
if (playSessionIndex < 0) {
fail(`${nginxPath} 缺少播放会话前缀 location:${PLAY_SESSION_LOCATION}`);
continue;
}
const body = findLocationBody(source, PLAY_SESSION_LOCATION);
if (body === null) {
fail(`${nginxPath} 播放会话前缀 location 没有配对的闭合块。`);
continue;
}
for (const fragment of [
'proxy_set_header Cookie "";',
'proxy_pass http://genarrative_api;',
'proxy_set_header Host $host;',
'proxy_set_header X-Real-IP $remote_addr;',
'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
'proxy_set_header X-Forwarded-Proto $scheme;',
]) {
if (!body.includes(fragment)) {
fail(`${nginxPath} 播放会话前缀 location 缺少代理片段:${fragment}`);
}
}
const genericApiIndex = source.indexOf(GENERIC_API_LOCATION);
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
fail(
`${nginxPath} 播放会话前缀 location 必须排在通用 /api location(${GENERIC_API_LOCATION})之前。`,
);
}
}
}
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
export const expectedPrefixRoutes = collectExpectedPrefixRoutes();
@@ -358,6 +428,7 @@ if (isMainModule) {
validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes);
}
validateMaintenanceInternalBypass();
validatePlaySessionCookieIsolation();
if (failures.length > 0) {
console.error('[check:nginx-spa-routes] FAILED');
+69
View File
@@ -1060,6 +1060,75 @@ async function runSmokeCases(
'发行入口形状不符时仍是真实 404',
);
// 平台付费游戏播放会话入口:/api/game-distribution/play-sessions/<token>/… 原样转发到 api 上游,
// 但必须清空 Cookie——api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留),
// Cookie 一旦被转发,iframe 与包内每个相对资源都会 403。
const playSessionPath =
'/api/game-distribution/play-sessions/token_smoke/index.html';
const playSessionBeforeCount = api.state.requests.length;
const playSessionResponse = await expectHttp(
baseUrl,
playSessionPath,
200,
'"upstream":"api"',
'播放会话入口转发到 api 上游且清空 Cookie',
{
headers: {
// API 接流保护是 1 req/s、无 burst,这里用独立来源 IP 取一个干净的令牌桶,
// 避免与同一进程里其它 loopback API 用例互相耗尽配额(与下面 429 用例同法)。
'X-Forwarded-For': '203.0.113.71',
'X-Request-Id': 'smoke-play-session-request-id',
Host: 'example.test',
Cookie: 'session=smoke-must-not-reach-play-session',
},
},
);
const playSessionPayload = JSON.parse(playSessionResponse.body);
ensure(
playSessionPayload.url === playSessionPath,
`播放会话入口上游路径不应被重写:${playSessionPayload.url}`,
);
const playSessionUpstreamRequests = api.state.requests.slice(
playSessionBeforeCount,
);
ensure(
playSessionUpstreamRequests.length === 1 &&
playSessionUpstreamRequests[0].url === playSessionPath,
`播放会话入口上游请求不符:${describeRequests(playSessionUpstreamRequests)}`,
);
ensure(
playSessionUpstreamRequests[0]?.headers.cookie === undefined,
`播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`,
);
// 前缀边界:裸 `/api/game-distribution/play-sessions`(无 token)不是播放会话资源,
// 必须继续走通用 `/api` 规则并保留 Cookie。api-server 在该前缀下只注册 GET 入口与包内资源,
// 创建会话是 `POST /api/game-distribution/games/{gameId}/play-session`(不在此前缀下)。
const createSessionBeforeCount = api.state.requests.length;
await expectHttp(
baseUrl,
'/api/game-distribution/play-sessions',
200,
'"upstream":"api"',
'创建会话入口保留 Cookie',
{
headers: {
'X-Forwarded-For': '203.0.113.72',
Host: 'example.test',
Cookie: 'session=smoke-must-reach-create-session',
},
},
);
const createSessionUpstreamRequests = api.state.requests.slice(
createSessionBeforeCount,
);
ensure(
createSessionUpstreamRequests.length === 1 &&
createSessionUpstreamRequests[0]?.headers.cookie ===
'session=smoke-must-reach-create-session',
`创建会话入口不应清空 Cookie:${describeRequests(createSessionUpstreamRequests)}`,
);
await expectHttp(
baseUrl,
'/api/upload',
+130
View File
@@ -18,6 +18,7 @@ const VALID_KINDS = new Set([
'proxy',
'static',
'release_gateway',
'play_session_gateway',
'redirect_permanent',
'shadow_probe',
'not_found',
@@ -45,6 +46,7 @@ const REQUIRED_ROUTE_IDS = [
'profile_spa_fallback',
'games_spa_fallback',
'games_release_gateway',
'play_sessions_gateway',
'web_root_spa',
'web_spa_case_trailing_slash',
'web_unknown_path_exact',
@@ -111,6 +113,22 @@ function validateExpectation(route) {
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。`);
}
@@ -238,6 +256,7 @@ function validateRustTestUsesMatrix() {
'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}`);
@@ -245,6 +264,115 @@ function validateRustTestUsesMatrix() {
}
}
// 播放会话前缀的 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 和矩阵都没有,门禁一直是绿的。
@@ -321,6 +449,8 @@ function validateRustMainSpaPrefixPaths() {
validateMatrixShape();
validateRustTestUsesMatrix();
validateRustPlaySessionGatewayIsolation();
validateContentGatewayCookieIsolation();
validateNginxLocationsAreCovered();
validateRustMainSpaRoutes();
validateRustMainSpaPrefixPaths();