docs(ADR): 游戏分发版本以 versionNumber 为对外身份
- 新增 ADR:对外版本身份改用客户端冻结的 versionNumber,version_id 保留为 SpacetimeDB 内部主键,clean cut 不迁移/不兜底。
- 记录完整端点表:POST /games(新作品+首版)与 /games/{game_id}/versions/{version_number} 全生命周期;后台族仍用 version_id。
- 记录 GitHub 术语对齐改名(derived→forks、lineage→network、contribution→network/stats、fork-source→source、project-bundle→source)及对应 DTO/记录类型改名清单。
- 记录 ts-rs 生成绑定范围、fork 字段可写位置与 projectKey 语义/必填状态。
- docs/README.md 登记该 ADR 入口。
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-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):已实现,本机真实栈 E2E 由人工验收脚本 `check:game-distribution-purchase-e2e` 覆盖(最近一次人工运行 76 PASS / 0 FAIL / 1 WARN),该脚本不在 CI 自动门禁内;待用户验收,未部署;作者可选买断制泥点付费,购买后永久可玩,后台审核可见价格且审核员不限次试用;网页与 AGC 两个发布入口一致支持定价并共用 `packages/shared` 组件 `PlatformGamePricingField`,AGC 定价的前端用例与 Rust 预填单测已覆盖,AGC 真实栈发布未覆盖。
|
||||
- [游戏分发统一发布接口与版本号自然幂等合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏分发统一发布接口与版本号自然幂等合同2026-10-07)、[里程碑](./project-memory/plans/【里程碑】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md)与[实施计划](./project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md):已定稿待实现;`POST /versions` 合并建作品与建首版,`versionNumber` 必填并作自然幂等键,首次发布媒体只上传一次。
|
||||
- [游戏分发版本以 versionNumber 为对外身份](./adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md):对外版本身份改用客户端冻结的 `versionNumber`,`version_id` 降为内部主键;发布路由收敛到 `games` 与 `games/{game_id}/versions/{version_number}`,clean cut 不做迁移与兼容。
|
||||
- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。
|
||||
- [游戏共创与作品 Fork](./【技术方案】游戏共创与作品Fork-2026-10-03.md):共创授权三态(只升不降)、作品级血缘与代际、成品包与工程源包两条改造路径、族谱树与溯源署名的产品设计与技术方案;同时作为该功能主规范,已评审通过,按 M1 逐里程碑实现。
|
||||
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# 【ADR】游戏分发版本以 versionNumber 为对外身份
|
||||
|
||||
状态:已接受(2026-10-07)
|
||||
|
||||
## 背景
|
||||
|
||||
游戏分发的版本行 `game_distribution_version` 以服务端生成的随机 UUID 为主键:统一发布
|
||||
`server-rs/crates/api-server/src/modules/game_distribution_publish.rs:114` 与旧 `create_version` 都是
|
||||
`format!("gamever_{}", Uuid::new_v4().simple())`。所有面向版本的对外路径都按它取行:
|
||||
`/api/game-distribution/versions/{version_id}/package`、`.../project-bundle`、`.../submit`、`.../cancel`、
|
||||
`GET /versions/{version_id}`(`api-server/src/modules/game_distribution.rs:651-705`),幂等收据也落
|
||||
`outcome_version_id`(`spacetime-module/src/game_distribution.rs`)。
|
||||
|
||||
合并 origin/master「游戏共创(作品 Fork)」后,发布写路径同时存在两套:我们分支的统一
|
||||
`POST /api/game-distribution/versions`(`shared-contracts/src/game_distribution_publish.rs:17` 的
|
||||
`NewGameVersionRequest`)与 master 的旧两步 `POST /games` + `POST /games/{game_id}/versions`
|
||||
(`game_distribution.rs:636 / 643 / 647`);两套都产生随机 `version_id`。
|
||||
|
||||
但版本号 `version_number` 早已由客户端冻结:网页侧首版固定 `1`、后续取作者视图最大
|
||||
`versionNumber + 1`,AGC 侧由工程内部版本序数派生;`module-game-distribution/src/domain.rs:73` 的
|
||||
`game_distribution_version_key(game_id, project_key, version_number)` 已经是自然幂等键(同键同摘要重放、
|
||||
同号异摘要 409),`game_id` 也由 `derive_game_distribution_game_id(owner, project_key)`
|
||||
(`domain.rs:87`)确定性派生。也就是说「版本号在同一作品内唯一、且由客户端决定」已经是现役语义,而
|
||||
URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构造。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 对外版本身份 = versionNumber,version_id 降为内部主键
|
||||
|
||||
- URL 中的版本一律用客户端冻结的正整数 `version_number` 标识;`version_id` 保留为 SpacetimeDB 主键与
|
||||
内部代理键,继续用于幂等收据 `outcome_version_id`、媒体命名空间、客户端草稿与生成绑定。
|
||||
- **不删列、不改主键、不搬迁数据**:只为 `game_distribution_version` 新增 `(game_id, version_number)`
|
||||
btree 索引用于按号定位;唯一性沿用自然幂等键,不由数据库唯一约束承担。
|
||||
- 版本号在同一作品内唯一:重复提交同号同包摘要 = 重放(200 + `replayed`),同号异摘要 = 409。 **放弃 master 旧口径**
|
||||
「同一版本号可反复提交、每次生成新 `versionId`」。
|
||||
|
||||
### 2. 发布路由收敛到作品之下
|
||||
|
||||
对外(作者 / 公开)版本一律用 `{version_number}`;后台与内部(`/admin/...`、收据、媒体命名空间)
|
||||
继续用 `{version_id}`。完整端点表:
|
||||
|
||||
| 方法 | 路径 | 请求 | 响应 | 鉴权 / 头 |
|
||||
|-------------------------------|-----------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------|---------------------------------------|
|
||||
| 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 校验一致 | `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` |
|
||||
| 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(幂等键同族) |
|
||||
| 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` |
|
||||
| DELETE | `/api/game-distribution/my-games/{game_id}` | – | 软删 ack | Bearer |
|
||||
| GET | `/api/game-distribution/games/{game_id}/forks` | – | `GameDistributionDerivedResponse` | 公开(原 `/derived` 改名) |
|
||||
| GET | `/api/game-distribution/games/{game_id}/network` | – | `GameDistributionLineageResponse`(整棵改编树) | 公开(原 `/lineage` 改名) |
|
||||
| GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionForkSourceResponse` / 二进制 | Bearer(原路径改名) |
|
||||
| GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionContributionResponse`(子树归集) | Bearer(原路径改名) |
|
||||
|
||||
- `POST /games` 首次发布只强制 `project_key`,`game_id` 由
|
||||
`derive_game_distribution_game_id(owner, project_key)` 派生;带 `fork` 时是衍生首版。
|
||||
- 作品资料编辑走既有通用 `PATCH /api/game-distribution/my-games/{game_id}`(自带
|
||||
`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}`。
|
||||
- 后台族继续按 `{version_id}`(`/admin/api/game-distribution/versions/{version_id}/…`),不参与本次换号。
|
||||
|
||||
#### GitHub 术语对齐
|
||||
|
||||
| 现名 | 语义 | GitHub 术语 | 新名 |
|
||||
| --- | --- | --- | --- |
|
||||
| `derived` | 直接子代列表 | forks | `forks` |
|
||||
| `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` |
|
||||
|
||||
- `forks` 只列直接子代,`network` 是整棵树,刻意分开,与 GitHub 的 forks / network members 同构。
|
||||
- 游戏级 `source`(可 fork 的来源内容)与版本级 `source`(可编辑工程源包)同名但不同层级;若嫌重名,
|
||||
游戏级改 `upstream`。
|
||||
|
||||
#### fork 字段的可写位置
|
||||
|
||||
- `forkAuthorization`(作品级共创授权档位)是**作品属性**,并入通用
|
||||
`PATCH /api/game-distribution/my-games/{game_id}`;衍生作品继承父档位后是终态(再改 409)。
|
||||
- `fork`(`parentGameId` / `parentVersionId`)只在 `POST /games` 创建时写一次,之后不可改——GitHub
|
||||
也不允许改一个 fork 的 parent。
|
||||
- `/forks` 是只读集合,不设 `PATCH /forks`;要改的授权属于**本作品**,不属于子代。
|
||||
|
||||
#### projectKey 语义与必填状态
|
||||
|
||||
`projectKey` 是**客户端本地工程标识**(网页 / AGC 的本地 project id),只做**首次发布的身份锚**:服务端
|
||||
用 `derive_game_distribution_game_id(owner, projectKey)` 确定性派生 `gameId`,保证同一工程断网重试 /
|
||||
多次发布落到同一作品。作品一旦建立,身份权威是路径里的 `gameId`,`projectKey` 不再参与对外寻址。
|
||||
|
||||
AGC 不是每次靠 `projectKey` 反查:它把远端身份**存在本地**——`.agent/manifest.json` 的
|
||||
`publication`(`GameCreationAppPublicationBinding`:`accountId` / `apiBaseUrl` / `gameId` / `revision` /
|
||||
`latestVersionId` / `latestVersionNumber` / `status`,按「账号 + origin」隔离)。发布时:
|
||||
**有绑定 → 只传 `gameId`**(走 `POST /games/{game_id}/versions/{version_number}`);**无绑定 → 只传
|
||||
`projectKey`**(走 `POST /games`)。无绑定时的恢复:`GET /my-games` 按 `projectKey` 精确匹配;绑定作品
|
||||
线上 404 → 清掉绑定、回到首次发布。
|
||||
|
||||
| 接口 | `projectKey` | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST /games` | **必填** | 没有 `gameId` 可参考,靠它派生作品身份;缺失 400 |
|
||||
| `POST /games/{game_id}/versions/{version_number}` | 可选 | 身份由路径 `game_id` 定;AGC 已绑定时根本不传,只在恢复时兜底 |
|
||||
| `PATCH /games/{game_id}/versions/{version_number}` | 不需要 | 不换身份 |
|
||||
| `PATCH /my-games/{game_id}` | 不需要 | 资料编辑不换身份 |
|
||||
|
||||
### 3. 请求体与类型只做收敛,不新增第二套
|
||||
|
||||
- 作品 / 版本内容继续用 `GameMetadata`,发布请求继续用 `NewGameVersionRequest`。
|
||||
- fork 血缘字段抽成一个可选子对象(暂称 `ForkMetadata`,由现有 `GameDistributionForkDeclaration`
|
||||
抽出 / 改名,精确类型名在实施计划里钉死),不再散落在 `GameMetadata` 或各请求体里。
|
||||
- 衍生首版只允许提供 `title`:`summary`、分类、标签、设备 / 输入 / 朝向与封面截图继承父作品。
|
||||
- **能用 ts-rs 生成的一律生成**:给 `NewGameVersionRequest`、`GameDistributionPublishVersionResponse`、
|
||||
`GameDistributionForkMetadata`、`GameDistributionUpdateGameMetadataRequest`、
|
||||
`GameDistributionDerivedResponse` 补 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` +
|
||||
`ts(export, export_to = …/packages/shared/src/contracts/generated/)`,Rust 作唯一真源;删掉
|
||||
`packages/shared/src/contracts/gameDistributionPublish.ts` 里对应手写镜像并改从 generated 导入。
|
||||
- 只有 ts-rs 表达不出的部分才手写 / 用 `ts(type = …)` 覆盖:multipart 二进制 part 不进 DTO;
|
||||
`deviceSupport` / `inputModes` / `orientation` / `forkAuthorization` 这类字面量联合沿用既有
|
||||
`#[ts(type = "…")]` 覆盖(`game_distribution.rs:677-710` 已是此模式),能升成 TS derive 的
|
||||
枚举 / 结构体(含 `ForkMetadata`)则升级。
|
||||
|
||||
#### DTO / 记录类型改名(与路由术语一致)
|
||||
|
||||
| 现名 | 新名 |
|
||||
| --- | --- |
|
||||
| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` |
|
||||
| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` |
|
||||
| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` |
|
||||
| `GameDistributionLineageNode` | `GameDistributionNetworkNode` |
|
||||
| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` |
|
||||
| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` |
|
||||
| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` |
|
||||
| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` |
|
||||
| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` |
|
||||
| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` |
|
||||
| `GameDistributionForkSource` | `GameDistributionSource` |
|
||||
| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` |
|
||||
| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` |
|
||||
| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` |
|
||||
| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata`(抽成独立子对象) |
|
||||
| `GameDistributionSetForkAuthorizationRequest` | 删除(并入 `PATCH /my-games/{game_id}`) |
|
||||
|
||||
- 改名同步落 `packages/shared` 手写契约、`contracts/generated`、`check:game-distribution-dto-parity`
|
||||
注册表与 `spacetime-client` 记录类型;旧名一律不留别名(clean cut)。
|
||||
- handler 名同步:`get_game_derived_games`→`get_game_forks`、`get_game_lineage`→`get_game_network`、
|
||||
`get_game_contribution`→`get_game_network_stats`、`get_fork_source*`→`get_source*`。
|
||||
|
||||
### 4. Clean cut:不迁移、不兜底、不双跑
|
||||
|
||||
- 删除统一 `POST /api/game-distribution/versions`(`game_distribution.rs:636`)、旧两步
|
||||
`POST /api/game-distribution/games/{game_id}/versions`(`:647`,被 `…/{version_number}` 取代)、
|
||||
所有 `/api/game-distribution/versions/{version_id}…` 对外路径(`:651-705`)、
|
||||
`/api/game-distribution/games/{game_id}/derived`(`:796`)与
|
||||
`PUT /api/game-distribution/games/{game_id}/fork-authorization`(`:721`)。
|
||||
- 改名的旧路径一并删:`/lineage`、`/fork-source`(含 `/package` `/project`)、`/contribution`、
|
||||
`/versions/{version_id}/project-bundle`(由 `/network`、`/source`、`/network/stats`、
|
||||
`/versions/{version_number}/source` 取代)。
|
||||
- 不保留兼容路由、不保留旧 DTO 双写、不为历史 `version_id` 写迁移。历史由 Git 保存。
|
||||
- `/api/game-distribution/*` 不在 `/api/external/v1` 下,本次不涉及 External OpenAPI;但需同步权威
|
||||
`docs/` 与 `packages/shared` 生成契约。
|
||||
|
||||
## 影响与代价
|
||||
|
||||
- `game_distribution_version` 只增 `(game_id, version_number)` 索引,不搬迁数据、不改字段;该索引不在
|
||||
`check:spacetime-schema` 的字段比对范围内(guard 只比字段删除 / 改名 / 重排 / 类型 / 属性)。
|
||||
- `version_id` 仍是收据、媒体命名空间与客户端草稿的事实键:创建响应体继续返回 `versionId`,客户端把它
|
||||
存起来,URL 用 `versionNumber`。
|
||||
- 旧客户端会拿到 404 / 405:仓库内 `createGame` / `createGameVersion` 已无调用方,属预期 clean cut。
|
||||
- 版本号由客户端冻结、服务端不自增的既有约定不变;服务端不再用随机 id 消解重复号,版本号冲突改由自然
|
||||
幂等键显式判定(重放 / 409)。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
1. **URL 继续用随机 `version_id`**:读接口不可读,客户端建版本前无法独立构造路径,每次建版本都要先查
|
||||
或先拿响应才能引用。已否决。
|
||||
2. **把 `version_number` 升为 SpacetimeDB 主键、删 `version_id`**:要改主键 + 重写约 500 处引用
|
||||
(收据 `outcome_version_id`、媒体命名空间、客户端草稿、ts-rs / 绑定)+ 迁移计划;只为 URL 好看
|
||||
不值得。已否决。
|
||||
3. **GitHub 式 `tags/{versionNumber}` 只读别名、写仍用 id**:写路径仍要随机 id,客户端仍需先建后查,
|
||||
等于保留两套身份。已否决。
|
||||
4. **保留双路由 / 兼容层一段时间**:与「clean cut」的口径冲突。已否决。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不迁移历史 `version_id`,不保留旧统一 `/versions` 路由与按 UUID 的路径。
|
||||
- 不做服务端版本号自增(号仍由客户端冻结)。
|
||||
- 不为 fork 另建第二套 metadata / 请求类型。
|
||||
|
||||
## 落地与验收
|
||||
|
||||
- 实施边界:`shared-contracts`(复用 `NewGameVersionRequest` / `GameMetadata`,抽 `ForkMetadata`,补
|
||||
ts-rs derive)、`spacetime-module`(`game_distribution_version` 增 `(game_id, version_number)` 索引 +
|
||||
按号查找 helper)、`spacetime-client`(facade / mapper / 绑定)、`api-server`(路由与 handler 收敛、
|
||||
删除退役端点)、`packages/shared/src/contracts/generated`(ts-rs 生成文件)、网页
|
||||
`gameDistributionClient` 与 AGC 发布链路。
|
||||
- 权威文档同步:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、
|
||||
`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、本项目主规范 / 里程碑 / 实施计划;本 ADR 覆盖
|
||||
原「游戏分发统一发布接口与版本号自然幂等合同」的路由拓扑部分,自然幂等键口径不变。
|
||||
- 验收判据:
|
||||
- 一次 `POST /api/game-distribution/games` 即产出作品 + 首版(母版与衍生各一)。
|
||||
- `POST /games/{game_id}/versions/{version_number}` 同号同摘要重放、同号异摘要 409。
|
||||
- `GET / PATCH .../versions/{version_number}` 与 `package / source / submit / cancel` 全部按号
|
||||
可用。
|
||||
- 已删除的 `POST /versions`、`/versions/{version_id}`、`/derived`、`PUT .../fork-authorization` 返回
|
||||
404 / 405。
|
||||
- ts-rs 生成文件与 Rust 逐字节一致(`check:generated-bindings`),新契约进
|
||||
`check:game-distribution-dto-parity` 且通过。
|
||||
- Rust / 前端定向测试与 schema / 绑定 / DTO parity / 编码 / doc-index 门禁全绿。
|
||||
|
||||
## 修订记录
|
||||
|
||||
- 2026-10-07:初版。
|
||||
Reference in New Issue
Block a user