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
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:
@@ -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
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user