【付费游玩】游戏买断制泥点付费规范:主规范合同、里程碑与实施计划(#634) #635
Reference in New Issue
Block a user
Delete Branch "feat/game-purchase"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
关联
本次改动(切片 1:规范先行)
方案摘要
0为免费,付费走同一条发布/审核链路;展示资料编辑不能直接改价。game_distribution_game末尾追加price_mud_points;新增game_distribution_purchase((user_id, game_id)唯一);泥点流水新增game_purchase来源类型。Idempotency-Key加唯一约束,扣费与购买记录同事务,余额不足失败关闭。/games/<gameId>/;服务端签发绑定gameId加用户加短时效的播放会话(复用后台待审预览的 token 前缀模式);未购买直连返回 404,作者与管理员免购买。前后对照
published即对所有游客免费可玩,游戏与版本没有任何价格字段,钱包与游戏分发零关联。待确认
1_000_000。验证(本切片)
npm run check:doc-index通过(244 份 Markdown)npm run check:encoding通过(5268 文件)git diff --check通过后续实现按实施计划顺序推进,并在本 PR 内持续推送。
- server-rs/crates/module-game-distribution:新增价格校验 normalize_game_price_mud_points(0..=1000000,0 为免费)、购买资格 resolve_game_purchase_decision、GamePurchaseSnapshot 与 game_purchase_id(前缀 gdpurchase_);GameSnapshot/GameVersionSnapshot 增加 price_mud_points,审核通过时随版本价格生效;新增购买服务方法与纯内存测试。 - server-rs/crates/spacetime-module/src/game_distribution.rs:GameDistributionGame 末尾追加 #[default(0u64)] price_mud_points;新增私有表 game_distribution_purchase(purchase_id 主键 + user_id/game_id btree 索引);新增 purchase_game_distribution_game_and_return(同事务校验可见性/价格 CAS/未购买并原子扣费、幂等收据 action=purchase)与只读 get_game_distribution_purchase_and_return;approve 在同一事务内把冻结资料 priceMudPoints 生效到游戏行;公开游戏投影带出 priceMudPoints。 - server-rs/crates/spacetime-module/src/runtime/active/profile.rs:新增 consume_profile_wallet_points_for_game_purchase 复用通用钱包写入路径(来源类型 game_purchase、流水 ID game_purchase:{userId}:{gameId}、metadata_json 记 kind/gameId);profile_wallet_balance 提升为 pub(crate)。 - server-rs/crates/spacetime-module/src/migration.rs:迁移表白名单登记 game_distribution_purchase。 - server-rs/crates/module-runtime/src/domain.rs:RuntimeProfileWalletLedgerSourceType 追加 GamePurchase 变体与 game_purchase 文案,未改 profile_wallet_ledger 表结构。 - server-rs/crates/shared-contracts:新增 PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE;公开游戏摘要/详情增加 priceMudPoints 与 purchased;GameDistributionPrivateVersion 与 GameDistributionCreateVersionRequest 增加 priceMudPoints;新增 GameDistributionPurchaseRequest/GameDistributionPurchase/GameDistributionPurchaseResponse/GameDistributionPlaySessionResponse。 - server-rs/crates/spacetime-client:新增购买与回读 typed facade、GameDistributionPurchaseRecord 与 row mapper,GameDistributionGameRecord 带出 price_mud_points,钱包来源 mapper 支持 GamePurchase。 - docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md:登记 price_mud_points、game_distribution_purchase 与 game_purchase 流水来源。 - 重新生成 SpacetimeDB Rust 绑定(购买表/过程与价格字段)。- server-rs/crates/api-server/src/runtime_profile.rs:钱包流水来源类型补 GamePurchase 分支并导入 game_purchase 常量,修复 non-exhaustive match 编译错误。 - server-rs/crates/api-server/src/modules/game_distribution.rs:创建版本接受并校验 priceMudPoints(复用 normalize_game_price_mud_points,越界 400),随版本冻结资料一起落库。 - server-rs/crates/api-server/src/modules/game_distribution.rs:新增 POST /api/game-distribution/games/{gameId}/purchase(require_bearer_auth + 必填 Idempotency-Key,请求体 { expectedPriceMudPoints }),经 facade 原子扣费并返回 GameDistributionPurchaseResponse。 - server-rs/crates/api-server/src/modules/game_distribution.rs:购买错误映射——余额不足 400 INSUFFICIENT_MUD_POINTS、价格已变化 409、免费游戏 400、游戏未公开或不存在 404、缺幂等键 400。 - server-rs/crates/api-server/src/modules/game_distribution.rs:新增 POST /api/game-distribution/games/{gameId}/play-session;免费作品直接回既有公开入口,付费作品接受管理员令牌(沿用现有 admin 鉴权,免购买)与用户令牌,校验已购买 / 作者本人,未登录 401、未购买 403,签发绑定 gameId + userId 的 2 小时会话。 - server-rs/crates/api-server/src/modules/game_distribution.rs:新增 GET /api/game-distribution/play-sessions/{token}[/{assetPath}] 网关,无登录中间件、凭令牌读取,能解析平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架或封禁、缺少有效公开版本一律 404,复用发行包读取与 CSP/MIME/nosniff/CORP 并全程 no-store。 - server-rs/crates/api-server/src/modules/game_distribution.rs:公开发行网关 releases/{gameId} 在当前公开版本 price_mud_points > 0 时返回 404,付费作品只能走播放会话路径。 - server-rs/crates/api-server/src/modules/game_distribution.rs:game_payload 发出 priceMudPoints 与 purchased 默认值;public_game_payload 按查看者解析 purchased,并对未购买且非作者 / 非管理员的付费作品把 currentVersion.entryUrl 置 null;公开详情接口可选鉴权读取当前用户,坏令牌按匿名、购买态读取失败按未购买。 - server-rs/crates/api-server/src/modules/game_distribution.rs:private_version_payload 发出该版本冻结价(历史缺字段为 0);作者 / 管理员版本详情的 game payload 按「冻结价优先、否则游戏行价」。 - server-rs/crates/api-server/src/state.rs:新增付费游戏播放会话的进程内存储与读写方法,读取时清理过期会话。 - server-rs/crates/api-server/src/admin.rs:抽出 resolve_admin_session,新增 try_authenticate_admin_from_headers 供同时接受管理员 / 用户令牌的端点复用,require_admin_auth 的校验与鉴权行为不变。 - server-rs/crates/spacetime-client/src/active.rs:补出 GameDistributionPurchaseRecordInput 的 re-export。 - docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md:登记购买、播放会话、公开详情入口隐藏与公开发行网关的 HTTP 契约。 - 补齐 api-server game_distribution 定向测试:价格校验 400、公开投影入口门禁(未购买 / 已购买 / 作者 / 管理员 / 免费)、冻结价回读、购买错误映射、购买路由鉴权与幂等键、播放会话网关 Cookie / 未知与过期令牌 / 路由挂载。主规范路由表新增 POST /games/{gameId}/purchase 行,写明幂等键、请求体与余额不足/作者自购/管理员令牌/价格变化错误口径 主规范路由表新增 POST /games/{gameId}/play-session 行,写明付费需登录、免费回公开入口与 { playUrl, expiresAt } 响应 主规范路由表新增 GET /game-distribution/play-sessions/{token}[/{assetPath}] 行,写明凭令牌、不读 Cookie、no-store、失效一律 404 主规范路由表更新 GET /games/{gameId} 行,补充 priceMudPoints 与 purchased,付费未购买时 currentVersion.entryUrl 为 null 主规范路由表更新发行网关行,当前公开版本 price_mud_points > 0 时统一 404 主规范合同 Status 由 proposed 改为 implemented(本地验证通过,待用户验收;未部署) 主规范收口价格上限 1_000_000 与免购买范围(作者本人 + 后台管理员令牌),删除正文悬置措辞并注明日期 主规范补充播放会话进程内会话、始终解析当前公开版本两条实现约束,记录后台待审预览前缀未清 Cookie 的同类风险 里程碑 Status 由 proposed 改为 implemented-awaiting-runtime-acceptance docs/README.md 该合同/里程碑/实施计划条目描述改为已实现、本机真实栈 E2E 通过、待用户验收、未部署- admin.rs:钱包流水来源映射补齐第 17 个变体(索引 16 → game_purchase),后台「消耗泥点」白名单新增 game_purchase,并抽出效果分类与单行累加函数 - profile.rs:消耗口径抽出 profile_wallet_ledger_source_counts_as_consumption,消费投影增量、历史花费重建、投影初始化三处一并纳入 GamePurchase - module-game-distribution/errors.rs:价格上限文案改为插值 MAX_GAME_PRICE_MUD_POINTS,并新增文案断言 - 新增 scripts/check-game-distribution-price-limit-parity.mjs 并挂进 npm run lint(随 Repository checks 在 CI 生效):断言 TS / 服务端 Rust / AGC Rust 常量与网页侧别名四处同源 - api-server 发行网关抽出可注入 loader 的 serve_public_release_asset,付费 404 严格发生在读取包字节之前;播放会话准入抽出 resolve_paid_play_session_entitlement - 新增 api-server 单测:付费发行路径 404 且零次读包字节、免费放行且读到包字节、未购买 403 与作者 / 已购买 / 管理员放行 - spacetime-module:把「先判定后扣费」从源码扫描改为 resolve_game_purchase_pre_charge_plan 纯函数断言,删除 include_str 源码切片用例 - E2E 脚本:平台同源断言缺配置时记 SKIP 并在报告末尾汇总 SKIP 清单,E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,汇总行改为输出 PASS/FAIL/WARN/SKIP 计数 - 修正注释里不存在的路由 POST /api/game-distribution/play-sessions(真实为 POST /api/game-distribution/games/{gameId}/play-session)与「管理员令牌 403」口径(403 需带 admin 角色的用户令牌)买断制付费(游戏买断制泥点付费)本轮评审结论与修复说明
本轮对买断制付费做了 3 路独立评审(后端 + 边缘网关 / 三端前端 + 契约 + 文档 / 客观门禁复跑),结论是:发现 1 个阻断、1 个高、若干中低;阻断已修(合并最新
origin/master),高与中按级修复,低风险项同批修掉。功能面——防绕过、购买原子性与幂等、播放会话、SpacetimeDB schema 与迁移、免费作品零回归——经评审 PASS。1. 阻断(已修):分支基线过期,且 master 的发行链路优化会被静默回退
分支落后
origin/master22 个提交(自共同祖先起算),master上已有「在线游玩加载体验」与「AGC 运行画面点选」两条已合并 PR 的产出。直接把
origin/master合进本分支时实际产生 9 个冲突文件,其中 3 个是会被静默回退的功能代码:src/components/game-distribution/GameDetailPage.tsxsrc/components/game-distribution/GamePlayPage.tsxvite.config.tssrc/components/game-distribution/GameDistributionPages.test.tsx、apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx、deploy/nginx/README.md、docs/project-memory/shared-memory/pitfalls.md、docs/project-memory/shared-memory/decision-log.md、docs/【玩法创作】平台入口与玩法链路-2026-05-15.md回退风险具体是:
master侧的发行网关 ETag /If-None-Match304 /Content-Encoding: gzip(server-rs/crates/api-server/src/modules/game_distribution.rs)与共享加载面PlatformGameLoadingSurface(packages/shared/src/components/PlatformGameLoadingSurface.tsx,网页游玩页与后台试玩共用)会在这三个文件上被旧版本覆盖。处理:已合并
origin/master,冲突逐块手工合并,保留 master 侧优化同时保留本分支的买断制改动。判定命令(CI 的
Resolve comparison base门禁就是这一条):2. 高(已修):
game_purchase未进入后台流水来源映射与统计白名单game_purchase这个钱包流水来源没有进入后台的来源映射与三处统计白名单,导致买断消耗在「Dashboard 消耗」「消费投影」「历史花费」里整类丢失——买断制上线后后台看不到这部分消耗。server-rs/crates/api-server/src/admin.rs:流水来源索引映射补上16 => "game_purchase"(含GamePurchase字符串形态),把消费判定收口到单一助手resolve_admin_dashboard_wallet_ledger_effect,统计累加收口到accumulate_admin_dashboard_wallet_ledger_row。server-rs/crates/spacetime-module/src/runtime/active/profile.rs:消费判定谓词统一为profile_wallet_ledger_source_counts_as_consumption,历史花费汇总sum_historical_profile_wallet_consumed_points与它同源。dashboard_wallet_ledger_effect_counts_game_purchase_as_consumptiondashboard_wallet_stats_accumulate_game_purchase_consumptionwallet_consumption_source_predicate_includes_game_purchase3. 中(已修)
a. 网页发布页草稿恢复不预填价格
对已付费作品恢复草稿时会显示「免费」,存在静默改价风险。现改为按版本冻结价预填(该版本可能仍在
pending_review/rejected,因此预填值未必已生效),仅在冻结价缺失或为0时才回落作品行当前价,两条来源都取不到才按免费,并在 UI 上写明「沿用当前价格」。落点:src/components/game-distribution/GamePublishPage.tsx、文档docs/【玩法创作】平台入口与玩法链路-2026-05-15.md;AGC 壳侧同口径实现为resolve_publication_prefill_price(apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs)。b. 价格上限
1_000_000四处硬编码上限同时存在于共享 TS 常量、服务端 Rust 常量、AGC 壳 Rust 常量与网页侧再导出别名,任一处单独改动都会造成「前端允许提交、后端拒绝」的口径分叉,且只在真实发布时才暴露。新增门禁脚本
scripts/check-game-distribution-price-limit-parity.mjs(npm run check:game-distribution-price-limit-parity),断言「3 处常量声明同源(共享 TS 常量、服务端 Rust 常量、AGC 壳 Rust 常量取同一个上限值)+ 网页侧别名必须是引用共享常量而不是另写字面量」,合计覆盖 4 个落点;已挂进npm run lint链,因此Repository checks会经scripts/check-repository-ci.sh跑到。上限错误文案也改为常量插值,由cargo test -p module-game-distribution断言。c. 付费 404 与未购买 403 只有人工 E2E 证据
把公开发行包读取与播放会话鉴权入口抽成可注入依赖的形式,补 3 组单测(在
server-rs/crates/api-server/src/modules/game_distribution.rs):paid_game_public_release_path_is_rejected_without_reading_package_bytes:付费作品走公开发行包直读路径必须被拒,且不得先读包体。free_game_public_release_path_serves_the_package_asset:免费作品走同一条路径仍能正常读到包内资源(正向对照,证明上面那条不是「一律拒绝」)。paid_play_session_entitlement_rejects_unpurchased_and_grants_owner_buyer_admin:未购买拒绝、作者/购买者/管理员放行。并做了变异验证:把付费判定短路后,对应用例会转红(证明断言不是恒真)。
d. E2E 平台同源断言静默跳过
scripts/check-game-distribution-purchase-e2e.mjs缺主站地址时原先静默返回,会把「没验过」当成通过。现改为显式记SKIP并在报告末尾汇总(汇总行同时给出 PASS / FAIL / WARN / SKIP 四项计数与 SKIP 清单);设置E2E_REQUIRE_PLATFORM_ORIGIN=1时缺配置直接FAIL,供发布前验收与 CI 化演练使用。e. 前端契约口径统一
packages/shared/src/contracts/gameDistribution.ts的价格/播放会话字段「可选 / 必需」口径与实现对齐,消除调用方各自兜底的写法。f. 弱测试改写与契约用例补齐
改写若干条恒真或断言过弱的用例(
GamePublishPage.test.tsx、GameDistributionPages.test.tsx、AdminGameDistributionReviewPage.test.tsx等),并补上:gameDistributionClient契约用例(src/services/gameDistributionClient.test.ts):购买请求带路径、幂等键与expectedPriceMudPoints;播放会话请求路径固定、playUrl/expiresAt原样解析;本地标识缺失时失败关闭且不发出请求;余额不足映射成400+INSUFFICIENT_MUD_POINTS;播放会话被拒时同样抛ApiClientError。服务端返回 409 价格已变化时提示已刷新并保留购买入口。purchaseGameMock在同一购买请求窗口内只被调用一次。src/components/game-distribution/gameDistributionGuards.test.ts):..路径穿越、超长、控制字符。4. 低(已修)
location ^~ /api/game-distribution/play-sessions/本身就已覆盖/play-sessions/<token>与/play-sessions/<token>/…两种形态(都在该前缀内,都会被清 Cookie),这部分行为不变。改的是注释里引用了一条不存在的路由:三份 nginx 模板、deploy/nginx/README.md与vite.config.ts原先写「创建会话是POST /api/game-distribution/play-sessions」,实际 api-server 在该前缀下只注册 GET 入口与包内资源,创建会话是POST /api/game-distribution/games/<gameId>/play-session;现已改为真实路径,scripts/check-nginx-spa-routes.mjs同步。token段字符集收窄为[A-Za-z0-9-](令牌是 UUID v4):原先放开的.与/会让…/play-sessions/../../x被new URL归一成同源其它路径;正则同时显式允许尾部斜杠。apps/admin-web/src/pages/adminGameDistributionReview.css)已调整。AdminGameDistributionReviewPage.tsx)。apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx与apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts都改为从@genarrative/shared/components导入共享定价组件与常量。apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs):改为「取最新版本冻结价 → 该价缺失或为0时回落游戏行当前生效价 → 两条都取不到才按0(免费)」,与网页侧同一口径。5. 明确 PASS 的项(功能面)
UUIDv4(CSPRNG)会话 ID、2 小时 TTL、绑定game + user、过期回收、不跨作品复用。price_mud_points在表末尾追加并带默认值#[default(0u64)],新表唯一性约束成立,迁移白名单与生成绑定同步。0表示免费,与付费共用同一条发布/审核链路,免费链路行为不变。api-server不越过 facade 直连表,spacetime-module侧领域规则收口。6. 已知风险 / 未覆盖(如实列出)
401,该分支属纵深防御;已有单测覆盖,但没有端到端可达路径。priceMudPoints时,服务端#[serde(default)]会落0(即变成免费)。AGC 壳侧有预填兜底,服务端尚未兜底。是否需要服务端拒绝/沿用旧价,需要产品口径。npm run check:game-distribution-purchase-e2e需要真实 SpacetimeDB /api-server/ OSS 与E2E_ADMIN_USER/E2E_ADMIN_PASSWORD。CI 绿不代表付费链路的运行时行为已验证;上线前必须人工运行,并用E2E_REQUIRE_PRIVATE_BUCKET=1复核生产桶不允许匿名直取发行包。suspend之后既有播放会话的失效未做单独立项验证。7. 验证命令清单(供复核)
以下命令均已在
package.json/Cargo.toml/ workflow 中核对存在,或在本次取证中实跑过。8. 本轮关键提交
origin/master(22 个提交):959ed4fd515bbb5594d18998b71b4bf37671、4d901eec6dcef9b62aClose #634