From 12584951298a9458f613914ce61ba8dab61d0de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 8 Oct 2026 14:29:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=B7=AF=E5=BE=84=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=20projectKey=20=E5=B9=B6=E5=8E=BB=E6=8E=89=E6=8C=89?= =?UTF-8?q?=20key=20=E5=85=9C=E5=BA=95=E4=B8=8E=E5=A4=8D=E6=B4=BB=E6=94=B9?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E9=94=9A=20=E8=B7=AF=E5=BE=84=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=20`POST=20/games/{game=5Fid}/versions/{version=5Fnumb?= =?UTF-8?q?er}`=20=E6=94=B6=E5=88=B0=20body=20=E9=87=8C=E7=9A=84=20`projec?= =?UTF-8?q?tKey`=20=E7=9B=B4=E6=8E=A5=20400=EF=BC=8C=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E5=8F=AA=E4=BB=8E=E8=B7=AF=E5=BE=84=E6=9D=A5=EF=BC=9B`projectK?= =?UTF-8?q?ey`=20=E5=8F=AA=E5=9C=A8=20`POST=20/games`=20=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E9=94=9A=20`get=5For=5Fcreate=5Fgame=5Fdistr?= =?UTF-8?q?ibution=5Fgame=5Ffor=5Fpublish=5Ftx`=20=E5=88=A0=E9=99=A4=20`(o?= =?UTF-8?q?wner,=20project=5Fkey)`=20=E5=85=9C=E5=BA=95=E6=9F=A5=E6=89=BE?= =?UTF-8?q?=EF=BC=8C`project=5Fkey`=20=E5=8F=AA=E5=86=99=E8=BF=9B=E6=9C=AC?= =?UTF-8?q?=E6=AC=A1=E6=96=B0=E5=BB=BA=E7=9A=84=E4=BD=9C=E5=93=81=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=AF=BB=E5=8F=96=E5=88=AB=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=20key=20=E5=81=9A=E8=BA=AB=E4=BB=BD=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=20=E8=BD=AF=E5=88=A0=E5=A4=8D=E6=B4=BB=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=88=96=E8=A6=86=E7=9B=96=20`project=5Fkey`?= =?UTF-8?q?=EF=BC=9A=E8=BA=AB=E4=BB=BD=E9=94=9A=E5=9C=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E5=88=BB=E5=86=99=E5=AE=9A=EF=BC=8C=E5=A4=8D?= =?UTF-8?q?=E6=B4=BB=E6=B2=BF=E7=94=A8=E6=97=A2=E6=9C=89=E5=80=BC=EF=BC=9B?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=AB=AF=E7=82=B9=E4=B9=9F=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=87=AD=20key=20=E8=AE=A4=E9=A2=86=E8=BD=AF=E5=88=A0=E4=BD=9C?= =?UTF-8?q?=E5=93=81=EF=BC=88=E4=B8=8D=E5=AD=98=E5=9C=A8/=E5=B7=B2?= =?UTF-8?q?=E8=BD=AF=E5=88=A0=E4=B8=80=E5=BE=8B=20404=EF=BC=89=20=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E6=91=98=E8=A6=81=E6=81=A2=E5=A4=8D=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=8E=9F=E6=A0=B7=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E9=99=A4=20`publish=5Fdigest=5Fpayload`=20?= =?UTF-8?q?=E5=BD=92=E4=B8=80=20helper=20=E4=B8=8E=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=20`publish=5Fgame=5Fdistribution=5Fversion?= =?UTF-8?q?=5Ftx`=20=E5=8E=BB=E6=8E=89=E3=80=8C=E6=A0=A1=E9=AA=8C=20`game?= =?UTF-8?q?=5Fid`=20=E5=8D=B4=E4=B8=A2=E6=8E=89=20trim=20=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E3=80=8D=E7=9A=84=E5=88=86=E5=8F=89=EF=BC=88`game=5Fi?= =?UTF-8?q?d`=20=E7=94=B1=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=94=9F=E6=88=90?= =?UTF-8?q?=EF=BC=8C=E6=97=A0=E9=9C=80=20trim=EF=BC=89=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=8F=91=E5=B8=83=E5=85=A5=E5=8F=A3=E5=AF=B9=E9=9D=9E?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=20`fork`=20/=20`forkAuthorization`=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD=20400=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=20200=20=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83=E5=85=B1?= =?UTF-8?q?=E5=88=9B=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/game_distribution_publish.rs | 99 +++++++++---------- .../spacetime-module/src/game_distribution.rs | 51 +++------- 2 files changed, 62 insertions(+), 88 deletions(-) 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 589be87e9..4ef55b8b3 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 @@ -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" ); } } diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index b85b87250..f310bbec1 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -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 做身份匹配" ); }