diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3b411d24d..f193a64da 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6591,5 +6591,5 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:作者删除作品后用同一 `projectKey` 重新发布,若实现仅把软删行「复活」而保留上一世代的 `create_version` 收据,第一次重发会命中旧收据:摘要相同就被当成重放、返回已删除的旧游戏/版本;摘要不同(换了包或资料)直接 `409 IDEMPOTENCY_CONFLICT`。用户视角是「删了却发不出来」。 - **根因**:自然幂等键 `(owner, projectKey | gameId, versionNumber)` 跨软删仍然稳定;旧收据是上一世代身份的记账,复活后不能继续代表新作品。 -- **现行口径**:模块在按确定性 `gameId` 命中已软删行时就地复活覆盖(清 `deleted_at`、回到未公开、`publication_revision=0`、清空活动版本与 `play_count`/`price_mud_points`、用本次资料覆盖作品级字段),同时按 `owner` 索引筛出该作品的 `create_version` 收据并删除;发布事务在收据命中但指向已软删作品时也只删该收据、继续走复活路径,不重放。api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活,避免误建。 +- **现行口径**:模块在按确定性 `gameId` 命中已软删行时就地复活覆盖(清 `deleted_at`、回到未公开、`publication_revision=0`、清空活动版本与 `play_count`/`price_mud_points`、用本次资料覆盖作品级字段),同时按 `owner` 索引筛出该作品的 `create_version` 收据并删除;发布事务在收据命中但指向已软删作品时也只删该收据、继续走复活路径,不重放。api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活,避免误建。沿用媒体判定同步放宽为「当前行媒体 **或** 该作品媒体命名空间内的历史媒体」,否则软删后 `current` 为空会让「沿用上一世代封面/截图」误报 400。 - **关联**:`server-rs/crates/spacetime-module/src/game_distribution.rs`(`revived_game_distribution_game_for_publish`、`revive_game_distribution_game_for_publish_tx`、`delete_game_distribution_publish_receipts_tx`、`publish_game_distribution_version_tx` 的陈旧收据分支)、`server-rs/crates/api-server/src/modules/game_distribution_publish.rs`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 76f6415ec..154ddab81 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -508,7 +508,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):`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。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。 +- 统一发布接口与版本号自然幂等(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)」。 ### `game_distribution_review` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 852909aa8..91a714821 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -85,7 +85,7 @@ - **客户端冻结**:一次发布意图(含重试、续传、响应丢失后重发)必须复用同一 `versionNumber`。AGC 取 `projectKey = manifest.projectId`,版本号取面板发布开始时读到的平台 max+1 并在该次意图内固定;网页新建发布时生成并持久化一个 `projectKey`、`versionNumber` 固定为 `1`,更新既有作品时先读作者中心 max 再 +1 并在该次意图内固定。主动发起新的一次发布才重新取号。 - **原子性**:作品 get-or-create、版本行写入与幂等收据在单个 SpacetimeDB procedure 的一次 `try_with_tx` 内提交;移除「同号 pending 被新提交取消替换」与「同号创建新 versionId」。 - **媒体解析一次**:合并后同一次发布只解析(上传)一次封面/截图字节,同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料;不再出现建作品与建首版各上传一次。 -- **软删后重发**:作品被软删后,同一 `(ownerUserId, projectKey)` 身份再次发布不再失败关闭,而是就地把该软删行复活覆盖为全新作品(清 `deletedAt`、回到未公开、`publicationRevision=0`、清空活动版本与 `playCount`/`priceMudPoints`、用本次 `gameMetadata` 与媒体覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当作重放或摘要冲突。带 `gameId` 更新时只有该 id 恰好等于本 `projectKey` 的确定性身份才走复活,其余不存在 / 非本身份的 `gameId` 仍 404。 +- **软删后重发**:作品被软删后,同一 `(ownerUserId, projectKey)` 身份再次发布不再失败关闭,而是就地把该软删行复活覆盖为全新作品(清 `deletedAt`、回到未公开、`publicationRevision=0`、清空活动版本与 `playCount`/`priceMudPoints`、用本次 `gameMetadata` 与媒体覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当作重放或摘要冲突。带 `gameId` 更新时只有该 id 恰好等于本 `projectKey` 的确定性身份才走复活,其余不存在 / 非本身份的 `gameId` 仍 404。沿用封面/截图时服务端按「当前行媒体 **或** 该作品媒体命名空间 `{GAME_DISTRIBUTION_MEDIA_PREFIX}{gameId}/` 内的历史媒体」放行,因此软删后重发(`current` 为空)也能沿用上一世代传过的 objectKey,不必强制重新上传。 - **响应**:`{ game, version, replayed }`;`game` 为作者侧游戏投影(含 `publicationRevision`),`version` 为私有版本投影。创建后发行包仍走既有 `PUT /versions/{versionId}/package` 链路。 - **不变**:`PATCH /my-games/{gameId}`、`PUT /versions/{versionId}/package`、`POST /versions/{versionId}/submit|cancel`、审核、`publicationRevision` CAS 与公开读全部不变;`Idempotency-Key` 继续用于购买、送审、审核、撤回、下架、软删等其他写动作。 diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index cd752b4d0..6e407db3f 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -3949,8 +3949,12 @@ async fn resolve_publish_media_inner( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| bad_request("发布游戏必须提供封面"))?; - if current_cover.as_deref() != Some(requested) { - return Err(bad_request("沿用封面必须属于该作品当前媒体")); + // 沿用允许「当前行媒体」或「落在该作品媒体命名空间内的历史媒体」:作品被软删后 + // 重新发布时 `current` 为空,但上一世代传过的 objectKey 仍属于同一 game_id,必须能沿用。 + if current_cover.as_deref() != Some(requested) + && !game_media_object_key_belongs_to_game(requested, game_id) + { + return Err(bad_request("沿用封面必须属于该作品媒体命名空间")); } requested.to_string() } @@ -3965,8 +3969,10 @@ async fn resolve_publish_media_inner( if requested.is_empty() { return Err(bad_request("游戏截图沿用 objectKey 不能为空")); } - if !current_screenshots.iter().any(|key| key == requested) { - return Err(bad_request("沿用截图必须属于该作品当前媒体")); + if !current_screenshots.iter().any(|key| key == requested) + && !game_media_object_key_belongs_to_game(requested, game_id) + { + return Err(bad_request("沿用截图必须属于该作品媒体命名空间")); } resolved.push(requested.to_string()); }