发布路径拒绝 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:
2026-10-08 14:29:53 +08:00
parent d025ec4ba8
commit 1258495129
2 changed files with 62 additions and 88 deletions
@@ -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 做身份匹配"
);
}