文档同步发布面 clean cut 的最终口径

ADR 修订记录回写:版本级上行保留 project-bundle、PATCH .../versions/{version_number} 未落地、血缘与发布 DTO 改由 ts-rs 生成
玩法链路与技术方案文档的路由表、授权 PATCH、DTO 名同步到新口径,旧段落标注为历史记录
数据契约文档改写 fork-authorization、统一发布接口与 /lineage 段落
共享记忆 decision-log 修正 project-bundle 与 ts-rs 清单,并回写本轮落地证据
实施计划 §9 回填 M1 ts-rs / M2 / M3 / M4 / M5 证据,里程碑标记已实现
This commit is contained in:
2026-10-08 00:48:18 +08:00
parent adde641ae9
commit 762b240114
7 changed files with 73 additions and 36 deletions
@@ -44,13 +44,13 @@ URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构
| POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 |
| POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上;body 不含 `gameId` / `versionNumber`,身份全部取自路径 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 |
| GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer |
| PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` |
| PATCH(未落地) | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` |
| PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` |
| GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer |
| PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer |
| POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` |
| POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer |
| PUT / GET / PUT / POST / POST | `…/versions/{version_number}/source`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) |
| PUT / GET / PUT / POST / POST | `…/versions/{version_number}/project-bundle`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) |
| POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` |
| POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` |
| PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` |
@@ -67,9 +67,9 @@ URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构
`expected_publication_revision` CAS);共创授权 `forkAuthorization` 并入该 PATCH,删除单字段
`PUT /api/game-distribution/games/{game_id}/fork-authorization`(`game_distribution.rs:721`)与
`GameDistributionSetForkAuthorizationRequest`。
- `PATCH …/versions/{version_number}` 的请求体优先复用现有
`GameDistributionUpdateGameMetadataRequest`(CAS + 资料字段),不新增类型;若最终确认只需作品级
资料编辑,则此行删除,改资料仍走 `PATCH /my-games/{game_id}`。
- `PATCH …/versions/{version_number}` **未落地**(2026-10-07 决定):作品级资料编辑只需
`PATCH /my-games/{game_id}`,不为版本详情再开一条资料写路径,因此上表该行删除;
`GameDistributionUpdateGameMetadataRequest` 仍是 `PATCH /my-games/{game_id}` 的请求体。
- 后台族继续按 `{version_id}`(`/admin/api/game-distribution/versions/{version_id}/…`),不参与本次换号。
#### 路径即身份(body 不重复路径参数)
@@ -90,11 +90,13 @@ URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构
| `lineage` | 以根为顶的整棵改编树 | fork network(UI「network members」) | `network` |
| `contribution` | 子树各代游玩数归集(`own` / `inherited` / `total`) | Insights / stats(`/stats/…`) | `network/stats` |
| `fork-source` | 拿本作品的成品包 / 工程源包去改编 | `source`(fork 的来源仓库) | `source` |
| `project-bundle` | 某版本作者上传的**可编辑工程源包**(与成品 `package` 并列的上行资产) | source archive(tarball / zipball) | 版本级 `source` |
| `project-bundle` | 某版本作者上传的**可编辑工程源包**(与成品 `package` 并列的上行资产) | source archive(tarball / zipball) | 维持 `project-bundle`(见修订记录) |
- `forks` 只列直接子代,`network` 是整棵树,刻意分开,与 GitHub 的 forks / network members 同构。
- 游戏级 `source`(可 fork 的来源内容)与版本级 `source`(可编辑工程源包)同名但不同层级;若嫌重名,
游戏级改 `upstream`。
- **修订(2026-10-07 落地回写)**:版本级上行资产**不**改名为 `source`,继续用 `project-bundle`。理由:GitHub 的 source archive 指
**可下载**的来源内容,对应本合同的**游戏级** `GET /games/{game_id}/source`(`/package` `/project`);版本的 `project-bundle` 是作者**上传**的
可编辑工程包,与成品 `package` 并列,改名会造成「游戏级 source 与版本级 source 同名不同层级」——正是下表原先标注的隐患。因此
`PackageUploadAsset::ProjectBundle`、`upload_project_bundle_for_version`、对象键与 urn 段名一律保持 `project-bundle`,不做别名。
#### fork 字段的可写位置
@@ -178,9 +180,9 @@ AGC 不是每次靠 `projectKey` 反查:它把远端身份 **存在本地**—
- 一并删除只服务旧两步的 DTO:`GameDistributionCreateGameRequest`、
`GameDistributionCreateVersionRequest`(body 里带 `versionNumber`)、
`GameDistributionSetForkAuthorizationRequest`(并入 `PATCH /my-games/{game_id}`)。
- 改名的旧路径一并删:`/lineage`、`/fork-source`(含 `/package` `/project`)、`/contribution`、
`/versions/{version_id}/project-bundle`(由 `/network`、`/source`、`/network/stats`、
`/versions/{version_number}/source` 取代)。
- 改名的旧路径一并删:`/lineage`、`/fork-source`(含 `/package` `/project`)、`/contribution`,
以及 `/versions/{version_id}/project-bundle` 的**随机 id 前缀**(由 `/network`、`/source`、`/network/stats`、
`/versions/{version_number}/project-bundle` 取代;资产段名 `project-bundle` 本身不变,见修订记录)。
- 不保留兼容路由、不保留旧 DTO 双写、不为历史 `version_id` 写迁移。历史由 Git 保存。
- `/api/game-distribution/*` 不在 `/api/external/v1` 下,本次不涉及 External OpenAPI;但需同步权威
`docs/` 与 `packages/shared` 生成契约。
@@ -225,8 +227,8 @@ AGC 不是每次靠 `projectKey` 反查:它把远端身份 **存在本地**—
- 验收判据:
- 一次 `POST /api/game-distribution/games` 即产出作品 + 首版(母版与衍生各一)。
- `POST /games/{game_id}/versions/{version_number}` 同号同摘要重放、同号异摘要 409。
- `GET / PATCH .../versions/{version_number}` 与 `package / source / submit / cancel` 全部按号
可用。
- `GET .../versions/{version_number}` 与 `package / project-bundle / submit / cancel` 全部按号
可用(`PATCH .../versions/{version_number}` 未落地,见修订记录)。
- 已删除的 `POST /versions`、`/versions/{version_id}`、`/derived`、`PUT .../fork-authorization` 返回
404 / 405。
- ts-rs 生成文件与 Rust 逐字节一致(`check:generated-bindings`),新契约进
@@ -236,3 +238,7 @@ AGC 不是每次靠 `projectKey` 反查:它把远端身份 **存在本地**—
## 修订记录
- 2026-10-07:初版。
- 2026-10-07(落地回写):版本级上行族保持 `project-bundle`(不改名 `source`);`PATCH …/versions/{version_number}`
未落地,资料编辑统一走 `PATCH /my-games/{game_id}`;血缘与发布 DTO(`GameDistributionForkMetadata` /
`GameDistributionNetworkNode` / `GameDistributionForksResponse` / `GameDistributionUpdateGameMetadataRequest` /
`NewGameVersionRequest`)已改由 ts-rs 生成并从手写镜像迁出。
@@ -131,11 +131,32 @@ cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml
`check:game-distribution-dto-parity`、root/AGC typecheck、`cargo test -p api-server game_distribution`(119)、
`cargo test -p spacetime-client`、AGC `fork_source`(8)全绿;`check:generated-bindings` 159 文件未变。
- 边界:`module_bindings/` 与 `spacetime-module` 的 `game_distribution_lineage` 表行类型不动,只按词边界改手写文件。
- M1 ts-rs 生成:未完成(下一轮)。`GameDistributionForkMetadata` / `NewGameVersionRequest` /
`GameDistributionUpdateGameMetadataRequest` / `GameDistributionForksResponse` 可生成;
`GameDistributionPublishVersionResponse` 依赖 `GameDistributionGameSummary` / `GameDistributionPrivateVersion`
的整条 TS 级联,需单独评估后再决定生成或保留手写。
- M2:
- M3:
- M4:
- M5:
- M1 ts-rs 生成(已完成,`adde641ae`):`GameDistributionForkMetadata` /
`GameDistributionNetworkNode` / `GameDistributionForksResponse` /
`GameDistributionUpdateGameMetadataRequest` / `NewGameVersionRequest` 增加 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 并导出;
手写 TS 镜像删除、改从 `contracts/generated` 再导出;5 个类型移入 `check:game-distribution-dto-parity` 的
`GENERATED_RUST_TYPES`(58 组逐字段 + 19 构建器 + 15 手拼类型仍登记)。
`Option` 字段用 `ts(optional = nullable)` 保持 `?: T | null`,`u64` 用 `ts(type = "number")`,字面量联合用 `ts(type = …)`。
门禁:`export_bindings` 29 个文件、`check:generated-bindings`(164 文件)逐字节一致、root/AGC typecheck、
网页 352 + AGC 2216 测试、`check:encoding`(5725 文件)全绿;`GameDistributionPublishVersionResponse`
仍手写(引用尚未生成的版本投影),留待后续整条级联评估。
- M2(已完成,`1e8709042`):`game_distribution_version` 追加 `(game_id, version_number)` btree 索引与按号查找 helper;
纯新增、不改字段,`check:spacetime-schema` 通过(需 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 放行既有 `local_project_id → project_key` 改名)。
- M3(已完成):`565d6ad7e` 发布面 clean cut 到 `{game_id}/versions/{version_number}`
(删除 `POST /versions`、`POST /games/{game_id}/versions`、全部对外 `/versions/{version_id}…`、
`GameDistributionCreateGameRequest` / `GameDistributionCreateVersionRequest` 与 `From<..> for GameMetadata`;
13 个作者 handler 改 `Path(VersionInGamePath { game_id, version_number })`);
`54094b5c1` 共创授权并入资料 PATCH(删 `PUT .../fork-authorization` 与 `GameDistributionSetForkAuthorizationRequest`)
并把公开读路由对齐 `/forks` `/network` `/network/stats` `/source`。
门禁:`check:game-distribution-dto-parity`、`cargo test -p api-server game_distribution`(117)、
`cargo test -p shared-contracts -p api-server`、root typecheck、网页 352 全绿。
- M4(已完成,`79b506337`):AGC `publish_local_project_game` 按 `target_game_id` 选
`POST /games` 或 `POST /games/{game_id}/versions/{version_number}`;
`fetch_frozen_metadata`、送审与 `game_package_upload`(`GamePackageUploadRequest` 增 `game_id` / `version_number`)
统一改按号路径;`gameDistributionPublishLive` 契约测试跟随新路由(opt-in,默认跳过)。
门禁:`cargo check --all-targets`(AGC)、AGC `game_package_upload`(5)/ `game_distribution_publish`(24)/ `fork_source`(8)、
AGC typecheck、`gameDistributionPublish` vitest(21)全绿。
- M5(已完成):ADR 修订记录回写(版本级上行保留 `project-bundle`、`PATCH .../versions/{version_number}` 未落地、
ts-rs 迁移);技术方案与玩法链路两份文档的路由表 / 授权 PATCH / DTO 名同步;本文档 §9 回填;数据契约文档
`fork-authorization`、`POST /versions` 与 `/lineage` 段落改写。已知未做:`GameDistributionLineage` 类型改名与
JSON 字段 `lineage` 仍留 `TODO(rename-refactor)`。
@@ -1,7 +1,7 @@
# 【里程碑】游戏分发发布面 clean cut 与术语对齐
- Version: `v1`
- Status: 待实现(2026-10-07 定稿)
- Status: 已实现(M1–M5 已合入,证据见实施计划 §9;版本级上行段名保留 `project-bundle`,`PATCH .../versions/{version_number}` 未落地)
- Date: `2026-10-07`
- Parent Spec:
- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(游戏分发发布面 clean cut 与术语对齐合同(2026-10-07))
@@ -14,7 +14,7 @@
`POST /api/game-distribution/games`(新作品 + 首版)与 `/api/game-distribution/games/{game_id}/versions/{version_number}`
全生命周期:对外版本身份一律是客户端冻结的 `versionNumber`,`version_id` 降为 SpacetimeDB 内部主键;
按 GitHub 术语把 DTO / 记录与读写路由改名(derived→forks、lineage→network、contribution→network/stats、
fork-source→source、project-bundle→source);旧统一 `/versions`、按 `version_id` 的对外路径、`/derived`、
fork-source→source);旧统一 `/versions`、按 `version_id` 的对外路径、`/derived`、
`PUT .../fork-authorization` 一次性退役,不迁移、不兜底、不双跑。
## 边界
@@ -10027,10 +10027,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-10-07:发布面 REST 收敛——对外版本身份改为 versionNumber
- 决策(ADR `docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md`):对外(作者 / 公开)版本身份一律用客户端冻结的 `versionNumber`;`version_id`(`gamever_<uuid>`)降为 SpacetimeDB 内部主键,只在后台族 `/admin/api/game-distribution/versions/{version_id}/…`、收据与媒体命名空间使用。
- 路由拓扑反转上一节「单一路由 `POST /versions`」:新作品 + 首版 = `POST /api/game-distribution/games`(只强制 `projectKey`,`gameId` 由 `(owner, projectKey)` 确定性派生);加版本 = `POST /api/game-distribution/games/{game_id}/versions/{version_number}`;`GET/PATCH` 与 `package` / `source` / `submit` / `cancel` 子资源全部挂在同一 `{version_number}` 前缀下。
- 路由拓扑反转上一节「单一路由 `POST /versions`」:新作品 + 首版 = `POST /api/game-distribution/games`(只强制 `projectKey`,`gameId` 由 `(owner, projectKey)` 确定性派生);加版本 = `POST /api/game-distribution/games/{game_id}/versions/{version_number}`;`GET` 与 `package` / `project-bundle` / `submit` / `cancel` 子资源全部挂在同一 `{version_number}` 前缀下(`PATCH .../versions/{version_number}` 未落地,资料编辑仍走 `PATCH /my-games/{game_id}`)。
- clean cut:删除统一 `POST /versions`、`POST /games/{game_id}/versions`、所有对外 `/versions/{version_id}…`、`/games/{game_id}/derived`、`PUT /games/{game_id}/fork-authorization` 与 `GameDistributionSetForkAuthorizationRequest`;不迁移、不兜底、不双跑,历史由 Git 保存。
- 术语对齐 GitHub:`derived`→`forks`(直接子代)、`lineage`→`network`(整棵改编树)、`contribution`→`network/stats`、`fork-source`→`source`、`project-bundle`→版本级 `source`(可编辑工程源包);对应 DTO / 记录类型同步改名,不留旧名别名。
- 类型收敛:能 ts-rs 生成的一律生成(`NewGameVersionRequest`、`GameDistributionPublishVersionResponse`、`GameDistributionForkMetadata`、`GameDistributionUpdateGameMetadataRequest`、`GameDistributionForksResponse`),删除手写镜像;只有字面量联合与 multipart 二进制 part 继续手写 / `ts(type=…)`。
- 术语对齐 GitHub:`derived`→`forks`(直接子代)、`lineage`→`network`(整棵改编树)、`contribution`→`network/stats`、`fork-source`→`source`;对应 DTO / 记录类型同步改名,不留旧名别名。**版本级上行族保持 `project-bundle`**(不改名 `source`):GitHub 语义的 source 指可下载来源,对应游戏级 `GET /games/{game_id}/source`;版本级 `project-bundle` 是作者上传的可编辑工程包,改名会造成同名不同层级。
- 类型收敛:能 ts-rs 生成的一律生成(`NewGameVersionRequest`、`GameDistributionForkMetadata`、`GameDistributionUpdateGameMetadataRequest`、`GameDistributionForksResponse`、`GameDistributionNetworkNode`),删除手写镜像并移入 `check:game-distribution-dto-parity` 的 `GENERATED_RUST_TYPES`;`GameDistributionPublishVersionResponse` 因引用尚未生成的两份版本投影仍手写,留待后续。字面量联合仍用 `ts(type=…)`。
- `forkAuthorization` 并入通用 `PATCH /my-games/{game_id}`;`fork` 血缘声明只在 `POST /games` 写一次、之后不可改;`/forks` 只读;AGC 的远端 `gameId` 存在项目内 `.agent/manifest.json` 的 `publication` 绑定(按账号 + origin 隔离),`projectKey` 只做首次发布与丢绑定恢复的锚。
- schema:`game_distribution_version` 追加 `(game_id, version_number)` btree 索引(纯新增,不改字段)。
- 落地顺序:doc first(主规范 + 里程碑 + 实施计划)→ M1 类型改名与 ts-rs → M2 索引 → M3 api-server 路由收敛(新代码拆小文件)→ M4 客户端 → M5 收口。
- 落地回写(2026-10-07):api-server 路由与 DTO clean cut 已合入;AGC `publish_local_project_game` 按 `target_game_id` 选 `POST /games` 或 `POST /games/{game_id}/versions/{version_number}`,冻结资料回读、送审与发行包 / 工程源包分片续传统一改按号路径(`game_package_upload` 请求带 `game_id` + `version_number`);血缘与发布 DTO 的 ts-rs 迁移已合入并删除手写镜像;`forkAuthorization` 的两段式提升走派生幂等键 `{key}:fork` 与派生摘要 `(gameId, 提升前档位, 目标档位)`。
@@ -501,16 +501,16 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 复用规则:末尾可空列 `project_key` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `project_key` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
- 软删除:游戏行末尾追加可空 `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`)、公开媒体读取判定(`get_game_distribution_media_read_access_and_return`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。**资料编辑不得直接改公开价格**:调价必须走新版本审核。
- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:**母版**只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。**创建时的档位来源分两种(2026-10-06)**:母版按请求值(缺省 `forbidden`);带 `forked_from_game_id` / `forked_from_version_id` 的**衍生作品继承父作品当时的档位**(创建时快照,父作品之后提升不会回溯改写既有子作品的行),请求里的 `forkAuthorization` 一律被忽略且不报错(旧客户端惯常带默认值),父作品为禁止共创时在血缘解析里就以 `FORK_NOT_AUTHORIZED` 失败,因此继承到的档位只会是 `nonCommercial` / `full`;「收窄授权」不实现(产品决定 2026-10-06)。**衍生作品的档位是终态(2026-10-06)**:存在血缘行时任何 PUT 都被拒——**409 `FORK_AUTHORIZATION_INHERITED`**(与降级同为 409、码不同),含「传同值」这种幂等重试;判定取血缘行且**先于 CAS**(同一衍生作品不因载荷不同而回不同的码),母版的只升不降 + CAS 语义不变。写入只发生在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`)发生:只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`;衍生作品永远写不了这条收据,因此重放分支只服务母版)。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级与继承终态各 409、非本人 403、未登录 401),路由 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 受发布灰度开关约束(收紧时 503,读接口不受影响)。
- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:**母版**只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。**创建时的档位来源分两种(2026-10-06)**:母版按请求值(缺省 `forbidden`);带 `forked_from_game_id` / `forked_from_version_id` 的**衍生作品继承父作品当时的档位**(创建时快照,父作品之后提升不会回溯改写既有子作品的行),请求里的 `forkAuthorization` 一律被忽略且不报错(旧客户端惯常带默认值),父作品为禁止共创时在血缘解析里就以 `FORK_NOT_AUTHORIZED` 失败,因此继承到的档位只会是 `nonCommercial` / `full`;「收窄授权」不实现(产品决定 2026-10-06)。**衍生作品的档位是终态(2026-10-06)**:存在血缘行时任何资料 PATCH 都被拒——**409 `FORK_AUTHORIZATION_INHERITED`**(与降级同为 409、码不同),含「传同值」这种幂等重试;判定取血缘行且**先于 CAS**(同一衍生作品不因载荷不同而回不同的码),母版的只升不降 + CAS 语义不变。写入只发生在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`):只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`;衍生作品永远写不了这条收据,因此重放分支只服务母版)。**HTTP 写入路径(2026-10-07 并入资料 PATCH)**:独立的 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 与其 `expectedForkAuthorization` CAS 已删除——档位随 `PATCH /api/game-distribution/my-games/{gameId}`(`GameDistributionUpdateGameMetadataRequest.forkAuthorization`,可选、缺省/`null` 表示不动档位)提交;并发控制统一由该 PATCH 的 `expectedPublicationRevision` 承担,api-server 在资料事务成功后以派生幂等键 `{Idempotency-Key}:fork` 与派生摘要 `(gameId, 提升前档位, 目标档位)` 调用上面的提升 procedure,目标档位与提升前相同时直接跳过(不产生收据、不报 409),因此同一 PATCH 重放不会二次提升。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级与继承终态各 409、非本人 403、未登录 401),整条 PATCH 受发布灰度开关约束(收紧时 503,读接口不受影响)。
- 共创授权与软删除的交叉口径:已软删除的作品(`deleted_at` 非空)不再是可用的改编来源——`resolve_game_distribution_fork_declaration_tx` 先判 `deleted_at`,命中即按 `FORK_SOURCE_NOT_AVAILABLE` 失败(HTTP 409,与「未公开」同一错误码,不用错误码区分删除事实);既有子作品与血缘行不受影响,父作品的删除不会连带下线子作品。衍生计数(`game_distribution_public_fork_count`)只统计 `deleted_at` 为空且 `visibility = published` 的子作品,作者删除子作品后父作品的「已被改编 N 次」随之下降。
- 买断制定价(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`。
- 买断制定价(2026-10-05):游戏行末尾追加 `price_mud_points: u64` 并设置 `#[default(0u64)]`;`0` 表示免费,上限 `1_000_000`(复用 `module-game-distribution::normalize_game_price_mud_points` 校验)。价格是版本冻结资料的一部分:作者在 `NewGameVersionRequest.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` 为空且活动版本存在、状态为 `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`,完整行为见玩法链路的「游玩计数(已实现)」。
- 发布媒体直传(2026-10-06):游戏行只存 `cover_object_key` 与 objectKey 数组 `screenshots_json`;版本冻结资料 `GameDistributionFrozenMetadata` 用 `coverObjectKey`(`String`)与 `screenshots`(`Vec<String>`,objectKey),`GameDistributionFrozenScreenshot` 不存在。请求 DTO(create game / create version / update metadata)用 `coverObjectKey` 与 `screenshots: (string|null)[]`:`string` 槽位沿用该作品当前媒体的 objectKey,`null` 槽位按序消费可重复的 `screenshot` 二进制 part,可选 `cover` part 出现时覆盖 `metadata.coverObjectKey`。公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定为「已发布且 active 的 game + objectKey 命中 `cover_object_key` / `screenshots_json`」,不经素材库 ACL;owner 作用域读路由 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(`read-bytes` 同理,需 bearer)按「作品归属 + objectKey 落在该作品媒体命名空间」放行(不要求命中游戏行当前媒体,待审 / 被驳回 / 历史版本以及首版发布上传得到的 key 都始终对作者可见),供作者预览未发布 / 被驳回作品的封面与截图(软删作品该路径 404);公开读授权 procedure 只按 objectKey 查询(`get_game_distribution_media_read_access_and_return`),owner 读直接复用 `get_game_distribution_game`(带 `owner_user_id`),不新增 procedure。`request_digest` 覆盖规范化元数据 + 图片字节 hash + 沿用 objectKey,覆盖 create / version / update 三条写路径。硬切、无历史数据;schema 变更仍须同步 `migration.rs`、表目录与生成绑定并运行 `npm run check:spacetime-schema`。
- 统一发布接口与版本号自然幂等(2026-10-07):`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`;`metadata` 增加必填 `versionNumber`,删掉 `resolve_game_distribution_version_number` 的 `None => max_existing + 1` 自增分支。无 `gameId` 时以 `projectKey` 为身份锚,`gameId` 由 `(ownerUserId, projectKey)` 确定性派生;新 procedure 在一次 `try_with_tx` 内 get-or-create 游戏行、写入版本行并落一张 `create_version` 收据(`idempotency_key = "{anchor}:v{versionNumber}"`,同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 口径不变;媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 合并为一次调用;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `projectKey`,更新读作者中心 max+1 后冻结。软删后同身份重发成功:模块按确定性 `gameId` 命中已软删行时,就地把它复活覆盖为全新作品(清 `deleted_at`、回到未公开、`publication_revision=0`、清空 `active_version_id` 与 `play_count`/`price_mud_points`、用本次资料覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当作重放或摘要冲突;api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活路径,其余不存在 / 非本身份的 `gameId` 仍 404。沿用封面/截图按「当前行媒体 **或** `{GAME_DISTRIBUTION_MEDIA_PREFIX}{gameId}/` 命名空间内的历史媒体」放行,软删后 `current` 为空也能沿用旧 objectKey。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。
- 统一发布接口:路径即身份(2026-10-07 clean cut):`POST /api/game-distribution/games`(新作品,无路径身份;`projectKey` 必填、首版号固定 `1`)与 `POST /api/game-distribution/games/{game_id}/versions/{version_number}`(追加 / 自然重放版本,身份只在路径里、body 不重复转写)。请求体统一为 `NewGameVersionRequest`(`projectKey` 可选、`priceMudPoints` + 包摘要 + `gameMetadata`),原 `POST /api/game-distribution/versions`、`POST .../games/{gameId}/versions` 与二者请求 DTO(`GameDistributionCreateGameRequest` / `GameDistributionCreateVersionRequest`)已删除,无兼容路由。`versionNumber` 由路径给出,删掉 `resolve_game_distribution_version_number` 的 `None => max_existing + 1` 自增分支;新作品以 `projectKey` 为身份锚,`gameId` 由 `(ownerUserId, projectKey)` 确定性派生,一次 `try_with_tx` 内 get-or-create 游戏行、写入版本行并落一张 `create_version` 收据(`idempotency_key = "{anchor}:v{versionNumber}"`,同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 口径不变;媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 按 `target_game_id` 是否为空选上面两条路由;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `projectKey`,更新读作者中心 max+1 后冻结。软删后同身份重发成功:模块按确定性 `gameId` 命中已软删行时,就地把它复活覆盖为全新作品(清 `deleted_at`、回到未公开、`publication_revision=0`、清空 `active_version_id` 与 `play_count`/`price_mud_points`、用本次资料覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当作重放或摘要冲突;api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活路径,其余不存在 / 非本身份的 `gameId` 仍 404。沿用封面/截图按「当前行媒体 **或** `{GAME_DISTRIBUTION_MEDIA_PREFIX}{gameId}/` 命名空间内的历史媒体」放行,软删后 `current` 为空也能沿用旧 objectKey。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。
### `game_distribution_lineage`
@@ -545,7 +545,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`GameDistributionThemeMember`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表。
- 用途:「主题 ↔ 根作品」的运营归组事实。字段:`member_id: String`(主键)、`theme_id`、`root_game_id`、`sort_order: i64`、`created_at: Timestamp`。
- 只允许**根作品**:根 = 该作品没有血缘行(第 0 代)。判定事实由调用方用血缘点查给出(`game_distribution_lineage().game_id().find(&game_id)` 命中即有行 ⇒ 非根),规则由纯函数 `game_distribution_theme_root_acceptable` 表达;非根写入被拒(`THEME_MEMBER_NOT_ROOT` 409)。理由:主题页呈现的作品树由 `/games/{id}/lineage` **按根**聚合,若允许子作品也入主题,同一棵树会在主题页里出现多次。
- 只允许**根作品**:根 = 该作品没有血缘行(第 0 代)。判定事实由调用方用血缘点查给出(`game_distribution_lineage().game_id().find(&game_id)` 命中即有行 ⇒ 非根),规则由纯函数 `game_distribution_theme_root_acceptable` 表达;非根写入被拒(`THEME_MEMBER_NOT_ROOT` 409)。理由:主题页呈现的作品树由 `/games/{id}/network` **按根**聚合,若允许子作品也入主题,同一棵树会在主题页里出现多次。
- 防重:主键是确定性构造的 `{theme_id}:{root_game_id}`(`module_game_distribution::game_distribution_theme_member_id`),同一 (主题, 根) 在结构上不可能出现第二行——去重由主键约束本身承担,而不是「事务里先查后写」。因此**不需要**额外的 `(theme_id, root_game_id)` 唯一索引。跨主题的同一根会有**多行**(每主题一行),这是预期行为而不是重复数据。
- 索引:`by_game_distribution_theme_member_theme_id`(主题页按主题取成员)、`by_game_distribution_theme_member_root_game_id`(作品详情按根反查所属公开主题,因此第 N 代作品也能看到自己的主题)。
- 可见性:成员只出现在「作品公开未删且存在当前公开版本」时——判定**委托**收藏的同一条口径 `module_game_distribution::game_distribution_theme_member_visible`(内部即 `game_distribution_collection_visible`),不新写第二个同义判定。作品下架 / 软删除时该成员在**投影里跳过但不删除行**,作品重新公开后自动回到主题页;主题详情的 `roots` 与列表 / 详情的 `memberCount` 用**同一判定**(因此成员口径永不漂移),但**两个数在可见成员超过详情响应体积上限 50 时故意不相等**:`memberCount` 报真实可见成员数,`roots` 只回前 50 条,差额由详情响应的 `rootsTruncated` 表达(纯函数 `module_game_distribution::game_distribution_theme_roots_truncated`,与族谱 `truncated` 同约定)。
@@ -5,6 +5,14 @@
> 状态:`draft`(待评审)。评审通过前不写业务代码。
> 本文件同时作为「游戏共创」的主规范与技术方案;行为合同部分不得绑定类名、文件名和实现算法。
> **2026-10-07 发布面 clean cut 修订(以 `docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md` 与《玩法创作》最新合同为准;本文以下按旧名的段落属历史记录):**
>
> - 发布写入口改为 `POST /api/game-distribution/games`(新作品 + 首版,`projectKey` 必填、首版号固定 `1`)与 `POST /api/game-distribution/games/{game_id}/versions/{version_number}`(追加版本,身份只在路径里);`POST /versions` 与 `POST /games/{game_id}/versions` 已删除。
> - 共创授权 `forkAuthorization` **并入** `PATCH /api/game-distribution/my-games/{game_id}`:删除 `PUT /games/{game_id}/fork-authorization`、`GameDistributionSetForkAuthorizationRequest` 与 `expectedForkAuthorization` CAS(并发统一由该 PATCH 的 `expectedPublicationRevision` 承担)。母版只升不降、衍生作品终态 409 `FORK_AUTHORIZATION_INHERITED` 的领域规则不变。
> - 读面改名:`/derived`→`/forks`、`/lineage`→`/network`、`/contribution`→`/network/stats`、`/fork-source`(含 `/package` `/project`)→`/source`;对应 DTO / 记录类型同步改名(`GameDistributionForksResponse`、`GameDistributionNetworkNode`、`GameDistributionSource*`、`GameDistributionForkMetadata`)。
> - 版本级上行族资产段名保持 `project-bundle`(不改名 `source`;GitHub 语义的 source 对应游戏级 `GET /games/{game_id}/source`)。
> - 下文出现 `/lineage`、`/derived`、`/contribution`、`/fork-source`、`PUT …/fork-authorization`、`expectedForkAuthorization` 的段落保留为**历史设计记录**;新实现与新契约以修订后的路由与合并后的 PATCH 为准。
## 0. 结论摘要
1. **Fork 关系挂在 `game`,不挂在 `version`。** `game` 是稳定作品身份,`version` 是不可变内容快照;血缘是「作品 ↔ 作品」关系。唯一留在版本级的是**被复刻的内容本身**(已存在的成品包与新增的工程源包都属于版本资产),因为内容随版本演进。
@@ -60,7 +60,7 @@
| Date | 2026-10-06 |
| 适用边界 | game-distribution 发布资料媒体(封面/截图)的上传与公开读取;发行包上传链路不变 |
- 写:`POST /api/game-distribution/versions`(2026-10-07 起取代 `POST /games` 与 `POST /games/{gameId}/versions`)、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不接受素材 ID 形式的媒体引用。
- 写:`POST /api/game-distribution/games`(新作品,无路径身份、首版号固定 `1`)与 `POST /api/game-distribution/games/{game_id}/versions/{version_number}`(追加版本;2026-10-07 clean cut 起取代 `POST /versions`)、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不接受素材 ID 形式的媒体引用。
- 存储:新图由服务端写入项目快照桶 `agc/project-snapshots/v1/game-distribution/media/<gameId>/{cover|screenshot}-<uuid>.<ext>`,**不建 `asset_object`**;冻结资料与游戏行只存 `coverObjectKey` / 截图 objectKey。
- 读:`GET /api/game-distribution/media/read-url`(匿名)按 objectKey 返回签名读地址;判定 = 已发布且 active 的 game 且 objectKey 命中其冻结媒体;需要同源字节时用 `.../media/read-bytes`。作者预览自己作品走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,`read-bytes` 同理):判定 = 该 `gameId` 属于当前登录主体且 objectKey 落在该作品媒体命名空间 `agc/project-snapshots/v1/game-distribution/media/<gameId>/`,不再要求命中游戏行当前媒体;未发布 / 待审 / 被驳回、以及审核通过前尚未生效到游戏行的版本媒体都能换签(软删作品返回 404),与公开读共用签名器。
- 幂等:`request_digest` 覆盖规范化元数据 + 新图字节 hash + 沿用 objectKey,保持重放语义。
@@ -99,7 +99,7 @@
| Status | current(已定稿;实现与证据按实施计划回写) |
| Date | 2026-10-07 |
| 适用边界 | game-distribution 对外发布与读面的路由拓扑、版本身份、项目锚与命名;媒体直传沿用「游戏分发发布媒体直传合同(2026-10-06)」;后台 `/admin/...`、幂等收据与媒体命名空间仍按 `{version_id}` |
| 取代 | 上一节「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」的对外路由拓扑(`POST /versions` 与 `/versions/{versionId}/...`),以及 `/derived`、`/lineage`、`/contribution`、`/fork-source`、`/fork-authorization`、`project-bundle` 命名;`versionNumber` 必填与自然幂等键口径不变 |
| 取代 | 上一节「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」的对外路由拓扑(`POST /versions` 与 `/versions/{versionId}/...`),以及 `/derived`、`/lineage`、`/contribution`、`/fork-source`、`/fork-authorization` 命名(`project-bundle` 资产段名保留,只换随机 id 前缀);`versionNumber` 必填与自然幂等键口径不变 |
| 决策来源 | `docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md` |
### 对外端点表
@@ -111,13 +111,13 @@
| POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 |
| POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上;body 不含 `gameId` / `versionNumber`,身份全部取自路径 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 |
| GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer |
| PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` |
| PATCH(未落地) | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` |
| PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` |
| GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer |
| PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer |
| POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` |
| POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer |
| PUT / GET / PUT / POST / POST | `…/versions/{version_number}/source`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) |
| PUT / GET / PUT / POST / POST | `…/versions/{version_number}/project-bundle`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) |
| POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` |
| POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` |
| PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` |
@@ -129,7 +129,8 @@
- `POST /games` 首次发布只强制 `projectKey`,`game_id` 由 `derive_game_distribution_game_id(owner, project_key)` 派生;**首版号固定为 `1`**(无路径槽),带 `fork` 时是衍生首版。
- 作品资料编辑走既有通用 `PATCH /my-games/{game_id}`(自带 `expectedPublicationRevision` CAS);共创授权 `forkAuthorization` 并入该 PATCH,删除单字段 `PUT /games/{game_id}/fork-authorization` 与 `GameDistributionSetForkAuthorizationRequest`。
- `PATCH …/versions/{version_number}` 的请求体优先复用现有 `GameDistributionUpdateGameMetadataRequest`(CAS + 资料字段),不新增类型;若最终确认只需作品级资料编辑,则删除该行,资料编辑仍走 `PATCH /my-games/{game_id}`。
- `PATCH …/versions/{version_number}` **未落地**(2026-10-07 决定):作品级资料编辑只需 `PATCH /my-games/{game_id}`,不为版本详情再开资料写路径,上表该行删除;`GameDistributionUpdateGameMetadataRequest` 仍是 `PATCH /my-games/{game_id}` 的请求体。
- 版本级上行族资产段名保持 `project-bundle`(不改名 `source`):GitHub 语义的 source 指可下载来源,对应本表游戏级 `GET /games/{game_id}/source`;版本级 `project-bundle` 是作者上传的可编辑工程包,改名会造成同名不同层级(见 ADR 修订记录)。
- 后台族继续按 `{version_id}`(`/admin/api/game-distribution/versions/{version_id}/…`),不参与本次换号。
- 表中 Response 列已按下方「DTO / 记录类型改名」使用新名。
@@ -809,7 +810,7 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地
- 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 常量 `PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE` 见 `server-rs/crates/shared-contracts/src/runtime.rs`),同步 Rust `shared-contracts`、`packages/shared`、前端与后台文案;接口侧来源文案由 `server-rs/crates/api-server/src/runtime_profile.rs` 的 `format_profile_wallet_ledger_source_type` 输出,后台落点为本轮已补齐的 `server-rs/crates/api-server/src/admin.rs`(流水来源映射 `wallet_ledger_source_type_to_string` 与后台统计消耗白名单),钱包消耗口径的 `game_purchase` 一并纳入 `server-rs/crates/spacetime-module/src/runtime/` 的 profile 消耗统计。
- 游戏 DTO 增加 `priceMudPoints` 与购买态;新增购买与播放会话 DTO。免费游戏必须能忽略这些新字段而不改变行为。
- AGC 版本请求携带 `priceMudPoints`(Rust DTO `GameDistributionCreateVersionRequest.price_mud_points`,`#[serde(default)]`);字段仅用于向后兼容不传该字段的旧客户端(按免费处理),新客户端从发布面板透传作者选择的价格。
- AGC 版本请求携带 `priceMudPoints`(Rust DTO `NewGameVersionRequest.price_mud_points`,`#[serde(default)]`);字段仅用于向后兼容不传该字段的旧客户端(按免费处理),新客户端从发布面板透传作者选择的价格。
- 不修改已有表字段的类型或顺序;新增字段一律末尾追加并带明确默认值。
### 验收标准与证据