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 7647a3c4f..1dbd1a5e6 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 @@ -95,6 +95,19 @@ fn resolve_publish_project_key( .or_else(|| request_project_key.map(str::to_string)) } +/// 取幂等摘要前的 payload 归一:把 `projectKey` 换成 `canonical_project_key`。 +/// +/// `projectKey` 是身份锚,不是发布内容;`POST /games` 带它、路径端点通常省略它,若不归一, +/// 同一逻辑发布会因身份写法不同而摘要不同,重发时被模块判成「摘要不一致」→ 409。 +fn canonical_publish_digest_payload( + payload: &NewGameVersionRequest, + canonical_project_key: Option<&str>, +) -> NewGameVersionRequest { + let mut digest_payload = payload.clone(); + digest_payload.project_key = canonical_project_key.map(str::to_string); + digest_payload +} + /// 发布深模块:路由只决定目标,这里统一做校验、媒体解析、幂等键与事务收口。 /// /// `metadata` part 反序列化成 [`NewGameVersionRequest`];`cover` / `screenshot` @@ -169,10 +182,24 @@ async fn publish_version_core( let now = now_micros(); let version_id = format!("gamever_{}", Uuid::new_v4().simple()); - // 摘要覆盖完整 metadata(内容 + projectKey)+ 新图原始字节:身份在路径里, + // 锚点优先取作品行已存的 project_key(更新路径),其次取请求里的 project_key(首次发布)。 + // `game_id` + 规范化 `project_key` + 版本号三元组让两种身份写法收敛到同一个自然幂等键。 + let canonical_project_key = resolve_publish_project_key( + project_key.as_deref(), + current + .as_ref() + .and_then(|game| game.project_key.as_deref()), + ); + // 摘要覆盖完整 metadata(内容 + 规范化 projectKey)+ 新图原始字节:身份在路径里, // 自然幂等键已含版本号,因此摘要不需要重复 `gameId` / `versionNumber`。 + // `projectKey` 是身份锚而不是内容,必须先归一到 `canonical_project_key`:否则 + // `POST /games`(带 projectKey)与 `POST /games/{game_id}/versions/{n}`(省略)会因身份 + // 写法不同而算出不同摘要,同一逻辑发布重发时撞 409 摘要冲突。 // 不含服务端生成的 objectKey,同键重试摘要稳定、命中既有结果。 - let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?; + let digest_payload = + canonical_publish_digest_payload(&payload, canonical_project_key.as_deref()); + let request_digest = + publish_request_digest(&digest_payload, cover.as_ref(), screenshots.as_slice())?; let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media( &state, game_id.as_str(), @@ -190,14 +217,6 @@ async fn publish_version_core( payload.price_mud_points, ) .await?; - // 锚点优先取作品行已存的 project_key(更新路径),其次取请求里的 project_key(首次发布)。 - // `game_id` + 规范化 `project_key` + 版本号三元组让两种身份写法收敛到同一个自然幂等键。 - let canonical_project_key = resolve_publish_project_key( - project_key.as_deref(), - current - .as_ref() - .and_then(|game| game.project_key.as_deref()), - ); let idempotency_key = game_distribution_version_key( game_id.as_str(), canonical_project_key.as_deref(), @@ -260,9 +279,43 @@ async fn publish_version_core( #[cfg(test)] mod tests { use super::{ - derive_game_distribution_game_id, game_distribution_version_key, - resolve_publish_project_key, + canonical_publish_digest_payload, derive_game_distribution_game_id, + game_distribution_version_key, publish_request_digest, resolve_publish_project_key, }; + use shared_contracts::game_distribution::{ + GameDistributionDeviceSupport, GameDistributionForkAuthorization, + GameDistributionInputMode, GameDistributionOrientation, GameMetadata, + }; + + fn digest_fixture(project_key: Option<&str>) -> super::NewGameVersionRequest { + super::NewGameVersionRequest { + project_key: project_key.map(str::to_string), + price_mud_points: 0, + package_sha256: "sha256".to_string(), + package_bytes: 1, + package_file_count: 1, + package_entry_path: "index.html".to_string(), + game_metadata: GameMetadata { + title: "标题".to_string(), + summary: "简介".to_string(), + description: None, + category: "益智".to_string(), + tags: Vec::new(), + cover_object_key: None, + screenshots: Vec::new(), + device_support: GameDistributionDeviceSupport { + desktop: true, + mobile: false, + touch: false, + }, + input_modes: Vec::::new(), + orientation: GameDistributionOrientation::Responsive, + fork_authorization: GameDistributionForkAuthorization::Forbidden, + fork: None, + }, + change_summary: None, + } + } /// 更新路径必须用作品行已存的 `project_key` 当锚,首次发布才回退到请求值。 #[test] @@ -311,4 +364,26 @@ mod tests { assert_eq!(create_shape, update_shape); assert_eq!(create_shape, update_with_both); } + + /// `projectKey` 是身份锚不是内容:`POST /games`(带 key)与路径端点(省略 key)归一到同一个 + /// 权威值后,幂等摘要必须一致,否则自然键相同却会 409「请求摘要不一致」。 + #[test] + fn publish_request_digest_is_stable_across_project_key_sources() { + let with_request_key = digest_fixture(Some("proj-1")); + let without_request_key = digest_fixture(None); + + let from_request = canonical_publish_digest_payload(&with_request_key, Some("proj-1")); + let from_stored = canonical_publish_digest_payload(&without_request_key, Some("proj-1")); + assert_eq!( + publish_request_digest(&from_request, None, &[]).expect("摘要"), + publish_request_digest(&from_stored, None, &[]).expect("摘要"), + "身份写法不同但 canonical projectKey 相同,摘要必须一致" + ); + + // 未归一化时二者原本不同——证明归一化不是空操作。 + assert_ne!( + publish_request_digest(&with_request_key, None, &[]).expect("摘要"), + publish_request_digest(&without_request_key, None, &[]).expect("摘要") + ); + } }