From 90f4e0cfb6588399dbd0072302b30c53497aaef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 19:03:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=B8=B8=E6=88=8F=E5=88=86=E5=8F=91):=20?= =?UTF-8?q?=E8=BD=AF=E5=88=A0=E4=BD=9C=E5=93=81=E9=87=8D=E5=8F=91=E6=97=B6?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E5=A4=8D=E6=B4=BB=E5=B9=B6=E6=B8=85=E6=97=A7?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E6=94=B6=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模块按确定性 gameId 命中已软删行时就地复活覆盖为全新作品:清 deleted_at、回到未公开、publication_revision=0、清空活动版本与 play_count/price_mud_points、用本次资料覆盖作品级字段 - 复活时按 owner 索引删除该作品旧的 create_version 收据;发布事务收据命中但指向已软删作品时只删该收据并继续复活,旧版本号不再被当成重放或 409 - api-server 显式 gameId 命中不存在或已软删、且恰好等于该 projectKey 的确定性身份时按首次发布继续,其余不存在或非本身份的 gameId 仍 404 - 新增 spacetime-module 单测 revive_overwrites_soft_deleted_game_as_fresh_identity - 同步玩法链路、后端数据契约、实施计划风险节与 decision-log/pitfalls --- ...¸戏分发统一发布接口与版本号自然幂等-2026-10-07.md | 6 +- .../shared-memory/decision-log.md | 1 + docs/project-memory/shared-memory/pitfalls.md | 7 + ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 1 + .../src/modules/game_distribution_publish.rs | 21 +- .../spacetime-module/src/game_distribution.rs | 226 ++++++++++++++++-- 7 files changed, 230 insertions(+), 34 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md b/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md index c262f245d..d5fbe2874 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md +++ b/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md @@ -135,7 +135,7 @@ ## 7. 风险与回滚 - **确定性 gameId 改变 ID 形态**:模块不在线、无历史数据,硬切;回滚 = 撤销 M1/M2 提交,schema 随代码回滚。旧随机 gameId 若已写入本地验收库需重建。 -- **软删后重发的身份**:确定性 gameId 用 `(owner, projectKey)` 派生,一旦该作品被软删,同一 projectKey 再次发布仍会派生到被占用的 gameId;当前合并事务按「游戏已删除」失败关闭,尚未实现「删除后重新发布得到新身份」。模块不在线、无线上数据,先记为已知边界,待确认是否需要在 api-server 侧为软删命中换发随机后缀。 +- **软删后重发的身份**:确定性 gameId 用 `(owner, projectKey)` 派生;同一 projectKey 在作品软删后重发时,模块就地把该软删行**复活覆盖**为全新作品(清 `deleted_at`、回到未公开、`publication_revision=0`、清空活动版本与历史指标、用本次资料覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当成重放或摘要冲突。api-server 侧只有「显式 gameId 恰好等于该 projectKey 的确定性身份」时才走这条复活路径,避免误建。 - **媒体只解析一次**:唯一一次 `resolve_publish_media` 同时服务游戏行与版本;失败时 `cleanup_uncommitted_publish_media` 仍覆盖两条写出之前的上传。以「同键重放不新增 objectKey」测试锁定。 - **客户端冻结版本号**:网页若在重试时重读 max 会跳号生成重复版本;draft 预持久化是硬要求,用「响应丢失后重发同键命中」用例覆盖。 - **大文件抽模块**:抽出 `game_distribution_publish.rs` 会移动大量代码,与并发改动冲突风险高;先做零行为变化的重构提交,再改行为。 @@ -162,9 +162,9 @@ 定向验证结果: -- `cargo test -p module-game-distribution` 32 passed;`cargo test -p shared-contracts game_distribution` 8 passed;`cargo test -p api-server game_distribution` 63 passed;`cargo test -p spacetime-module game_distribution` 6 passed。 +- `cargo test -p module-game-distribution` 32 passed;`cargo test -p shared-contracts game_distribution` 8 passed;`cargo test -p api-server game_distribution` 63 passed;`cargo test -p spacetime-module game_distribution` 7 passed(含软删复活单测)。 - `cargo check --workspace --all-targets` 通过(仅既有 `TEST_AGC_MODEL_DEFAULT_ID` 未用 warning)。 - `npm run check:spacetime-schema`(改名窗口带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1`)通过 94 张表;`npm run check:generated-bindings` 158 文件一致;`npm run check:game-distribution-dto-parity` 41 组一致。 - 网页发布三件套 vitest 52 passed、`npx tsc --noEmit` 无新增错误;`npm run check:encoding`、`git diff --check`、`node scripts/check-doc-index.mjs` 通过。 -已知边界(登记在风险节):确定性 `gameId` 下「软删后同 `projectKey` 重发」仍按删除失败关闭,尚未换发新身份。 +补充边界:确定性 `gameId` 下「软删后同 `projectKey` 重发」已在模块内复活覆盖并清理旧收据;api-server 仅在显式 gameId 等于该 projectKey 的确定性身份时进入该路径,其余不存在 / 非本身份的 gameId 仍 404。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 01ced4b31..9e966b2ba 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9943,5 +9943,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 版本号:`versionNumber` 改为必填且由客户端在一次发布意图内冻结;删掉服务端 `None => max_existing + 1` 自增分支,删掉「同号创建新 versionId」与「同号 pending 被取消替换」。自然幂等键 = `(ownerUserId, projectKey | gameId, versionNumber)`,同键同 `request_digest` 重放、不同摘要 409;该路由不要求 `Idempotency-Key`。购买、送审、审核、撤回、下架、软删等其他写动作继续用 `Idempotency-Key`。 - 作品 ID:无 `gameId` 时由 `(ownerUserId, projectKey)` 确定性派生,媒体 objectKey 前缀在事务前稳定。作品 get-or-create、建版本、写 `create_version` 收据在单个 SpacetimeDB procedure 的一次 `try_with_tx` 内提交;同一次发布只解析一次封面/截图字节,消除首次发布「建作品传一遍、建首版再传一遍」的重复上传。 - 客户端:AGC `publish_local_project_game` 合并为一次调用,发布开始时冻结版本号;网页新建发布生成并持久化 `projectKey`、`versionNumber` 固定为 `1`,更新既有作品先读作者中心 max 再 +1 并冻结。 +- 软删后重发(2026-10-07 追加):作品被软删后,同一 `(ownerUserId, projectKey)` 身份再次发布不再失败关闭,而是模块就地把该软删行复活覆盖为全新作品(清 `deletedAt`、回到未公开、`publicationRevision=0`、清空活动版本与 `playCount`/`priceMudPoints`、用本次资料覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可重新发布而不被当成重放或摘要冲突。api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活路径,其余不存在 / 非本身份的 `gameId` 仍 404;`spacetime-module` 单测 `revive_overwrites_soft_deleted_game_as_fresh_identity` 锁定覆盖语义。 - 落地与清理(2026-10-07):M1 契约与领域 `efdd29c59`、M2 procedure + AGC 单次调用 `c7c99567b`、M3 统一端点 + 网页深模块 `7a710bb2b`、AGC TS 改名 `2dca06be3`;M5 删除已无调用方的 `GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT` 映射与常量、`GameDistributionCreateVersionRequest`(Rust+TS+parity)及 `create_game_distribution_game_and_return` / `create_game_distribution_version_and_return` 两个 procedure 与 facade 方法,绑定再生成 915 文件。api-server `game_distribution` 63 passed、module 32 passed、shared-contracts `game_distribution` 8 passed、spacetime-module `game_distribution` 6 passed。 - 权威行为见玩法链路「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」、后端数据契约同日条目与 AGC 实施计划;模块不在线,硬切无迁移。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ba03b7660..3b411d24d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6586,3 +6586,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现行口径**:合并为 `POST /api/game-distribution/versions`,无 `gameId` 时按 `(owner, projectKey)` get-or-create 作品并确定性派生 `gameId`,一次解析的同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料。 - **验证与清理**:`cargo test -p api-server game_distribution` 63 passed、网页发布 vitest 52 passed;随后删除已无调用方的 `create_game_distribution_game_and_return` / `create_game_distribution_version_and_return` procedure 与对应 facade 方法,防止再走回两条写路径。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs`、`src/components/game-distribution/GamePublishPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`。 + +## 2026-10-07 软删后重发:只复活作品行、不清旧 create_version 收据会被当成重放或摘要冲突 + +- **现象**:作者删除作品后用同一 `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` 的确定性身份时进入复活,避免误建。 +- **关联**:`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 27bb65998..76f6415ec 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 后冻结。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(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。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。 ### `game_distribution_review` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 21f8f41c7..852909aa8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -85,6 +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。 - **响应**:`{ 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_publish.rs b/server-rs/crates/api-server/src/modules/game_distribution_publish.rs index a233a7569..086bc62e0 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution_publish.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution_publish.rs @@ -63,16 +63,29 @@ pub(crate) async fn publish_version( .map(str::to_string); let (game_id, current) = match explicit_game_id { Some(game_id) => { - let current = state + let existing = state .spacetime_client() .get_game_distribution_game(GameDistributionGetGameRecordInput { game_id: game_id.clone(), owner_user_id: Some(owner_user_id.clone()), }) .await - .map_err(map_spacetime_error)? - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - (game_id, Some(current)) + .map_err(map_spacetime_error)?; + match existing { + Some(current) => (game_id, Some(current)), + // 指向不存在或已软删的作品:只有在该 gameId 恰好是本 projectKey 的确定性身份时, + // 才按首次发布继续(模块侧就地把软删行覆盖为全新作品);否则维持 404,避免误建。 + None => { + let anchor = project_key + .as_deref() + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let derived = derive_game_distribution_game_id(owner_user_id.as_str(), anchor); + if derived != game_id { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + (derived, None) + } + } } None => { let anchor = project_key diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index c95719614..3e45118ca 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -2002,7 +2002,10 @@ fn get_or_create_game_distribution_game_for_publish_tx( if game.owner_user_id != owner_user_id { return Err("游戏 owner 不匹配".to_string()); } - ensure_game_distribution_game_not_deleted(&game)?; + if game.deleted_at.is_some() { + // 软删后同身份重发:就地复活并覆盖为全新作品,而不是失败关闭。 + return revive_game_distribution_game_for_publish_tx(ctx, game, input); + } if game.visibility == GAME_DISTRIBUTION_VISIBILITY_SUSPENDED { return Err("游戏已被暂停,不能创建版本".to_string()); } @@ -2066,6 +2069,92 @@ fn get_or_create_game_distribution_game_for_publish_tx( .ok_or_else(|| "创建游戏后读取失败".to_string()) } +/// 软删作品复活后的行:清空公开态与历史指标,用本次发布的作品级资料整体覆盖。 +/// +/// 保留 `game_id`(确定性身份不变)与历史版本行;`publication_revision` 归零,公开切换回到未公开。 +fn revived_game_distribution_game_for_publish( + mut game: GameDistributionGame, + input: &GameDistributionPublishVersionInput, + now: Timestamp, +) -> GameDistributionGame { + game.deleted_at = None; + game.title = input.title.clone(); + game.summary = input.summary.clone(); + game.description = input.description.clone().unwrap_or_default(); + game.category = input.category.clone(); + game.tags_json = input.tags_json.clone(); + game.author_name = input.author_name.clone(); + game.author_avatar_url = input.author_avatar_url.clone(); + game.device_support_desktop = input.device_support_desktop; + game.device_support_mobile = input.device_support_mobile; + game.device_support_touch = input.device_support_touch; + game.input_modes_json = input.input_modes_json.clone(); + game.orientation = input.orientation.clone(); + game.cover_object_key = input.cover_object_key.clone(); + game.screenshots_json = input.screenshots_json.clone(); + game.project_key = input + .project_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + game.publication_revision = 0; + game.active_version_id = None; + game.visibility = GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED.to_string(); + game.play_count = 0; + game.price_mud_points = 0; + game.created_at = now; + game.updated_at = now; + game +} + +/// 复活软删作品并清掉它旧的 `create_version` 收据:旧版本号必须能在新身份里重新发布, +/// 不能被上一世代的收据当成重放或摘要冲突拦截。 +fn revive_game_distribution_game_for_publish_tx( + ctx: &ReducerContext, + game: GameDistributionGame, + input: &GameDistributionPublishVersionInput, +) -> Result { + let owner_user_id = game.owner_user_id.clone(); + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let revived = revived_game_distribution_game_for_publish(game, input, now); + ctx.db + .game_distribution_game() + .game_id() + .update(revived.clone()); + delete_game_distribution_publish_receipts_tx( + ctx, + owner_user_id.as_str(), + revived.game_id.as_str(), + ); + Ok(revived) +} + +/// 删除某作品旧的 `create_version` 收据(按 owner 索引缩小扫描,再按 game_id/动作过滤)。 +fn delete_game_distribution_publish_receipts_tx( + ctx: &ReducerContext, + owner_user_id: &str, + game_id: &str, +) { + let receipt_ids: Vec = ctx + .db + .game_distribution_idempotency_receipt() + .by_game_distribution_receipt_owner_user_id() + .filter(owner_user_id) + .filter(|receipt| { + receipt.action == GAME_DISTRIBUTION_ACTION_CREATE_VERSION + && receipt.game_id.as_deref() == Some(game_id) + }) + .map(|receipt| receipt.receipt_id) + .collect(); + for receipt_id in receipt_ids { + ctx.db + .game_distribution_idempotency_receipt() + .receipt_id() + .delete(&receipt_id); + } +} + /// 统一发布事务:收据命中即重放,否则 get-or-create 作品行、写版本,再写 `create_version` 收据, /// 收据同时落 `outcome_game_id` 与 `outcome_version_id`。整个过程由 procedure 的 `try_with_tx` 保证原子。 fn publish_game_distribution_version_tx( @@ -2101,33 +2190,53 @@ fn publish_game_distribution_version_tx( ); if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { - ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; - let existing_game_id = receipt + // 收据指向已软删作品时不能重放:这是「删除后同名重发」,按全新发布走复活路径。 + // 先删掉这张陈旧的 `create_version` 收据,否则旧版本号会命中上一世代的摘要。 + let replay_game_deleted = receipt .outcome_game_id - .ok_or_else(|| "幂等收据缺少游戏 ID".to_string())?; - let existing_version_id = receipt - .outcome_version_id - .ok_or_else(|| "幂等收据缺少版本 ID".to_string())?; - let game = ctx - .db - .game_distribution_game() - .game_id() - .find(&existing_game_id) - .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; - let version = ctx - .db - .game_distribution_version() - .version_id() - .find(&existing_version_id) - .ok_or_else(|| "幂等收据对应的版本已不存在".to_string())?; - if game.owner_user_id != owner_user_id || version.owner_user_id != owner_user_id { - return Err("游戏 owner 不匹配".to_string()); + .as_deref() + .and_then(|game_id| { + ctx.db + .game_distribution_game() + .game_id() + .find(&game_id.to_string()) + }) + .map(|game| game.deleted_at.is_some()) + .unwrap_or(false); + if replay_game_deleted { + ctx.db + .game_distribution_idempotency_receipt() + .receipt_id() + .delete(&receipt.receipt_id); + } else { + ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; + let existing_game_id = receipt + .outcome_game_id + .ok_or_else(|| "幂等收据缺少游戏 ID".to_string())?; + let existing_version_id = receipt + .outcome_version_id + .ok_or_else(|| "幂等收据缺少版本 ID".to_string())?; + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&existing_game_id) + .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; + let version = ctx + .db + .game_distribution_version() + .version_id() + .find(&existing_version_id) + .ok_or_else(|| "幂等收据对应的版本已不存在".to_string())?; + if game.owner_user_id != owner_user_id || version.owner_user_id != owner_user_id { + return Err("游戏 owner 不匹配".to_string()); + } + return Ok(( + game_distribution_game_snapshot(&game), + game_distribution_version_snapshot_for_game(ctx, &version), + true, + )); } - return Ok(( - game_distribution_game_snapshot(&game), - game_distribution_version_snapshot_for_game(ctx, &version), - true, - )); } let game = @@ -4296,6 +4405,71 @@ mod tests { } } + fn publish_input(title: &str, project_key: &str) -> GameDistributionPublishVersionInput { + GameDistributionPublishVersionInput { + game_id: "game-1".to_string(), + owner_user_id: "owner-1".to_string(), + project_key: Some(project_key.to_string()), + version_id: "version-new".to_string(), + version_number: 1, + metadata_json: "{}".to_string(), + package_sha256: "a".repeat(64), + package_bytes: 10, + package_file_count: 1, + package_entry_path: "index.html".to_string(), + title: title.to_string(), + summary: "新简介".to_string(), + description: Some("新描述".to_string()), + category: "益智".to_string(), + tags_json: "[\"新标签\"]".to_string(), + author_name: Some("作者".to_string()), + author_avatar_url: None, + device_support_desktop: true, + device_support_mobile: true, + device_support_touch: false, + input_modes_json: "[\"touch\"]".to_string(), + orientation: "portrait".to_string(), + cover_object_key: Some("agc/media/game-1/cover-new.png".to_string()), + screenshots_json: Some("[\"agc/media/game-1/shot-new.png\"]".to_string()), + idempotency_key: "proj-1:v1".to_string(), + request_digest: "digest".to_string(), + now_micros: 42, + } + } + + /// 软删后同身份重发必须成功:就地复活同一行并覆盖为全新作品,而不是失败关闭。 + #[test] + fn revive_overwrites_soft_deleted_game_as_fresh_identity() { + let mut deleted = game_row(GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED, "owner-1", 100); + deleted.deleted_at = Some(Timestamp::from_micros_since_unix_epoch(1)); + deleted.play_count = 7; + deleted.project_key = Some("proj-1".to_string()); + + let input = publish_input("复活后的作品", "proj-1"); + let revived = revived_game_distribution_game_for_publish( + deleted, + &input, + Timestamp::from_micros_since_unix_epoch(input.now_micros), + ); + + assert_eq!(revived.game_id, "game-1", "确定性身份沿用同一行"); + assert!(revived.deleted_at.is_none(), "复活后不再是删除态"); + assert_eq!(revived.visibility, GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED); + assert_eq!(revived.publication_revision, 0); + assert!(revived.active_version_id.is_none()); + assert_eq!(revived.play_count, 0); + assert_eq!(revived.price_mud_points, 0); + assert_eq!(revived.title, "复活后的作品"); + assert_eq!(revived.summary, "新简介"); + assert_eq!(revived.category, "益智"); + assert_eq!(revived.orientation, "portrait"); + assert_eq!(revived.project_key.as_deref(), Some("proj-1")); + assert_eq!( + revived.updated_at, + Timestamp::from_micros_since_unix_epoch(input.now_micros) + ); + } + /// 生产购买判定必须覆盖免费、未公开、已拥有与作者自购四种分支。 #[test] fn purchase_decision_covers_free_unpublished_owned_and_owner_cases() {