修复买断制付费评审问题:后台消耗统计、定价上限校验、付费入口自动化断言
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
+1 -1
View File
@@ -115,6 +115,6 @@ curl -sSI -H 'Accept-Encoding: br' \
- 付费游戏的可玩入口是 `POST /api/game-distribution/games/<gameId>/play-session` 换到的 `/api/game-distribution/play-sessions/<token>/`,直接作为 sandbox iframe 的 `src`;包内相对资源沿同一前缀解析。该前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie),Cookie 一旦被转发到 `api-server` 播放网关就会命中它的 403 纵深防御,iframe 与包内每个资源都不可用。 - 付费游戏的可玩入口是 `POST /api/game-distribution/games/<gameId>/play-session` 换到的 `/api/game-distribution/play-sessions/<token>/`,直接作为 sandbox iframe 的 `src`;包内相对资源沿同一前缀解析。该前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie),Cookie 一旦被转发到 `api-server` 播放网关就会命中它的 403 纵深防御,iframe 与包内每个资源都不可用。
- 因此三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`,代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只多一条 `proxy_set_header Cookie ""`。 - 因此三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`,代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只多一条 `proxy_set_header Cookie ""`。
- `^~` 不能省:不加时 nginx 会先命中正则 location `~ ^/api(?:/|$)`,Cookie 又被转发回去。前缀末尾的斜杠也不能省:创建会话的 `POST /api/game-distribution/play-sessions` 需要账号凭证,必须继续走通用 `/api` 并保留 Cookie。 - `^~` 不能省:不加时 nginx 会先命中正则 location `~ ^/api(?:/|$)`,Cookie 又被转发回去。前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token):该形态继续走通用 `/api`,而 api-server 在这个前缀下只注册了 GET 入口与包内资源,创建会话是 `POST /api/game-distribution/games/<gameId>/play-session`,不在此前缀下,因此不存在「带账号凭证却落在清 Cookie 前缀里」的请求;`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内、都会被清 Cookie。
- 本地 dev 由 `vite.config.ts` 里排在 `/api/game-distribution` 之前的同名前缀规则做同一件事(`proxyReq.removeHeader('cookie')`);`api-server` 播放网关的 403 纵深防御不放宽,只保证边缘/dev 转发时不带 Cookie。 - 本地 dev 由 `vite.config.ts` 里排在 `/api/game-distribution` 之前的同名前缀规则做同一件事(`proxyReq.removeHeader('cookie')`);`api-server` 播放网关的 403 纵深防御不放宽,只保证边缘/dev 转发时不带 Cookie。
- 门禁:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` 前缀 location 存在、清空 Cookie、代理头齐全且排在通用 `/api` location 之前;`npm run check:pingora-route-parity` 断言矩阵里的 `play_sessions_gateway` 用例(以及 Pingora 的 `RouteDecision::PlaySessionGateway`)指向独立的清 Cookie 转发,不会被合并回通用 `/api` 规则。 - 门禁:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` 前缀 location 存在、清空 Cookie、代理头齐全且排在通用 `/api` location 之前;`npm run check:pingora-route-parity` 断言矩阵里的 `play_sessions_gateway` 用例(以及 Pingora 的 `RouteDecision::PlaySessionGateway`)指向独立的清 Cookie 转发,不会被合并回通用 `/api` 规则。
+3 -1
View File
@@ -121,7 +121,9 @@ server {
# 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而
# api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。
# 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。
# 只匹配带尾斜杠的前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api`。 # 只匹配带尾斜杠的前缀:`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内,
# 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET
# 入口与包内资源;创建会话是 `POST /api/game-distribution/games/<gameId>/play-session`,不在此前缀下。
location ^~ /api/game-distribution/play-sessions/ { location ^~ /api/game-distribution/play-sessions/ {
default_type application/json; default_type application/json;
client_max_body_size 210m; client_max_body_size 210m;
+3 -1
View File
@@ -149,7 +149,9 @@ server {
# 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而
# api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。
# 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。
# 只匹配带尾斜杠的前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api`。 # 只匹配带尾斜杠的前缀:`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内,
# 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET
# 入口与包内资源;创建会话是 `POST /api/game-distribution/games/<gameId>/play-session`,不在此前缀下。
location ^~ /api/game-distribution/play-sessions/ { location ^~ /api/game-distribution/play-sessions/ {
default_type application/json; default_type application/json;
client_max_body_size 210m; client_max_body_size 210m;
+2 -1
View File
@@ -74,6 +74,7 @@
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
"check:generated-bindings": "node scripts/check-generated-bindings.mjs", "check:generated-bindings": "node scripts/check-generated-bindings.mjs",
"check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs", "check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs",
"check:game-distribution-price-limit-parity": "node scripts/check-game-distribution-price-limit-parity.mjs",
"check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs", "check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs",
"check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs", "check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs",
"check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs", "check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs",
@@ -137,7 +138,7 @@
"check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && npm run check:module-runtime-artifact && node scripts/check-server-rs-ddd-boundaries.mjs", "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && npm run check:module-runtime-artifact && node scripts/check-server-rs-ddd-boundaries.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
"lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run check:nginx-spa-routes && npm run check:pingora-route-parity && npm run lint:eslint && npm run typecheck", "lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:game-distribution-price-limit-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run check:nginx-spa-routes && npm run check:pingora-route-parity && npm run lint:eslint && npm run typecheck",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
"format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml",
"format": "prettier --write . && npm run format:rust", "format": "prettier --write . && npm run format:rust",
@@ -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(?:/|$)'; const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)';
/** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */ /** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */
@@ -378,7 +379,8 @@ function findLocationBody(source, locationHeader) {
* `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403, * `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403,
* Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。 * Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。
* `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去; * `^~` 不能省——不加时正则 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() { function validatePlaySessionCookieIsolation() {
for (const nginxPath of NGINX_PATHS) { for (const nginxPath of NGINX_PATHS) {
+3 -2
View File
@@ -1101,8 +1101,9 @@ async function runSmokeCases(
`播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`, `播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`,
); );
// 创建会话的 `POST /api/game-distribution/play-sessions`(无尾斜杠)需要账号凭证, // 前缀边界:裸 `/api/game-distribution/play-sessions`(无 token)不是播放会话资源,
// 必须继续走通用 `/api` 规则并保留 Cookie。 // 必须继续走通用 `/api` 规则并保留 Cookie。api-server 在该前缀下只注册 GET 入口与包内资源,
// 创建会话是 `POST /api/game-distribution/games/{gameId}/play-session`(不在此前缀下)。
const createSessionBeforeCount = api.state.requests.length; const createSessionBeforeCount = api.state.requests.length;
await expectHttp( await expectHttp(
baseUrl, baseUrl,
+160 -29
View File
@@ -2781,27 +2781,72 @@ async fn fetch_admin_dashboard_wallet_stats(
if !range.contains_day_key(day_key) { if !range.contains_day_key(day_key) {
continue; continue;
} }
match source_type.as_str() { accumulate_admin_dashboard_wallet_ledger_row(
"asset_operation_consume" | "llm_router_consume" if amount_delta < 0 => { &mut stats,
stats &source_type,
.consumed_mud_points amount_delta,
.add(day_key, amount_delta.unsigned_abs()); day_key,
} );
// 生成失败 / 精选审核返还,以及 LLM Router 的正向冲正流水,都按退还对冲消耗。
"asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => {
stats.refunded_mud_points.add(day_key, amount_delta as u64);
}
"points_recharge" if amount_delta > 0 => {
stats.recharged_mud_points = stats
.recharged_mud_points
.saturating_add(amount_delta as u64);
}
_ => {}
}
} }
stats stats
} }
/// 单条钱包流水对后台面板的贡献。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminDashboardWalletLedgerEffect {
/// 真实扣费:资产操作、LLM Router 与买断制购买。
Consume,
/// 退还 / 冲正:生成失败与精选审核返还,以及 LLM Router 的正向冲正流水。
Refund,
/// 充值入账。
Recharge,
/// 不计入消耗、退还或充值。
Ignore,
}
/// 后台消耗口径的来源白名单:与运行时消费投影(`GamePurchase` 也计入消耗)保持一致,
/// 新增流水来源时必须同步这里,否则后台统计会静默丢失这一类消耗。
fn resolve_admin_dashboard_wallet_ledger_effect(
source_type: &str,
amount_delta: i64,
) -> AdminDashboardWalletLedgerEffect {
match source_type {
"asset_operation_consume" | "llm_router_consume" | "game_purchase" if amount_delta < 0 => {
AdminDashboardWalletLedgerEffect::Consume
}
"asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => {
AdminDashboardWalletLedgerEffect::Refund
}
"points_recharge" if amount_delta > 0 => AdminDashboardWalletLedgerEffect::Recharge,
_ => AdminDashboardWalletLedgerEffect::Ignore,
}
}
/// 把一条已通过日期筛选的流水累加进后台统计。
fn accumulate_admin_dashboard_wallet_ledger_row(
stats: &mut AdminDashboardWalletStats,
source_type: &str,
amount_delta: i64,
day_key: i64,
) {
match resolve_admin_dashboard_wallet_ledger_effect(source_type, amount_delta) {
AdminDashboardWalletLedgerEffect::Consume => {
stats
.consumed_mud_points
.add(day_key, amount_delta.unsigned_abs());
}
AdminDashboardWalletLedgerEffect::Refund => {
stats.refunded_mud_points.add(day_key, amount_delta as u64);
}
AdminDashboardWalletLedgerEffect::Recharge => {
stats.recharged_mud_points = stats
.recharged_mud_points
.saturating_add(amount_delta as u64);
}
AdminDashboardWalletLedgerEffect::Ignore => {}
}
}
async fn fetch_admin_dashboard_user_stats( async fn fetch_admin_dashboard_user_stats(
state: &AppState, state: &AppState,
range: &AdminDashboardRangeResolved, range: &AdminDashboardRangeResolved,
@@ -4800,6 +4845,7 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option<String> {
13 => "daily_free_reset", 13 => "daily_free_reset",
14 => "recharge_refund_recovery", 14 => "recharge_refund_recovery",
15 => "llm_router_consume", 15 => "llm_router_consume",
16 => "game_purchase",
_ => return Some(Value::Array(items.to_vec()).to_string()), _ => return Some(Value::Array(items.to_vec()).to_string()),
} }
.to_string(), .to_string(),
@@ -5090,14 +5136,15 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, AdminDashboardGranularity, AdminDashboardSeries, AdminDashboardWalletLedgerEffect,
EditorShowcaseAssetRecord, SpacetimeSchemaResponse, admin_dashboard_user_stats_from_record, AdminDashboardWalletStats, AdminDisplayNameDirectory, EditorShowcaseAssetRecord,
admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, SpacetimeSchemaResponse, accumulate_admin_dashboard_wallet_ledger_row,
admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters, admin_editor_asset_group_payload, admin_editor_asset_payload_from_record,
build_admin_asset_read_url_audit, build_admin_dashboard_chart, admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk,
build_admin_database_enum_labels, build_admin_database_table_row, apply_admin_database_table_filters, build_admin_asset_read_url_audit,
build_admin_editor_showcase_campaign_image_confirm_request, build_admin_dashboard_chart, build_admin_database_enum_labels,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql, build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_database_table_limit, build_spacetime_schema_url, clamp_admin_database_table_limit,
@@ -5110,10 +5157,11 @@ mod tests {
parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response,
parse_spacetime_sql_count_response, parse_timestamp_text_to_micros, parse_spacetime_sql_count_response, parse_timestamp_text_to_micros,
resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, resolve_admin_dashboard_range, resolve_admin_dashboard_range_at,
resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters, resolve_admin_dashboard_wallet_ledger_effect, resolve_admin_database_table_sql_limit,
sum_admin_user_recharged_cents, timestamp_value_to_micros, trim_preview, resolve_admin_editor_asset_filters, sum_admin_user_recharged_cents,
validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor,
verify_admin_password, wallet_ledger_source_type_to_string, validate_admin_external_api_key_query, verify_admin_password,
wallet_ledger_source_type_to_string,
}; };
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -6311,6 +6359,89 @@ mod tests {
wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(), wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(),
Some("llm_router_consume") Some("llm_router_consume")
); );
// 买断制购买是第 17 个变体(索引 16);漏映射时后台会直接展示原始数组文本。
assert_eq!(
wallet_ledger_source_type_to_string(&json!([16, []])).as_deref(),
Some("game_purchase")
);
assert_eq!(
wallet_ledger_source_type_to_string(&json!("GamePurchase")).as_deref(),
Some("game_purchase")
);
}
/// `game_purchase` 必须计入后台「消耗泥点」,否则买断消耗会整类从后台统计里丢失。
#[test]
fn dashboard_wallet_ledger_effect_counts_game_purchase_as_consumption() {
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("asset_operation_consume", -40),
AdminDashboardWalletLedgerEffect::Consume
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("llm_router_consume", -7),
AdminDashboardWalletLedgerEffect::Consume
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("game_purchase", -30),
AdminDashboardWalletLedgerEffect::Consume
);
// LLM Router 的正向冲正按退还对冲消耗;买断制没有退还来源,正向流水不计入。
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("llm_router_consume", 7),
AdminDashboardWalletLedgerEffect::Refund
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("asset_operation_refund", 40),
AdminDashboardWalletLedgerEffect::Refund
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("points_recharge", 100),
AdminDashboardWalletLedgerEffect::Recharge
);
// 未列入白名单的来源按不计入处理,避免新增变体被悄悄算进某一类。
for source_type in [
"snapshot_sync",
"membership_period_grant",
"redeem_code_reward",
] {
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect(source_type, 100),
AdminDashboardWalletLedgerEffect::Ignore,
"source_type={source_type}"
);
}
// 买断制购买没有成对的退还来源,正向流水不能算成退还。
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("game_purchase", 30),
AdminDashboardWalletLedgerEffect::Ignore
);
}
/// 一条买断流水要真的累加进面板序列,而不只是分类正确。
#[test]
fn dashboard_wallet_stats_accumulate_game_purchase_consumption() {
let mut stats = AdminDashboardWalletStats {
consumed_mud_points: AdminDashboardSeries::default(),
refunded_mud_points: AdminDashboardSeries::default(),
recharged_mud_points: 0,
warnings: vec![],
};
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "game_purchase", -30, 100);
accumulate_admin_dashboard_wallet_ledger_row(
&mut stats,
"asset_operation_consume",
-40,
100,
);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "llm_router_consume", 7, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "asset_operation_refund", 40, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "points_recharge", 100, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "snapshot_sync", 500, 100);
assert_eq!(stats.consumed_mud_points.value(100), 70);
assert_eq!(stats.consumed_mud_points.total(), 70);
assert_eq!(stats.refunded_mud_points.value(100), 47);
assert_eq!(stats.recharged_mud_points, 100);
} }
#[test] #[test]
@@ -1,5 +1,6 @@
use std::{ use std::{
collections::{BTreeMap, HashMap, VecDeque}, collections::{BTreeMap, HashMap, VecDeque},
future::Future,
io::Write, io::Write,
sync::{Arc, Mutex, OnceLock}, sync::{Arc, Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH}, time::{Instant, SystemTime, UNIX_EPOCH},
@@ -829,12 +830,49 @@ async fn serve_release_entry(
/// 公开发行网关。 /// 公开发行网关。
/// ///
/// 只服务当前已公开版本的游戏文件,路径必须在白名单内容类型内;私有 ZIP 对象和 /// 只服务当前已公开版本的游戏文件,路径必须在白名单内容类型内;私有 ZIP 对象和
/// 未公开版本不会因为知道 ID 而可读。 /// 未公开版本不会因为知道 ID 而可读。付费作品的公开同源路径同样关闭(404),
/// 判定全部发生在读取包字节之前。
async fn serve_release_asset( async fn serve_release_asset(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path((game_id, asset_path)): Path<(String, String)>, Path((game_id, asset_path)): Path<(String, String)>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let public_game_state = state.clone();
serve_public_release_asset(
headers,
game_id,
asset_path,
move |game_id: String| async move {
public_game_state
.spacetime_client()
.get_public_game_distribution_game(game_id)
.await
},
move |game_id: String, version_id: String| async move {
release_package_bytes(&state, &game_id, &version_id).await
},
)
.await
}
/// 把「公开投影读取」与「包字节读取」都做成注入依赖的发行网关实现。
///
/// 发行网关是公开无鉴权端点,包字节要经 OSS 读取;「付费作品必须 404 且不读包字节」这条契约
/// 只有在判定顺序可观测时才守得住,因此准入判定全部排在 `load_package` 之前——单测用记录
/// 调用次数的 loader 断言拒绝路径确实没有读字节,而不是靠人读代码确认顺序。
async fn serve_public_release_asset<G, GFut, P, PFut>(
headers: HeaderMap,
game_id: String,
asset_path: String,
load_public_game: G,
load_package: P,
) -> Result<Response, AppError>
where
G: FnOnce(String) -> GFut,
GFut: Future<Output = Result<Option<GameDistributionPublicGameRecord>, SpacetimeClientError>>,
P: FnOnce(String, String) -> PFut,
PFut: Future<Output = Result<Arc<Vec<u8>>, AppError>>,
{
// 发行文件必须由独立来源提供。带上平台 Cookie 的请求说明它正落在主站来源上, // 发行文件必须由独立来源提供。带上平台 Cookie 的请求说明它正落在主站来源上,
// 此时同源脚本可以读到平台会话,必须直接关闭而不是降级服务。 // 此时同源脚本可以读到平台会话,必须直接关闭而不是降级服务。
if headers.contains_key(header::COOKIE) { if headers.contains_key(header::COOKIE) {
@@ -858,9 +896,7 @@ async fn serve_release_asset(
); );
AppError::from_status(StatusCode::NOT_FOUND) AppError::from_status(StatusCode::NOT_FOUND)
})?; })?;
let public_game = state let public_game = load_public_game(game_id.clone())
.spacetime_client()
.get_public_game_distribution_game(game_id.clone())
.await .await
.map_err(map_spacetime_error)? .map_err(map_spacetime_error)?
.ok_or_else(|| { .ok_or_else(|| {
@@ -896,6 +932,7 @@ async fn serve_release_asset(
return Err(AppError::from_status(StatusCode::NOT_FOUND)); return Err(AppError::from_status(StatusCode::NOT_FOUND));
} }
// 付费作品只经播放会话路径开放;公开同源发行路径对所有人关闭,避免猜测 gameId 绕过购买。 // 付费作品只经播放会话路径开放;公开同源发行路径对所有人关闭,避免猜测 gameId 绕过购买。
// 这条判定必须留在 `load_package` 之前:否则付费包字节已经读出来了。
if public_game.game.price_mud_points > 0 { if public_game.game.price_mud_points > 0 {
debug!( debug!(
operation = "release_rejected", operation = "release_rejected",
@@ -907,7 +944,7 @@ async fn serve_release_asset(
); );
return Err(AppError::from_status(StatusCode::NOT_FOUND)); return Err(AppError::from_status(StatusCode::NOT_FOUND));
} }
let package = release_package_bytes(&state, &game_id, &version.version_id).await?; let package = load_package(game_id.clone(), version.version_id.clone()).await?;
let etag = release_asset_etag(&version.version_id, &asset_path); let etag = release_asset_etag(&version.version_id, &asset_path);
release_package_asset_response( release_package_asset_response(
&package, &package,
@@ -1804,9 +1841,12 @@ async fn create_version(
/// ///
/// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥, /// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥,
/// `require_bearer_auth` 无法区分,这里按角色显式拒绝,管理员免购买且绝不扣费。 /// `require_bearer_auth` 无法区分,这里按角色显式拒绝,管理员免购买且绝不扣费。
/// 现役登录链路下后台管理员令牌会先被 `/api/*` 用户路由的 `require_bearer_auth` 判为
/// 无效登录态(401),因此 403 分支只在「用户令牌带 admin 角色」时可达,保留为纵深防御。
/// ///
/// 错误口径:余额不足 400 `INSUFFICIENT_MUD_POINTS`;价格已由新版本改变 409;免费游戏 400; /// 错误口径:余额不足 400 `INSUFFICIENT_MUD_POINTS`;价格已由新版本改变 409;免费游戏 400;
/// 作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;管理员令牌 403;游戏不可见 / 不存在 404。 /// 作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;带 admin 角色的用户令牌 403
/// `GAME_PURCHASE_ADMIN_NOT_ALLOWED`;游戏不可见 / 不存在 404。
/// 免费游戏与已购买账号都不产生新扣费。 /// 免费游戏与已购买账号都不产生新扣费。
async fn purchase_game( async fn purchase_game(
State(state): State<AppState>, State(state): State<AppState>,
@@ -2950,6 +2990,39 @@ async fn serve_admin_version_preview_asset_inner(
/// 播放会话短时效:付费作品每次进入游戏都重新签发,过期后必须重新鉴权,不能长期留一个令牌。 /// 播放会话短时效:付费作品每次进入游戏都重新签发,过期后必须重新鉴权,不能长期留一个令牌。
const GAME_PLAY_SESSION_TTL_SECONDS: i64 = 2 * 60 * 60; const GAME_PLAY_SESSION_TTL_SECONDS: i64 = 2 * 60 * 60;
/// 付费作品播放会话的准入主体。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PaidPlaySessionViewer {
/// 后台管理员令牌:免购买试玩。
Admin,
/// 用户令牌:作者本人免购买,其余账号必须已有购买记录。
User { is_author: bool, has_purchase: bool },
}
/// 付费作品播放会话的准入判定(免费作品在更早的分支直接回公开入口,不进入这里)。
///
/// 管理员与作者本人免购买;其余账号必须已有购买记录,否则失败关闭——判定不通过时
/// 调用方直接返回,绝不会签发播放令牌。
fn resolve_paid_play_session_entitlement(viewer: PaidPlaySessionViewer) -> Result<(), AppError> {
match viewer {
PaidPlaySessionViewer::Admin => Ok(()),
PaidPlaySessionViewer::User {
is_author: true, ..
} => Ok(()),
PaidPlaySessionViewer::User {
is_author: false,
has_purchase: true,
} => Ok(()),
PaidPlaySessionViewer::User {
is_author: false,
has_purchase: false,
} => {
Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("这款游戏需要先购买才能游玩"))
}
}
}
/// 为付费作品签发播放会话;免费作品直接回现有公开入口,不引入新的游玩路径。 /// 为付费作品签发播放会话;免费作品直接回现有公开入口,不引入新的游玩路径。
/// ///
/// 鉴权顺序:管理员令牌(按现有后台鉴权判定,免购买)→ 用户令牌(作者本人或已购买)。 /// 鉴权顺序:管理员令牌(按现有后台鉴权判定,免购买)→ 用户令牌(作者本人或已购买)。
@@ -2990,7 +3063,10 @@ async fn create_game_play_session(
let admin = try_authenticate_admin_from_headers(&state, &headers).await; let admin = try_authenticate_admin_from_headers(&state, &headers).await;
let identity = match admin { let identity = match admin {
// 管理员免购买试玩:会话仍绑定具体身份,避免把管理身份折叠成匿名令牌。 // 管理员免购买试玩:会话仍绑定具体身份,避免把管理身份折叠成匿名令牌。
Some(admin) => admin.session().subject.clone(), Some(admin) => {
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::Admin)?;
admin.session().subject.clone()
}
None => { None => {
let authenticated = optional_access_token_from_headers( let authenticated = optional_access_token_from_headers(
&state, &state,
@@ -3005,17 +3081,20 @@ async fn create_game_play_session(
})?; })?;
let user_id = authenticated.claims().user_id().to_string(); let user_id = authenticated.claims().user_id().to_string();
let is_author = game.owner_user_id == user_id; let is_author = game.owner_user_id == user_id;
if !is_author { let has_purchase = if is_author {
false
} else {
let (purchase, _) = state let (purchase, _) = state
.spacetime_client() .spacetime_client()
.get_game_distribution_purchase(game_id.clone(), user_id.clone()) .get_game_distribution_purchase(game_id.clone(), user_id.clone())
.await .await
.map_err(map_spacetime_error)?; .map_err(map_spacetime_error)?;
if purchase.is_none() { purchase.is_some()
return Err(AppError::from_status(StatusCode::FORBIDDEN) };
.with_message("这款游戏需要先购买才能游玩")); resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
} is_author,
} has_purchase,
})?;
user_id user_id
} }
}; };
@@ -5537,6 +5616,200 @@ mod tests {
decoded decoded
} }
/// 付费作品的公开同源发行路径必须 404,并且**在读取包字节之前**就失败关闭:否则包字节
/// 已经经 OSS 读出,未购买者只差一个响应体。这里用记录调用次数的 loader 把顺序变成断言,
/// 而不是靠人工 E2E 或读代码确认。
#[tokio::test]
async fn paid_game_public_release_path_is_rejected_without_reading_package_bytes() {
let paid_game = || {
public_game_record(
paid_game_record("user_author", 30),
public_version_record(None, None),
)
};
let pending_version = || {
let mut version = public_version_record(None, None);
version.status = "pending_review".to_string();
public_game_record(paid_game_record("user_author", 30), version)
};
let platform_cookie = || {
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, HeaderValue::from_static("refresh=token"));
headers
};
let cases: [(
&str,
HeaderMap,
Option<GameDistributionPublicGameRecord>,
&str,
StatusCode,
); 7] = [
(
"付费作品入口",
HeaderMap::new(),
Some(paid_game()),
"index.html",
StatusCode::NOT_FOUND,
),
(
"付费作品包内资源",
HeaderMap::new(),
Some(paid_game()),
"assets/main.js",
StatusCode::NOT_FOUND,
),
(
"付费作品未知扩展名",
HeaderMap::new(),
Some(paid_game()),
"secret.bin",
StatusCode::NOT_FOUND,
),
(
"带平台 Cookie 的付费作品请求",
platform_cookie(),
Some(paid_game()),
"index.html",
StatusCode::FORBIDDEN,
),
(
"没有公开可玩版本",
HeaderMap::new(),
None,
"index.html",
StatusCode::NOT_FOUND,
),
(
"公开投影缺当前版本",
HeaderMap::new(),
Some(GameDistributionPublicGameRecord {
game: paid_game_record("user_author", 30),
current_version: None,
rating_summary: GameDistributionRatingSummaryRecord {
average_score: None,
rating_count: 0,
},
}),
"index.html",
StatusCode::NOT_FOUND,
),
(
"当前版本未公开",
HeaderMap::new(),
Some(pending_version()),
"index.html",
StatusCode::NOT_FOUND,
),
];
let package_reads = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
for (label, headers, public_game, asset_path, expected_status) in cases {
let package_reads = package_reads.clone();
let error = serve_public_release_asset(
headers,
"game_1".to_string(),
asset_path.to_string(),
move |_game_id| async move { Ok(public_game) },
move |game_id, version_id| {
let package_reads = package_reads.clone();
async move {
package_reads
.lock()
.expect("reads lock")
.push((game_id, version_id));
Ok(Arc::new(Vec::new()))
}
},
)
.await
.expect_err(label);
assert_eq!(error.status_code(), expected_status, "{label}");
}
assert_eq!(
package_reads.lock().expect("reads lock").len(),
0,
"被拒绝的发行请求不得读取包字节"
);
}
/// 免费作品照旧放行:包字节真的被读出并原样下发。
#[tokio::test]
async fn free_game_public_release_path_serves_the_package_asset() {
let marker = "genarrative-free-release-marker";
let package = release_package_fixture("assets/main.js", marker.as_bytes());
let reads = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let response = serve_public_release_asset(
HeaderMap::new(),
"game_1".to_string(),
"assets/main.js".to_string(),
move |_game_id| async move {
Ok(Some(public_game_record(
paid_game_record("user_author", 0),
public_version_record(Some("/games/game_1/"), None),
)))
},
{
let reads = reads.clone();
let package = package.clone();
move |game_id, version_id| {
let reads = reads.clone();
let package = package.clone();
async move {
reads
.lock()
.expect("reads lock")
.push((game_id, version_id));
Ok(Arc::new(package))
}
}
},
)
.await
.expect("免费作品的公开发行路径必须放行");
assert_eq!(
reads.lock().expect("reads lock").as_slice(),
[("game_1".to_string(), "version_1".to_string())]
);
assert_eq!(response.status(), StatusCode::OK);
let body = release_response_body(response).await;
assert!(
String::from_utf8_lossy(&body).contains(marker),
"响应正文必须包含包内资源字节"
);
}
/// 未购买账号的播放会话入口必须被拒;作者本人、已购买账号与管理员免购买放行。
#[test]
fn paid_play_session_entitlement_rejects_unpurchased_and_grants_owner_buyer_admin() {
assert!(
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::Admin).is_ok(),
"管理员免购买"
);
assert!(
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author: true,
has_purchase: false,
})
.is_ok(),
"作者本人免购买"
);
assert!(
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author: false,
has_purchase: true,
})
.is_ok(),
"已购买账号放行"
);
let refused = resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author: false,
has_purchase: false,
})
.expect_err("未购买账号必须被拒");
assert_eq!(refused.status_code(), StatusCode::FORBIDDEN);
assert_eq!(refused.message(), "这款游戏需要先购买才能游玩");
}
#[tokio::test] #[tokio::test]
async fn release_asset_response_gzips_accepted_text_and_keeps_binary_untouched() { async fn release_asset_response_gzips_accepted_text_and_keeps_binary_untouched() {
let script = "console.log('genarrative-game');\n".repeat(64); let script = "console.log('genarrative-game');\n".repeat(64);
@@ -1,6 +1,6 @@
use std::{error::Error, fmt}; use std::{error::Error, fmt};
use crate::{GameVersionStatus, GameVisibility}; use crate::{GameVersionStatus, GameVisibility, domain::MAX_GAME_PRICE_MUD_POINTS};
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameDistributionFieldError { pub enum GameDistributionFieldError {
@@ -33,7 +33,13 @@ impl fmt::Display for GameDistributionFieldError {
Self::MissingReviewReason => "拒绝审核必须提供理由", Self::MissingReviewReason => "拒绝审核必须提供理由",
Self::InvalidVersionNumber => "版本号必须是大于 0 的整数", Self::InvalidVersionNumber => "版本号必须是大于 0 的整数",
Self::VersionNumberExhausted => "版本号已达上限,无法自动递增", Self::VersionNumberExhausted => "版本号已达上限,无法自动递增",
Self::InvalidGamePrice => "priceMudPoints 必须是 0 到 1000000 之间的整数", // 上限只从常量取,避免改价格上限时漏改文案、前后端口径分叉。
Self::InvalidGamePrice => {
return write!(
formatter,
"priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数"
);
}
Self::MissingWalletLedgerId => "walletLedgerId 不能为空", Self::MissingWalletLedgerId => "walletLedgerId 不能为空",
}; };
formatter.write_str(message) formatter.write_str(message)
@@ -122,3 +128,19 @@ fn _visibility_is_exhaustive(visibility: GameVisibility) -> bool {
GameVisibility::Unpublished | GameVisibility::Published | GameVisibility::Suspended GameVisibility::Unpublished | GameVisibility::Published | GameVisibility::Suspended
) )
} }
#[cfg(test)]
mod tests {
use super::*;
/// 价格上限只在 `domain::MAX_GAME_PRICE_MUD_POINTS` 定义一次;文案必须插值该常量,
/// 否则改上限时前端 / AGC 会继续按旧上限校验,两侧口径分叉。
#[test]
fn invalid_game_price_message_interpolates_the_price_limit_constant() {
let message = GameDistributionFieldError::InvalidGamePrice.to_string();
assert_eq!(
message,
format!("priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数")
);
}
}
+6 -4
View File
@@ -1705,8 +1705,10 @@ fn release_gateway_upstream_path(path: &str) -> Option<String> {
/// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions/<token>/…`。 /// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions/<token>/…`。
/// ///
/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:只认带尾斜杠的 /// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:`/play-sessions/<token>`
/// 前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api` 路由。 /// 与 `/play-sessions/<token>/…` 都属于该前缀(前缀含 `<token>` 前的斜杠),只有裸前缀
/// `/api/game-distribution/play-sessions` 继续走通用 `/api`。该前缀下 api-server 只注册 GET 入口
/// 与包内资源;创建会话是 `POST /api/game-distribution/games/{gameId}/play-session`,不在此前缀下。
fn is_play_session_proxy_path(path: &str) -> bool { fn is_play_session_proxy_path(path: &str) -> bool {
path.starts_with("/api/game-distribution/play-sessions/") path.starts_with("/api/game-distribution/play-sessions/")
} }
@@ -4124,8 +4126,8 @@ mod tests {
); );
} }
// 创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api`, // 裸前缀 `/api/game-distribution/play-sessions`(无 token)仍然走通用 `/api`:
// 它需要账号凭证,绝不能跟着清空 Cookie。 // 它不是播放会话资源,绝不能跟着清空 Cookie。
for path in [ for path in [
"/api/game-distribution/play-sessions", "/api/game-distribution/play-sessions",
"/api/game-distribution/games/game_1/play-session", "/api/game-distribution/games/game_1/play-session",
@@ -3794,6 +3794,40 @@ fn resolve_game_distribution_purchase_decision(
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
/// 购买事务在扣费前的完整前置判定结果。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GamePurchasePreChargePlan {
/// 换幂等键重复购买:已有所有权时只补记收据并回传既有记录,绝不扣费。
ReplayExistingOwnership,
/// 首次购买:判定与期望价都已通过,允许进入扣费步骤。
Charge,
}
/// 扣费前的唯一放行口:`Free` 失败关闭;`AlreadyOwned` 只重放;只有 `Purchasable` 且期望价
/// 一致才允许扣费。
///
/// 事务里 `consume_profile_wallet_points_for_game_purchase` 只能出现在本函数的 `Charge`
/// 分支之后,所以「先判定后扣费」是这条纯函数上的可断言语义,不再依赖源码扫描。
fn resolve_game_purchase_pre_charge_plan(
decision: module_game_distribution::GamePurchaseDecision,
expected_price_mud_points: u64,
price_mud_points: u64,
) -> Result<GamePurchasePreChargePlan, String> {
match decision {
module_game_distribution::GamePurchaseDecision::Free => {
return Err("免费游戏不需要购买".to_string());
}
module_game_distribution::GamePurchaseDecision::AlreadyOwned => {
return Ok(GamePurchasePreChargePlan::ReplayExistingOwnership);
}
module_game_distribution::GamePurchaseDecision::Purchasable => {}
}
if expected_price_mud_points != price_mud_points {
return Err("价格已变化,请刷新后重试".to_string());
}
Ok(GamePurchasePreChargePlan::Charge)
}
/// 购买事务:先查幂等收据与现有所有权,再校验可见性/价格,最后扣费 + 落购买行 + 记收据。 /// 购买事务:先查幂等收据与现有所有权,再校验可见性/价格,最后扣费 + 落购买行 + 记收据。
fn purchase_game_distribution_game_tx( fn purchase_game_distribution_game_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
@@ -3839,12 +3873,13 @@ fn purchase_game_distribution_game_tx(
&user_id, &user_id,
)?; )?;
let price_mud_points = game.price_mud_points; let price_mud_points = game.price_mud_points;
match decision { match resolve_game_purchase_pre_charge_plan(
module_game_distribution::GamePurchaseDecision::Free => { decision,
return Err("免费游戏不需要购买".to_string()); input.expected_price_mud_points,
} price_mud_points,
)? {
// 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。 // 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。
module_game_distribution::GamePurchaseDecision::AlreadyOwned => { GamePurchasePreChargePlan::ReplayExistingOwnership => {
let purchase = existing_purchase.expect("已拥有的判定必然对应既有购买记录"); let purchase = existing_purchase.expect("已拥有的判定必然对应既有购买记录");
insert_game_distribution_receipt( insert_game_distribution_receipt(
ctx, ctx,
@@ -3870,10 +3905,8 @@ fn purchase_game_distribution_game_tx(
true, true,
)); ));
} }
module_game_distribution::GamePurchaseDecision::Purchasable => {} // 只有「可购买 + 期望价一致」才会走到这里,扣费前不再有其它分支。
} GamePurchasePreChargePlan::Charge => {}
if input.expected_price_mud_points != price_mud_points {
return Err("价格已变化,请刷新后重试".to_string());
} }
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
@@ -4383,35 +4416,33 @@ mod tests {
); );
} }
/// `ReducerContext` 无法在单测里构造,事务只能靠源扫描兜底:生产路径必须复用领域判定, /// 扣费前置:免费与价格已变化都失败关闭,已拥有只重放不扣费,只有可购买才允许进入扣费。
/// 且必须先判定后扣费,不得内联第二份可购买性判断。 ///
/// 这条断言直接打在生产事务唯一的扣费前放行口上(`consume_profile_wallet_points_for_game_purchase`
/// 只出现在 `Charge` 分支之后),因此重构不会误报,也不会因为注释里多写一句反例就变红。
#[test] #[test]
fn purchase_transaction_reuses_domain_decision_before_charging() { fn purchase_pre_charge_plan_gates_every_non_purchasable_decision() {
let source = include_str!("game_distribution.rs"); use module_game_distribution::GamePurchaseDecision;
let body = source
.split("fn purchase_game_distribution_game_tx(") assert_eq!(
.nth(1) resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Free, 100, 100)
.expect("购买事务应存在"); .expect_err("免费游戏不能购买"),
let body = &body[..body.find("\nfn ").unwrap_or(body.len())]; "免费游戏不需要购买"
assert!(
body.contains("resolve_game_distribution_purchase_decision("),
"生产事务必须调用领域购买判定"
); );
assert!( // 已有所有权:即使客户端带的期望价与当前价不一致也只重放,绝不扣费。
body.contains("public_game_distribution_version(ctx, &game).is_some()"), assert_eq!(
"可玩性口径必须与公开投影一致(活动版本存在且 published)" resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::AlreadyOwned, 0, 100),
Ok(GamePurchasePreChargePlan::ReplayExistingOwnership)
); );
assert!( assert_eq!(
!body.contains("game.active_version_id.is_none()"), resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Purchasable, 100, 999)
"不得再用 active_version_id 直接判可购买" .expect_err("价格变化必须失败关闭"),
"价格已变化,请刷新后重试"
);
assert_eq!(
resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Purchasable, 100, 100),
Ok(GamePurchasePreChargePlan::Charge)
); );
let decision = body
.find("resolve_game_distribution_purchase_decision(")
.expect("判定应存在");
let charge = body
.find("consume_profile_wallet_points_for_game_purchase")
.expect("扣费应存在");
assert!(decision < charge, "必须先判定后扣费");
} }
/// M1 的事务侧:审核通过时把冻结资料里的价格整体生效到游戏行;历史版本缺 `priceMudPoints` /// M1 的事务侧:审核通过时把冻结资料里的价格整体生效到游戏行;历史版本缺 `priceMudPoints`
@@ -2926,7 +2926,7 @@ mod tests {
} }
#[test] #[test]
fn historical_wallet_spend_counts_asset_and_llm_consumption() { fn historical_wallet_spend_counts_every_consumption_source() {
assert_eq!( assert_eq!(
sum_historical_profile_wallet_consumed_points([ sum_historical_profile_wallet_consumed_points([
( (
@@ -2939,6 +2939,8 @@ mod tests {
), ),
(RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, -7), (RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, -7),
(RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, 2), (RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, 2),
(RuntimeProfileWalletLedgerSourceType::GamePurchase, -30),
(RuntimeProfileWalletLedgerSourceType::GamePurchase, 30),
( (
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
40 40
@@ -2949,10 +2951,36 @@ mod tests {
), ),
(RuntimeProfileWalletLedgerSourceType::PointsRecharge, 100), (RuntimeProfileWalletLedgerSourceType::PointsRecharge, 100),
]), ]),
55 85
); );
} }
/// 买断制购买必须与资产 / LLM 扣费同口径计入消耗:漏一处会让消费投影凭空少算整类扣费。
#[test]
fn wallet_consumption_source_predicate_includes_game_purchase() {
for source_type in [
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
RuntimeProfileWalletLedgerSourceType::LlmRouterConsume,
RuntimeProfileWalletLedgerSourceType::GamePurchase,
] {
assert!(
profile_wallet_ledger_source_counts_as_consumption(source_type),
"应计入消耗:{source_type:?}"
);
}
for source_type in [
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery,
RuntimeProfileWalletLedgerSourceType::SnapshotSync,
] {
assert!(
!profile_wallet_ledger_source_counts_as_consumption(source_type),
"不应计入消耗:{source_type:?}"
);
}
}
#[test] #[test]
fn historical_wallet_spend_saturates_instead_of_overflowing() { fn historical_wallet_spend_saturates_instead_of_overflowing() {
assert_eq!( assert_eq!(
@@ -5476,11 +5504,8 @@ fn initialize_profile_wallet_consumption_projections(
for row in ctx.db.profile_wallet_ledger().iter() { for row in ctx.db.profile_wallet_ledger().iter() {
scanned_ledger_count = scanned_ledger_count.saturating_add(1); scanned_ledger_count = scanned_ledger_count.saturating_add(1);
let total = totals.entry(row.user_id).or_default(); let total = totals.entry(row.user_id).or_default();
if matches!( if profile_wallet_ledger_source_counts_as_consumption(row.source_type)
row.source_type, && row.amount_delta < 0
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
) && row.amount_delta < 0
{ {
*total = total.saturating_add(row.amount_delta.unsigned_abs()); *total = total.saturating_add(row.amount_delta.unsigned_abs());
} }
@@ -5602,18 +5627,28 @@ fn rebuild_profile_wallet_consumption_total(ctx: &ReducerContext, user_id: &str)
) )
} }
/// 是否计入「消耗」口径:资产操作扣费、LLM Router 扣费与买断制购买扣费。
///
/// 消费投影增量、缺失行重建与历史花费重建共用这一判定,新增扣费来源只补一处,
/// 否则某个入口会静默漏算整类消耗。
fn profile_wallet_ledger_source_counts_as_consumption(
source_type: RuntimeProfileWalletLedgerSourceType,
) -> bool {
matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
| RuntimeProfileWalletLedgerSourceType::GamePurchase
)
}
fn sum_historical_profile_wallet_consumed_points( fn sum_historical_profile_wallet_consumed_points(
entries: impl IntoIterator<Item = (RuntimeProfileWalletLedgerSourceType, i64)>, entries: impl IntoIterator<Item = (RuntimeProfileWalletLedgerSourceType, i64)>,
) -> u64 { ) -> u64 {
entries entries
.into_iter() .into_iter()
.fold(0_u64, |total, (source_type, amount_delta)| { .fold(0_u64, |total, (source_type, amount_delta)| {
if matches!( if profile_wallet_ledger_source_counts_as_consumption(source_type) && amount_delta < 0 {
source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
) && amount_delta < 0
{
total.saturating_add(amount_delta.unsigned_abs()) total.saturating_add(amount_delta.unsigned_abs())
} else { } else {
total total
@@ -10646,12 +10681,7 @@ fn apply_profile_wallet_signed_delta(
metadata_json: Some(ledger_metadata_json), metadata_json: Some(ledger_metadata_json),
}); });
if matches!( if profile_wallet_ledger_source_counts_as_consumption(source_type) && amount_delta < 0 {
source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
) && amount_delta < 0
{
record_profile_wallet_consumption( record_profile_wallet_consumption(
ctx, ctx,
user_id, user_id,
+3 -1
View File
@@ -701,7 +701,9 @@ export default defineConfig(({ mode }) => {
// 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 sandbox // 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 sandbox
// iframe 的 src,包内相对资源沿同一前缀解析。它必须排在通用 `/api/game-distribution` // iframe 的 src,包内相对资源沿同一前缀解析。它必须排在通用 `/api/game-distribution`
// 规则之前,否则 Cookie 会被带到 api-server 播放网关并触发 403(纵深防御保留); // 规则之前,否则 Cookie 会被带到 api-server 播放网关并触发 403(纵深防御保留);
// 生产由 nginx 同名前缀 location 做同一件事。 // 生产由 nginx 同名前缀 location 做同一件事。前缀键同时覆盖 `/play-sessions/<token>`
// (无尾斜杠);创建会话的 `POST /api/game-distribution/games/<gameId>/play-session`
// 不在此前缀下,仍走通用 `/api/game-distribution` 并保留 Cookie。
'/api/game-distribution/play-sessions/': { '/api/game-distribution/play-sessions/': {
target: runtimeServerTarget, target: runtimeServerTarget,
changeOrigin: true, changeOrigin: true,