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