统一发布幂等摘要按权威 projectKey 归一

取摘要前先把请求 `projectKey` 换成作品行已存的权威值,避免 `POST /games`(带 key)与 `POST /games/{game_id}/versions/{n}`(省略 key)身份写法不同导致同一逻辑发布重发时 409 摘要冲突
新增摘要跨 projectKey 来源一致的单元测试
This commit is contained in:
2026-10-08 12:19:35 +08:00
parent 80a801c166
commit bc32e0b7e2
@@ -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::<GameDistributionInputMode>::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("摘要")
);
}
}