修复买断制付费评审问题:后台消耗统计、定价上限校验、付费入口自动化断言
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
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:
@@ -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,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user