diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index f41e962a6..34db5d5ed 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -115,6 +115,6 @@ curl -sSI -H 'Accept-Encoding: br' \ - 付费游戏的可玩入口是 `POST /api/game-distribution/games//play-session` 换到的 `/api/game-distribution/play-sessions//`,直接作为 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 ""`。 -- `^~` 不能省:不加时 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//play-session`,不在此前缀下,因此不存在「带账号凭证却落在清 Cookie 前缀里」的请求;`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内、都会被清 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` 规则。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index a51f7032e..89b6ecd03 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -121,7 +121,9 @@ server { # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 - # 只匹配带尾斜杠的前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api`。 + # 只匹配带尾斜杠的前缀:`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内, + # 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET + # 入口与包内资源;创建会话是 `POST /api/game-distribution/games//play-session`,不在此前缀下。 location ^~ /api/game-distribution/play-sessions/ { default_type application/json; client_max_body_size 210m; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index ac4d28eb2..987b5f9e1 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -149,7 +149,9 @@ server { # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 - # 只匹配带尾斜杠的前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api`。 + # 只匹配带尾斜杠的前缀:`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内, + # 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET + # 入口与包内资源;创建会话是 `POST /api/game-distribution/games//play-session`,不在此前缀下。 location ^~ /api/game-distribution/play-sessions/ { default_type application/json; client_max_body_size 210m; diff --git a/package.json b/package.json index 6561d8567..201fd65ae 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.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-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-owner-isolation": "node scripts/check-game-distribution-owner-isolation.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", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "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 .", "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", diff --git a/scripts/check-game-distribution-price-limit-parity.mjs b/scripts/check-game-distribution-price-limit-parity.mjs new file mode 100644 index 000000000..e29ade30e --- /dev/null +++ b/scripts/check-game-distribution-price-limit-parity.mjs @@ -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} 处声明一致,` + + `且网页侧别名引用共享常量`, +); diff --git a/scripts/check-game-distribution-purchase-e2e.mjs b/scripts/check-game-distribution-purchase-e2e.mjs index dbeacdf06..e6761f21b 100644 --- a/scripts/check-game-distribution-purchase-e2e.mjs +++ b/scripts/check-game-distribution-purchase-e2e.mjs @@ -13,7 +13,9 @@ // 默认不自行起服务:直接从仓库根 `.app/dev-stack.json` 读取实际端口(api-server 与 // 主站 Vite),端口漂移后仍以文件为准,不臆断 3000 / 8082 这类默认值。 // E2E_API_BASE / E2E_WEB_BASE 可显式覆盖两个地址;E2E_WEB_BASE 缺省且本地没有 -// 主站 Vite 时,平台同源路径 `/games//` 只做 WARN,不误判为通过。 +// 主站 Vite 时,平台同源路径 `/games//` 记 SKIP 并在报告末尾汇总(既不算 PASS +// 也不算 FAIL);设置 E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,避免把「没验过」 +// 当成通过。脚本末尾的汇总行同时给出 PASS / FAIL / WARN / SKIP 四项计数。 // E2E_PRICE_MUD_POINTS 覆盖付费游戏定价(默认 30)。 // E2E_ADMIN_USER / E2E_ADMIN_PASSWORD 必填,脚本不读取仓库内任何凭据文件。 // 「购买记录条数」用 `spacetime sql` 直读 `game_distribution_purchase` 做交叉验证; @@ -52,7 +54,10 @@ import JSZip from 'jszip'; const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const DEV_STACK_PATH = resolve(REPO_ROOT, '.app/dev-stack.json'); -const STACK_LOG_PATH = resolve(REPO_ROOT, 'logs/e2e-game-distribution-purchase.log'); +const STACK_LOG_PATH = resolve( + REPO_ROOT, + 'logs/e2e-game-distribution-purchase.log', +); const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; @@ -74,6 +79,13 @@ if (!Number.isInteger(PRICE_MUD_POINTS) || PRICE_MUD_POINTS <= 0) { } const START_STACK = (process.env.E2E_START_STACK ?? '').trim() === '1'; +/** + * 设置后,平台同源断言缺少主站地址时直接 FAIL(而不是 SKIP)。 + * + * 发布前验收或 CI 化演练应打开它:那时「没验过」必须当成失败,不能当成通过。 + */ +const REQUIRE_PLATFORM_ORIGIN = + (process.env.E2E_REQUIRE_PLATFORM_ORIGIN ?? '').trim() === '1'; const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase'; /** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */ @@ -83,15 +95,31 @@ const COVER_PNG = Buffer.from( ); let failures = 0; +let passes = 0; +let warnings = 0; +/** 被跳过的断言:缺配置时既不算 PASS 也不算 FAIL,但必须在报告末尾显式列出。 */ +const skips = []; function check(name, ok, detail = '') { - if (!ok) failures += 1; - console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`); + if (ok) { + passes += 1; + } else { + failures += 1; + } + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); } function warn(name, detail = '') { + warnings += 1; console.log(`WARN ${name}${detail ? ` :: ${detail}` : ''}`); } +function skip(name, detail = '') { + skips.push({ name, detail }); + console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`); +} + function readDevStack() { try { return JSON.parse(readFileSync(DEV_STACK_PATH, 'utf8')); @@ -101,7 +129,9 @@ function readDevStack() { } function normalizeBase(value) { - return String(value ?? '').trim().replace(/\/+$/u, ''); + return String(value ?? '') + .trim() + .replace(/\/+$/u, ''); } // api-server / 主站 Vite 的实际地址只认显式覆盖或 .app/dev-stack.json,不用默认端口兜底。 @@ -299,37 +329,56 @@ async function createVersionAndSubmit({ }, }); if (version.status !== 200 || !version.data?.versionId) { - throw new Error(`创建版本失败 ${version.status} ${version.text.slice(0, 300)}`); + throw new Error( + `创建版本失败 ${version.status} ${version.text.slice(0, 300)}`, + ); } const versionId = version.data.versionId; - const upload = await api(`/api/game-distribution/versions/${versionId}/package`, { - method: 'PUT', - token: authorToken, - headers: { - 'Idempotency-Key': `e2e-purchase-upload-${id}`, - 'Content-Type': 'application/zip', + const upload = await api( + `/api/game-distribution/versions/${versionId}/package`, + { + method: 'PUT', + token: authorToken, + headers: { + 'Idempotency-Key': `e2e-purchase-upload-${id}`, + 'Content-Type': 'application/zip', + }, + binary: pkg.bytes, }, - binary: pkg.bytes, - }); + ); if (upload.status !== 200 || upload.data?.status !== 'uploaded') { - throw new Error(`上传发行包失败 ${upload.status} ${upload.text.slice(0, 300)}`); + throw new Error( + `上传发行包失败 ${upload.status} ${upload.text.slice(0, 300)}`, + ); } - const submitted = await api(`/api/game-distribution/versions/${versionId}/submit`, { - method: 'POST', - token: authorToken, - headers: { 'Idempotency-Key': `e2e-purchase-submit-${id}` }, - body: { expectedPublicationRevision }, - }); + const submitted = await api( + `/api/game-distribution/versions/${versionId}/submit`, + { + method: 'POST', + token: authorToken, + headers: { 'Idempotency-Key': `e2e-purchase-submit-${id}` }, + body: { expectedPublicationRevision }, + }, + ); if (submitted.status !== 202) { - throw new Error(`送审失败 ${submitted.status} ${submitted.text.slice(0, 300)}`); + throw new Error( + `送审失败 ${submitted.status} ${submitted.text.slice(0, 300)}`, + ); } return { versionId, pkg }; } /** 走完「建游戏 → 建版本(冻结价格)→ 传包 → 送审 → 管理员通过」的真实发布链路。 */ -async function publishGame({ authorToken, adminToken, coverAssetId, price, marker, title }) { +async function publishGame({ + authorToken, + adminToken, + coverAssetId, + price, + marker, + title, +}) { const id = stamp(); const metadata = gameMetadata(title, coverAssetId); const created = await api('/api/game-distribution/games', { @@ -339,7 +388,9 @@ async function publishGame({ authorToken, adminToken, coverAssetId, price, marke body: metadata, }); if (created.status !== 200 || !created.data?.id) { - throw new Error(`创建游戏失败 ${created.status} ${created.text.slice(0, 300)}`); + throw new Error( + `创建游戏失败 ${created.status} ${created.text.slice(0, 300)}`, + ); } const gameId = created.data.id; @@ -364,12 +415,15 @@ async function publishGame({ authorToken, adminToken, coverAssetId, price, marke headers: { 'Idempotency-Key': `e2e-purchase-approve-${id}` }, body: { decision: 'approve', - expectedPublicationRevision: readback.data?.version?.publicationRevision ?? 0, + expectedPublicationRevision: + readback.data?.version?.publicationRevision ?? 0, }, }, ); if (approved.status !== 200) { - throw new Error(`审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`); + throw new Error( + `审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`, + ); } return { gameId, versionId, pkg }; } @@ -444,9 +498,28 @@ function gamePurchaseLedgerCount(entries) { ).length; } -/** 平台同源发行入口:只有主站 Vite 在跑时才能真实走 `/games//`。 */ -async function fetchPlatformEntry(gameId, assetPath = '') { - if (!WEB) return null; +/** + * 平台同源发行入口:只有主站 Vite 在跑时才能真实走 `/games//`。 + * + * 缺主站地址时不再静默返回 null:显式记录 SKIP 并在报告末尾汇总;设置 + * `E2E_REQUIRE_PLATFORM_ORIGIN=1` 时缺配置直接 FAIL,避免「没验过」被当成通过。 + */ +async function fetchPlatformEntry(assertionName, gameId, assetPath = '') { + if (!WEB) { + if (REQUIRE_PLATFORM_ORIGIN) { + check( + assertionName, + false, + 'E2E_REQUIRE_PLATFORM_ORIGIN=1 但缺少 E2E_WEB_BASE / dev-stack 主站地址', + ); + } else { + skip( + assertionName, + '未发现主站 Vite(缺少 E2E_WEB_BASE),本轮未验证平台同源路径', + ); + } + return null; + } const response = await fetch(`${WEB}/games/${gameId}/${assetPath}`); const body = await response.text(); return { status: response.status, body }; @@ -559,7 +632,10 @@ function stopStack(dumpLogs) { const instanceId = recorded?.instanceId; if (instanceId) { for (const service of Object.values(recorded?.services ?? {})) { - if (service?.instanceId === instanceId && Number.isInteger(service?.pid)) { + if ( + service?.instanceId === instanceId && + Number.isInteger(service?.pid) + ) { pids.add(String(service.pid)); } } @@ -621,18 +697,34 @@ async function main() { // 1. 账号与管理员 const author = await registerAccount('137'); - check('作者账号注册拿到 token', author.status === 200 && Boolean(author.token), `status=${author.status}`); + check( + '作者账号注册拿到 token', + author.status === 200 && Boolean(author.token), + `status=${author.status}`, + ); const buyer = await registerAccount('138'); - check('买家账号注册拿到 token', buyer.status === 200 && Boolean(buyer.token), `status=${buyer.status}`); + check( + '买家账号注册拿到 token', + buyer.status === 200 && Boolean(buyer.token), + `status=${buyer.status}`, + ); const stranger = await registerAccount('139'); - check('未购买账号注册拿到 token', stranger.status === 200 && Boolean(stranger.token), `status=${stranger.status}`); + check( + '未购买账号注册拿到 token', + stranger.status === 200 && Boolean(stranger.token), + `status=${stranger.status}`, + ); const adminLogin = await api('/admin/api/login', { method: 'POST', body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, }); const adminToken = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check('管理员登录成功', adminLogin.status === 200 && Boolean(adminToken), `status=${adminLogin.status}`); + check( + '管理员登录成功', + adminLogin.status === 200 && Boolean(adminToken), + `status=${adminLogin.status}`, + ); if (!author.token || !buyer.token || !stranger.token || !adminToken) { console.error('[e2e] 缺少必要身份,无法继续'); return 1; @@ -652,10 +744,18 @@ async function main() { description: 'E2E 买断制付费发布灰度', }, }); - check('发布灰度可开启并放量', gateOpen.status === 200, `status=${gateOpen.status}`); + check( + '发布灰度可开启并放量', + gateOpen.status === 200, + `status=${gateOpen.status}`, + ); const coverAssetId = await uploadCover(author.token, stamp()); - check('作者封面素材直传并 confirm', Boolean(coverAssetId), String(coverAssetId).slice(0, 24)); + check( + '作者封面素材直传并 confirm', + Boolean(coverAssetId), + String(coverAssetId).slice(0, 24), + ); // 2. 付费游戏:发布 → 审核通过 const paidMarker = `E2E-PAID-OK-${stamp()}`; @@ -667,13 +767,20 @@ async function main() { marker: paidMarker, title: `买断制支付验证 ${stamp().slice(-6)}`, }); - check('付费游戏发布并审核通过', Boolean(paid.gameId && paid.versionId), `gameId=${paid.gameId}`); + check( + '付费游戏发布并审核通过', + Boolean(paid.gameId && paid.versionId), + `gameId=${paid.gameId}`, + ); // 3. 未购买用户:看得到资料与价格,但拿不到任何可播放入口 - const anonymousDetail = await api(`/api/game-distribution/games/${paid.gameId}`); + const anonymousDetail = await api( + `/api/game-distribution/games/${paid.gameId}`, + ); check( '匿名公开详情展示价格', - anonymousDetail.status === 200 && anonymousDetail.data?.priceMudPoints === PRICE_MUD_POINTS, + anonymousDetail.status === 200 && + anonymousDetail.data?.priceMudPoints === PRICE_MUD_POINTS, `status=${anonymousDetail.status} price=${anonymousDetail.data?.priceMudPoints}`, ); check( @@ -683,9 +790,12 @@ async function main() { `purchased=${anonymousDetail.data?.purchased} entryUrl=${String(anonymousDetail.data?.currentVersion?.entryUrl)}`, ); - const strangerDetail = await api(`/api/game-distribution/games/${paid.gameId}`, { - token: stranger.token, - }); + const strangerDetail = await api( + `/api/game-distribution/games/${paid.gameId}`, + { + token: stranger.token, + }, + ); check( '未购买账号详情 purchased=false 且无发行入口', strangerDetail.status === 200 && @@ -694,7 +804,9 @@ async function main() { `status=${strangerDetail.status} purchased=${strangerDetail.data?.purchased} entryUrl=${String(strangerDetail.data?.currentVersion?.entryUrl)}`, ); - const paidGateway = await fetch(`${API}/api/game-distribution/releases/${paid.gameId}`); + const paidGateway = await fetch( + `${API}/api/game-distribution/releases/${paid.gameId}`, + ); const paidGatewayBody = await paidGateway.text(); check( '付费作品直连发行网关入口 404', @@ -710,7 +822,10 @@ async function main() { `status=${paidGatewayAsset.status}`, ); - const paidPlatformEntry = await fetchPlatformEntry(paid.gameId); + const paidPlatformEntry = await fetchPlatformEntry( + '付费作品平台同源 /games// 404', + paid.gameId, + ); if (paidPlatformEntry) { check( '付费作品平台同源 /games// 404', @@ -747,12 +862,15 @@ async function main() { ); const purchaseKey = `e2e-purchase-${stamp()}`; - const purchase = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, { - method: 'POST', - token: buyer.token, - headers: { 'Idempotency-Key': purchaseKey }, - body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, - }); + const purchase = await api( + `/api/game-distribution/games/${paid.gameId}/purchase`, + { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': purchaseKey }, + body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, + }, + ); check( '购买成功并扣费', purchase.status === 200 && @@ -777,12 +895,15 @@ async function main() { ); // 同 key 重放:必须回放既有购买,不再扣费 - const replay = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, { - method: 'POST', - token: buyer.token, - headers: { 'Idempotency-Key': purchaseKey }, - body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, - }); + const replay = await api( + `/api/game-distribution/games/${paid.gameId}/purchase`, + { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': purchaseKey }, + body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, + }, + ); check( '同 Idempotency-Key 重放只扣一次', replay.status === 200 && @@ -793,12 +914,15 @@ async function main() { ); // 换 key 重复购买:已有所有权,同样不再扣费 - const duplicate = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, { - method: 'POST', - token: buyer.token, - headers: { 'Idempotency-Key': `e2e-purchase-${stamp()}` }, - body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, - }); + const duplicate = await api( + `/api/game-distribution/games/${paid.gameId}/purchase`, + { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': `e2e-purchase-${stamp()}` }, + body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, + }, + ); check( '换 key 重复购买只扣一次', duplicate.status === 200 && @@ -902,7 +1026,9 @@ async function main() { const refreshSetCookie = cookieLogin.setCookies.find((entry) => /refresh|session/iu.test(entry), ); - const cookiePath = refreshSetCookie ? refreshCookiePath(refreshSetCookie) : ''; + const cookiePath = refreshSetCookie + ? refreshCookiePath(refreshSetCookie) + : ''; check( '取到平台 refresh Cookie 且 Path 限定非 /api/game-distribution', cookieLogin.status === 200 && @@ -970,7 +1096,11 @@ async function main() { marker: freeMarker, title: `免费游戏回归 ${stamp().slice(-6)}`, }); - check('免费游戏发布并审核通过', Boolean(free.gameId && free.versionId), `gameId=${free.gameId}`); + check( + '免费游戏发布并审核通过', + Boolean(free.gameId && free.versionId), + `gameId=${free.gameId}`, + ); const freeDetail = await api(`/api/game-distribution/games/${free.gameId}`); check( @@ -981,7 +1111,9 @@ async function main() { `status=${freeDetail.status} price=${freeDetail.data?.priceMudPoints} entryUrl=${String(freeDetail.data?.currentVersion?.entryUrl)}`, ); - const freeGateway = await fetch(`${API}/api/game-distribution/releases/${free.gameId}/`); + const freeGateway = await fetch( + `${API}/api/game-distribution/releases/${free.gameId}/`, + ); const freeGatewayBody = await freeGateway.text(); check( '免费游戏发行网关直接可玩', @@ -989,11 +1121,15 @@ async function main() { `status=${freeGateway.status} marker=${freeGatewayBody.includes(freeMarker)}`, ); - const freePlatformEntry = await fetchPlatformEntry(free.gameId); + const freePlatformEntry = await fetchPlatformEntry( + '免费游戏平台同源 /games// 直接可玩', + free.gameId, + ); if (freePlatformEntry) { check( '免费游戏平台同源 /games// 直接可玩', - freePlatformEntry.status === 200 && freePlatformEntry.body.includes(freeMarker), + freePlatformEntry.status === 200 && + freePlatformEntry.body.includes(freeMarker), `status=${freePlatformEntry.status} marker=${freePlatformEntry.body.includes(freeMarker)}`, ); } @@ -1047,13 +1183,19 @@ async function main() { `balance=${balanceAfterFailure}/${balanceBeforeFailure} ledger=${ledgerAfterFailure}/${ledgerBeforeFailure}`, ); } else { - warn('跳过余额不足用例', `买家余额 ${buyerBefore} 已达上限价,无法构造不足场景`); + warn( + '跳过余额不足用例', + `买家余额 ${buyerBefore} 已达上限价,无法构造不足场景`, + ); } // 9. 作者本人免购买(作者对自己的付费作品有免购买权,且绝不扣费) - const authorDetail = await api(`/api/game-distribution/games/${paid.gameId}`, { - token: author.token, - }); + const authorDetail = await api( + `/api/game-distribution/games/${paid.gameId}`, + { + token: author.token, + }, + ); check( '作者读取自己付费作品详情:有发行入口但 purchased=false', authorDetail.status === 200 && @@ -1088,7 +1230,8 @@ async function main() { const authorRows = purchaseRowCount(jwtSubject(author.token), paid.gameId); check( '作者被拒后余额与 game_purchase 流水不变', - authorBalanceAfter === authorBalanceBefore && authorLedgerAfter === authorLedgerBefore, + authorBalanceAfter === authorBalanceBefore && + authorLedgerAfter === authorLedgerBefore, `balance=${authorBalanceAfter}/${authorBalanceBefore} ledger=${authorLedgerAfter}/${authorLedgerBefore}`, ); if (authorRows === null) { @@ -1236,11 +1379,18 @@ async function main() { strangerLedger === 1, `count=${strangerLedger}`, ); - const strangerRows = purchaseRowCount(jwtSubject(stranger.token), paid.gameId); + const strangerRows = purchaseRowCount( + jwtSubject(stranger.token), + paid.gameId, + ); if (strangerRows === null) { warn('跳过并发购买记录条数直查', 'spacetime sql 不可用'); } else { - check('真并发购买:只落 1 条购买记录', strangerRows === 1, `rows=${strangerRows}`); + check( + '真并发购买:只落 1 条购买记录', + strangerRows === 1, + `rows=${strangerRows}`, + ); } const strangerDetailAfterPurchase = await api( `/api/game-distribution/games/${paid.gameId}`, @@ -1264,7 +1414,8 @@ async function main() { marker: pendingMarker, title: `待审付费版本 ${stamp().slice(-6)}`, coverAssetId, - expectedPublicationRevision: paidPublicBeforePending.data?.publicationRevision ?? 0, + expectedPublicationRevision: + paidPublicBeforePending.data?.publicationRevision ?? 0, }); const pendingReadback = await api( `/api/game-distribution/versions/${pending.versionId}`, @@ -1377,7 +1528,10 @@ async function main() { packageBytes: 1024, packageFileCount: 1, packageEntryPath: 'index.html', - gameMetadata: gameMetadata(`定价边界验证 ${suffix}`, boundsCoverAssetId), + gameMetadata: gameMetadata( + `定价边界验证 ${suffix}`, + boundsCoverAssetId, + ), }, }); const tooHigh = await declareVersion(1_000_001, 'toohigh'); @@ -1392,7 +1546,8 @@ async function main() { ); check( '越界价格不落版本', - versionsAfterTooHigh === 0 && (rowsAfterTooHigh === null || rowsAfterTooHigh === 0), + versionsAfterTooHigh === 0 && + (rowsAfterTooHigh === null || rowsAfterTooHigh === 0), `ownerVersions=${versionsAfterTooHigh} tableRows=${String(rowsAfterTooHigh)}`, ); if (rowsAfterTooHigh === null) { @@ -1476,8 +1631,16 @@ async function main() { ); console.log( - `\n[e2e] 断言汇总:${failures === 0 ? '全部通过' : `${failures} 条失败`}`, + `\n[e2e] 断言汇总:${passes} PASS / ${failures} FAIL / ${warnings} WARN / ${skips.length} SKIP`, ); + if (skips.length > 0) { + console.log('[e2e] SKIP 清单(缺配置未验证,不等于通过):'); + for (const entry of skips) { + console.log( + ` - ${entry.name}${entry.detail ? ` :: ${entry.detail}` : ''}`, + ); + } + } return failures === 0 ? 0 : 1; } diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index fe7a10320..de8438b34 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -346,7 +346,8 @@ function validateMaintenanceInternalBypass() { } } -const PLAY_SESSION_LOCATION = 'location ^~ /api/game-distribution/play-sessions/'; +const PLAY_SESSION_LOCATION = + 'location ^~ /api/game-distribution/play-sessions/'; const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)'; /** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */ @@ -378,7 +379,8 @@ function findLocationBody(source, locationHeader) { * `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403, * Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。 * `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去; - * 前缀末尾的斜杠也不能省——创建会话的 `POST /api/game-distribution/play-sessions` 需要账号凭证。 + * 前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token,api-server 未注册 + * 该路径);`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内并被清 Cookie。 */ function validatePlaySessionCookieIsolation() { for (const nginxPath of NGINX_PATHS) { diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index 13c2d2d91..8c6a1d76e 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -1101,8 +1101,9 @@ async function runSmokeCases( `播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`, ); - // 创建会话的 `POST /api/game-distribution/play-sessions`(无尾斜杠)需要账号凭证, - // 必须继续走通用 `/api` 规则并保留 Cookie。 + // 前缀边界:裸 `/api/game-distribution/play-sessions`(无 token)不是播放会话资源, + // 必须继续走通用 `/api` 规则并保留 Cookie。api-server 在该前缀下只注册 GET 入口与包内资源, + // 创建会话是 `POST /api/game-distribution/games/{gameId}/play-session`(不在此前缀下)。 const createSessionBeforeCount = api.state.requests.length; await expectHttp( baseUrl, diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index a87108933..03993d103 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2781,27 +2781,72 @@ async fn fetch_admin_dashboard_wallet_stats( if !range.contains_day_key(day_key) { continue; } - match source_type.as_str() { - "asset_operation_consume" | "llm_router_consume" if amount_delta < 0 => { - stats - .consumed_mud_points - .add(day_key, amount_delta.unsigned_abs()); - } - // 生成失败 / 精选审核返还,以及 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); - } - _ => {} - } + accumulate_admin_dashboard_wallet_ledger_row( + &mut stats, + &source_type, + amount_delta, + day_key, + ); } 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( state: &AppState, range: &AdminDashboardRangeResolved, @@ -4800,6 +4845,7 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { 13 => "daily_free_reset", 14 => "recharge_refund_recovery", 15 => "llm_router_consume", + 16 => "game_purchase", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), @@ -5090,14 +5136,15 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, - EditorShowcaseAssetRecord, SpacetimeSchemaResponse, admin_dashboard_user_stats_from_record, - admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, - admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, - append_spacetime_sql_response_chunk, apply_admin_database_table_filters, - build_admin_asset_read_url_audit, build_admin_dashboard_chart, - build_admin_database_enum_labels, build_admin_database_table_row, - build_admin_editor_showcase_campaign_image_confirm_request, + AdminDashboardGranularity, AdminDashboardSeries, AdminDashboardWalletLedgerEffect, + AdminDashboardWalletStats, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, + SpacetimeSchemaResponse, accumulate_admin_dashboard_wallet_ledger_row, + admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, + admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, + admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, + apply_admin_database_table_filters, build_admin_asset_read_url_audit, + 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_tracking_events_sql, build_body_preview, build_debug_base_url, 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_spacetime_sql_count_response, parse_timestamp_text_to_micros, resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, - resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters, - sum_admin_user_recharged_cents, timestamp_value_to_micros, trim_preview, - validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, - verify_admin_password, wallet_ledger_source_type_to_string, + resolve_admin_dashboard_wallet_ledger_effect, resolve_admin_database_table_sql_limit, + resolve_admin_editor_asset_filters, sum_admin_user_recharged_cents, + timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor, + validate_admin_external_api_key_query, verify_admin_password, + wallet_ledger_source_type_to_string, }; use std::collections::BTreeMap; @@ -6311,6 +6359,89 @@ mod tests { wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(), 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] diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 6b323c76e..249c38ed3 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1,5 +1,6 @@ use std::{ collections::{BTreeMap, HashMap, VecDeque}, + future::Future, io::Write, sync::{Arc, Mutex, OnceLock}, time::{Instant, SystemTime, UNIX_EPOCH}, @@ -829,12 +830,49 @@ async fn serve_release_entry( /// 公开发行网关。 /// /// 只服务当前已公开版本的游戏文件,路径必须在白名单内容类型内;私有 ZIP 对象和 -/// 未公开版本不会因为知道 ID 而可读。 +/// 未公开版本不会因为知道 ID 而可读。付费作品的公开同源路径同样关闭(404), +/// 判定全部发生在读取包字节之前。 async fn serve_release_asset( State(state): State, headers: HeaderMap, Path((game_id, asset_path)): Path<(String, String)>, ) -> Result { + 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( + headers: HeaderMap, + game_id: String, + asset_path: String, + load_public_game: G, + load_package: P, +) -> Result +where + G: FnOnce(String) -> GFut, + GFut: Future, SpacetimeClientError>>, + P: FnOnce(String, String) -> PFut, + PFut: Future>, AppError>>, +{ // 发行文件必须由独立来源提供。带上平台 Cookie 的请求说明它正落在主站来源上, // 此时同源脚本可以读到平台会话,必须直接关闭而不是降级服务。 if headers.contains_key(header::COOKIE) { @@ -858,9 +896,7 @@ async fn serve_release_asset( ); AppError::from_status(StatusCode::NOT_FOUND) })?; - let public_game = state - .spacetime_client() - .get_public_game_distribution_game(game_id.clone()) + let public_game = load_public_game(game_id.clone()) .await .map_err(map_spacetime_error)? .ok_or_else(|| { @@ -896,6 +932,7 @@ async fn serve_release_asset( return Err(AppError::from_status(StatusCode::NOT_FOUND)); } // 付费作品只经播放会话路径开放;公开同源发行路径对所有人关闭,避免猜测 gameId 绕过购买。 + // 这条判定必须留在 `load_package` 之前:否则付费包字节已经读出来了。 if public_game.game.price_mud_points > 0 { debug!( operation = "release_rejected", @@ -907,7 +944,7 @@ async fn serve_release_asset( ); 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); release_package_asset_response( &package, @@ -1804,9 +1841,12 @@ async fn create_version( /// /// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥, /// `require_bearer_auth` 无法区分,这里按角色显式拒绝,管理员免购买且绝不扣费。 +/// 现役登录链路下后台管理员令牌会先被 `/api/*` 用户路由的 `require_bearer_auth` 判为 +/// 无效登录态(401),因此 403 分支只在「用户令牌带 admin 角色」时可达,保留为纵深防御。 /// /// 错误口径:余额不足 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( State(state): State, @@ -2950,6 +2990,39 @@ async fn serve_admin_version_preview_asset_inner( /// 播放会话短时效:付费作品每次进入游戏都重新签发,过期后必须重新鉴权,不能长期留一个令牌。 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 identity = match admin { // 管理员免购买试玩:会话仍绑定具体身份,避免把管理身份折叠成匿名令牌。 - Some(admin) => admin.session().subject.clone(), + Some(admin) => { + resolve_paid_play_session_entitlement(PaidPlaySessionViewer::Admin)?; + admin.session().subject.clone() + } None => { let authenticated = optional_access_token_from_headers( &state, @@ -3005,17 +3081,20 @@ async fn create_game_play_session( })?; let user_id = authenticated.claims().user_id().to_string(); let is_author = game.owner_user_id == user_id; - if !is_author { + let has_purchase = if is_author { + false + } else { let (purchase, _) = state .spacetime_client() .get_game_distribution_purchase(game_id.clone(), user_id.clone()) .await .map_err(map_spacetime_error)?; - if purchase.is_none() { - return Err(AppError::from_status(StatusCode::FORBIDDEN) - .with_message("这款游戏需要先购买才能游玩")); - } - } + purchase.is_some() + }; + resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User { + is_author, + has_purchase, + })?; user_id } }; @@ -5537,6 +5616,200 @@ mod tests { 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, + &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] async fn release_asset_response_gzips_accepted_text_and_keeps_binary_untouched() { let script = "console.log('genarrative-game');\n".repeat(64); diff --git a/server-rs/crates/module-game-distribution/src/errors.rs b/server-rs/crates/module-game-distribution/src/errors.rs index 735eeff80..a4f6721ae 100644 --- a/server-rs/crates/module-game-distribution/src/errors.rs +++ b/server-rs/crates/module-game-distribution/src/errors.rs @@ -1,6 +1,6 @@ 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)] pub enum GameDistributionFieldError { @@ -33,7 +33,13 @@ impl fmt::Display for GameDistributionFieldError { Self::MissingReviewReason => "拒绝审核必须提供理由", Self::InvalidVersionNumber => "版本号必须是大于 0 的整数", Self::VersionNumberExhausted => "版本号已达上限,无法自动递增", - Self::InvalidGamePrice => "priceMudPoints 必须是 0 到 1000000 之间的整数", + // 上限只从常量取,避免改价格上限时漏改文案、前后端口径分叉。 + Self::InvalidGamePrice => { + return write!( + formatter, + "priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数" + ); + } Self::MissingWalletLedgerId => "walletLedgerId 不能为空", }; formatter.write_str(message) @@ -122,3 +128,19 @@ fn _visibility_is_exhaustive(visibility: GameVisibility) -> bool { 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} 之间的整数") + ); + } +} diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 0f130f205..1f7e2ed13 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -1705,8 +1705,10 @@ fn release_gateway_upstream_path(path: &str) -> Option { /// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions//…`。 /// -/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:只认带尾斜杠的 -/// 前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api` 路由。 +/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:`/play-sessions/` +/// 与 `/play-sessions//…` 都属于该前缀(前缀含 `` 前的斜杠),只有裸前缀 +/// `/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 { path.starts_with("/api/game-distribution/play-sessions/") } @@ -4124,8 +4126,8 @@ mod tests { ); } - // 创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api`, - // 它需要账号凭证,绝不能跟着清空 Cookie。 + // 裸前缀 `/api/game-distribution/play-sessions`(无 token)仍然走通用 `/api`: + // 它不是播放会话资源,绝不能跟着清空 Cookie。 for path in [ "/api/game-distribution/play-sessions", "/api/game-distribution/games/game_1/play-session", diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 6dbe8cf1b..310afa888 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -3794,6 +3794,40 @@ fn resolve_game_distribution_purchase_decision( .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 { + 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( ctx: &ReducerContext, @@ -3839,12 +3873,13 @@ fn purchase_game_distribution_game_tx( &user_id, )?; let price_mud_points = game.price_mud_points; - match decision { - module_game_distribution::GamePurchaseDecision::Free => { - return Err("免费游戏不需要购买".to_string()); - } + match resolve_game_purchase_pre_charge_plan( + decision, + input.expected_price_mud_points, + price_mud_points, + )? { // 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。 - module_game_distribution::GamePurchaseDecision::AlreadyOwned => { + GamePurchasePreChargePlan::ReplayExistingOwnership => { let purchase = existing_purchase.expect("已拥有的判定必然对应既有购买记录"); insert_game_distribution_receipt( ctx, @@ -3870,10 +3905,8 @@ fn purchase_game_distribution_game_tx( true, )); } - module_game_distribution::GamePurchaseDecision::Purchasable => {} - } - if input.expected_price_mud_points != price_mud_points { - return Err("价格已变化,请刷新后重试".to_string()); + // 只有「可购买 + 期望价一致」才会走到这里,扣费前不再有其它分支。 + GamePurchasePreChargePlan::Charge => {} } 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] - fn purchase_transaction_reuses_domain_decision_before_charging() { - let source = include_str!("game_distribution.rs"); - let body = source - .split("fn purchase_game_distribution_game_tx(") - .nth(1) - .expect("购买事务应存在"); - let body = &body[..body.find("\nfn ").unwrap_or(body.len())]; - assert!( - body.contains("resolve_game_distribution_purchase_decision("), - "生产事务必须调用领域购买判定" + fn purchase_pre_charge_plan_gates_every_non_purchasable_decision() { + use module_game_distribution::GamePurchaseDecision; + + assert_eq!( + resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Free, 100, 100) + .expect_err("免费游戏不能购买"), + "免费游戏不需要购买" ); - assert!( - body.contains("public_game_distribution_version(ctx, &game).is_some()"), - "可玩性口径必须与公开投影一致(活动版本存在且 published)" + // 已有所有权:即使客户端带的期望价与当前价不一致也只重放,绝不扣费。 + assert_eq!( + resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::AlreadyOwned, 0, 100), + Ok(GamePurchasePreChargePlan::ReplayExistingOwnership) ); - assert!( - !body.contains("game.active_version_id.is_none()"), - "不得再用 active_version_id 直接判可购买" + assert_eq!( + resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Purchasable, 100, 999) + .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` diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index d5c8e9de0..1d9e1771d 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -2926,7 +2926,7 @@ mod tests { } #[test] - fn historical_wallet_spend_counts_asset_and_llm_consumption() { + fn historical_wallet_spend_counts_every_consumption_source() { assert_eq!( sum_historical_profile_wallet_consumed_points([ ( @@ -2939,6 +2939,8 @@ mod tests { ), (RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, -7), (RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, 2), + (RuntimeProfileWalletLedgerSourceType::GamePurchase, -30), + (RuntimeProfileWalletLedgerSourceType::GamePurchase, 30), ( RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, 40 @@ -2949,10 +2951,36 @@ mod tests { ), (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] fn historical_wallet_spend_saturates_instead_of_overflowing() { assert_eq!( @@ -5476,11 +5504,8 @@ fn initialize_profile_wallet_consumption_projections( for row in ctx.db.profile_wallet_ledger().iter() { scanned_ledger_count = scanned_ledger_count.saturating_add(1); let total = totals.entry(row.user_id).or_default(); - if matches!( - row.source_type, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - | RuntimeProfileWalletLedgerSourceType::LlmRouterConsume - ) && row.amount_delta < 0 + if profile_wallet_ledger_source_counts_as_consumption(row.source_type) + && row.amount_delta < 0 { *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( entries: impl IntoIterator, ) -> u64 { entries .into_iter() .fold(0_u64, |total, (source_type, amount_delta)| { - if matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - | RuntimeProfileWalletLedgerSourceType::LlmRouterConsume - ) && amount_delta < 0 - { + if profile_wallet_ledger_source_counts_as_consumption(source_type) && amount_delta < 0 { total.saturating_add(amount_delta.unsigned_abs()) } else { total @@ -10646,12 +10681,7 @@ fn apply_profile_wallet_signed_delta( metadata_json: Some(ledger_metadata_json), }); - if matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - | RuntimeProfileWalletLedgerSourceType::LlmRouterConsume - ) && amount_delta < 0 - { + if profile_wallet_ledger_source_counts_as_consumption(source_type) && amount_delta < 0 { record_profile_wallet_consumption( ctx, user_id, diff --git a/vite.config.ts b/vite.config.ts index dee998ab4..003f5518e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -701,7 +701,9 @@ export default defineConfig(({ mode }) => { // 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox // iframe 的 src,包内相对资源沿同一前缀解析。它必须排在通用 `/api/game-distribution` // 规则之前,否则 Cookie 会被带到 api-server 播放网关并触发 403(纵深防御保留); - // 生产由 nginx 同名前缀 location 做同一件事。 + // 生产由 nginx 同名前缀 location 做同一件事。前缀键同时覆盖 `/play-sessions/` + // (无尾斜杠);创建会话的 `POST /api/game-distribution/games//play-session` + // 不在此前缀下,仍走通用 `/api/game-distribution` 并保留 Cookie。 '/api/game-distribution/play-sessions/': { target: runtimeServerTarget, changeOrigin: true,