修复买断制付费评审问题:后台消耗统计、定价上限校验、付费入口自动化断言
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 7m48s
Project CI / Repository checks (pull_request) Successful in 5m36s

- admin.rs:钱包流水来源映射补齐第 17 个变体(索引 16 → game_purchase),后台「消耗泥点」白名单新增 game_purchase,并抽出效果分类与单行累加函数
- profile.rs:消耗口径抽出 profile_wallet_ledger_source_counts_as_consumption,消费投影增量、历史花费重建、投影初始化三处一并纳入 GamePurchase
- module-game-distribution/errors.rs:价格上限文案改为插值 MAX_GAME_PRICE_MUD_POINTS,并新增文案断言
- 新增 scripts/check-game-distribution-price-limit-parity.mjs 并挂进 npm run lint(随 Repository checks 在 CI 生效):断言 TS / 服务端 Rust / AGC Rust 常量与网页侧别名四处同源
- api-server 发行网关抽出可注入 loader 的 serve_public_release_asset,付费 404 严格发生在读取包字节之前;播放会话准入抽出 resolve_paid_play_session_entitlement
- 新增 api-server 单测:付费发行路径 404 且零次读包字节、免费放行且读到包字节、未购买 403 与作者 / 已购买 / 管理员放行
- spacetime-module:把「先判定后扣费」从源码扫描改为 resolve_game_purchase_pre_charge_plan 纯函数断言,删除 include_str 源码切片用例
- E2E 脚本:平台同源断言缺配置时记 SKIP 并在报告末尾汇总 SKIP 清单,E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,汇总行改为输出 PASS/FAIL/WARN/SKIP 计数
- 修正注释里不存在的路由 POST /api/game-distribution/play-sessions(真实为 POST /api/game-distribution/games/{gameId}/play-session)与「管理员令牌 403」口径(403 需带 admin 角色的用户令牌)
This commit is contained in:
2026-10-06 02:24:40 +08:00
parent 4d901eec65
commit dcef9b62a8
15 changed files with 980 additions and 188 deletions
@@ -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
+4 -2
View File
@@ -346,7 +346,8 @@ function validateMaintenanceInternalBypass() {
}
}
const PLAY_SESSION_LOCATION = 'location ^~ /api/game-distribution/play-sessions/';
const PLAY_SESSION_LOCATION =
'location ^~ /api/game-distribution/play-sessions/';
const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)';
/** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */
@@ -378,7 +379,8 @@ function findLocationBody(source, locationHeader) {
* `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403,
* Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。
* `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去;
* 前缀末尾的斜杠也不能省——创建会话的 `POST /api/game-distribution/play-sessions` 需要账号凭证。
* 前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token,api-server 未注册
* 该路径);`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内并被清 Cookie。
*/
function validatePlaySessionCookieIsolation() {
for (const nginxPath of NGINX_PATHS) {
+3 -2
View File
@@ -1101,8 +1101,9 @@ async function runSmokeCases(
`播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`,
);
// 创建会话的 `POST /api/game-distribution/play-sessions`(无尾斜杠)需要账号凭证,
// 必须继续走通用 `/api` 规则并保留 Cookie。
// 前缀边界:裸 `/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,