From 7d24b0ac50b48cbd58132d889f31bb56f423b9fa Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:36:13 +0800 Subject: [PATCH 01/25] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E4=B9=B0=E6=96=AD=E5=88=B6=E6=B3=A5=E7=82=B9=E4=BB=98=E8=B4=B9?= =?UTF-8?q?=E8=A7=84=E8=8C=83=EF=BC=9A=E4=B8=BB=E8=A7=84=E8=8C=83=E5=90=88?= =?UTF-8?q?=E5=90=8C=E3=80=81=E9=87=8C=E7=A8=8B=E7=A2=91=E4=B8=8E=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范《平台入口与玩法链路》解除“不做交易”排除,新增“游戏买断制泥点付费与播放鉴权合同”章节,明确定价冻结、买断所有权、播放鉴权、后台审核展示与幂等边界 - 新增【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md:范围、不在范围内、依赖、验收标准与证据要求 - 新增【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md:修改边界、实现顺序、验证命令与回滚点 - docs/README.md 登记新合同、里程碑与实施计划入口 --- docs/README.md | 1 + ...划】游戏买断制泥点付费与播放鉴权-2026-10-05.md | 48 ++++++++++ ...¢‘】游戏买断制泥点付费与播放鉴权-2026-10-05.md | 52 ++++++++++ ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 95 ++++++++++++++++++- 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 docs/project-memory/plans/【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md create mode 100644 docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md diff --git a/docs/README.md b/docs/README.md index 65d144aa8..d91dda5a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 - [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 +- [游戏买断制泥点付费与播放鉴权合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏买断制泥点付费与播放鉴权合同)、[里程碑](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md)与[实施计划](./project-memory/plans/【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md):proposed;作者可选买断制泥点付费,购买后永久可玩,后台审核可见价格且审核员不限次试用;未实现。 - [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 diff --git a/docs/project-memory/plans/【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md b/docs/project-memory/plans/【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md new file mode 100644 index 000000000..9e8c77922 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md @@ -0,0 +1,48 @@ +# 【实施计划】游戏买断制泥点付费与播放鉴权 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md` | +| Status | ready | +| Owner | Genarrative Agent | + +## 修改边界 + +- 允许修改: + - `server-rs/crates/module-game-distribution`:定价、购买与播放授权的领域规则与纯内存测试。 + - `server-rs/crates/module-runtime`:新增 `game_purchase` 流水来源类型。 + - `server-rs/crates/spacetime-module`:游戏表价格字段、购买表、相关 procedure、`migration.rs`。 + - `server-rs/crates/spacetime-client`:facade 与 row mapper。 + - `server-rs/crates/api-server`:创建/更新版本接受价格、购买接口、播放会话接口、公开详情与发行网关鉴权、admin 展示。 + - `server-rs/crates/shared-contracts`、`packages/shared`:价格、购买态、购买与播放会话 DTO 及钱包文案。 + - 主站 `src/components/game-distribution`、`src/services/gameDistributionClient.ts` 与发布/详情/游玩/广场相关组件。 + - `apps/admin-web`:审核详情与待审列表价格展示。 + - 文档、后端表目录、生成绑定与定向测试。 +- 明确不修改:`/api/external/v1` 及其 OpenAPI;AGC 发布面板;评分/评价;免费游戏公开链路行为。 + +## 实现顺序 + +1. 主规范与里程碑评审确认;主规范已解除“不做交易”排除并新增合同章。 +2. 领域与 schema:游戏表末尾追加 `price_mud_points`;新增 `game_distribution_purchase`;`module-game-distribution` 定价/购买/授权规则与纯内存测试;同步 `migration.rs`、表目录、生成绑定并运行 schema 检查。 +3. 钱包:`RuntimeProfileWalletLedgerSourceType` 追加 `game_purchase`;购买时原子扣费并写流水;同步 Rust/TS 契约与前端/后台文案。 +4. API 与鉴权:创建/更新版本接受并校验价格;购买接口(`Idempotency-Key` + `(user_id, game_id)` 唯一);播放会话接口;公开详情返回价格与购买态并按需隐藏入口;发行网关对付费游戏拒绝公开路径;admin 详情与列表展示价格。 +5. 前端:发布页定价字段与校验;详情页价格与购买(复用泥点确认弹窗与钱包 store);游玩页经播放会话挂载 iframe;广场角标(可选)。 +6. 后台:审核详情与待审列表价格展示。 +7. 回写主规范、产出真实栈 smoke 与验收证据矩阵,删除本实施计划。 + +## 验证命令 + +1. `cargo test -p module-game-distribution` +2. `cargo test -p api-server game_distribution` +3. `npm run spacetime:generate && npm run check:spacetime-schema` +4. `npm run typecheck` +5. 定向 `npx vitest run src/components/game-distribution` 与 `apps/admin-web` 相关用例 +6. `npm run check:encoding`、`git diff --check` +7. 真实本地栈 smoke(见里程碑“证据要求”) + +## 风险与回滚点 + +- schema:游戏表只末尾追加带默认值字段,购买为新独立表;如迁移不可用,回滚点是不开放定价选项,保留领域与接口的非持久化部分。 +- 付费鉴权:播放会话未就绪时,回滚点是付费游戏只做“购买态 + 平台页隐藏入口”,但不得对外宣称已具备防绕过能力。 +- 钱包:扣费与购买记录必须同事务;无法原子时回滚到不开放购买,只保留价格展示。 +- 免费链路:若改动影响免费游戏,优先回滚该分支,保证免费回归为绿。 diff --git a/docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md b/docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md new file mode 100644 index 000000000..506c2ec93 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md @@ -0,0 +1,52 @@ +# 【里程碑】游戏买断制泥点付费与播放鉴权 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-10-05 | +| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(「游戏买断制泥点付费与播放鉴权合同」章) | + +## 目标 + +把游戏分发从“公开即免费可玩”扩展为“作者可选买断制泥点付费;其他用户购买后永久可玩;后台审核可见价格且审核员不限次试玩”。 + +## 范围 + +- 作者端定价:网页 `/games/publish` 新建与更新模式选择“免费 / 买断制 N 泥点”。 +- 玩家端:公开详情展示价格与购买态、泥点购买、购买后游玩。 +- 付费游玩鉴权:未购买不得游玩,直连公开发行路径必须失败。 +- 泥点钱包扣费与 `game_purchase` 流水。 +- 后台审核详情与待审列表展示价格。 + +## 不在范围内 + +- AGC 发布面板的定价入口(后续切片,不与本里程碑同批验收)。 +- 创作者分成与结算、退款/撤销购买、促销/限时免费、订阅或游戏内购、价格运营审批流。 +- 评分/评论与付费的耦合、外部 OpenAPI。 +- 免费游戏现有链路的任何行为变化。 + +## 依赖与前置条件 + +- 现有游戏分发状态机、审核 CAS/幂等与版本冻结资料机制。 +- runtime profile 钱包的原子写与 `profile_wallet_ledger` 流水。 +- 现有发行网关 sandbox/CSP/Cookie 约束,以及后台待审版本预览会话(token 前缀承载相对资源)模式。 +- 主规范“游戏买断制泥点付费与播放鉴权合同”与本文档评审通过。评审前不写业务代码。 + +## 验收标准 + +- [ ] 作者网页发布可选「免费」或「买断制 N 泥点」;负数/超上限/非整数被前后端同时拦截。 +- [ ] 后台审核详情展示该版本价格;无价格的历史版本按免费展示。 +- [ ] 审核员不限次数试玩待审付费版本,不校验购买、不扣泥点。 +- [ ] 未购买用户详情可见资料但无可播放入口;直连 `/games//` 返回 404。 +- [ ] 余额不足购买失败,余额、账单与购买记录都不变。 +- [ ] 购买成功:余额减少 N、账单出现 `game_purchase` 流水、购买记录唯一、可反复游玩不再扣费。 +- [ ] 双击、并发与响应丢失重试只扣一次。 +- [ ] 作者本人与管理员免购买即可游玩付费作品且不扣费。 +- [ ] 免费游戏的发布、详情、游玩与计数链路无行为变化。 + +## 证据要求 + +- 自动化:Rust 定向测试(定价校验、购买幂等与并发、授权判定、状态机)、Vitest(发布表单定价、详情购买态、游玩页会话挂载、审核价格展示)、schema/绑定与编码门禁。 +- 运行时:真实本地栈跑通「发布付费游戏 → 后台审核可见价格并试玩 → 通过 → 未购买访问失败 → 购买 → 游玩 → 重复游玩不扣费」。 +- 边界:并发购买、余额不足、下架/封禁后播放会话失效、猜测 URL 直连、免费游戏回归。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index bac2dd9bc..ff27cc0dd 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -64,7 +64,7 @@ 交付“AGC 一键提交游戏 / 网页上传游戏 ZIP → 后端收取真实发行包 → 校验与人工审核 → 主站发现、详情、游客在线游玩 → 更新与下架”的完整闭环。验收必须使用真实上传、真实存储、真实审核状态和隔离发行域名;静态演示卡片、metadata-only 请求或前端本地发布状态不能作为完成证据。 -首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。本发行合同 Version 0.2 不包含用户评分与评论,其扩展范围见下文“网站游戏评分与评价合同”;关注、榜单、推荐算法、交易和创作者收入仍不在范围内。 +首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。本发行合同 Version 0.2 不包含用户评分与评论,其扩展范围见下文“网站游戏评分与评价合同”;买断制泥点付费与付费游玩鉴权见下文“游戏买断制泥点付费与播放鉴权合同”。关注、榜单、推荐算法、创作者收入与结算仍不在范围内。 ### 入口与产品体验 @@ -550,3 +550,96 @@ - 管理员权限继续复用现有游戏审核权限映射,还是单独增加 `game-review-preview` 动作权限;第一里程碑默认复用现有审核权限,不扩大权限模型。 - 预览 Token 的单次 HTML 读取与同包静态资源多次读取需要统一 TTL 和失效规则;第一里程碑使用短 TTL 的版本绑定会话。 + +## 游戏买断制泥点付费与播放鉴权合同 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-10-05 | +| 适用边界 | 主站游戏分发(`/games`)的作者买断制定价、泥点购买、付费游玩鉴权与后台审核价格展示;不含创作者结算 | +| 里程碑 | [游戏买断制泥点付费与播放鉴权](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md) | +| 实施计划 | [实施计划](./project-memory/plans/【实施计划】游戏买断制泥点付费与播放鉴权-2026-10-05.md) | + +### 目标 + +- 作者发布游戏时可选择“免费”或“买断制 N 泥点”;付费作品对其他用户在完成购买前不可游玩。 +- 买断制:购买一次永久拥有,重复进入不再扣费;换设备、重新登录或更新版本都不清除所有权。 +- 后台审核详情展示该版本价格;审核员试玩待审版本不受购买限制、不限次数、不扣泥点。 + +### 非目标 + +- 不做创作者分成与结算、退款与撤销购买、促销/限时免费、订阅或游戏内购、价格运营审批流。 +- 不扩展 `/api/external/v1` 及其 OpenAPI。 +- 不新建第二套账号、钱包或发行通道;泥点沿用 runtime profile 域。 + +### 入口与边界 + +- 作者入口:网页 `/games/publish`(新建与更新模式)。AGC 发布面板的定价入口列为后续切片,不与网页同批验收。 +- 玩家入口:`/games`、`/games/detail`、`/games/play`。 +- 审核入口:后台 `#game-distribution` 审核页(详情与待审列表)。 +- 正式状态来源:SpacetimeDB 的游戏价格、购买记录与钱包余额/流水;前端只负责表现与请求编排。 + +### 必须成立的行为 + +#### 定价与冻结 + +- 价格是版本冻结资料的一部分:作者提交新版本时携带价格,与封面/截图一样在审核通过时整体生效到游戏行;未通过审核的价格不改变当前公开价格。 +- 价格是整数泥点数,`0` 表示免费,取值域由服务端约束(上限待评审,初值 `1_000_000`);免费与付费共用同一条发布与审核链路。 +- 展示资料编辑(`PATCH /my-games/{gameId}`)不得直接变更公开价格;调整价格必须走新版本审核。 + +#### 购买与所有权 + +- `price_mud_points = 0` 的游戏对所有人免费可玩,行为与现状一致。 +- 付费游戏对未购买用户:详情可见资料与价格,但不返回可播放入口。 +- 每账号每游戏最多一条购买记录;重复购买幂等返回既有记录且不再扣费。 +- 购扣泥点必须与购买记录写入处于同一原子事务:不允许“扣了没记录”或“记录了没扣”。 +- 余额不足时明确失败,不变更余额、不写购买记录。 +- 作者本人与管理员免购买即可游玩付费作品,且不扣费。 + +#### 播放鉴权 + +- 付费游戏的发行入口不经公开同源路径对外开放;未购买者直接访问 `/games//` 必须失败(404),不能靠猜测 `gameId` 绕过。 +- 允许游玩时由服务端签发绑定“`gameId` + 用户 + 短时效”的播放会话,游戏入口与包内资源经会话前缀读取;会话过期、游戏下架/封禁或目标版本失效后不可继续播放。 +- 播放会话沿用现有发行沙箱与网关约束:`sandbox="allow-scripts"` 不透明来源、拒绝真实平台 Cookie、CSP/MIME/`nosniff`/CORP 与运行期 storage 兼容层不变;会话响应按现有发行缓存口径(≤60s)或 `no-store`。 +- 免费游戏继续使用现有 `/games//` 公开入口,播放鉴权不得改变免费链路。 +- 游玩计数只在真正进入游戏后上报;未购买或被拒绝的访问不计数。 + +#### 后台审核 + +- 审核详情必须展示该版本价格(“免费”或“N 泥点”);无价格的历史版本按免费口径展示。 +- 审核员试玩待审版本维持现有预览会话语义:不校验购买、不扣泥点、不限次数。 +- 审核通过后公开价格随版本生效;拒绝不影响当前公开价格。 + +#### 失败、重试与幂等 + +- 购买请求携带 `Idempotency-Key`:同 key 同请求返回原结果,同 key 不同请求返回冲突。 +- 并发或重复点击只产生一条购买记录、只扣一次。 +- 服务端不可用时购买失败关闭,不出现“未扣费却已解锁”或“已扣费却未解锁”的中间态。 + +### 契约与迁移 + +- SpacetimeDB:`game_distribution_game` 末尾追加 `price_mud_points: u64`(明确默认 0);新增私有 `game_distribution_purchase`(`purchase_id` 主键,含 `game_id`、`user_id`、成交价快照、钱包流水 ID 与创建时间,`(user_id, game_id)` 唯一);同步 `migration.rs`、后端表目录与生成绑定,并运行 `npm run check:spacetime-schema`。 +- 泥点流水新增来源类型 `game_purchase`(`RuntimeProfileWalletLedgerSourceType`),同步 Rust `shared-contracts`、`packages/shared`、前端与后台文案。 +- 游戏 DTO 增加 `priceMudPoints` 与购买态;新增购买与播放会话 DTO。免费游戏必须能忽略这些新字段而不改变行为。 +- 不修改已有表字段的类型或顺序;新增字段一律末尾追加并带明确默认值。 + +### 验收标准与证据 + +| 条款 | 验收方式 | 证据 | +| --- | --- | --- | +| 定价 | 网页发布可选「免费/买断制」并拦截非法值,前后端口径一致 | 待补 | +| 后台可见 | 审核详情与待审列表显示该版本价格 | 待补 | +| 审核试玩 | 审核员不限次试玩待审付费版本,不校验购买、不扣泥点 | 待补 | +| 购买 | 余额不足失败且不变更;成功扣 N、账单出现 `game_purchase` 流水、购买记录唯一 | 待补 | +| 幂等 | 双击、并发、响应丢失重试只扣一次 | 待补 | +| 播放鉴权 | 未购买直连公开入口 404;购买后可反复游玩且不再扣费 | 待补 | +| 免购买游玩 | 作者与管理员可游玩付费作品且不扣费 | 待补 | +| 免费回归 | 免费游戏发布/详情/游玩/计数无行为变化 | 待补 | +| 工程门禁 | schema/绑定、两端类型、定向测试、编码与 diff 检查通过 | 待补 | + +### 待评审决策 + +1. 价格上限取值,以及是否允许作者在作品已公开后通过新版本随意调价(对已购买用户不再收费的口径已确定)。 +2. “免购买游玩”的判定范围:仅限作者本人与现有管理员身份,还是需要单独运营角色。 From 4d7cd98dc67827f0b95bbc70b9d93008cd1756f7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:47:26 +0800 Subject: [PATCH 02/25] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E9=A1=B5=E5=B1=95=E7=A4=BA=E4=B9=B0=E6=96=AD?= =?UTF-8?q?=E5=88=B6=E6=B3=A5=E7=82=B9=E4=BB=B7=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adminApiTypes:AdminGameDistributionReviewEntry 与 AdminGameDistributionGameDetail 新增 priceMudPoints: number 字段 - 审核页待审列表新增「价格」列:priceMudPoints 为 0、缺失或旧响应时显示「免费」,否则显示「N 泥点」 - 审核详情资料区新增「价格」项,与列表共用同一格式化口径,避免出现 NaN - 同步审核表 nth-child 列宽定义:新增价格列 96px,提交时间与审核列顺移,窄屏媒体查询一并覆盖第 6 列 - 补齐 AdminGameDistributionReviewPage 测试:付费 30 泥点、免费与缺字段回落各一条,并在详情用例断言价格展示 --- apps/admin-web/src/api/adminApiTypes.ts | 4 ++ .../AdminGameDistributionReviewPage.test.tsx | 50 ++++++++++++++++++- .../pages/AdminGameDistributionReviewPage.tsx | 11 ++++ .../src/pages/adminGameDistributionReview.css | 13 ++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 8fecfe485..91544e8cc 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1138,6 +1138,8 @@ export interface AdminGameDistributionReviewEntry { packageBytes: number; status: string; publicationRevision: number; + /** 买断制泥点价格;0 表示免费。旧响应可能缺该字段。 */ + priceMudPoints: number; reviewReason: string | null; createdAt: string; updatedAt: string; @@ -1166,6 +1168,8 @@ export interface AdminGameDistributionGameDetail { orientation: string; status: string; publicationRevision: number; + /** 买断制泥点价格;0 表示免费。旧响应可能缺该字段。 */ + priceMudPoints: number; playCount: number; createdAt: string; } diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 3ca7ab6be..b3d0be77c 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -48,6 +48,7 @@ const entry: AdminGameDistributionReviewEntry = { packageBytes: 2048, status: 'pending_review', publicationRevision: 4, + priceMudPoints: 30, reviewReason: null, // 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。 createdAt: '1791008678.817257Z', @@ -92,6 +93,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = { orientation: 'landscape', status: 'unpublished', publicationRevision: 7, + priceMudPoints: 30, playCount: 0, createdAt: '2026-09-20T08:00:00Z', }, @@ -228,7 +230,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', const row = screen.getByText(gameTitle).closest('tr'); expect(row).not.toBeNull(); - const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3]; + // 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。 + const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4]; // 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。 expect(submittedCell?.textContent).not.toBe('-'); expect(submittedCell?.textContent).toMatch( @@ -236,6 +239,48 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', ); }); +test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => { + const freeEntry: AdminGameDistributionReviewEntry = { + ...entry, + versionId: 'version-2', + gameId: 'game_2', + versionNumber: 1, + priceMudPoints: 0, + }; + // 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。 + const legacyEntry = { + ...entry, + versionId: 'version-3', + gameId: 'game_3', + versionNumber: 3, + priceMudPoints: undefined, + } as unknown as AdminGameDistributionReviewEntry; + vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ + entries: [entry, freeEntry, legacyEntry], + nextCursor: null, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + const cellAt = (text: string) => { + const row = screen.getByText(text).closest('tr'); + expect(row).not.toBeNull(); + // 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。 + return within(row as HTMLElement).getAllByRole('cell')[3]?.textContent; + }; + + expect(cellAt(gameTitle)).toBe('30 泥点'); + expect(cellAt('game_2')).toBe('免费'); + expect(cellAt('game_3')).toBe('免费'); + expect(screen.queryByText(/NaN/u)).toBeNull(); +}); + test('取消理由输入时不做审核操作', async () => { render( 0 ? `${value} 泥点` : '免费'; +} + function createReviewIdempotencyKey(versionId: string) { const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto @@ -437,6 +442,7 @@ export function AdminGameDistributionReviewPage({ { key: 'gameId', label: '游戏' }, { key: 'versionNumber', label: '版本' }, { key: 'packageBytes', label: '发行包' }, + { key: 'priceMudPoints', label: '价格' }, { key: 'createdAt', label: '提交时间' }, { key: 'actions', label: '审核' }, ]} @@ -482,6 +488,7 @@ export function AdminGameDistributionReviewPage({ {entry.packageSha256.slice(0, 12)} + {formatPriceMudPoints(entry.priceMudPoints)} {formatAdminDateTime(entry.createdAt)} @@ -629,6 +636,10 @@ function AdminGameDistributionReviewDetailView({ versionLabel={`v${detail.version.versionNumber}`} infoCards={[ { label: '状态', value: detail.version.status }, + { + label: '价格', + value: formatPriceMudPoints(detail.game.priceMudPoints), + }, { label: '提交时间', value: formatAdminDateTime(detail.version.createdAt), diff --git a/apps/admin-web/src/pages/adminGameDistributionReview.css b/apps/admin-web/src/pages/adminGameDistributionReview.css index 2eed66619..28e6ef8cd 100644 --- a/apps/admin-web/src/pages/adminGameDistributionReview.css +++ b/apps/admin-web/src/pages/adminGameDistributionReview.css @@ -27,14 +27,21 @@ table.admin-game-review-table td:nth-child(3) { width: 124px; } +/* 价格列:只展示「免费」或「N 泥点」,窄列即可。 */ table.admin-game-review-table th:nth-child(4), table.admin-game-review-table td:nth-child(4) { - width: 172px; + width: 96px; white-space: nowrap; } table.admin-game-review-table th:nth-child(5), table.admin-game-review-table td:nth-child(5) { + width: 172px; + white-space: nowrap; +} + +table.admin-game-review-table th:nth-child(6), +table.admin-game-review-table td:nth-child(6) { width: 240px; } @@ -100,7 +107,9 @@ table.admin-game-review-table td .admin-muted-text { table.admin-game-review-table th:nth-child(4), table.admin-game-review-table td:nth-child(4), table.admin-game-review-table th:nth-child(5), - table.admin-game-review-table td:nth-child(5) { + table.admin-game-review-table td:nth-child(5), + table.admin-game-review-table th:nth-child(6), + table.admin-game-review-table td:nth-child(6) { width: auto; } } From d27e204f21c7094f4d946f0a2be8ccfa820f7265 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:49:52 +0800 Subject: [PATCH 03/25] =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E6=94=B9=E7=94=A8=E5=BE=85=E5=AE=A1=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=86=BB=E7=BB=93=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applyFrozenGameMetadata 叠加冻结资料中的数值型 priceMudPoints:价格随版本冻结,审核员要看到待审版本将要生效的价格,而不是游戏行当前已公开价格 - 冻结资料缺 priceMudPoints 或为旧响应时保留 game.priceMudPoints,详情资料区仍渲染 detail.game.priceMudPoints - 新增两条详情用例:游戏行 0 泥点但冻结 30 泥点时展示「30 泥点」;旧版本冻结资料无价格时回落「免费」且不出现 NaN --- .../AdminGameDistributionReviewPage.test.tsx | 54 +++++++++++++++++++ .../pages/AdminGameDistributionReviewPage.tsx | 5 ++ 2 files changed, 59 insertions(+) diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index b3d0be77c..dda1e4022 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -17,6 +17,7 @@ import { reviewAdminGameDistributionVersion, } from '../api/adminApiClient'; import type { + AdminGameDistributionGameDetail, AdminGameDistributionGameListResponse, AdminGameDistributionReviewEntry, AdminGameDistributionVersionDetailResponse, @@ -365,3 +366,56 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async ), ); }); + +test('详情优先展示待审版本冻结价格,而不是游戏行当前价格', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + // 游戏行当前是免费,待审版本冻结为 30 泥点:审核员要看到将要生效的价格。 + game: { ...detailResponse.game, priceMudPoints: 0 }, + version: { + ...detailResponse.version, + frozenMetadata: { title: '测试游戏', priceMudPoints: 30 }, + }, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + + expect(screen.getByText('价格')).toBeTruthy(); + expect(screen.getByText('30 泥点')).toBeTruthy(); + expect(screen.queryByText('免费')).toBeNull(); +}); + +test('旧版本冻结资料没有价格时按免费回落', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + game: { + ...detailResponse.game, + priceMudPoints: undefined, + } as unknown as AdminGameDistributionGameDetail, + version: { + ...detailResponse.version, + frozenMetadata: { title: '测试游戏' }, + }, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + + expect(screen.getByText('免费')).toBeTruthy(); + expect(screen.queryByText(/NaN/u)).toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 4fc7782b8..46fb377cb 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -98,6 +98,11 @@ function applyFrozenGameMetadata( typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation, + // 价格随版本冻结:审核页要展示待审版本将要生效的价格,而不是游戏行当前已公开价格。 + priceMudPoints: + typeof metadata.priceMudPoints === 'number' + ? metadata.priceMudPoints + : game.priceMudPoints, }; } From 7b38ef9623586f8ace8818f2cab7b35ac4d82803 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:59:45 +0800 Subject: [PATCH 04/25] =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=B8=B8=E6=88=8F=E4=B9=B0=E6=96=AD=E5=88=B6=E6=B3=A5?= =?UTF-8?q?=E7=82=B9=E4=BB=98=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared 游戏契约补买断价与购买态:GameDistributionGame 增 priceMudPoints/purchased,版本摘要 entryUrl 可为 null - packages/shared 版本请求增 priceMudPoints,私有版本回读增同名字段;新增购买请求、购买记录、购买响应与播放会话 DTO - packages/shared 钱包流水来源类型增 game_purchase,网页与共享账单文案增「购买游戏」标签 - 游戏分发客户端新增 purchaseGame(Idempotency-Key + 期望价格)与 createGamePlaySession - 发布页新增「付费方式」免费/买断制表单,定价校验与服务端 0..=1000000 口径一致,价格随版本请求提交 - 详情页展示定价、付费未购买时主动作改为「N 泥点购买」,复用泥点确认弹窗展示余额,成功后刷新钱包并回到可玩状态,余额不足给出充值入口 - 游玩页付费作品先签发播放会话再挂载 iframe,免费作品链路不变;会话被拒时不挂载 iframe 也不上报游玩 - 发行入口守卫接受播放会话前缀路径,拒绝其它非发行路径 - 定向契约测试登记 4 组购买/播放会话 DTO,补发布定价、未购买不可玩、购买确认与播放会话挂载用例 --- .../PlatformProfileWalletLedgerModal/model.ts | 1 + .../shared/src/contracts/gameDistribution.ts | 50 ++- packages/shared/src/contracts/runtime.ts | 3 +- .../check-game-distribution-dto-parity.mjs | 4 + .../game-distribution/GameDetailPage.tsx | 297 +++++++++++++++--- .../GameDistributionPages.test.tsx | 201 ++++++++++++ .../game-distribution/GamePlayPage.tsx | 103 +++++- .../GamePublishPage.test.tsx | 162 +++++++++- .../game-distribution/GamePublishPage.tsx | 76 ++++- .../gameDistributionGuards.test.ts | 24 ++ .../gameDistributionGuards.ts | 12 +- .../game-distribution/gamePublishMetadata.ts | 46 +++ .../PlatformEntryActiveFlowShell.tsx | 1 + .../platformProfileFundsModel.ts | 1 + src/services/gameDistributionClient.ts | 48 ++- 15 files changed, 972 insertions(+), 57 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index a18adce79..514c40987 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -20,6 +20,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { redeem_code_reward: '兑换码奖励', puzzle_author_incentive_claim: '拼图作者奖励', daily_task_reward: '每日任务奖励', + game_purchase: '购买游戏', } satisfies Record; export type ProfileWalletLedgerEntryPresentation = { diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index f7803ebdb..be4f27506 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -104,8 +104,10 @@ export type GameDistributionVersionSummary = { /** * 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 * 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。 + * + * 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。 */ - entryUrl: string; + entryUrl: string | null; sha256: string; publishedAt: string; controls: string[]; @@ -141,6 +143,10 @@ export type GameDistributionGame = { currentVersion?: GameDistributionVersionSummary | null; /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ ratingSummary?: GameDistributionRatingSummary; + /** 买断价(整数泥点,`0` 表示免费)。旧响应可省略,读取方按 `0`(免费)兜底。 */ + priceMudPoints: number; + /** 当前查看者是否已拥有;匿名与未登录恒为 `false`。 */ + purchased: boolean; playCount: number; createdAt: string; }; @@ -176,6 +182,13 @@ export type GameDistributionCreateVersionRequest = { * 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。 */ versionNumber?: number | null; + /** + * 作者提交的买断价(整数泥点,`0` 表示免费)。 + * + * 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。 + * 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。 + */ + priceMudPoints?: number; packageSha256: string; packageBytes: number; packageFileCount: number; @@ -214,6 +227,8 @@ export type GameDistributionPrivateVersion = { status: GameDistributionVersionStatus; /** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */ publicationRevision: number; + /** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */ + priceMudPoints?: number; reviewReason?: string | null; /** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */ entryUrl: string | null; @@ -276,3 +291,36 @@ export type GameDistributionCancelVersionResponse = { version: GameDistributionPrivateVersion; replayed: boolean; }; + +/** + * 买断制购买请求体。 + * + * 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。 + */ +export type GameDistributionPurchaseRequest = { + expectedPriceMudPoints: number; +}; + +/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */ +export type GameDistributionPurchase = { + purchaseId: string; + gameId: string; + priceMudPoints: number; + createdAt: string; +}; + +/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */ +export type GameDistributionPurchaseResponse = { + purchase: GameDistributionPurchase; + walletBalance: number; + replayed: boolean; +}; + +/** + * 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。 + * 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。 + */ +export type GameDistributionPlaySessionResponse = { + playUrl: string; + expiresAt: string; +}; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index eee7978ed..033d5b171 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -82,7 +82,8 @@ export type ProfileWalletLedgerEntry = { | 'recharge_refund_recovery' | 'redeem_code_reward' | 'puzzle_author_incentive_claim' - | 'daily_task_reward'; + | 'daily_task_reward' + | 'game_purchase'; createdAt: string; reason?: string; }; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a149af079..0a05d36c5 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -54,6 +54,10 @@ const PAIRS = [ 'GameDistributionUpdateGameMetadataRequest', ], ['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'], + ['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'], + ['GameDistributionPurchase', 'GameDistributionPurchase'], + ['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'], + ['GameDistributionPlaySessionResponse', 'GameDistributionPlaySessionResponse'], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1fee3520d..3efa3373b 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -12,6 +12,7 @@ import type { GameDistributionVersionSummary, } from '../../../packages/shared/src/contracts/gameDistribution'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, type GameDistributionInputMode, @@ -20,9 +21,12 @@ import { getGame, getMyGame, loadVersionRecoveryActions, + purchaseGame, } from '../../services/gameDistributionClient'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { normalizeGameEntryUrl, @@ -45,6 +49,11 @@ type GameDetailPageProps = { gameId: string | null; onBack: () => void; onPlay: (gameId: string) => void; + /** + * 打开平台充值面板;余额不足以购买付费作品时由宿主提供入口。 + * 宿主没有充值能力时(例如独立嵌入)传入空值,页面只保留余额提示。 + */ + onOpenRecharge?: () => void; /** * 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。 * @@ -55,6 +64,24 @@ type GameDetailPageProps = { ownerView?: boolean; }; +/** 购买后的页面级反馈:成功、余额不足(带充值入口)与失败共用一套展示。 */ +type GamePurchaseNotice = { + tone: 'info' | 'error'; + message: string; + canRecharge: boolean; +}; + +let purchaseKeySeed = 0; +/** 一次购买的幂等键:双击、响应丢失重试复用同一个键,服务端据此只扣一次。 */ +function createPurchaseKey() { + purchaseKeySeed += 1; + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${purchaseKeySeed}`; + return `web-game-purchase-${random}`; +} + /** * 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`), * 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。 @@ -105,6 +132,7 @@ export function GameDetailPage({ gameId, onBack, onPlay, + onOpenRecharge, ownerView = false, }: GameDetailPageProps) { const authUi = useAuthUi(); @@ -119,8 +147,34 @@ export function GameDetailPage({ >({}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [isPurchaseDialogOpen, setIsPurchaseDialogOpen] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [purchaseNotice, setPurchaseNotice] = + useState(null); const requestSerialRef = useRef(0); + const purchaseInFlightRef = useRef(false); + const purchaseKeyRef = useRef(null); const isMobileViewport = useIsMobileViewport(); + const mudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const legacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const walletBalance = + mudPointBalance?.totalPoints ?? legacyWalletBalance ?? null; + const balanceOwnerMatchesCurrentUser = usePlatformWalletStore( + (state) => state.ownerUserId === (authUi?.user?.id ?? null), + ); + // 换账号或换作品时立刻丢弃上一份购买反馈与在途幂等键,避免把结论带到别的作品上。 + useEffect(() => { + setPurchaseNotice(null); + setIsPurchaseDialogOpen(false); + purchaseKeyRef.current = null; + }, [gameId, ownerView, authUi?.user?.id]); const loadPublicGame = useCallback(() => { const requestSerial = ++requestSerialRef.current; @@ -136,20 +190,34 @@ export function GameDetailPage({ void getGame(normalizedGameId) .then((value) => { if (requestSerial !== requestSerialRef.current) return; - if (value?.currentVersion) { - const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl); - if (!entryUrl) { - setError('游戏入口地址无效'); - return; - } + if (!value) { + setPublicGame(null); + setError('找不到这款游戏'); + return; + } + const currentVersion = value.currentVersion ?? null; + if (!currentVersion) { + setPublicGame(value); + return; + } + const rawEntryUrl = currentVersion.entryUrl; + // 买断制作品在未购买时服务端不下发公开入口:保留空入口,主行动作改由购买接手。 + if (!rawEntryUrl) { setPublicGame({ ...value, - currentVersion: { ...value.currentVersion, entryUrl }, + currentVersion: { ...currentVersion, entryUrl: null }, }); return; } - setPublicGame(value); - if (!value) setError('找不到这款游戏'); + const entryUrl = normalizeGameEntryUrl(rawEntryUrl); + if (!entryUrl) { + setError('游戏入口地址无效'); + return; + } + setPublicGame({ + ...value, + currentVersion: { ...currentVersion, entryUrl }, + }); }) .catch((loadError: unknown) => { if (requestSerial !== requestSerialRef.current) return; @@ -221,6 +289,80 @@ export function GameDetailPage({ const resolvedGame = ownerView ? ownerGame : publicGame; const reload = ownerView ? loadOwnerGame : loadPublicGame; + /** + * 打开购买确认。未登录先走平台登录;幂等键在打开弹窗时生成, + * 同一次购买(含失败后重试)复用同一个键,避免重复扣费。 + */ + function requestPurchase() { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + purchaseKeyRef.current = createPurchaseKey(); + setPurchaseNotice(null); + setIsPurchaseDialogOpen(true); + } + + async function handleConfirmPurchase() { + if (!resolvedGame || purchaseInFlightRef.current) return; + const priceMudPoints = resolvedGame.priceMudPoints ?? 0; + if (priceMudPoints <= 0) return; + purchaseInFlightRef.current = true; + setIsPurchasing(true); + try { + if (!purchaseKeyRef.current) purchaseKeyRef.current = createPurchaseKey(); + await purchaseGame( + resolvedGame.id, + { expectedPriceMudPoints: priceMudPoints }, + purchaseKeyRef.current, + ); + purchaseKeyRef.current = null; + setIsPurchaseDialogOpen(false); + // 扣费成功先刷新平台钱包 store,再回读详情拿 `purchased` 与可播放入口。 + await onWalletBalanceMayHaveChanged(); + setPurchaseNotice({ + tone: 'info', + message: '购买成功,现在可以直接开始游玩。', + canRecharge: false, + }); + reload(); + } catch (purchaseError: unknown) { + purchaseKeyRef.current = null; + setIsPurchaseDialogOpen(false); + const insufficient = + purchaseError instanceof ApiClientError && + purchaseError.code === 'INSUFFICIENT_MUD_POINTS'; + const priceChanged = + purchaseError instanceof ApiClientError && purchaseError.status === 409; + if (insufficient) { + setPurchaseNotice({ + tone: 'error', + message: '泥点不足,充值后即可购买这款游戏。', + canRecharge: true, + }); + } else if (priceChanged) { + setPurchaseNotice({ + tone: 'error', + message: '价格已更新,已为你刷新最新价格,请重新确认购买。', + canRecharge: false, + }); + reload(); + } else { + setPurchaseNotice({ + tone: 'error', + message: + purchaseError instanceof Error && purchaseError.message.trim() + ? purchaseError.message + : '购买失败,请稍后重试。', + canRecharge: false, + }); + } + } finally { + purchaseInFlightRef.current = false; + setIsPurchasing(false); + } + } + if ( isLoading || (resolvedGame && resolvedGame.id !== normalizeGameId(gameId)) @@ -250,19 +392,45 @@ export function GameDetailPage({ ); const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData); + const purchasePriceMudPoints = resolvedGame.priceMudPoints ?? 0; return ( - authUi?.openLoginModal()} - onBack={onBack} - onPlay={onPlay} - /> + <> + authUi?.openLoginModal()} + onBack={onBack} + onPlay={onPlay} + /> + { + if (isPurchasing) return; + setIsPurchaseDialogOpen(false); + }} + onConfirm={() => void handleConfirmPurchase()} + > +

+ 当前泥点余额: + {balanceOwnerMatchesCurrentUser && walletBalance !== null + ? `${walletBalance} 泥点` + : '读取中…'} +

+
+ ); } @@ -273,6 +441,10 @@ function GameDetailContent({ recoveryActions, isMobileViewport, canReview, + isPurchasing, + purchaseNotice, + onOpenPurchase, + onOpenRecharge, onLogin, onBack, onPlay, @@ -283,6 +455,10 @@ function GameDetailContent({ recoveryActions: Record; isMobileViewport: boolean; canReview: boolean; + isPurchasing: boolean; + purchaseNotice: GamePurchaseNotice | null; + onOpenPurchase: () => void; + onOpenRecharge?: () => void; onLogin: () => void; onBack: () => void; onPlay: (gameId: string) => void; @@ -317,7 +493,30 @@ function GameDetailContent({ : resolveNonPublicCoverLabel(game.status, latestVersion?.status); const latestReviewReason = latestVersion?.reviewReason?.trim() ?? ''; const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; - const playbackBlocked = !version || mobilePlaybackBlocked; + /** + * 买断价与购买态:缺字段按免费/未购买兜底,付费且未购买时后端不下发可播放入口, + * 前端也不再展示「立即玩」,改由购买动作接手。 + */ + const priceMudPoints = game.priceMudPoints ?? 0; + const isPaidLocked = + !ownerView && priceMudPoints > 0 && game.purchased !== true; + const hasPlayableEntry = Boolean(version?.entryUrl); + /** + * 公开视角按「后端是否下发入口」判定可否开玩:付费未购买时入口为空。 + * 作者视角的版本条目不带公开入口,沿用「有已公开版本即可开玩」的既有口径。 + */ + const playbackBlocked = ownerView + ? !version || mobilePlaybackBlocked + : !hasPlayableEntry || mobilePlaybackBlocked; + const priceInfoCards = + typeof game.priceMudPoints === 'number' + ? [ + { + label: '定价', + value: game.priceMudPoints > 0 ? `${game.priceMudPoints} 泥点` : '免费', + }, + ] + : []; return (
+ ) : null} + + ) : null} {ownerView && ownerGame ? (
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 4ecd4ebc3..b9d426f60 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -12,6 +12,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution'; +import { ApiClientError } from '../../services/apiClient'; import type { GameDistributionGame, GameDistributionMyGame, @@ -30,6 +31,8 @@ const recordGamePlayMock = vi.hoisted(() => vi.fn()); const resolvedAssetUrlMock = vi.hoisted(() => vi.fn()); const listGameReviewsMock = vi.hoisted(() => vi.fn()); const getMyGameReviewMock = vi.hoisted(() => vi.fn()); +const purchaseGameMock = vi.hoisted(() => vi.fn()); +const createGamePlaySessionMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, @@ -39,6 +42,21 @@ vi.mock('../../services/gameDistributionClient', () => ({ recordGamePlay: recordGamePlayMock, listGameReviews: listGameReviewsMock, getMyGameReview: getMyGameReviewMock, + purchaseGame: purchaseGameMock, + createGamePlaySession: createGamePlaySessionMock, +})); + +// 详情页购买后要刷新平台钱包;这里只提供选择器需要的状态与刷新钩子,避免真实取余额请求。 +const walletMocks = vi.hoisted(() => ({ + ownerUserId: 'author-1', + mudPointBalance: { totalPoints: 500, permanentPoints: 500, limitedPoints: 0 }, + legacyWalletBalance: 500, + onWalletBalanceMayHaveChanged: vi.fn(async () => {}), +})); + +vi.mock('../../stores/usePlatformWalletStore', () => ({ + usePlatformWalletStore: (selector: (state: typeof walletMocks) => T) => + selector(walletMocks), })); // 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。 @@ -101,6 +119,8 @@ function createGame( }, playCount: 12, createdAt: '2026-09-18T00:00:00.000Z', + priceMudPoints: 0, + purchased: true, ...overrides, }; } @@ -143,6 +163,9 @@ beforeEach(() => { }); getMyGameReviewMock.mockResolvedValue({ review: null }); recordGamePlayMock.mockResolvedValue(undefined); + purchaseGameMock.mockReset(); + createGamePlaySessionMock.mockReset(); + walletMocks.onWalletBalanceMayHaveChanged.mockClear(); resolvedAssetUrlMock.mockImplementation((objectKey: string) => objectKey ? `https://assets.example.test/${objectKey}` : '', ); @@ -246,6 +269,120 @@ describe('GameDetailPage', () => { expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy(); }); + + function createLockedPaidGame(): GameDistributionGame { + return createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }); + } + + it('付费未购买时展示价格与购买主动作,不再给出游玩入口', async () => { + getGameMock.mockResolvedValueOnce(createLockedPaidGame()); + render( + , + ); + + const buyButton = await screen.findByRole('button', { + name: /120 泥点购买/u, + }); + expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull(); + // 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。 + expect(screen.getByText('120 泥点')).toBeTruthy(); + expect(screen.queryByText('游戏入口地址无效')).toBeNull(); + expect((buyButton as HTMLButtonElement).disabled).toBe(false); + }); + + it('购买走二次确认并展示余额,成功后刷新钱包并回到可玩状态', async () => { + getGameMock + .mockResolvedValueOnce(createLockedPaidGame()) + .mockResolvedValueOnce( + createGame({ priceMudPoints: 120, purchased: true }), + ); + purchaseGameMock.mockResolvedValue({ + purchase: { + purchaseId: 'gdpurchase_1', + gameId: 'game-1', + priceMudPoints: 120, + createdAt: '2026-10-05T00:00:00Z', + }, + walletBalance: 380, + replayed: false, + }); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + const dialog = await screen.findByRole('dialog', { + name: '确认消耗泥点', + }); + expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy(); + expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '确认购买' })); + + await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1)); + expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1'); + expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({ + expectedPriceMudPoints: 120, + }); + // 幂等键必须非空,双击与重试才会被服务端按同一键重放。 + expect(String(purchaseGameMock.mock.calls[0]?.[2] ?? '')).not.toBe(''); + await waitFor(() => + expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(), + ); + expect( + await screen.findByText('购买成功,现在可以直接开始游玩。'), + ).toBeTruthy(); + expect( + await screen.findByRole('button', { name: /立即玩/u }), + ).toBeTruthy(); + }); + + it('余额不足时失败关闭并给出充值入口', async () => { + const onOpenRecharge = vi.fn(); + getGameMock.mockResolvedValue(createLockedPaidGame()); + purchaseGameMock.mockRejectedValueOnce( + new ApiClientError({ + message: '泥点余额不足', + status: 400, + code: 'INSUFFICIENT_MUD_POINTS', + }), + ); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + fireEvent.click( + await screen.findByRole('button', { name: '确认购买' }), + ); + + expect( + await screen.findByText('泥点不足,充值后即可购买这款游戏。'), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '去充值' })); + expect(onOpenRecharge).toHaveBeenCalledTimes(1); + // 失败关闭:既没有游玩入口,也没有伪造成功。 + expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull(); + expect( + screen.queryByText('购买成功,现在可以直接开始游玩。'), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /120 泥点购买/u }), + ).toBeTruthy(); + }); }); describe('GameDetailPage 作者视角', () => { @@ -478,6 +615,70 @@ describe('GamePlayPage', () => { expect(await screen.findByText('游戏入口地址无效')).toBeTruthy(); expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); }); + + it('免费游戏不请求播放会话', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + await screen.findByTitle('星轨防线 在线游玩'); + expect(createGamePlaySessionMock).not.toHaveBeenCalled(); + }); + + it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + priceMudPoints: 120, + purchased: true, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + createGamePlaySessionMock.mockResolvedValue({ + playUrl: '/api/game-distribution/play-sessions/token-1/', + expiresAt: '2026-10-05T00:05:00Z', + }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + const iframe = await screen.findByTitle('星轨防线 在线游玩'); + expect(iframe.getAttribute('src')).toBe( + new URL( + '/api/game-distribution/play-sessions/token-1/', + window.location.origin, + ).href, + ); + expect(iframe.getAttribute('sandbox')).toBe('allow-scripts'); + expect(createGamePlaySessionMock).toHaveBeenCalledWith('game-1'); + expect(recordGamePlayMock).toHaveBeenCalledWith('game-1'); + }); + + it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + createGamePlaySessionMock.mockRejectedValueOnce( + new ApiClientError({ + message: '尚未购买这款游戏', + status: 403, + code: 'FORBIDDEN', + }), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + expect( + await screen.findByText( + '这款游戏需要先购买才能游玩,请返回详情页完成购买。', + ), + ).toBeTruthy(); + expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); + expect(recordGamePlayMock).not.toHaveBeenCalled(); + }); }); describe('GameGalleryPage 筛选与返回上下文', () => { diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index f4f1efebd..04cec5c31 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -9,8 +9,10 @@ import { } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, + createGamePlaySession, getGame, recordGamePlay, } from '../../services/gameDistributionClient'; @@ -29,7 +31,7 @@ type GamePlayPageProps = { gameId: string | null; onBack: () => void }; /** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */ export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000; -type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout'; +type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout' | 'failed'; export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const frameRef = useRef(null); @@ -38,6 +40,10 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const [startupState, setStartupState] = useState('idle'); const [hasStarted, setHasStarted] = useState(false); const [frameRunId, setFrameRunId] = useState(0); + /** 实际交给 iframe 的发行入口:免费游戏来自详情,付费游戏来自播放会话。 */ + const [playUrl, setPlayUrl] = useState(null); + const [isPreparingPlayback, setIsPreparingPlayback] = useState(false); + const [playError, setPlayError] = useState(''); const [error, setError] = useState(''); const requestSerialRef = useRef(0); const isMobileViewport = useIsMobileViewport(); @@ -49,6 +55,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setGame(null); setHasStarted(false); setStartupState('idle'); + setPlayUrl(null); + setPlayError(''); setError(''); if (!normalizedGameId) { setError('缺少游戏编号'); @@ -59,14 +67,25 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { void getGame(normalizedGameId) .then((value) => { if (requestSerial !== requestSerialRef.current) return; - if (!value?.currentVersion?.entryUrl) { + const currentVersion = value?.currentVersion; + if (!value || !currentVersion) { throw new Error('这款游戏暂时没有可玩的版本'); } - const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl); + const priceMudPoints = value.priceMudPoints ?? 0; + const rawEntryUrl = currentVersion.entryUrl; + if (!rawEntryUrl) { + // 付费作品的入口由播放会话签发;免费游戏没有入口说明服务端确实没有可玩版本。 + if (priceMudPoints <= 0) { + throw new Error('这款游戏暂时没有可玩的版本'); + } + setGame({ ...value, currentVersion: { ...currentVersion, entryUrl: null } }); + return; + } + const entryUrl = normalizeGameEntryUrl(rawEntryUrl); if (!entryUrl) throw new Error('游戏入口地址无效'); setGame({ ...value, - currentVersion: { ...value.currentVersion, entryUrl }, + currentVersion: { ...currentVersion, entryUrl }, }); }) .catch((loadError: unknown) => { @@ -104,9 +123,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile; const showRotationHint = Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport; - const startGame = () => { - if (mobilePlaybackBlocked) return; - // 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。 + /** 进入游戏:计数是 fire-and-forget,失败也不阻断;付费作品在会话签发成功后才计数。 */ + const startFrame = () => { const playGameId = game?.id ?? normalizeGameId(gameId); if (playGameId) { void recordGamePlay(playGameId).catch(() => {}); @@ -116,8 +134,47 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setFrameRunId((runId) => runId + 1); }; - /** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */ + const startGame = () => { + if (mobilePlaybackBlocked || !game || isPreparingPlayback) return; + setPlayError(''); + const priceMudPoints = game.priceMudPoints ?? 0; + if (priceMudPoints <= 0) { + // 免费游戏维持原来的公开入口链路,不经过播放会话。 + setPlayUrl(game.currentVersion?.entryUrl ?? null); + startFrame(); + return; + } + setIsPreparingPlayback(true); + void createGamePlaySession(game.id) + .then((session) => { + const sessionUrl = normalizeGameEntryUrl(session.playUrl); + if (!sessionUrl) throw new Error('播放地址无效'); + setPlayUrl(sessionUrl); + startFrame(); + }) + .catch((sessionError: unknown) => { + setHasStarted(true); + setStartupState('failed'); + if (sessionError instanceof ApiClientError && sessionError.status === 403) { + setPlayError('这款游戏需要先购买才能游玩,请返回详情页完成购买。'); + return; + } + setPlayError( + sessionError instanceof Error && sessionError.message.trim() + ? sessionError.message + : '创建播放会话失败,请稍后重试。', + ); + }) + .finally(() => setIsPreparingPlayback(false)); + }; + + /** 重试:付费作品在会话缺失时重新签发,其余情况只重挂载发行页。 */ const retryGame = () => { + setPlayError(''); + if ((game?.priceMudPoints ?? 0) > 0 && !playUrl) { + startGame(); + return; + } setStartupState('loading'); setFrameRunId((runId) => runId + 1); }; @@ -182,7 +239,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {

) : null}
- {!hasStarted ? ( + {!hasStarted && !isPreparingPlayback ? (
@@ -211,8 +268,26 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
) : null} - {hasStarted && startupState === 'loading' ? ( -
游戏正在启动…
+ {isPreparingPlayback || + (hasStarted && startupState === 'loading' && !playError) ? ( +
+ {isPreparingPlayback ? '正在准备游戏…' : '游戏正在启动…'} +
+ ) : null} + {playError ? ( + + {playError} + + + ) : null} {hasStarted && startupState === 'timeout' ? ( ) : null} - {hasStarted ? ( + {hasStarted && playUrl ? (