统一发布幂等摘要按权威 projectKey 归一
取摘要前先把请求 `projectKey` 换成作品行已存的权威值,避免 `POST /games`(带 key)与 `POST /games/{game_id}/versions/{n}`(省略 key)身份写法不同导致同一逻辑发布重发时 409 摘要冲突
新增摘要跨 projectKey 来源一致的单元测试
This commit is contained in:
@@ -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("摘要")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user