发布路径拒绝 projectKey 并去掉按 key 兜底与复活改身份锚
路径端点 `POST /games/{game_id}/versions/{version_number}` 收到 body 里的 `projectKey` 直接 400,身份只从路径来;`projectKey` 只在 `POST /games` 作为身份锚
`get_or_create_game_distribution_game_for_publish_tx` 删除 `(owner, project_key)` 兜底查找,`project_key` 只写进本次新建的作品行,不再读取别行的 key 做身份匹配
软删复活不再读取或覆盖 `project_key`:身份锚在创建那一刻写定,复活沿用既有值;路径端点也不再凭 key 认领软删作品(不存在/已软删一律 404)
幂等摘要恢复为按请求原样序列化,删除 `publish_digest_payload` 归一 helper 与对应单测
`publish_game_distribution_version_tx` 去掉「校验 `game_id` 却丢掉 trim 结果」的分叉(`game_id` 由服务端生成,无需 trim)
统一发布入口对非默认 `fork` / `forkAuthorization` 失败关闭 400,不再 200 静默丢弃共创声明
This commit is contained in:
@@ -3,9 +3,9 @@
|
||||
//!
|
||||
//! 两条路由只做参数提取与鉴权,事务编排收敛在一个深模块 [`publish_version_core`]。
|
||||
//! **身份只从路径来**:新作品走 `POST /games`、首版号固定 `1`;追加版本用路径
|
||||
//! `{game_id}` + `{version_number}`;`projectKey` 只在 `POST /games` 作为首次发布身份锚。
|
||||
//! 媒体只解析一次,自然幂等键只由 `(gameId, versionNumber)` 派生(`projectKey` 已体现在
|
||||
//! `gameId` 里,不再参与键或摘要),不读 `Idempotency-Key` 头。作品行 get-or-create、写版本与
|
||||
//! `{game_id}` + `{version_number}`,body 里的 `projectKey` 一律拒绝。
|
||||
//! `projectKey` 只在 `POST /games` 作为首次发布身份锚。媒体只解析一次,自然幂等键只由
|
||||
//! `(gameId, versionNumber)` 派生,摘要按请求原样序列化。作品行 get-or-create、写版本与
|
||||
//! 自然幂等收据都在 SpacetimeDB 的单个 procedure 事务内完成。
|
||||
|
||||
use axum::{
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
use module_game_distribution::{derive_game_distribution_game_id, game_distribution_version_key};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::game_distribution::GameDistributionForkAuthorization;
|
||||
use shared_contracts::game_distribution_publish::NewGameVersionRequest;
|
||||
use spacetime_client::{
|
||||
GameDistributionGetGameRecordInput, GameDistributionPublishVersionRecordInput,
|
||||
@@ -83,23 +84,12 @@ pub(crate) async fn publish_existing_version(
|
||||
.await
|
||||
}
|
||||
|
||||
/// 取幂等摘要前的 payload 归一:把 `projectKey` 清空。
|
||||
///
|
||||
/// `projectKey` 只是创建作品的身份锚,已经体现在确定性派生的 `gameId` 里,不属于版本内容:
|
||||
/// create 带它、路径端点省略它,若不归一,同一逻辑发布会因身份写法不同而摘要不同,重发时被
|
||||
/// 模块判成「摘要不一致」→ 409。
|
||||
fn publish_digest_payload(payload: &NewGameVersionRequest) -> NewGameVersionRequest {
|
||||
let mut digest_payload = payload.clone();
|
||||
digest_payload.project_key = None;
|
||||
digest_payload
|
||||
}
|
||||
|
||||
/// 发布深模块:路由只决定目标,这里统一做校验、媒体解析、幂等键与事务收口。
|
||||
///
|
||||
/// `metadata` part 反序列化成 [`NewGameVersionRequest`];`cover` / `screenshot`
|
||||
/// 二进制 part 只在这里解析一次,解析出的 objectKey 同时用于作品行 bootstrap 与版本冻结资料。
|
||||
/// `ExistingVersion` 先按 owner 读一次既有作品,保持 404 语义并让沿用媒体校验有当前行可比;
|
||||
/// 指向已软删作品时,只有 `gameId` 恰好是本 `projectKey` 的确定性身份才按首次发布继续。
|
||||
/// 不存在或已软删一律 404,复活只发生在 `POST /games`。
|
||||
async fn publish_version_core(
|
||||
state: AppState,
|
||||
ctx: RequestContext,
|
||||
@@ -116,10 +106,17 @@ async fn publish_version_core(
|
||||
} = parse_publish_multipart(multipart).await?;
|
||||
let payload: NewGameVersionRequest =
|
||||
serde_json::from_value(metadata).map_err(|_| bad_request("metadata 字段不合法"))?;
|
||||
let project_key = normalize_project_key(payload.project_key.as_deref())?;
|
||||
// `projectKey` 只是**创建作品**的身份锚:`POST /games` 无路径槽时用它派生 `gameId` 并落作品行;
|
||||
// 追加版本走路径 `{game_id}`,既不回退到(owner, projectKey)找作品,也不把 key 写进版本行。
|
||||
// `projectKey` 只属于创建作品的 `POST /games`。路径端点的身份只从 `{game_id}` + `{version_number}`
|
||||
// 来,既不接受 `projectKey`,也不忽略它——直接失败关闭,省得它参与摘要或触发任何以 key 为准的查找。
|
||||
let is_create_path = matches!(&target, PublishTarget::NewGame);
|
||||
if !is_create_path && payload.project_key.is_some() {
|
||||
return Err(bad_request("路径端点不接受 projectKey;作品身份只从路径来"));
|
||||
}
|
||||
let project_key = if is_create_path {
|
||||
normalize_project_key(payload.project_key.as_deref())?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let requested_version_number = match &target {
|
||||
PublishTarget::NewGame => 1,
|
||||
PublishTarget::ExistingVersion { version_number, .. } => *version_number,
|
||||
@@ -127,6 +124,15 @@ async fn publish_version_core(
|
||||
// 版本号(>= 1)、价格、包摘要与资料一起在接触对象存储前校验,
|
||||
// 避免先上传封面/截图再由 `?` 提前返回留下孤儿媒体。
|
||||
validate_version_declaration(&payload, requested_version_number)?;
|
||||
// 统一发布暂不解析改编声明与共创档位:与其「200 成功但静默丢弃」,不如失败关闭。
|
||||
// 支持它们需要先定「谁在什么时机能声明改编 / 档位继承与禁止收窄」,属领域行为,等 ADR。
|
||||
if payload.game_metadata.fork.is_some()
|
||||
|| payload.game_metadata.fork_authorization != GameDistributionForkAuthorization::Forbidden
|
||||
{
|
||||
return Err(bad_request(
|
||||
"统一发布暂不支持改编声明或非默认共创档位,请等待后续版本",
|
||||
));
|
||||
}
|
||||
|
||||
let (game_id, version_number, current) = match target {
|
||||
PublishTarget::NewGame => {
|
||||
@@ -153,31 +159,20 @@ async fn publish_version_core(
|
||||
.map_err(map_spacetime_error)?;
|
||||
match existing {
|
||||
Some(current) => (game_id, version_number, 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, version_number, None)
|
||||
}
|
||||
// 路径端点的身份只从路径来、且已拒绝 `projectKey`:不存在或已软删一律 404。
|
||||
// 复活只发生在 `POST /games`(同一派生 gameId 命中软删行时模块侧覆盖),
|
||||
// 不再有「凭一个 projectKey 把路径 gameId 认领成自己」的入口。
|
||||
None => return Err(AppError::from_status(StatusCode::NOT_FOUND)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let now = now_micros();
|
||||
let version_id = format!("gamever_{}", Uuid::new_v4().simple());
|
||||
// 摘要覆盖完整 metadata(内容)+ 新图原始字节:`projectKey` 已体现在 `gameId` 里,不属于版本
|
||||
// 内容,归一成 `None` 后 `POST /games` 与 `POST /games/{game_id}/versions/{n}` 对同一逻辑发布
|
||||
// 得到同一摘要。自然幂等键也只由 `(gameId, versionNumber)` 派生,等同一次发布。
|
||||
// 摘要按请求原样序列化,不对 `projectKey` 做任何归一:路径端点已经在入口拒绝它,
|
||||
// 创建端点带它、且它的值就是派生 `gameId` 的来源,属于这次请求的真实内容。
|
||||
// 不含服务端生成的 objectKey,同键重试摘要稳定、命中既有结果。
|
||||
let digest_payload = publish_digest_payload(&payload);
|
||||
let request_digest =
|
||||
publish_request_digest(&digest_payload, cover.as_ref(), screenshots.as_slice())?;
|
||||
let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?;
|
||||
let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media(
|
||||
&state,
|
||||
game_id.as_str(),
|
||||
@@ -260,8 +255,7 @@ async fn publish_version_core(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
derive_game_distribution_game_id, game_distribution_version_key, publish_digest_payload,
|
||||
publish_request_digest,
|
||||
derive_game_distribution_game_id, game_distribution_version_key, publish_request_digest,
|
||||
};
|
||||
use shared_contracts::game_distribution::{
|
||||
GameDistributionDeviceSupport, GameDistributionForkAuthorization,
|
||||
@@ -321,25 +315,26 @@ mod tests {
|
||||
assert_ne!(create_shape, update_with_request_key);
|
||||
}
|
||||
|
||||
/// `projectKey` 已体现在 `gameId` 里、不属于版本内容:`POST /games`(带 key)与路径端点
|
||||
/// (省略 key)归一成 `None` 后,幂等摘要必须一致,否则自然键相同却会 409「请求摘要不一致」。
|
||||
/// 摘要按请求原样序列化,不对 `projectKey` 做任何归一:路径端点已在入口拒绝该字段,
|
||||
/// 创建端点则把它当作本次请求的真实内容,不再假设「两种身份写法必须得到同一摘要」。
|
||||
#[test]
|
||||
fn publish_request_digest_is_stable_across_project_key_sources() {
|
||||
fn publish_request_digest_covers_project_key_verbatim() {
|
||||
let with_request_key = digest_fixture(Some("proj-1"));
|
||||
let without_request_key = digest_fixture(None);
|
||||
|
||||
let from_request = publish_digest_payload(&with_request_key);
|
||||
let from_omitted = publish_digest_payload(&without_request_key);
|
||||
assert_eq!(
|
||||
publish_request_digest(&from_request, None, &[]).expect("摘要"),
|
||||
publish_request_digest(&from_omitted, None, &[]).expect("摘要"),
|
||||
"带不带 projectKey 必须得到同一摘要"
|
||||
);
|
||||
|
||||
// 未归一化时二者原本不同——证明归一化不是空操作。
|
||||
assert_ne!(
|
||||
publish_request_digest(&with_request_key, None, &[]).expect("摘要"),
|
||||
publish_request_digest(&without_request_key, None, &[]).expect("摘要")
|
||||
publish_request_digest(&without_request_key, None, &[]).expect("摘要"),
|
||||
"摘要必须原样覆盖 projectKey,不再做归一化"
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径端点的身份只从 `{game_id}` + `{version_number}` 来:请求里出现 `projectKey` 必须直接拒绝。
|
||||
#[test]
|
||||
fn existing_version_path_rejects_project_key() {
|
||||
let source = include_str!("game_distribution_publish.rs");
|
||||
assert!(
|
||||
source.contains("路径端点不接受 projectKey"),
|
||||
"路径端点必须在入口拒绝 projectKey"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3771,32 +3771,13 @@ fn get_or_create_game_distribution_game_for_publish_tx(
|
||||
return Ok(game);
|
||||
}
|
||||
|
||||
// `project_key` 只是**创建作品**的身份锚:只写进本次新建的作品行,不再按 owner + key 兜底
|
||||
// 查找别的作品——路径端点一律不带 `projectKey`,绝不能凭一个 key 把版本挂到并非路径所指的作品上。
|
||||
let project_key = input
|
||||
.project_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
// `project_key` 只是**创建作品**的身份锚(`POST /games` 无路径槽时用):同一
|
||||
// `(owner, project_key)` 必须确定性派生同一个 `game_id`,追加版本一律走路径 `game_id`、
|
||||
// 不读 `project_key`。这里按 owner + key 兜底命中时必须核对 game_id 相等,否则历史分叉会
|
||||
// 让版本行挂到与派生身份不一致的作品上,写出「版本属于 A、作品是 B」的孤儿数据。
|
||||
if let Some(project_key) = project_key {
|
||||
if let Some(game) = ctx
|
||||
.db
|
||||
.game_distribution_game()
|
||||
.by_game_distribution_game_owner_user_id()
|
||||
.filter(owner_user_id)
|
||||
.find(|game| {
|
||||
game.deleted_at.is_none() && game.project_key.as_deref() == Some(project_key)
|
||||
})
|
||||
{
|
||||
if game.game_id != input.game_id {
|
||||
return Err("projectKey 已绑定到另一个作品".to_string());
|
||||
}
|
||||
ensure_game_distribution_game_publishable(&game)?;
|
||||
return Ok(game);
|
||||
}
|
||||
}
|
||||
|
||||
// 事务内最后一道非空防线:api-server 已校验,但这条 get-or-create 是公开 procedure 的落库点,
|
||||
// 不能假设只有那一个调用方(与旧 `create_game_distribution_game_tx` 同口径)。
|
||||
@@ -4073,16 +4054,8 @@ fn revived_game_distribution_game_for_publish(
|
||||
game.orientation = input.orientation.clone();
|
||||
game.cover_object_key = input.cover_object_key.clone();
|
||||
game.screenshots_json = input.screenshots_json.clone();
|
||||
// `project_key` 是作品的身份锚,只在**创建**时落库:请求带非空 key 才覆盖,否则沿用既有行的值。
|
||||
// 追加版本按设计不带 `projectKey`,不能因此把存活作品的身份锚清空。
|
||||
if let Some(project_key) = input
|
||||
.project_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
game.project_key = Some(project_key.to_string());
|
||||
}
|
||||
// 复活不改身份锚:`project_key` 在创建那一刻写定,复活沿用既有行的值。
|
||||
// 请求里的 `projectKey` 只在**新建作品行**时生效,路径端点一律不带它。
|
||||
game.publication_revision = 0;
|
||||
game.active_version_id = None;
|
||||
game.visibility = GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED.to_string();
|
||||
@@ -4156,7 +4129,8 @@ fn publish_game_distribution_version_tx(
|
||||
> {
|
||||
let owner_user_id =
|
||||
required_game_distribution_text(input.owner_user_id.clone(), "owner_user_id")?;
|
||||
let _game_id = required_game_distribution_text(input.game_id.clone(), "game_id")?;
|
||||
// `game_id` 由服务端生成(创建时按 `(owner, projectKey)` 派生、追加版本直接来自路径),
|
||||
// 不存在需要 trim 的来源,因此不做 `required_...` 校验、也不产生「校验了却丢掉结果」的分叉。
|
||||
let version_id = required_game_distribution_text(input.version_id.clone(), "version_id")?;
|
||||
let package_sha256 =
|
||||
required_game_distribution_text(input.package_sha256.clone(), "package_sha256")?;
|
||||
@@ -8479,17 +8453,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `projectKey` 兜底命中时 `game_id` 必须一致,否则拒绝,避免孤儿版本。
|
||||
/// `projectKey` 只是创建作品的身份锚:事务里不得再按 `(owner, project_key)` 兜底查找别的作品,
|
||||
/// 否则路径端点能凭一个 key 把版本挂到并非路径所指的作品上。
|
||||
#[test]
|
||||
fn project_key_fallback_refuses_a_different_game_id() {
|
||||
fn create_bootstrap_does_not_look_up_by_project_key() {
|
||||
let source = include_str!("game_distribution.rs");
|
||||
let body = function_body(
|
||||
source,
|
||||
"fn get_or_create_game_distribution_game_for_publish_tx(",
|
||||
);
|
||||
assert!(
|
||||
body.contains("game.game_id != input.game_id"),
|
||||
"projectKey 兜底必须核对确定性派生的 game_id"
|
||||
!body.contains("by_game_distribution_game_owner_user_id()"),
|
||||
"不得再按 owner + project_key 兜底查找"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("game.project_key.as_deref()"),
|
||||
"不得读取别行的 project_key 做身份匹配"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user