fix(游戏分发): 软删作品重发时就地复活并清旧幂等收据
- 模块按确定性 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
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 实施计划;模块不在线,硬切无迁移。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -508,7 +508,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
|
||||
- 发布媒体直传(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 后冻结。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(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`
|
||||
|
||||
|
||||
@@ -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` 继续用于购买、送审、审核、撤回、下架、软删等其他写动作。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GameDistributionGame, String> {
|
||||
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<String> = 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() {
|
||||
|
||||
Reference in New Issue
Block a user