diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c0418e14e..079ab8b76 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-10-07 统一发布身份只从路径来;资料 PATCH 细粒度化并把共创授权并入同一事务 + +- 统一发布身份(#34/#36):`POST /games/{game_id}/versions/{version_number}` 的身份只从路径来,body 出现 `projectKey` 直接 400;模块 get-or-create 删除 `(owner, project_key)` 兜底查找,`project_key` 只写进本次新建的游戏行。软删复活只发生在 `POST /games`(同一派生 `gameId` 命中软删行时就地覆盖),复活不再读取或覆盖 `project_key`;路径端点不再有「凭 `projectKey` 认领 / 复活」分支,不存在或已软删一律 404。幂等摘要恢复为按请求原样序列化,删除 `publish_digest_payload` 归一 helper;`publish_game_distribution_version_tx` 去掉「校验 `game_id` 却丢掉 trim 结果」的分叉。 +- 统一发布共创(#33):入口收到 `fork` 声明或非默认 `forkAuthorization` 时失败关闭 400,不再 200 静默丢弃;支持它们需要先定「谁在什么时机能声明改编 / 档位继承与禁止收窄」并补 ADR。 +- 资料 PATCH(#16/#18/#20):`GameDistributionUpdateGameMetadataRequest` 除 CAS 外全部可选(缺省 / `null` = 不动,`description: ""` = 清空,`coverObjectKey` 不可清空);`GameMetadata` 仍为必填展示资料(#18),局部更新走独立更新 DTO。`update_game_distribution_game_metadata_and_return` 在同一事务内合并 `Some` 字段并裁决 `fork_authorization`(血缘终态 → 只升不降),删除独立提升 procedure `set_game_distribution_fork_authorization_and_return`、`GameDistributionSetForkAuthorizationInput` 与 `{Idempotency-Key}:fork` 第二事务。 +- 生成绑定(#32):用本地 SpacetimeDB CLI 2.8.3 整批重跑 codegen,把 31 个仍停留在 12 空格缩进的 `*_procedure.rs` 统一为 16 空格;纯缩进变化、无语义改动。 +- 影响范围:`server-rs/crates/{shared-contracts,spacetime-module,spacetime-client,api-server}`、`packages/shared/src/contracts/generated/GameDistributionUpdateGameMetadataRequest.ts`、`src/services/gameDistributionClient.ts`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`cargo test --locked -p spacetime-module`(315 passed)、`cargo test --locked -p api-server --bin api-server game_distribution`(118 passed)、`cargo check --locked -p spacetime-module -p api-server --all-targets`、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-07 陶泥儿截图槽位改存 objectKey:发布成功后把本地路径就地改写,不再用空数组隐式沿用 - 背景:陶泥儿表单的 `screenshotPaths` 原语义是「空数组 = 整组沿用线上」,但空数组既分不清「真的没有截图」与「沿用」,也读不出上一版本具体是哪几张;沿用只能整组、不能逐槽,上一版本预览也无从按槽位取。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f935631be..c65cf3104 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -498,10 +498,10 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs` - 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。 - 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(objectKey 字符串数组)。发布写路径(创建游戏 / 创建版本 / 编辑资料)收 `multipart/form-data`:新图由服务端直写项目快照桶 `agc/project-snapshots/v1/game-distribution/media//{cover|screenshot}-.` 并只回 objectKey,不建 `asset_object`;沿用槽位带该作品当前媒体的 objectKey,不命中即 400,附图校验 `image/*`、张数与体积上限。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published`、存在有效 `active_version_id` 且未软删除的游戏,其 `cover_object_key` / `screenshots_json` 中的 objectKey 才在 `GET /api/game-distribution/media/read-url`(需要同源字节时用 `.../media/read-bytes`)上按游戏分发域内判定获得匿名读授权,不经素材库 ACL;作者预览自己作品(未发布 / 待审 / 被驳回)走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(及 `.../media/read-bytes`,需 bearer),判定 = 该 `gameId` 属于当前登录主体且 objectKey 落在该作品媒体命名空间 `agc/project-snapshots/v1/game-distribution/media//`(file 段非空)内,不再要求命中游戏行当前 `cover_object_key` / `screenshots_json`,与公开读共用同一把签名器;软删作品在该读路径上一律 404。 -- 复用规则:末尾可空列 `project_key` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `project_key` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。 +- 复用规则:末尾可空列 `project_key` 保存发布方本地项目标识(AGC 的 `manifest.projectId`),只在创建作品时写定,是**身份锚**而非查找键。同一 `owner_user_id` 以相同 `project_key` 走 `POST /games` 时会派生同一个 `game_id`,按主键直接命中既有作品、只新增版本,避免“更新”被实现成新建游戏;由此也不再需要事务里按 `(owner_user_id, project_key)` 兜底查找别的作品行(已删除),路径端点更是直接拒绝 `projectKey`。该字段不构成所有权或路径凭证,也不能用于跨账号匹配。软删除后同身份重发:`POST /games` 命中同一派生 `game_id` 时就地复活覆盖(见下方「统一发布接口」);legacy `create_game_distribution_game` 路径仍按「删除后不复用」处理。 - 软删除:游戏行末尾追加可空 `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)**:存在血缘行时任何资料 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,读接口不受影响)。 +- 共创授权:游戏行末尾追加 `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`)与资料 PATCH(`update_game_distribution_game_metadata_and_return` 的 `GameDistributionUpdateMetadataInput`)。独立提升 procedure `set_game_distribution_fork_authorization_and_return`、其输入 DTO `GameDistributionSetForkAuthorizationInput` 与 `{Idempotency-Key}:fork` 第二事务已删除:档位与展示字段在同一资料事务里合并,裁决顺序为「血缘终态(衍生作品 409 `FORK_AUTHORIZATION_INHERITED`)→ 只升不降(以事务内读到的当前档位为 CAS,降级 / 未知 409)」,目标档位与当前相同时视为不动。独立 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 与其 `expectedForkAuthorization` CAS 也已删除——档位随 `PATCH /api/game-distribution/my-games/{gameId}`(`GameDistributionUpdateGameMetadataRequest.forkAuthorization`,可选、缺省 / `null` 表示不动档位)提交;并发控制统一由该 PATCH 的 `expectedPublicationRevision` 与 `request_digest` 承担,同一 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` 校验)。价格是版本冻结资料的一部分:作者在 `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`)的投影。 @@ -510,7 +510,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 发布媒体直传(2026-10-06):游戏行只存 `cover_object_key` 与 objectKey 数组 `screenshots_json`;版本冻结资料 `GameDistributionFrozenMetadata` 用 `coverObjectKey`(`String`)与 `screenshots`(`Vec`,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 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)」。 +- 统一发布接口:路径即身份(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 = game_distribution_version_key(gameId, None, versionNumber)`,自然键只由 `(gameId, versionNumber)` 派生;同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 按请求原样序列化(不做 `projectKey` 归一,路径端点也不接受该字段);媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 按 `target_game_id` 是否为空选上面两条路由;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `projectKey`,更新读作者中心 max+1 后冻结。软删后同身份重发只发生在 `POST /games`:模块按确定性 `gameId` 命中已软删行时就地复活覆盖为全新作品(清 `deleted_at`、回到未公开、`publication_revision=0`、清空 `active_version_id` 与 `play_count`/`price_mud_points`、用本次资料覆盖作品级字段;`project_key` 身份锚沿用既有行、不再由请求覆盖),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当作重放或摘要冲突。路径端点 `POST .../versions/{version_number}` 的身份只从路径来:body 出现 `projectKey` 直接 400,指向不存在或已软删的作品一律 404,不再有「凭 `projectKey` 认领 / 复活」分支;事务也不再按 `(owner, project_key)` 兜底查找别的作品行。统一发布路径暂不解析改编声明与共创档位:收到 `fork` 声明或非默认 `forkAuthorization` 时失败关闭 400,而不是 200 后静默丢弃。沿用封面/截图按「当前行媒体 **或** `{GAME_DISTRIBUTION_MEDIA_PREFIX}{gameId}/` 命名空间内的历史媒体」放行,软删后 `current` 为空也能沿用旧 objectKey。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。 ### `game_distribution_lineage` diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index ff45cfb49..7c20c9162 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -7,8 +7,8 @@ > **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` 的领域规则不变。 +> - 发布写入口改为 `POST /api/game-distribution/games`(新作品 + 首版,`projectKey` 必填、无版本号入参、服务端固定写 `1`)与 `POST /api/game-distribution/games/{game_id}/versions/{version_number}`(追加版本,身份只在路径里;body 出现 `projectKey` 直接 400);`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` 承担);档位与展示字段在同一 `update_game_distribution_game_metadata_and_return` 事务内裁决,已无调用方的独立提升 procedure `set_game_distribution_fork_authorization_and_return` 与 `GameDistributionSetForkAuthorizationInput` 一并删除。母版只升不降、衍生作品终态 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 为准。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 13c572deb..265828cb1 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -127,8 +127,8 @@ | GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionSourceResponse` / 二进制 | Bearer(原路径改名) | | GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionNetworkStatsResponse`(子树归集) | Bearer(原路径改名) | -- `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`。 +- `POST /games` 首次发布只强制 `projectKey`,`game_id` 由 `derive_game_distribution_game_id(owner, project_key)` 派生;**首版号无入参、由服务端固定写 `1`**(无路径槽、body 也不含版本号),带 `fork` 时是衍生首版。 +- 作品资料编辑走既有通用 `PATCH /my-games/{game_id}`(自带 `expectedPublicationRevision` CAS);共创授权 `forkAuthorization` 并入该 PATCH,删除单字段 `PUT /games/{game_id}/fork-authorization` 与其请求 DTO,以及后来已无调用方的独立提升 procedure `set_game_distribution_fork_authorization_and_return` 与 `GameDistributionSetForkAuthorizationInput`(档位与展示字段在同一资料事务内裁决)。 - `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}/…`),不参与本次换号。 @@ -137,13 +137,13 @@ ### 路径即身份(body 不重复路径参数) - 只要路径里有 `{game_id}` / `{version_number}`,请求体就**不得**再出现同名字段:handler 只从 `Path(…)` 取值,不存在「路径优先还是 body 优先」的校验分支。 -- `NewGameVersionRequest` 因此**去掉** `gameId` 与 `versionNumber`:`POST /games` 只带 `projectKey`(无路径槽,首版号固定 `1`);`POST /games/{game_id}/versions/{version_number}` 的身份全在路径。 +- `NewGameVersionRequest` 因此**去掉** `gameId` 与 `versionNumber`:`POST /games` 只带 `projectKey`(无路径槽,首版号由服务端固定写 `1`、没有版本号入参);`POST /games/{game_id}/versions/{version_number}` 的身份全在路径,body 里的 `projectKey` 直接拒绝。 - 复核:game-distribution 其余带路径参数的写端点(`submit` / `cancel` / `purchase` / `reviews` / `theme members` / `admin review` / `fork-authorization`)请求体都不含路径身份字段;唯二重复的是本请求与 **待删** 的 legacy `GameDistributionCreateVersionRequest.versionNumber`。 ### 版本身份(versionNumber 对外,version_id 内部主键) - URL 中的版本一律用客户端冻结的正整数 `version_number` 标识;`version_id` 保留为 SpacetimeDB 主键与内部代理键,继续用于幂等收据 `outcome_version_id`、媒体命名空间、客户端草稿与生成绑定。 -- 不删列、不改主键、不搬迁数据:只为 `game_distribution_version` 新增 `(game_id, version_number)` btree 索引用于按号定位;唯一性沿用自然幂等键 `game_distribution_version_key(game_id, project_key, version_number)`,不由数据库唯一约束承担。 +- 不删列、不改主键、不搬迁数据:只为 `game_distribution_version` 新增 `(game_id, version_number)` btree 索引用于按号定位;自然幂等键只由 `(gameId, versionNumber)` 派生(`game_distribution_version_key(gameId, None, versionNumber)`),不由数据库唯一约束承担;`projectKey` 只体现在派生出的 `gameId` 里,不再入键、也不参与摘要。 - 版本号在同一作品内唯一:重复提交同号同包摘要 = 重放(200 + `replayed`),同号异摘要 = `409 IDEMPOTENCY_CONFLICT`。放弃 master 旧口径「同一版本号可反复提交、每次生成新 `versionId`」。 - 创建响应体继续返回 `versionId`,客户端把它存起来(收据 / 媒体命名空间 / 草稿)供后续引用;URL 一律用 `versionNumber`。 - 后台与内部(`/admin/...`、幂等收据、媒体命名空间)继续按 `version_id`。 @@ -157,10 +157,12 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 | 接口 | `projectKey` | 说明 | | -------------------------------------------------- | ------------ | ------------------------------------------------------------- | | `POST /games` | **必填** | 没有 `gameId` 可参考,靠它派生作品身份;缺失 400 | -| `POST /games/{game_id}/versions/{version_number}` | 可选 | 身份由路径 `game_id` 定;AGC 已绑定时根本不传,只在恢复时兜底 | +| `POST /games/{game_id}/versions/{version_number}` | **禁止** | 身份由路径 `game_id` 定;body 里出现 `projectKey` 直接 400,不再有「凭 key 认领 / 复活别的作品」的兜底 | | `PATCH /games/{game_id}/versions/{version_number}` | 不需要 | 不换身份 | | `PATCH /my-games/{game_id}` | 不需要 | 资料编辑不换身份 | +路径端点不携带 `projectKey`,因此也不再有「请求 key 与路径 `gameId` 的确定性身份一致时就地复活软删作品」这条分支:软删作品经路径读写一律 404,复活只发生在 `POST /games`(同一派生 `gameId` 命中软删行时覆盖为全新作品)。AGC 无绑定恢复需要复活时,仍以「只传 `projectKey`」走 `POST /games`。 + ### GitHub 术语对齐 | 现名 | 语义 | GitHub 术语 | 新名 |