修复游戏买断制付费的作者豁免、价格口径与契约可空性
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- H1:购买事务在扣费前判定 `game.owner_user_id == user_id` 并失败关闭,领域 `resolve_game_purchase_decision` 新增 `is_owner` 标记返回 `OwnerCannotPurchase`(文案「作者本人无需购买」);api-server `map_purchase_error` 映射为 400 `GAME_PURCHASE_OWNER_EXEMPT`;购买路由按角色显式拒绝管理员令牌(403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`),管理员免购买不扣费。 - M1:`version_detail_payload` 的历史版本价格改为 `frozen_version_price_mud_points(version).unwrap_or(0)`,与待审列表及审核通过后生效的游戏行价格统一;同步把既有断言改为「历史版本缺 `priceMudPoints` 按免费(0)」。 - M2:`shared-contracts::GameDistributionVersionSummary.entry_url` 改为 `#[serde(default)] Option<String>`,与公开投影对未购买者下发的 `entryUrl: null` 一致。 - M3/L5:SpacetimeDB 购买事务改为复用 `module_game_distribution::resolve_game_purchase_decision`(新增 `game_distribution_visibility` 与 `resolve_game_distribution_purchase_decision` 包装),可购买性口径统一为「活动版本存在且 `status = published`」(复用 `public_game_distribution_version`),删除事务内联判定。 - 测试:内存领域作者自购拒绝;SpacetimeDB 购买判定五分支(免费 / 未公开 / 版本未公开 / 已拥有 / 作者自购)、事务源扫描(先判定后扣费、公开投影口径)、审核通过时历史版本价格生效为 0;api-server 管理员令牌 403、公开投影 `entryUrl: null` 可被 Rust DTO 解析、错误映射补作者豁免。 - `scripts/check-game-distribution-dto-parity.mjs` 增加可空性一致性比对(TS `| null` ⟺ Rust `Option`,允许 `?` 表示可省略),已用负例验证能拦住同类漂移。 - 同步更新 docs/【后端架构】server-rs与SpacetimeDB数据契约:登记作者豁免 / 管理员 403 / 可玩性口径 / 历史价格按 0;顺带修复 `module-game-distribution` 与 `spacetime-module` 既有未格式化行。
This commit is contained in:
@@ -487,8 +487,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、发行网关素材授权(`game_distribution_asset_has_public_read_grant`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
|
||||
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。**资料编辑不得直接改公开价格**:调价必须走新版本审核。
|
||||
- 买断制定价(2026-10-05):游戏行末尾追加 `price_mud_points: u64` 并设置 `#[default(0u64)]`;`0` 表示免费,上限 `1_000_000`(复用 `module-game-distribution::normalize_game_price_mud_points` 校验)。价格是版本冻结资料的一部分:作者在 `GameDistributionCreateVersionRequest.priceMudPoints` 提交,写入版本冻结 `metadata_json.priceMudPoints`,只有 `approve_game_distribution_version_and_return` 通过审核时才随资料整体生效到本行;未通过审核或资料编辑都不会改变当前公开价格。公开投影(`get_public_game_distribution_game_and_return` 等)在游戏快照上带出 `priceMudPoints`。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且存在有效 `active_version_id` 的投影。
|
||||
- 购买与播放鉴权 HTTP(2026-10-05):`POST /api/game-distribution/games/{gameId}/purchase`(`require_bearer_auth` + 必填 `Idempotency-Key`,请求体 `{ expectedPriceMudPoints }`)经 facade 调 `purchase_game_distribution_game_and_return`,返回 `{ purchase, walletBalance, replayed }`;余额不足 400 `INSUFFICIENT_MUD_POINTS`、价格已变化 409、免费游戏 400、游戏不可见 404、缺幂等键 400、未登录 401。`POST /api/game-distribution/games/{gameId}/play-session` 对免费作品直接回既有公开入口 `/games/{gameId}/`;付费作品同时接受管理员令牌(按现有 admin 鉴权)与用户令牌,已购买 / 作者本人 / 管理员才签发绑定 `gameId + userId`、2 小时有效期的进程内会话,令牌为内存态,进程重启即失效。网关 `GET /api/game-distribution/play-sessions/{token}[/{assetPath}]` 不挂登录中间件、凭令牌读取当前公开版本包,能解析出平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架 / 封禁或没有有效公开版本一律 404,全部 `no-store`。公开详情 `GET /api/game-distribution/games/{gameId}` 可选鉴权读取查看者:`purchased` 只反映真实购买记录,付费作品对未购买且非作者 / 非管理员把 `currentVersion.entryUrl` 置 `null`(资料与价格仍可见);`GET /api/game-distribution/releases/{gameId}[/{assetPath}]` 在当前公开版本 `price_mud_points > 0` 时同样 404,付费作品只能经播放会话路径播放。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且活动版本存在、状态为 `published`(有效 `active_version_id`)的投影。
|
||||
- 购买与播放鉴权 HTTP(2026-10-05):`POST /api/game-distribution/games/{gameId}/purchase`(`require_bearer_auth` + 必填 `Idempotency-Key`,请求体 `{ expectedPriceMudPoints }`)经 facade 调 `purchase_game_distribution_game_and_return`,返回 `{ purchase, walletBalance, replayed }`;余额不足 400 `INSUFFICIENT_MUD_POINTS`、价格已变化 409、免费游戏 400、作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`(作者免购买,绝不扣费)、管理员令牌 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`(购买只接受普通用户 bearer)、游戏不可见 404、缺幂等键 400、未登录 401。`POST /api/game-distribution/games/{gameId}/play-session` 对免费作品直接回既有公开入口 `/games/{gameId}/`;付费作品同时接受管理员令牌(按现有 admin 鉴权)与用户令牌,已购买 / 作者本人 / 管理员才签发绑定 `gameId + userId`、2 小时有效期的进程内会话,令牌为内存态,进程重启即失效。网关 `GET /api/game-distribution/play-sessions/{token}[/{assetPath}]` 不挂登录中间件、凭令牌读取当前公开版本包,能解析出平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架 / 封禁或没有有效公开版本一律 404,全部 `no-store`。公开详情 `GET /api/game-distribution/games/{gameId}` 可选鉴权读取查看者:`purchased` 只反映真实购买记录,付费作品对未购买且非作者 / 非管理员把 `currentVersion.entryUrl` 置 `null`(资料与价格仍可见);`GET /api/game-distribution/releases/{gameId}[/{assetPath}]` 在当前公开版本 `price_mud_points > 0` 时同样 404,付费作品只能经播放会话路径播放。
|
||||
- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。
|
||||
|
||||
### `game_distribution_review`
|
||||
@@ -523,7 +523,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:不可变发行版本与真实包确认事实。创建后冻结 `package_sha256`、字节数、文件数、根入口和版本号;后续只推进上传、校验、审核、公开、撤回状态,并记录私有对象键、文件清单、入口 URL、审核者和阶段时间。
|
||||
- 索引:`by_game_distribution_version_game_id`、`by_game_distribution_version_owner_user_id`。真实 ZIP 由 `api-server` 校验并写入私有 OSS 后,才通过 facade 确认 `uploaded`;表不保存 ZIP 正文。
|
||||
- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图/买断制价格 `priceMudPoints`)并从素材记录派生对象键后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料与价格,旧版本(无快照)保持原值。<code>parse_game_distribution_frozen_metadata</code> 会用 <code>normalize_game_price_mud_points</code> 校验价格上限,越界快照在审核时失败关闭。
|
||||
- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图/买断制价格 `priceMudPoints`)并从素材记录派生对象键后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料与价格,旧版本(无快照)保持原值。**价格口径统一为「冻结资料缺 `priceMudPoints` 即免费(0)」**:待审列表、审核详情的版本价与审核通过后生效的游戏行价格都按 `0` 处理,不会回退到游戏行旧价。<code>parse_game_distribution_frozen_metadata</code> 会用 <code>normalize_game_price_mud_points</code> 校验价格上限,越界快照在审核时失败关闭。
|
||||
- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。
|
||||
- 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。
|
||||
|
||||
@@ -551,7 +551,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:游戏买断制购买记录。每账号每游戏最多一条:`purchase_id` 主键由 `game_id` 与 `user_id` 按长度前缀无歧义组合(`module-game-distribution::game_purchase_id`,前缀 `gdpurchase_`),并另建 `by_game_distribution_purchase_user_id` / `by_game_distribution_purchase_game_id` 两个 btree 索引供回读;`(user_id, game_id)` 唯一性由购买事务强制。
|
||||
- 字段:`purchase_id`(主键)、`game_id`、`user_id`、`price_mud_points`(成交价快照,之后调价不影响既有所有权)、`wallet_ledger_id`(对应 `profile_wallet_ledger` 流水)、`created_at`。表随迁移导出/导入。
|
||||
- 写入:只由 `purchase_game_distribution_game_and_return`(输入 `GameDistributionPurchaseInput { gameId, userId, expectedPriceMudPoints, idempotencyKey, requestDigest, nowMicros }`)在单一事务内写入:先校验游戏已公开且存在有效公开版本、价格大于 0、`expectedPriceMudPoints` 与当前 `price_mud_points` CAS 一致,再走 runtime profile 钱包扣费路径(`RuntimeProfileWalletLedgerSourceType::GamePurchase`,流水 ID `game_purchase:{userId}:{gameId}`,`metadata_json` 记 `{"kind":"game_purchase","gameId":...}`),最后插入购买行。余额不足、账户冻结或存在退款欠款时失败关闭,不写购买行。
|
||||
- 写入:只由 `purchase_game_distribution_game_and_return`(输入 `GameDistributionPurchaseInput { gameId, userId, expectedPriceMudPoints, idempotencyKey, requestDigest, nowMicros }`)在单一事务内写入:先校验游戏已公开且活动版本存在且 `status = published`(与公开投影同一口径)、作者本人自购直接失败关闭(`OwnerCannotPurchase`,返回 400,不扣费、不写购买行)、价格大于 0、`expectedPriceMudPoints` 与当前 `price_mud_points` CAS 一致,再走 runtime profile 钱包扣费路径(`RuntimeProfileWalletLedgerSourceType::GamePurchase`,流水 ID `game_purchase:{userId}:{gameId}`,`metadata_json` 记 `{"kind":"game_purchase","gameId":...}`),最后插入购买行。余额不足、账户冻结或存在退款欠款时失败关闭,不写购买行。
|
||||
- 幂等:复用 `game_distribution_idempotency_receipt`(`action = purchase`,`owner = 购买者`);同 key 同摘要返回既有记录,同 key 不同摘要冲突;换 key 重复购买命中既有 `(user_id, game_id)` 时只补记收据、不再扣费并回传 `replayed = true`。结果类型 `GameDistributionPurchaseResult { ok, purchase, walletBalance, replayed, errorMessage }`。
|
||||
- 回读:只读 procedure `get_game_distribution_purchase_and_return`(输入 `GameDistributionPurchaseLookupInput { gameId, userId }`)返回购买快照与当前钱包余额,未购买时 `purchase` 为空。完整行为合同见玩法链路的「游戏买断制泥点付费与播放鉴权合同」。
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
|
||||
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
|
||||
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
|
||||
// - 两侧字段的可空性必须一致:TS 允许 `null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时
|
||||
// TS 必须可空或可省略,避免「Rust 契约写 String、线上实际下发 null」这类漂移。
|
||||
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
|
||||
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
|
||||
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
|
||||
@@ -133,19 +135,49 @@ function rustDefinitions(source) {
|
||||
while ((match = pattern.exec(source))) {
|
||||
const { attrs, kind, name, body } = match.groups;
|
||||
const rename = /rename_all = "(?<style>\w+)"/.exec(attrs)?.groups?.style;
|
||||
const members =
|
||||
kind === 'struct'
|
||||
? [...body.matchAll(/^\s*pub (\w+):/gm)].map((item) =>
|
||||
renamedMember(item[1], kind, rename),
|
||||
)
|
||||
: [...body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)].map((item) =>
|
||||
renamedMember(item[1], kind, rename),
|
||||
);
|
||||
result.set(name, { kind, members });
|
||||
const members = [];
|
||||
const optionalFields = [];
|
||||
if (kind === 'struct') {
|
||||
for (const item of body.matchAll(/^\s*pub (\w+):\s*(.+?)\s*,?\s*$/gm)) {
|
||||
const member = renamedMember(item[1], kind, rename);
|
||||
members.push(member);
|
||||
// `Option<T>` 是「允许 null」的唯一 Rust 依据。
|
||||
if (/\bOption</.test(item[2])) optionalFields.push(member);
|
||||
}
|
||||
} else {
|
||||
for (const item of body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)) {
|
||||
members.push(renamedMember(item[1], kind, rename));
|
||||
}
|
||||
}
|
||||
result.set(name, { kind, members, optionalFields });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 括号是否闭合:跨行的 TS 类型只解析到第一行,无法判断可空性,这类字段跳过比对。
|
||||
function balancedType(typeText) {
|
||||
let depth = 0;
|
||||
for (const char of typeText) {
|
||||
if (char === '{' || char === '[' || char === '(' || char === '<') depth += 1;
|
||||
else if (char === '}' || char === ']' || char === ')' || char === '>') depth -= 1;
|
||||
}
|
||||
return depth === 0;
|
||||
}
|
||||
|
||||
// 只认字段自身类型上的 `| null`;内联对象 / 泛型里的 `| null` 属于嵌套成员,不算。
|
||||
function topLevelNullable(typeText) {
|
||||
let depth = 0;
|
||||
for (let index = 0; index < typeText.length; index += 1) {
|
||||
const char = typeText[index];
|
||||
if (char === '{' || char === '[' || char === '(' || char === '<') depth += 1;
|
||||
else if (char === '}' || char === ']' || char === ')' || char === '>') depth -= 1;
|
||||
else if (char === '|' && depth === 0 && /^\s*null\b/.test(typeText.slice(index + 1))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tsDefinitions(source) {
|
||||
const result = new Map();
|
||||
const objectPattern =
|
||||
@@ -154,13 +186,27 @@ function tsDefinitions(source) {
|
||||
while ((match = objectPattern.exec(source))) {
|
||||
const members = [];
|
||||
const required = [];
|
||||
const nullable = [];
|
||||
const complete = [];
|
||||
for (const line of match[2].split('\n')) {
|
||||
const member = /^ {2}(\w+)(\??):/.exec(line);
|
||||
const member = /^ {2}(\w+)(\??):\s*(.*)$/.exec(line);
|
||||
if (!member) continue;
|
||||
members.push(member[1]);
|
||||
if (member[2] !== '?') required.push(member[1]);
|
||||
if (balancedType(member[3])) {
|
||||
complete.push(member[1]);
|
||||
if (topLevelNullable(member[3])) nullable.push(member[1]);
|
||||
}
|
||||
}
|
||||
result.set(match[1], { kind: 'struct', members, required });
|
||||
const optionalFields = members.filter((name) => !required.includes(name));
|
||||
result.set(match[1], {
|
||||
kind: 'struct',
|
||||
members,
|
||||
required,
|
||||
nullable,
|
||||
complete,
|
||||
optionalFields,
|
||||
});
|
||||
}
|
||||
const unionPattern =
|
||||
/export type (GameDistribution\w+|AdminGameReview\w*) =\s*([^;]+);/g;
|
||||
@@ -369,6 +415,41 @@ for (const [rustName, tsName] of PAIRS) {
|
||||
}
|
||||
}
|
||||
|
||||
// 可空性一致性:TS 声明 `| null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时 TS 必须可空(`| null`)
|
||||
// 或可省略(`?`)。只比对两边都能完整解析(非跨行)的字段,避免误报。
|
||||
for (const [rustName, tsName] of PAIRS) {
|
||||
const rustDefinition = rust.get(rustName);
|
||||
const tsDefinition = ts.get(tsName);
|
||||
if (
|
||||
!rustDefinition ||
|
||||
!tsDefinition ||
|
||||
rustDefinition.kind !== 'struct' ||
|
||||
tsDefinition.kind !== 'struct'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const field of rustDefinition.optionalFields) {
|
||||
if (!tsDefinition.members.includes(field)) continue;
|
||||
if (
|
||||
!tsDefinition.nullable.includes(field) &&
|
||||
!tsDefinition.optionalFields.includes(field)
|
||||
) {
|
||||
failures.push(
|
||||
`${tsName}.${field} 必须可空(\`| null\`)或可选(\`?\`):${rustName} 的该字段是 Option`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const field of tsDefinition.nullable) {
|
||||
if (!rustDefinition.members.includes(field)) continue;
|
||||
if (!tsDefinition.complete.includes(field)) continue;
|
||||
if (!rustDefinition.optionalFields.includes(field)) {
|
||||
failures.push(
|
||||
`${tsName}.${field} 允许 null,但 ${rustName} 的该字段不是 Option,Rust 侧必须同步为可空`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of rust.keys()) {
|
||||
if (!mappedRust.has(name)) {
|
||||
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
|
||||
|
||||
@@ -1631,8 +1631,12 @@ async fn create_version(
|
||||
|
||||
/// 买断制购买:扣泥点与写购买记录在同一事务内完成,`Idempotency-Key` 保证双击与重试只扣一次。
|
||||
///
|
||||
/// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥,
|
||||
/// `require_bearer_auth` 无法区分,这里按角色显式拒绝,管理员免购买且绝不扣费。
|
||||
///
|
||||
/// 错误口径:余额不足 400 `INSUFFICIENT_MUD_POINTS`;价格已由新版本改变 409;免费游戏 400;
|
||||
/// 游戏不可见 / 不存在 404。免费游戏与已购买账号都不产生新扣费。
|
||||
/// 作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;管理员令牌 403;游戏不可见 / 不存在 404。
|
||||
/// 免费游戏与已购买账号都不产生新扣费。
|
||||
async fn purchase_game(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
@@ -1641,6 +1645,11 @@ async fn purchase_game(
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<GameDistributionPurchaseRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
if auth.claims().roles.iter().any(|role| role == "admin") {
|
||||
return Err(AppError::from_status(StatusCode::FORBIDDEN)
|
||||
.with_code("GAME_PURCHASE_ADMIN_NOT_ALLOWED")
|
||||
.with_message("管理员无需购买作品"));
|
||||
}
|
||||
let idempotency_key = idempotency_key(&headers)?;
|
||||
let game_id = game_id.trim().to_string();
|
||||
if game_id.is_empty() {
|
||||
@@ -3612,11 +3621,12 @@ fn version_detail_payload(
|
||||
object.insert("frozenMetadata".to_string(), frozen_metadata);
|
||||
}
|
||||
let mut game_payload = game_payload(game);
|
||||
// 版本详情里的价格口径:待审 / 该版本冻结价优先,历史无价格版本回退游戏行当前价。
|
||||
// 版本详情里的价格口径:待审 / 该版本冻结价优先;历史无价格版本按免费(0)展示,
|
||||
// 与待审列表和审核通过后的实际生效价统一,不再回退到游戏行旧价。
|
||||
if let Value::Object(object) = &mut game_payload {
|
||||
object.insert(
|
||||
"priceMudPoints".to_string(),
|
||||
json!(frozen_version_price_mud_points(version).unwrap_or(game.price_mud_points)),
|
||||
json!(frozen_version_price_mud_points(version).unwrap_or(0)),
|
||||
);
|
||||
}
|
||||
json!({ "game": game_payload, "version": payload })
|
||||
@@ -3678,7 +3688,8 @@ fn map_package_error(error: ReleasePackageError) -> AppError {
|
||||
/// 购买失败的错误映射。
|
||||
///
|
||||
/// 余额不足 400 `INSUFFICIENT_MUD_POINTS`(前端据此引导充值);价格已被新版本改变 409;
|
||||
/// 游戏未公开 / 不存在 404;免费游戏与其它业务拒绝走通用 400。
|
||||
/// 游戏未公开 / 不存在 404;作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;
|
||||
/// 免费游戏与其它业务拒绝走通用 400。
|
||||
fn map_purchase_error(error: SpacetimeClientError) -> AppError {
|
||||
if let SpacetimeClientError::Procedure(message) = &error {
|
||||
if message.contains("泥点余额不足") || message.contains("可消费泥点不足") {
|
||||
@@ -3695,6 +3706,13 @@ fn map_purchase_error(error: SpacetimeClientError) -> AppError {
|
||||
return AppError::from_status(StatusCode::NOT_FOUND)
|
||||
.with_details(json!({ "provider": "game-distribution", "message": message }));
|
||||
}
|
||||
// 作者本人免购买:这是业务拒绝(不是服务端故障),必须映射成 400 而不是 5xx。
|
||||
if message.contains("作者本人") {
|
||||
return AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_code("GAME_PURCHASE_OWNER_EXEMPT")
|
||||
.with_message("作者本人无需购买")
|
||||
.with_details(json!({ "provider": "game-distribution", "message": message }));
|
||||
}
|
||||
}
|
||||
map_spacetime_error(error)
|
||||
}
|
||||
@@ -4669,6 +4687,29 @@ mod tests {
|
||||
assert!(legacy_payload["version"]["frozenMetadata"].is_null());
|
||||
}
|
||||
|
||||
/// M2:付费作品对未购买查看者下发 `entryUrl: null`,Rust 契约必须能解析成 `None`。
|
||||
#[test]
|
||||
fn paid_game_hides_entry_url_from_unpurchased_viewer_and_dto_accepts_null() {
|
||||
let game = paid_game_record("user_author", 240);
|
||||
let version = public_version_record(
|
||||
Some("/games/game_1/"),
|
||||
Some(r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png"}"#),
|
||||
);
|
||||
let payload = public_game_payload(
|
||||
public_game_record(game, version),
|
||||
&GameViewerContext::default(),
|
||||
);
|
||||
assert!(payload["currentVersion"]["entryUrl"].is_null());
|
||||
assert_eq!(payload["purchased"], json!(false));
|
||||
|
||||
let summary: shared_contracts::game_distribution::GameDistributionGameSummary =
|
||||
serde_json::from_value(payload).expect("公开游戏响应应可解析");
|
||||
assert_eq!(
|
||||
summary.current_version.expect("应有当前版本").entry_url,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// 作者管理页依赖这条边界:公开投影不带版本私有状态,作者条目必须带。
|
||||
#[test]
|
||||
fn owner_game_entry_payload_carries_private_versions_that_public_payload_omits() {
|
||||
@@ -5648,7 +5689,8 @@ mod tests {
|
||||
assert_eq!(free["currentVersion"]["entryUrl"], json!("/games/game_1/"));
|
||||
}
|
||||
|
||||
/// 审核列表与版本详情都读版本冻结价;历史版本缺字段按免费 / 回退游戏行价。
|
||||
/// 审核列表与版本详情都读版本冻结价;历史版本缺 `priceMudPoints` 一律按免费(0),
|
||||
/// 不回退游戏行价,与审核通过后实际生效的价格口径一致。
|
||||
#[test]
|
||||
fn private_version_payload_reports_frozen_price_and_legacy_defaults() {
|
||||
let game = paid_game_record("user_author", 999);
|
||||
@@ -5666,7 +5708,7 @@ mod tests {
|
||||
assert_eq!(detail["game"]["priceMudPoints"], json!(240));
|
||||
assert_eq!(detail["version"]["priceMudPoints"], json!(240));
|
||||
|
||||
// 历史版本缺 priceMudPoints:列表按免费,详情回退游戏行当前价。
|
||||
// 历史版本缺 priceMudPoints:列表与详情都按免费(0),不回退游戏行当前价 999。
|
||||
version.metadata_json = Some(r#"{"coverAssetId":"asset_cover"}"#.to_string());
|
||||
assert_eq!(
|
||||
private_version_payload(&version)["priceMudPoints"],
|
||||
@@ -5674,7 +5716,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
version_detail_payload(&version, &game)["game"]["priceMudPoints"],
|
||||
json!(999)
|
||||
json!(0)
|
||||
);
|
||||
|
||||
// 完全没有冻结资料的旧版本同样按免费展示,不 panic。
|
||||
@@ -5685,7 +5727,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
version_detail_payload(&version, &game)["game"]["priceMudPoints"],
|
||||
json!(999)
|
||||
json!(0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5708,9 +5750,18 @@ mod tests {
|
||||
));
|
||||
assert_eq!(free.status_code(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// 作者本人自购是业务拒绝:必须 400 + 明确错误码,绝不能落到 5xx。
|
||||
let owner_exempt = map_purchase_error(SpacetimeClientError::Procedure(
|
||||
"作者本人无需购买".to_string(),
|
||||
));
|
||||
assert_eq!(owner_exempt.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(owner_exempt.code(), "GAME_PURCHASE_OWNER_EXEMPT");
|
||||
assert_eq!(owner_exempt.message(), "作者本人无需购买");
|
||||
|
||||
for message in [
|
||||
"游戏不存在",
|
||||
"游戏未公开或没有可玩版本,不能购买",
|
||||
"游戏当前未公开",
|
||||
"游戏版本当前未公开",
|
||||
"游戏已被删除",
|
||||
] {
|
||||
let error = map_purchase_error(SpacetimeClientError::Procedure(message.to_string()));
|
||||
@@ -5722,6 +5773,16 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn signed_test_user_token(state: &AppState, phone_number: &str) -> String {
|
||||
signed_test_token_with_roles(state, phone_number, vec!["user".to_string()]).await
|
||||
}
|
||||
|
||||
/// 用同一签名配置签发任意角色的用户令牌:管理员令牌与用户令牌共用密钥,
|
||||
/// 只有角色不同,因此必须按角色验证购买路由的拒绝行为。
|
||||
async fn signed_test_token_with_roles(
|
||||
state: &AppState,
|
||||
phone_number: &str,
|
||||
roles: Vec<String>,
|
||||
) -> String {
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
sign_access_token,
|
||||
@@ -5735,7 +5796,7 @@ mod tests {
|
||||
user_id: user.id.clone(),
|
||||
session_id,
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
roles,
|
||||
token_version: user.token_version,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
@@ -5832,6 +5893,37 @@ mod tests {
|
||||
assert_eq!(offline.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
/// H1 边界:管理员令牌与用户令牌共用同一签名密钥,`require_bearer_auth` 无法区分;
|
||||
/// 购买路由必须按角色显式拒绝管理员令牌,管理员免购买且绝不扣费。
|
||||
#[tokio::test]
|
||||
async fn purchase_route_rejects_admin_role_token() {
|
||||
use axum::http::Request;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = AppState::new(crate::config::AppConfig::default()).unwrap();
|
||||
let token =
|
||||
signed_test_token_with_roles(&state, "13800138207", vec!["admin".to_string()]).await;
|
||||
let app = crate::app::build_router(state);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/game-distribution/games/game_1/purchase")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("idempotency-key", "purchase-admin-key")
|
||||
.body(Body::from(r#"{"expectedPriceMudPoints":200}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
read_response_json(response).await["error"]["code"],
|
||||
json!("GAME_PURCHASE_ADMIN_NOT_ALLOWED")
|
||||
);
|
||||
}
|
||||
|
||||
/// 播放会话网关:拒绝平台 Cookie、未知 / 过期令牌与非法资源路径一律 404,且全部 no-store。
|
||||
#[tokio::test]
|
||||
async fn play_session_gateway_rejects_platform_cookie_and_invalid_tokens() {
|
||||
|
||||
@@ -597,7 +597,10 @@ impl GameDistributionService {
|
||||
input: PurchaseGameInput,
|
||||
) -> Result<PurchaseOperationResult, GameDistributionError> {
|
||||
let game_id = required(input.game_id, GameDistributionFieldError::MissingGameId)?;
|
||||
let user_id = required(input.user_id, GameDistributionFieldError::MissingActorUserId)?;
|
||||
let user_id = required(
|
||||
input.user_id,
|
||||
GameDistributionFieldError::MissingActorUserId,
|
||||
)?;
|
||||
let wallet_ledger_id = required(
|
||||
input.wallet_ledger_id,
|
||||
GameDistributionFieldError::MissingWalletLedgerId,
|
||||
@@ -608,15 +611,23 @@ impl GameDistributionService {
|
||||
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
|
||||
return purchase_result(outcome, true);
|
||||
}
|
||||
let (visibility, has_active_version, price_mud_points) = {
|
||||
let (visibility, has_active_version, price_mud_points, owner_user_id) = {
|
||||
let game = state
|
||||
.games
|
||||
.get(&game_id)
|
||||
.ok_or(GameDistributionError::GameNotFound)?;
|
||||
// 与公开投影同一口径:活动版本必须存在且 `status = published`,否则会出现
|
||||
// 「已扣费却不可玩」的付费作品。
|
||||
let has_active_version = game
|
||||
.active_version_id
|
||||
.as_ref()
|
||||
.and_then(|version_id| state.versions.get(version_id))
|
||||
.is_some_and(|version| version.status == GameVersionStatus::Published);
|
||||
(
|
||||
game.visibility,
|
||||
game.active_version_id.is_some(),
|
||||
has_active_version,
|
||||
game.price_mud_points,
|
||||
game.owner_user_id.clone(),
|
||||
)
|
||||
};
|
||||
let existing = state
|
||||
@@ -628,6 +639,7 @@ impl GameDistributionService {
|
||||
has_active_version,
|
||||
price_mud_points,
|
||||
existing.is_some(),
|
||||
owner_user_id == user_id,
|
||||
)?;
|
||||
match decision {
|
||||
GamePurchaseDecision::Free => {
|
||||
@@ -659,9 +671,7 @@ impl GameDistributionService {
|
||||
wallet_ledger_id,
|
||||
created_at_micros: input.created_at_micros,
|
||||
};
|
||||
state
|
||||
.purchases
|
||||
.insert((user_id, game_id), purchase.clone());
|
||||
state.purchases.insert((user_id, game_id), purchase.clone());
|
||||
remember(
|
||||
&mut state,
|
||||
scope,
|
||||
@@ -679,7 +689,10 @@ impl GameDistributionService {
|
||||
game_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<GamePurchaseSnapshot, GameDistributionError> {
|
||||
let game_id = required(game_id.to_string(), GameDistributionFieldError::MissingGameId)?;
|
||||
let game_id = required(
|
||||
game_id.to_string(),
|
||||
GameDistributionFieldError::MissingGameId,
|
||||
)?;
|
||||
let user_id = required(
|
||||
user_id.to_string(),
|
||||
GameDistributionFieldError::MissingActorUserId,
|
||||
@@ -1565,30 +1578,36 @@ mod tests {
|
||||
#[test]
|
||||
fn purchase_requires_published_game_with_active_version() {
|
||||
assert_eq!(
|
||||
resolve_game_purchase_decision(GameVisibility::Unpublished, true, 100, false)
|
||||
resolve_game_purchase_decision(GameVisibility::Unpublished, true, 100, false, false)
|
||||
.expect_err("未公开不能购买"),
|
||||
GameDistributionError::GameNotPublished
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_purchase_decision(GameVisibility::Published, false, 100, false)
|
||||
resolve_game_purchase_decision(GameVisibility::Published, false, 100, false, false)
|
||||
.expect_err("没有公开版本不能购买"),
|
||||
GameDistributionError::VersionNotPublished
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 0, false)
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 0, false, false)
|
||||
.expect("免费游戏"),
|
||||
GamePurchaseDecision::Free
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 100, true)
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 100, true, false)
|
||||
.expect("已拥有"),
|
||||
GamePurchaseDecision::AlreadyOwned
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 100, false)
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 100, false, false)
|
||||
.expect("可购买"),
|
||||
GamePurchaseDecision::Purchasable
|
||||
);
|
||||
// 作者本人拥有免购买权:付费作品不产生任何购买语义。
|
||||
assert_eq!(
|
||||
resolve_game_purchase_decision(GameVisibility::Published, true, 100, false, true)
|
||||
.expect_err("作者本人不能购买自己的作品"),
|
||||
GameDistributionError::OwnerCannotPurchase
|
||||
);
|
||||
|
||||
let service = service();
|
||||
create_game(&service);
|
||||
@@ -1598,6 +1617,26 @@ mod tests {
|
||||
assert_eq!(unpublished, GameDistributionError::GameNotPublished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_cannot_purchase_own_paid_game() {
|
||||
let service = service();
|
||||
let game = publish_version_with_price(&service, 100);
|
||||
assert_eq!(game.owner_user_id, "owner-1");
|
||||
let mut input = purchase_input("gdpurchase_owner", 100, "buy-owner");
|
||||
input.user_id = "owner-1".to_string();
|
||||
let rejected = service
|
||||
.purchase_game(input)
|
||||
.expect_err("作者本人自购必须被拒绝");
|
||||
assert_eq!(rejected, GameDistributionError::OwnerCannotPurchase);
|
||||
// 被拒绝的购买不留下任何所有权记录。
|
||||
assert_eq!(
|
||||
service
|
||||
.get_game_purchase("game-1", "owner-1")
|
||||
.expect_err("作者本人不应产生购买记录"),
|
||||
GameDistributionError::GamePurchaseNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_game_rejects_purchase_and_price_change_is_conflict() {
|
||||
let free_service = service();
|
||||
|
||||
@@ -17,7 +17,10 @@ pub fn generate_game_id(seed_micros: i64) -> String {
|
||||
|
||||
/// 长度前缀使任意游戏和账号组合都只有一个无歧义的主键;每账号每游戏最多一条购买记录。
|
||||
pub fn game_purchase_id(game_id: &str, user_id: &str) -> String {
|
||||
format!("{GAME_PURCHASE_ID_PREFIX}{}:{game_id}{user_id}", game_id.len())
|
||||
format!(
|
||||
"{GAME_PURCHASE_ID_PREFIX}{}:{game_id}{user_id}",
|
||||
game_id.len()
|
||||
)
|
||||
}
|
||||
|
||||
/// 校验买断制价格。类型本身已保证是整数,这里只拒绝超出 `0..=MAX_GAME_PRICE_MUD_POINTS`
|
||||
@@ -160,12 +163,15 @@ pub enum GamePurchaseDecision {
|
||||
|
||||
/// 判断购买资格:只有「公开 + 存在有效公开版本」的游戏才产生购买语义。
|
||||
///
|
||||
/// 免费与已拥有都不是错误,由调用方按决策回传对应结果;未公开或缺少公开版本时失败关闭。
|
||||
/// `has_active_version` 必须是权威判定「活动版本存在且 `status = published`」,与公开投影同一口径,
|
||||
/// 否则会出现「已扣费却不可玩」。免费、已拥有与作者本人自购都不是错误,由调用方按决策回传对应结果;
|
||||
/// 未公开或缺少公开版本时失败关闭。
|
||||
pub fn resolve_game_purchase_decision(
|
||||
visibility: GameVisibility,
|
||||
has_active_version: bool,
|
||||
price_mud_points: u64,
|
||||
already_owned: bool,
|
||||
is_owner: bool,
|
||||
) -> Result<GamePurchaseDecision, GameDistributionError> {
|
||||
if visibility != GameVisibility::Published {
|
||||
return Err(GameDistributionError::GameNotPublished);
|
||||
@@ -173,6 +179,10 @@ pub fn resolve_game_purchase_decision(
|
||||
if !has_active_version {
|
||||
return Err(GameDistributionError::VersionNotPublished);
|
||||
}
|
||||
// 作者免购买即可游玩自己的作品,绝不允许对自己的作品产生扣费。
|
||||
if is_owner {
|
||||
return Err(GameDistributionError::OwnerCannotPurchase);
|
||||
}
|
||||
if price_mud_points == 0 {
|
||||
return Ok(GamePurchaseDecision::Free);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub enum GameDistributionError {
|
||||
ReviewActionNotAllowed,
|
||||
/// 免费游戏不需要购买;客户端应直接进入游玩。
|
||||
FreeGameNotPurchasable,
|
||||
/// 作者本人无需购买:合同约定作者免购买即可游玩自己的付费作品,禁止产生扣费。
|
||||
OwnerCannotPurchase,
|
||||
/// `expectedPriceMudPoints` 与服务端当前价格不一致,必须刷新后重试。
|
||||
PriceChanged,
|
||||
GamePurchaseNotFound,
|
||||
@@ -98,6 +100,7 @@ impl fmt::Display for GameDistributionError {
|
||||
Self::VersionAlreadyPublished => formatter.write_str("游戏版本已经公开"),
|
||||
Self::ReviewActionNotAllowed => formatter.write_str("当前版本不允许审核操作"),
|
||||
Self::FreeGameNotPurchasable => formatter.write_str("免费游戏不需要购买"),
|
||||
Self::OwnerCannotPurchase => formatter.write_str("作者本人无需购买"),
|
||||
Self::PriceChanged => formatter.write_str("价格已变化,请刷新后重试"),
|
||||
Self::GamePurchaseNotFound => formatter.write_str("购买记录不存在"),
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ pub use commands::{
|
||||
CreateGameInput, CreateVersionInput, IdempotencyRequest, PurchaseGameInput, ReviewDecision,
|
||||
};
|
||||
pub use domain::{
|
||||
GAME_PURCHASE_ID_PREFIX, MAX_GAME_PRICE_MUD_POINTS, GameDistributionAction,
|
||||
GamePurchaseDecision, GamePurchaseSnapshot, GameSnapshot, GameVersionSnapshot,
|
||||
GameVersionStatus, GameVisibility, compute_request_digest, game_purchase_id,
|
||||
generate_game_id, generate_game_version_id, normalize_game_price_mud_points,
|
||||
resolve_game_purchase_decision, resolve_version_number,
|
||||
GAME_PURCHASE_ID_PREFIX, GameDistributionAction, GamePurchaseDecision, GamePurchaseSnapshot,
|
||||
GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility,
|
||||
MAX_GAME_PRICE_MUD_POINTS, compute_request_digest, game_purchase_id, generate_game_id,
|
||||
generate_game_version_id, normalize_game_price_mud_points, resolve_game_purchase_decision,
|
||||
resolve_version_number,
|
||||
};
|
||||
pub use errors::{GameDistributionError, GameDistributionFieldError};
|
||||
pub use events::GameDistributionEvent;
|
||||
|
||||
@@ -157,7 +157,10 @@ pub struct GameDistributionVersionSummary {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
/// 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
|
||||
pub entry_url: String,
|
||||
///
|
||||
/// 买断制作品对未购买且非作者 / 非管理员的查看者不下发入口(`null`),游玩入口改由播放会话接口签发。
|
||||
#[serde(default)]
|
||||
pub entry_url: Option<String>,
|
||||
pub sha256: String,
|
||||
pub published_at: String,
|
||||
pub controls: Vec<String>,
|
||||
|
||||
@@ -1379,9 +1379,7 @@ pub struct GameDistributionPurchaseResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
fn game_distribution_purchase_result_error(
|
||||
error: String,
|
||||
) -> GameDistributionPurchaseResult {
|
||||
fn game_distribution_purchase_result_error(error: String) -> GameDistributionPurchaseResult {
|
||||
GameDistributionPurchaseResult {
|
||||
ok: false,
|
||||
purchase: None,
|
||||
@@ -3746,6 +3744,44 @@ fn get_owner_game_distribution_version_tx(
|
||||
)))
|
||||
}
|
||||
|
||||
/// 把数据行里的可见性文本映射成领域枚举;未知取值失败关闭,不静默当成可购买。
|
||||
fn game_distribution_visibility(
|
||||
visibility: &str,
|
||||
) -> Result<module_game_distribution::GameVisibility, String> {
|
||||
match visibility {
|
||||
GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED => {
|
||||
Ok(module_game_distribution::GameVisibility::Unpublished)
|
||||
}
|
||||
GAME_DISTRIBUTION_VISIBILITY_PUBLISHED => {
|
||||
Ok(module_game_distribution::GameVisibility::Published)
|
||||
}
|
||||
GAME_DISTRIBUTION_VISIBILITY_SUSPENDED => {
|
||||
Ok(module_game_distribution::GameVisibility::Suspended)
|
||||
}
|
||||
other => Err(format!("未知的游戏可见性:{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生产购买判定:复用 `module-game-distribution` 的领域规则,事务里不再维护第二份判定。
|
||||
///
|
||||
/// `has_published_active_version` 必须是权威判定「活动版本存在且 `status = published`」,
|
||||
/// 与公开投影同一口径;作者本人拥有免购买权,判定为 `OwnerCannotPurchase` 而不是扣费。
|
||||
fn resolve_game_distribution_purchase_decision(
|
||||
game: &GameDistributionGame,
|
||||
has_published_active_version: bool,
|
||||
already_owned: bool,
|
||||
viewer_user_id: &str,
|
||||
) -> Result<module_game_distribution::GamePurchaseDecision, String> {
|
||||
module_game_distribution::resolve_game_purchase_decision(
|
||||
game_distribution_visibility(&game.visibility)?,
|
||||
has_published_active_version,
|
||||
game.price_mud_points,
|
||||
already_owned,
|
||||
game.owner_user_id == viewer_user_id,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 购买事务:先查幂等收据与现有所有权,再校验可见性/价格,最后扣费 + 落购买行 + 记收据。
|
||||
fn purchase_game_distribution_game_tx(
|
||||
ctx: &ReducerContext,
|
||||
@@ -3767,7 +3803,11 @@ fn purchase_game_distribution_game_tx(
|
||||
let purchase = find_game_distribution_purchase(ctx, &user_id, &game_id)
|
||||
.ok_or_else(|| "幂等收据对应的购买记录已不存在".to_string())?;
|
||||
let balance = profile_wallet_balance(ctx, &user_id);
|
||||
return Ok((game_distribution_purchase_snapshot(&purchase), balance, true));
|
||||
return Ok((
|
||||
game_distribution_purchase_snapshot(&purchase),
|
||||
balance,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
let game = ctx
|
||||
@@ -3777,45 +3817,56 @@ fn purchase_game_distribution_game_tx(
|
||||
.find(&game_id)
|
||||
.ok_or_else(|| "游戏不存在".to_string())?;
|
||||
ensure_game_distribution_game_not_deleted(&game)?;
|
||||
if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED || game.active_version_id.is_none()
|
||||
{
|
||||
return Err("游戏未公开或没有可玩版本,不能购买".to_string());
|
||||
}
|
||||
// 可玩性口径与公开投影一致:活动版本必须存在且已公开,避免「已扣费却不可玩」。
|
||||
let has_published_active_version = public_game_distribution_version(ctx, &game).is_some();
|
||||
let existing_purchase = find_game_distribution_purchase(ctx, &user_id, &game_id);
|
||||
let decision = resolve_game_distribution_purchase_decision(
|
||||
&game,
|
||||
has_published_active_version,
|
||||
existing_purchase.is_some(),
|
||||
&user_id,
|
||||
)?;
|
||||
let price_mud_points = game.price_mud_points;
|
||||
if price_mud_points == 0 {
|
||||
return Err("免费游戏不需要购买".to_string());
|
||||
match decision {
|
||||
module_game_distribution::GamePurchaseDecision::Free => {
|
||||
return Err("免费游戏不需要购买".to_string());
|
||||
}
|
||||
// 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。
|
||||
module_game_distribution::GamePurchaseDecision::AlreadyOwned => {
|
||||
let purchase = existing_purchase.expect("已拥有的判定必然对应既有购买记录");
|
||||
insert_game_distribution_receipt(
|
||||
ctx,
|
||||
GameDistributionReceiptInput {
|
||||
receipt_id,
|
||||
owner_user_id: user_id.clone(),
|
||||
action: GAME_DISTRIBUTION_ACTION_PURCHASE.to_string(),
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
game_id: Some(game_id),
|
||||
version_id: None,
|
||||
outcome_kind: "purchase".to_string(),
|
||||
outcome_game_id: None,
|
||||
outcome_version_id: None,
|
||||
outcome_json: Some(purchase.purchase_id.clone()),
|
||||
now_micros: input.now_micros,
|
||||
},
|
||||
)?;
|
||||
let balance = profile_wallet_balance(ctx, &user_id);
|
||||
return Ok((
|
||||
game_distribution_purchase_snapshot(&purchase),
|
||||
balance,
|
||||
true,
|
||||
));
|
||||
}
|
||||
module_game_distribution::GamePurchaseDecision::Purchasable => {}
|
||||
}
|
||||
if input.expected_price_mud_points != price_mud_points {
|
||||
return Err("价格已变化,请刷新后重试".to_string());
|
||||
}
|
||||
|
||||
// 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。
|
||||
if let Some(purchase) = find_game_distribution_purchase(ctx, &user_id, &game_id) {
|
||||
insert_game_distribution_receipt(
|
||||
ctx,
|
||||
GameDistributionReceiptInput {
|
||||
receipt_id,
|
||||
owner_user_id: user_id.clone(),
|
||||
action: GAME_DISTRIBUTION_ACTION_PURCHASE.to_string(),
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
game_id: Some(game_id),
|
||||
version_id: None,
|
||||
outcome_kind: "purchase".to_string(),
|
||||
outcome_game_id: None,
|
||||
outcome_version_id: None,
|
||||
outcome_json: Some(purchase.purchase_id.clone()),
|
||||
now_micros: input.now_micros,
|
||||
},
|
||||
)?;
|
||||
let balance = profile_wallet_balance(ctx, &user_id);
|
||||
return Ok((game_distribution_purchase_snapshot(&purchase), balance, true));
|
||||
}
|
||||
|
||||
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
|
||||
let wallet_ledger_id = format!(
|
||||
"{GAME_DISTRIBUTION_PURCHASE_LEDGER_PREFIX}:{user_id}:{game_id}"
|
||||
);
|
||||
let wallet_ledger_id =
|
||||
format!("{GAME_DISTRIBUTION_PURCHASE_LEDGER_PREFIX}:{user_id}:{game_id}");
|
||||
let metadata_json = serde_json::json!({
|
||||
"kind": "game_purchase",
|
||||
"gameId": game_id,
|
||||
@@ -3836,9 +3887,7 @@ fn purchase_game_distribution_game_tx(
|
||||
wallet_ledger_id: wallet_ledger_id.clone(),
|
||||
created_at: now,
|
||||
};
|
||||
ctx.db
|
||||
.game_distribution_purchase()
|
||||
.insert(purchase.clone());
|
||||
ctx.db.game_distribution_purchase().insert(purchase.clone());
|
||||
insert_game_distribution_receipt(
|
||||
ctx,
|
||||
GameDistributionReceiptInput {
|
||||
@@ -3869,8 +3918,8 @@ fn get_game_distribution_purchase_tx(
|
||||
) -> Result<(Option<GameDistributionPurchaseSnapshot>, u64), String> {
|
||||
let user_id = required_game_distribution_text(input.user_id, "user_id")?;
|
||||
let game_id = required_game_distribution_text(input.game_id, "game_id")?;
|
||||
let purchase =
|
||||
find_game_distribution_purchase(ctx, &user_id, &game_id).map(|row| game_distribution_purchase_snapshot(&row));
|
||||
let purchase = find_game_distribution_purchase(ctx, &user_id, &game_id)
|
||||
.map(|row| game_distribution_purchase_snapshot(&row));
|
||||
Ok((purchase, profile_wallet_balance(ctx, &user_id)))
|
||||
}
|
||||
|
||||
@@ -4052,8 +4101,8 @@ pub(crate) struct GameDistributionFrozenDeviceSupport {
|
||||
fn parse_game_distribution_frozen_metadata(
|
||||
metadata_json: &str,
|
||||
) -> Result<GameDistributionFrozenMetadata, String> {
|
||||
let frozen: GameDistributionFrozenMetadata = serde_json::from_str(metadata_json)
|
||||
.map_err(|_| "版本冻结资料格式无效".to_string())?;
|
||||
let frozen: GameDistributionFrozenMetadata =
|
||||
serde_json::from_str(metadata_json).map_err(|_| "版本冻结资料格式无效".to_string())?;
|
||||
module_game_distribution::normalize_game_price_mud_points(frozen.price_mud_points)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(frozen)
|
||||
@@ -4241,4 +4290,136 @@ mod tests {
|
||||
// 溢出不能回绕成 0 或静默改写已有版本。
|
||||
assert!(!overflow.contains("版本号 0"));
|
||||
}
|
||||
|
||||
fn game_row(
|
||||
visibility: &str,
|
||||
owner_user_id: &str,
|
||||
price_mud_points: u64,
|
||||
) -> GameDistributionGame {
|
||||
GameDistributionGame {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
title: "测试作品".to_string(),
|
||||
summary: String::new(),
|
||||
description: String::new(),
|
||||
category: "其他".to_string(),
|
||||
tags_json: "[]".to_string(),
|
||||
cover_asset_id: None,
|
||||
author_name: None,
|
||||
author_avatar_url: None,
|
||||
device_support_desktop: true,
|
||||
device_support_mobile: false,
|
||||
device_support_touch: false,
|
||||
input_modes_json: "[]".to_string(),
|
||||
orientation: "landscape".to_string(),
|
||||
publication_revision: 1,
|
||||
active_version_id: Some("version-1".to_string()),
|
||||
visibility: visibility.to_string(),
|
||||
play_count: 0,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(0),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(0),
|
||||
local_project_id: None,
|
||||
cover_object_key: None,
|
||||
screenshots_json: None,
|
||||
deleted_at: None,
|
||||
price_mud_points,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生产购买判定必须覆盖免费、未公开、已拥有与作者自购四种分支。
|
||||
#[test]
|
||||
fn purchase_decision_covers_free_unpublished_owned_and_owner_cases() {
|
||||
let published = game_row(GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, "owner-1", 100);
|
||||
assert_eq!(
|
||||
resolve_game_distribution_purchase_decision(&published, true, false, "player-1")
|
||||
.expect("可购买"),
|
||||
module_game_distribution::GamePurchaseDecision::Purchasable
|
||||
);
|
||||
// 已拥有:回传既有所有权,不再扣费。
|
||||
assert_eq!(
|
||||
resolve_game_distribution_purchase_decision(&published, true, true, "player-1")
|
||||
.expect("已拥有"),
|
||||
module_game_distribution::GamePurchaseDecision::AlreadyOwned
|
||||
);
|
||||
// 免费游戏:直接进入游玩。
|
||||
let free = game_row(GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, "owner-1", 0);
|
||||
assert_eq!(
|
||||
resolve_game_distribution_purchase_decision(&free, true, false, "player-1")
|
||||
.expect("免费游戏"),
|
||||
module_game_distribution::GamePurchaseDecision::Free
|
||||
);
|
||||
// 未公开:失败关闭,避免「已扣费却不可玩」。
|
||||
let unpublished = game_row(GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED, "owner-1", 100);
|
||||
assert_eq!(
|
||||
resolve_game_distribution_purchase_decision(&unpublished, true, false, "player-1")
|
||||
.expect_err("未公开不能购买"),
|
||||
"游戏当前未公开"
|
||||
);
|
||||
// 已公开但活动版本不是 published:同样失败关闭。
|
||||
let version_not_published =
|
||||
resolve_game_distribution_purchase_decision(&published, false, false, "player-1")
|
||||
.expect_err("活动版本未公开不能购买");
|
||||
assert!(
|
||||
version_not_published.contains("未公开"),
|
||||
"{version_not_published}"
|
||||
);
|
||||
// 作者本人自购:明确业务拒绝,绝不扣费、绝不落购买记录。
|
||||
assert_eq!(
|
||||
resolve_game_distribution_purchase_decision(&published, true, false, "owner-1")
|
||||
.expect_err("作者本人不能购买自己的作品"),
|
||||
"作者本人无需购买"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ReducerContext` 无法在单测里构造,事务只能靠源扫描兜底:生产路径必须复用领域判定,
|
||||
/// 且必须先判定后扣费,不得内联第二份可购买性判断。
|
||||
#[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("),
|
||||
"生产事务必须调用领域购买判定"
|
||||
);
|
||||
assert!(
|
||||
body.contains("public_game_distribution_version(ctx, &game).is_some()"),
|
||||
"可玩性口径必须与公开投影一致(活动版本存在且 published)"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("game.active_version_id.is_none()"),
|
||||
"不得再用 active_version_id 直接判可购买"
|
||||
);
|
||||
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`
|
||||
/// 时必须生效为 `0`(免费),与列表 / 详情口径一致。
|
||||
#[test]
|
||||
fn approving_frozen_metadata_applies_frozen_price_or_free_for_legacy_versions() {
|
||||
let mut game = game_row(GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, "owner-1", 240);
|
||||
|
||||
let legacy = parse_game_distribution_frozen_metadata(
|
||||
r#"{"title":"标题","summary":"摘要","category":"益智","coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png","deviceSupport":{"desktop":true,"mobile":false,"touch":false},"orientation":"responsive"}"#,
|
||||
)
|
||||
.expect("历史冻结资料应可解析");
|
||||
apply_game_distribution_frozen_metadata(&mut game, &legacy);
|
||||
assert_eq!(game.price_mud_points, 0, "历史无价格版本通过后应为免费");
|
||||
|
||||
let priced = parse_game_distribution_frozen_metadata(
|
||||
r#"{"title":"标题","summary":"摘要","category":"益智","coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png","deviceSupport":{"desktop":true,"mobile":false,"touch":false},"orientation":"responsive","priceMudPoints":100}"#,
|
||||
)
|
||||
.expect("带价格冻结资料应可解析");
|
||||
apply_game_distribution_frozen_metadata(&mut game, &priced);
|
||||
assert_eq!(game.price_mud_points, 100);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user