From 3aa3e33ceb3ae75e9bb48477e95a442864b13a6e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 16:41:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BB=9F=E4=B8=80=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=8F=91=E8=A1=8C=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 projectVersion 作为 AGC 唯一用户版本,支持同版本重复提交和版本回退。 保留 publicationRevision 并发校验与旧客户端自动递增兼容。 --- .../src/pages/AdminGrayReleaseConfigPage.tsx | 2 +- .../scripts/check-config.mjs | 1 + .../src-tauri/src/desktop.rs | 1 + .../src/game_distribution_publish.rs | 123 ++++++++++++++---- .../src-tauri/src/game_publish_attempt.rs | 54 +++++--- .../src-tauri/src/project/export.rs | 7 +- .../src-tauri/src/project/manifest.rs | 15 +++ apps/ai-game-creator-shell/src/App.tsx | 2 + .../GameDistributionPublishPanel.tsx | 108 +++++++++++---- .../src/services/gameDistributionPublish.ts | 82 ++++++++---- apps/ai-game-creator-shell/src/styles.css | 17 +++ .../tests/gameDistributionPublish.test.ts | 90 +++++++++++-- .../tests/gameDistributionPublishLive.test.ts | 53 +++++++- .../gameDistributionPublishPanel.test.tsx | 97 ++++++++++++-- .../shared/src/contracts/gameCreationApp.ts | 47 +++++++ .../shared/src/contracts/gameDistribution.ts | 7 +- .../src/modules/game_distribution.rs | 18 +-- .../src/application.rs | 79 +++++++++-- .../module-game-distribution/src/commands.rs | 4 + .../module-game-distribution/src/domain.rs | 20 +++ .../module-game-distribution/src/errors.rs | 4 + .../module-game-distribution/src/lib.rs | 2 +- .../shared-contracts/src/game_creation_app.rs | 102 +++++++++++++++ .../shared-contracts/src/game_distribution.rs | 15 ++- .../spacetime-client/src/game_distribution.rs | 3 +- .../spacetime-module/src/game_distribution.rs | 80 +++++------- 26 files changed, 819 insertions(+), 214 deletions(-) diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 5a7e62262..7b0b78ab3 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [ key: 'game-distribution:publish', label: '游戏发布', description: - '未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行', + '游戏发布入口灰度:未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行', }, ]; diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b47e8cbe3..dad6a5271 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -135,6 +135,7 @@ const allowedUncalledTauriCommands = [ 'read_game_cover_generation_price', 'suggest_game_distribution_publish_metadata', 'read_game_distribution_publication', + 'update_game_distribution_project_version', 'upload_local_project_game_package', // 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts` // 里以字面量出现,静态扫描仍按共享注册表核验。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 1eec25092..7c4ff8fb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -611,6 +611,7 @@ pub(super) fn run() { read_game_cover_generation_price, generate_game_distribution_cover, read_game_distribution_publication, + update_game_distribution_project_version, publish_local_project_game, read_game_creator_app_config, clear_game_creator_acl_elevation_denials, diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 9741c9cf5..6ff955013 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -22,7 +22,8 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; use shared_contracts::game_creation_app::{ - GameCreationAppManifest, GameCreationAppPublicationBinding, + resolve_game_creation_app_project_version, GameCreationAppManifest, + GameCreationAppPublicationBinding, }; use shared_contracts::game_distribution::{ GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, @@ -342,8 +343,8 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { server_error_detail(code, message, || "当前账号无权执行此操作".to_string()) ); } - // 目标版本号冲突必须是可读且可区分的提示:这是更新发布会遇到的主要正常失败, - // 不能让作者看到笼统的「HTTP 409」。 + // 版本号无法分配必须是可读且可区分的提示:只有旧客户端不传版本号、服务端自动递增撞上 + // 上限时才会走到这里,不能让作者看到笼统的「HTTP 409」。 if status == StatusCode::CONFLICT && (code.as_deref() == Some("VERSION_NUMBER_CONFLICT") || message @@ -360,7 +361,7 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .trim() }) .filter(|value| !value.is_empty()) - .unwrap_or("目标版本号必须大于当前最大版本号"); + .unwrap_or("服务端无法为这次发布分配版本号"); return format!("版本号冲突:{detail}"); } let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16())); @@ -427,8 +428,8 @@ fn normalized_origin(value: &str) -> String { } fn metadata_digest(metadata: &GameDistributionCreateGameRequest) -> Result { - let bytes = serde_json::to_vec(metadata) - .map_err(|error| format!("计算发布资料摘要失败:{error}"))?; + let bytes = + serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?; let mut hasher = Sha256::new(); hasher.update(&bytes); Ok(format!("{:x}", hasher.finalize())) @@ -451,8 +452,8 @@ async fn request_json_for_read( segments: &[&str], fallback: &str, ) -> Result { - let current = current_scoped_session(snapshot) - .map_err(GameDistributionReadError::Unavailable)?; + let current = + current_scoped_session(snapshot).map_err(GameDistributionReadError::Unavailable)?; let url = endpoint(snapshot, segments).map_err(GameDistributionReadError::Rejected)?; let request = client .request(method, &url) @@ -474,10 +475,9 @@ async fn request_json_for_read( }) })?; let status = response.status(); - let text = response - .text() - .await - .map_err(|error| GameDistributionReadError::Unavailable(format!("{fallback}:读取响应失败:{error}")))?; + let text = response.text().await.map_err(|error| { + GameDistributionReadError::Unavailable(format!("{fallback}:读取响应失败:{error}")) + })?; if let Err(message) = validate_session(snapshot) { return Err(GameDistributionReadError::Unavailable(message)); } @@ -524,11 +524,20 @@ fn unavailable_state(message: Option) -> GameDistributionPublicationRead } } +/// 回写发布绑定;旧清单缺 `projectVersion` 时按绑定回填。 +/// +/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离,所以回填只会用**本项目**的线上版本号, +/// 不会把别的账号/环境的版本写进这份清单。已有值一律保留:用户手动改过的版本标签,重开项目 +/// 不能被线上最大值悄悄覆盖。 fn write_publication_binding( root: &Path, binding: GameCreationAppPublicationBinding, ) -> Result<(), String> { crate::project::mutate_manifest_at(root, |manifest| { + manifest.project_version = Some(resolve_game_creation_app_project_version( + manifest.project_version, + Some(&binding), + )); manifest.publication = Some(binding.clone()); Ok(()) }) @@ -717,7 +726,9 @@ async fn build_publication_draft( .into_iter() .map(|screenshot| (screenshot.asset_id, screenshot.object_key)) .collect::>(), - frozen.device_support.or_else(|| entry.device_support.clone()), + frozen + .device_support + .or_else(|| entry.device_support.clone()), if frozen.input_modes.is_empty() { entry.input_modes.clone() } else { @@ -743,8 +754,11 @@ async fn build_publication_draft( entry.orientation.clone(), ), }; - let normalize_asset_id = - |value: Option| value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()); + let normalize_asset_id = |value: Option| { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; let cover = if cover_asset_id.is_some() || cover_object_key.is_some() { Some(GameDistributionPublicationImageDraft { asset_id: normalize_asset_id(cover_asset_id), @@ -839,8 +853,14 @@ pub(crate) async fn read_game_distribution_publication( Some(binding) => { let game_id = binding.game_id.trim().to_string(); let segments = ["api", "game-distribution", "my-games", game_id.as_str()]; - match request_json_for_read(&client, &snapshot, Method::GET, &segments, "读取线上作品失败") - .await + match request_json_for_read( + &client, + &snapshot, + Method::GET, + &segments, + "读取线上作品失败", + ) + .await { Ok(value) => { let entry = @@ -865,7 +885,9 @@ pub(crate) async fn read_game_distribution_publication( message: Some(format!("无法刷新线上状态:{message}")), }) } - Err(GameDistributionReadError::Rejected(message)) => Ok(unavailable_state(Some(message))), + Err(GameDistributionReadError::Rejected(message)) => { + Ok(unavailable_state(Some(message))) + } } } None => { @@ -912,6 +934,36 @@ pub(crate) async fn read_game_distribution_publication( } } +/// 只写项目清单里的用户发行版本标签(`项目版本 vN`),不发布、不改发布绑定。 +/// +/// 版本标签是"对同一个 gameId 反复提交的标签",所以这里不做递增校验、也不与线上版本比较: +/// 重复提交同一标签与回退到更小标签都合法,具体提交实例由不可变 versionId 区分。非法输入 +/// (0 或超出安全整数)在这里失败关闭,避免坏值落进清单。 +/// +/// 返回整份 manifest,调用方据此同步工作台状态(与发布成功同一条回写语义)。 +#[tauri::command] +pub(crate) fn update_game_distribution_project_version( + project_path: String, + project_version: u64, +) -> Result { + let root = Path::new(project_path.trim()); + if root.as_os_str().is_empty() { + return Err("发布需要绑定本地项目".to_string()); + } + if project_version == 0 { + return Err("项目版本必须是大于 0 的整数".to_string()); + } + if project_version > MAX_SAFE_VERSION_NUMBER { + return Err("项目版本超出安全整数范围".to_string()); + } + // 与发布同一条门禁:改版本标签属于发布资料的一部分,不是独立的新权限。 + crate::project::enforce_project_permission_policy(root, "project.export_package")?; + crate::project::mutate_manifest_at(root, |manifest| { + manifest.project_version = Some(project_version); + Ok(manifest.clone()) + }) +} + fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option { payload .asset_object_id @@ -1250,17 +1302,17 @@ pub(crate) async fn publish_local_project_game( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); - // 版本号按 JSON number 传输:超出安全整数就已经丢精度,必须在发请求前失败关闭。 + // 项目版本按 JSON number 传输:超出安全整数就已经丢精度,必须在发请求前失败关闭。 if let Some(number) = version_number { if number == 0 { - return Err("目标版本号必须是大于 0 的整数".to_string()); + return Err("项目版本必须是大于 0 的整数".to_string()); } if number > MAX_SAFE_VERSION_NUMBER { - return Err("目标版本号超出安全整数范围".to_string()); + return Err("项目版本超出安全整数范围".to_string()); } } if target_game_id.is_some() && version_number.is_none() { - return Err("更新游戏必须填写目标版本号".to_string()); + return Err("更新游戏必须填写项目版本".to_string()); } if target_game_id.is_some() && expected_publication_revision.is_none() { return Err("缺少公开修订号,请重新打开发布面板后再试".to_string()); @@ -1463,8 +1515,10 @@ pub(crate) async fn publish_local_project_game( uploaded.status.clone() } }); - // 发布成功:把平台作品绑定和最近版本状态写回本地清单。写回失败不回滚已经成功的发布, - // 但会把错误交给作者——否则下次打开项目会退回首次发布,甚至可能重复建作品。 + // 发布成功:把平台作品绑定和最近版本状态写回本地清单,并把项目版本标签对齐到服务端返回值 + // (服务端是版本号的唯一事实来源;旧客户端没传版本号时这里会把自动递增的结果记下来)。 + // 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布, + // 甚至可能重复建作品。 let binding = GameCreationAppPublicationBinding { account_id: snapshot.user_id.clone(), api_base_url: normalized_origin(&snapshot.api_base_url), @@ -1475,6 +1529,7 @@ pub(crate) async fn publish_local_project_game( status: Some(status.clone()), }; let manifest = crate::project::mutate_manifest_at(root, |manifest| { + manifest.project_version = Some(version.version_number); manifest.publication = Some(binding.clone()); Ok(manifest.clone()) })?; @@ -1557,14 +1612,15 @@ mod tests { #[test] fn version_number_conflict_is_a_readable_distinct_message() { - // 更新发布会遇到的主要正常失败:必须让作者看到「版本号冲突」而不是笼统 HTTP 409。 + // 只有旧客户端不传版本号、服务端自动递增撞上上限时才可能走到这里;必须让作者看到 + // 「版本号冲突」而不是笼统 HTTP 409。 assert_eq!( map_http_error( StatusCode::CONFLICT, - r#"{"error":{"code":"VERSION_NUMBER_CONFLICT","message":"VERSION_NUMBER_CONFLICT: 版本号 3 必须严格大于当前最大版本号 5"}}"#, + r#"{"error":{"code":"VERSION_NUMBER_CONFLICT","message":"VERSION_NUMBER_CONFLICT: 版本号已达上限 18446744073709551615,无法自动递增"}}"#, "创建游戏发行版本失败" ), - "版本号冲突:版本号 3 必须严格大于当前最大版本号 5" + "版本号冲突:版本号已达上限 18446744073709551615,无法自动递增" ); // 没有 message 时给出兜底提示,不泄漏原始英文代码。 assert_eq!( @@ -1573,10 +1629,21 @@ mod tests { r#"{"error":{"code":"VERSION_NUMBER_CONFLICT"}}"#, "创建游戏发行版本失败" ), - "版本号冲突:目标版本号必须大于当前最大版本号" + "版本号冲突:服务端无法为这次发布分配版本号" ); } + #[test] + fn repeated_or_lower_project_version_is_not_a_client_side_conflict() { + // 重复提交同一版本、回退到更小版本都由服务端接受,客户端不得在本地把它当成冲突。 + let repeated = map_http_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"BAD_REQUEST","message":"版本号必须是大于 0 的整数"}}"#, + "创建游戏发行版本失败", + ); + assert_eq!(repeated, "创建游戏发行版本失败:版本号必须是大于 0 的整数"); + } + #[test] fn metadata_digest_changes_with_frozen_metadata() { let mut metadata = GameDistributionCreateGameRequest { diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs b/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs index 8ba251276..b7af2d4cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs @@ -36,7 +36,7 @@ struct GamePublishAttemptRecord { /// 目标游戏身份:更新已有作品时非空;首次发布为空。 #[serde(default)] target_game_id: Option, - /// 用户选择的目标版本号:更新发布必须记录,换了目标版本就是一次新尝试。 + /// 这次发布提交的项目版本标签(清单里的 `projectVersion`):换了标签就是一次新尝试。 #[serde(default)] target_version_number: Option, /// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。 @@ -342,22 +342,28 @@ mod tests { fn same_package_reuses_root_key_and_new_package_rotates_it() { let dir = fixture_dir("reuse"); let sha = "a".repeat(64); - let (first, reused) = - resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha)) - .expect("first key"); + let (first, reused) = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &sha), + ) + .expect("first key"); assert!(!reused); assert!(first.starts_with("agc-publish-")); - let (again, reused) = - resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha)) - .expect("reused key"); + let (again, reused) = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &sha), + ) + .expect("reused key"); assert!(reused, "同一份包重发必须复用同一个根幂等键"); assert_eq!(again, first); // 重启不改变结论:换一个进程视角重新读盘仍然是同一个键。 - let (after_restart, reused) = - resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha)) - .expect("restarted key"); + let (after_restart, reused) = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &sha), + ) + .expect("restarted key"); assert!(reused); assert_eq!(after_restart, first); @@ -398,8 +404,8 @@ mod tests { bumped_metadata.metadata_digest = &digest_b; bumped_metadata.target_game_id = Some("game-1"); bumped_metadata.target_version_number = Some(4); - let (metadata_rotated, reused) = resolve_game_publish_root_key(&dir, bumped_metadata) - .expect("metadata rotated key"); + let (metadata_rotated, reused) = + resolve_game_publish_root_key(&dir, bumped_metadata).expect("metadata rotated key"); assert!(!reused, "资料修订变化也必须换键"); assert_ne!(metadata_rotated, bumped); @@ -414,9 +420,11 @@ mod tests { fn attempts_are_scoped_per_account_origin_and_project() { let dir = fixture_dir("scope"); let sha = "c".repeat(64); - let (base, _) = - resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha)) - .expect("base key"); + let (base, _) = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &sha), + ) + .expect("base key"); for (account, origin, project) in [ ("user-2", "https://dev.test", "proj-1"), ("user-1", "https://release.test", "proj-1"), @@ -435,14 +443,18 @@ mod tests { fn clearing_attempt_rotates_the_next_publish_of_the_same_package() { let dir = fixture_dir("clear"); let sha = "d".repeat(64); - let (first, _) = - resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha)) - .expect("first key"); + let (first, _) = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &sha), + ) + .expect("first key"); clear_game_publish_attempt(&dir, "user-1", "https://dev.test", "proj-1") .expect("clear attempt"); - let (second, reused) = - resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha)) - .expect("second key"); + let (second, reused) = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &sha), + ) + .expect("second key"); assert!(!reused, "收口过的尝试不再复用,下一次发布是新版本"); assert_ne!(second, first); // 清掉不存在的记录是幂等的空操作。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 09a9fc13b..2fe8aa290 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -546,7 +546,6 @@ pub(crate) fn read_local_project_export_package_at( } let content = normalize_release_asset_references(&path, content); entries.push((path, content)); - } entries.sort_by(|left, right| left.0.cmp(&right.0)); if entries.is_empty() { @@ -594,7 +593,11 @@ pub(crate) fn read_local_project_export_package_at( fn normalize_release_asset_references(path: &str, content: Vec) -> Vec { let is_text = matches!( - path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(), + path.rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), "css" | "html" | "htm" | "js" | "json" | "mjs" ); if !is_text { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index a09c68f68..18694e112 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -205,6 +205,17 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin Ok(()) } +/// 唯一用户发行版本标签:缺省合法(旧清单),写了就必须是正整数。 +/// +/// `0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0,服务端与面板都会把它当成非法 +/// 版本号,所以在读写两侧都失败关闭,避免坏值被静默传播。 +fn validate_manifest_project_version(value: Option) -> Result<(), String> { + if value == Some(0) { + return Err("manifest projectVersion 必须是大于 0 的整数".to_string()); + } + Ok(()) +} + /// Unity 工程身份来自根目录普通文件和目录;禁止借助链接跳到其它工程。 pub(crate) fn discover_local_unity_project_root(root: &Path) -> Result, String> { if root.as_os_str().is_empty() || !root.is_absolute() || project_path_has_control_chars(root) { @@ -2273,6 +2284,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result { if (published.manifest) setManifest(published.manifest); }} + onManifestUpdated={setManifest} /> void; onPublished?: (result: GameDistributionPublishResult) => void; + /** 项目版本写回本地清单后回调整份 manifest,让工作台同步这份唯一发行版本。 */ + onManifestUpdated?: (manifest: GameCreationAppManifest) => void; }) { const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); @@ -272,9 +279,13 @@ export function GameDistributionPublishPanel({ >(null); const [publicationRefreshed, setPublicationRefreshed] = useState(false); const [publicationMessage, setPublicationMessage] = useState(''); - const [targetVersion, setTargetVersion] = useState('1'); - /** 已知最大版本号;首次发布为 0。 */ + /** 项目清单里唯一的用户发行版本标签(`项目版本 vN`),输入框的当前文本。 */ + const [projectVersion, setProjectVersion] = useState('1'); + /** 已写回清单的项目版本;失焦时用它判断是否需要落盘。 */ + const [savedProjectVersion, setSavedProjectVersion] = useState(0); + /** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */ const [latestVersionNumber, setLatestVersionNumber] = useState(0); + const projectVersionSaveRequestRef = useRef(0); const metadataSuggestionRequestRef = useRef(0); const publicationRequestRef = useRef(0); const publicationDraftAppliedRef = useRef(false); @@ -317,7 +328,8 @@ export function GameDistributionPublishPanel({ setPublicationBinding(null); setPublicationRefreshed(false); setPublicationMessage(''); - setTargetVersion('1'); + setProjectVersion(String(resolveGameCreationAppProjectVersion(manifest))); + setSavedProjectVersion(resolveGameCreationAppProjectVersion(manifest)); setLatestVersionNumber(0); summaryTouchedRef.current = false; categoryTouchedRef.current = false; @@ -381,14 +393,19 @@ export function GameDistributionPublishPanel({ return; } if (recovery.state === 'update') { - const latest = recovery.binding.latestVersionNumber ?? null; - const max = latest ?? 0; + // 线上最近提交的版本号只用于展示与旧清单回填;用户输入的项目版本才是这次提交的标签。 + const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0; + const resolved = resolveGameCreationAppProjectVersion( + manifest, + recovery.binding, + ); setPublicationState('update'); setPublicationBinding(recovery.binding); setPublicationRefreshed(recovery.refreshed); setPublicationMessage(recovery.message?.trim() ?? ''); - setLatestVersionNumber(max); - setTargetVersion(String(max + 1)); + setLatestVersionNumber(latestVersionNumber); + setProjectVersion(String(resolved)); + setSavedProjectVersion(resolved); if (recovery.draft) { applyPublicationDraft(recovery.draft); } @@ -657,6 +674,40 @@ export function GameDistributionPublishPanel({ if (screenshotInputRef.current) screenshotInputRef.current.value = ''; } + /** + * 失焦即落盘:清单里的项目版本必须是用户改过的那个值,而不是只在这次面板里有效。 + * + * 非法输入不写盘、直接回退到已保存值并给出可读原因;落盘失败保留用户输入(发布仍用输入值), + * 只提示失败,避免"看起来没改成功、其实改了一半"。 + */ + async function handleProjectVersionCommit() { + const parsed = parseProjectVersion(projectVersion); + if (parsed === null) { + setError('项目版本必须是大于 0 的整数'); + setProjectVersion(String(savedProjectVersion)); + return; + } + if (parsed === savedProjectVersion) return; + const requestId = ++projectVersionSaveRequestRef.current; + try { + const updated = await saveGameDistributionProjectVersion({ + projectPath, + projectVersion: parsed, + }); + if (requestId !== projectVersionSaveRequestRef.current) return; + setSavedProjectVersion(parsed); + setError(''); + if (updated) onManifestUpdated?.(updated); + } catch (saveError) { + if (requestId !== projectVersionSaveRequestRef.current) return; + setError( + saveError instanceof Error + ? saveError.message + : '项目版本保存失败,请重试', + ); + } + } + async function handleSubmit() { if (!packageResult || !projectPath.trim()) { setError('请先导出有效的试玩包'); @@ -677,15 +728,9 @@ export function GameDistributionPublishPanel({ ); return; } - const parsedTargetVersion = parseTargetVersion(targetVersion); - if (parsedTargetVersion === null) { - setError('目标版本号必须是大于 0 的整数'); - return; - } - if (parsedTargetVersion <= latestVersionNumber) { - setError( - `目标版本号必须大于当前最大版本号 ${latestVersionNumber},不能覆盖已有版本`, - ); + const parsedProjectVersion = parseProjectVersion(projectVersion); + if (parsedProjectVersion === null) { + setError('项目版本必须是大于 0 的整数'); return; } if (!cover) { @@ -756,7 +801,7 @@ export function GameDistributionPublishPanel({ }, gameId: publicationState === 'update' ? publicationBinding?.gameId ?? null : null, - versionNumber: parsedTargetVersion, + versionNumber: parsedProjectVersion, expectedPublicationRevision: publicationState === 'update' ? publicationBinding?.revision ?? null : null, }); @@ -816,7 +861,7 @@ export function GameDistributionPublishPanel({ 已提交审核

版本已进入审核队列,审核通过后才会在游戏广场公开展示。

- 版本 {result.versionNumber} ·{' '} + 项目版本 v{result.versionNumber} ·{' '} {result.packageSizeBytes.toLocaleString()} B ·{' '} {result.packageSha256.slice(0, 16)}…

@@ -843,8 +888,8 @@ export function GameDistributionPublishPanel({ {publicationState === 'update' && publicationBinding ? ( - 当前版本{' '} - {latestVersionNumber > 0 ? latestVersionNumber : '—'} ·{' '} + 线上最近提交{' '} + {latestVersionNumber > 0 ? `v${latestVersionNumber}` : '—'} ·{' '} {publicationVersionStatusLabel(publicationBinding.status)} {publicationRefreshed ? '' @@ -905,26 +950,33 @@ export function GameDistributionPublishPanel({ {latestVersionNumber > 0 - ? `当前最大版本号 ${latestVersionNumber},建议 ${latestVersionNumber + 1};允许跳号,但不能小于等于当前版本号。` - : '首次发布建议使用版本号 1;版本号必须是大于 0 的整数。'} + ? `版本标签不要求递增:可以重复提交同一个版本,也可以回退到更小的版本号(线上最近提交 v${latestVersionNumber})。` + : '第一次发布建议用 v1;版本号是大于 0 的整数,之后可以重复提交或回退,不要求递增。'} { + const projectPath = args.projectPath.trim(); + if (!projectPath) { + throw new Error('发布需要绑定本地项目'); + } + const projectVersion = args.projectVersion; + if (!Number.isSafeInteger(projectVersion) || projectVersion <= 0) { + throw new Error('项目版本必须是大于 0 的安全整数'); + } + const payload = await requireNativeInvoke(invoke)< + GameCreationAppManifest | null | undefined + >('update_game_distribution_project_version', { + projectPath, + projectVersion, + }); + return payload ?? null; +} + export type GameDistributionPublishMetadataSuggestion = { summary: string; category: GameDistributionCategory; @@ -294,11 +325,16 @@ export async function publishLocalProjectGame(args: { metadata?: Partial; /** * 更新已有作品时的目标游戏身份(来自发布绑定)。提供时必须同时给出 - * `versionNumber` 与 `expectedPublicationRevision`:原生会跳过 create game, - * 直接在原 gameId 上创建新版本。 + * `expectedPublicationRevision`:原生会跳过 create game,直接在原 gameId 上创建新版本。 */ gameId?: string | null; - /** 用户选择的目标正整数版本号;首次发布默认 1。不传则退回服务端自动递增。 */ + /** + * 这次提交使用的项目版本标签。 + * + * 缺省时取清单的唯一发行版本 `manifest.projectVersion`(缺失按发布绑定回填,最终落到首版 1): + * 发布面板编辑的就是这个字段,所以「用户改了什么、这次就提交什么」,不存在第二份版本来源。 + * 同一个版本标签可以反复提交,每次提交生成新的 `versionId`。 + */ versionNumber?: number | null; /** 更新送审所需的公开修订号 CAS;来自发布绑定。 */ expectedPublicationRevision?: number | null; @@ -321,30 +357,24 @@ export async function publishLocalProjectGame(args: { throw new Error('发布需要本地项目标识,请重新打开项目后再试'); } const gameId = args.gameId?.trim() ?? ''; - const versionNumber = args.versionNumber ?? null; - if (versionNumber !== null) { - if ( - !Number.isSafeInteger(versionNumber) || - versionNumber <= 0 - ) { - throw new Error('目标版本号必须是大于 0 的安全整数'); - } + // 版本标签只有一个来源:调用方显式传入的值优先(发布面板编辑中的输入),否则回落到清单里 + // 的项目版本(旧清单按发布绑定回填)。服务端的自动递增路径只服务不传版本号的老客户端。 + const versionNumber = + args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest); + if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) { + throw new Error('项目版本必须是大于 0 的安全整数'); } - if (gameId) { - if (versionNumber === null) { - throw new Error('更新游戏必须填写目标版本号'); - } - if ( - args.expectedPublicationRevision === null || - args.expectedPublicationRevision === undefined - ) { - throw new Error('缺少公开修订号,请重新打开发布面板后再试'); - } + if ( + gameId && + (args.expectedPublicationRevision === null || + args.expectedPublicationRevision === undefined) + ) { + throw new Error('缺少公开修订号,请重新打开发布面板后再试'); } const gameMetadata = toCreateGameRequest(metadata, localProjectId); const rootKey = args.idempotencyKey?.trim() ?? ''; // 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递 - // 已归一化的资料、目标游戏/版本和本地项目相对包路径,不接触 token、base URL 或整包字节。 + // 已归一化的资料、项目版本和本地项目相对包路径,不接触 token、base URL 或整包字节。 return args.invoke( 'publish_local_project_game', { @@ -352,7 +382,7 @@ export async function publishLocalProjectGame(args: { packageRelativePath, metadata: gameMetadata, ...(gameId ? { gameId } : {}), - ...(versionNumber !== null ? { versionNumber } : {}), + versionNumber, ...(gameId ? { expectedPublicationRevision: args.expectedPublicationRevision } : {}), diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 23721cf63..8be9d91fa 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3737,6 +3737,23 @@ textarea { line-height: 1.5; } +/* 项目版本标签:与榜单/详情页看到的「vN」一致,避免用户以为它是内部版本号。 */ +.game-distribution-publish-panel__version-label { + display: flex; + align-items: center; + gap: 8px; +} + +.game-distribution-publish-panel__version-tag { + padding: 1px 8px; + border-radius: 999px; + background: rgb(168 102 61 / 12%); + color: #a8663d; + font-size: 11px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + .game-distribution-publish-panel__cover-editor { display: grid; align-content: start; diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 67be97d81..1f56aaa31 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -8,6 +8,7 @@ import { publishLocalProjectGame, readGameCoverGenerationPrice, readGameDistributionPublication, + saveGameDistributionProjectVersion, suggestGameDistributionPublishMetadata, } from '../src/services/gameDistributionPublish'; const MANIFEST = { @@ -291,7 +292,7 @@ describe('Rust 发布 facade', () => { ).rejects.toThrow('发布状态回读结果无效'); }); - test('更新发布会带上原 gameId、目标版本号与公开修订号', async () => { + test('更新发布会带上原 gameId、项目版本与公开修订号', async () => { const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT); await publishLocalProjectGame({ @@ -312,18 +313,41 @@ describe('Rust 发布 facade', () => { }); }); - test('更新发布缺少目标版本号或公开修订号时在 native command 前失败关闭', async () => { + test('未显式给出项目版本时沿用清单里的唯一发行版本', async () => { + const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT); + + // 旧清单没有 projectVersion:首版回落 1,不把「没写」变成服务端自动递增。 + await publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: 'asset_cover' }, + }); + expect(calls[0]?.args).toMatchObject({ versionNumber: 1 }); + + // 清单写过的项目版本就是这次提交的标签;同一标签重复提交由服务端接受。 + await publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: { + ...MANIFEST, + projectVersion: 4, + } as unknown as GameCreationAppManifest, + metadata: { coverAssetId: 'asset_cover' }, + gameId: 'game_9', + expectedPublicationRevision: 7, + }); + expect(calls[1]?.args).toMatchObject({ + gameId: 'game_9', + versionNumber: 4, + expectedPublicationRevision: 7, + }); + }); + + test('更新发布缺少公开修订号时在 native command 前失败关闭', async () => { const invoke = vi.fn() as unknown as TauriInvoke; - await expect( - publishLocalProjectGame({ - invoke, - projectPath: '/tmp/project', - packageRelativePath: 'exports/playtest-package-1.zip', - manifest: MANIFEST, - metadata: { coverAssetId: 'asset_cover' }, - gameId: 'game_9', - }), - ).rejects.toThrow('更新游戏必须填写目标版本号'); await expect( publishLocalProjectGame({ invoke, @@ -338,7 +362,7 @@ describe('Rust 发布 facade', () => { expect(invoke).not.toHaveBeenCalled(); }); - test('目标版本号不是安全整数时在 native command 前失败关闭', async () => { + test('项目版本不是正整数安全整数时在 native command 前失败关闭', async () => { const invoke = vi.fn() as unknown as TauriInvoke; for (const versionNumber of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { await expect( @@ -350,8 +374,46 @@ describe('Rust 发布 facade', () => { metadata: { coverAssetId: 'asset_cover' }, versionNumber, }), - ).rejects.toThrow('目标版本号必须是大于 0 的安全整数'); + ).rejects.toThrow('项目版本必须是大于 0 的安全整数'); } expect(invoke).not.toHaveBeenCalled(); }); + + test('项目版本写回清单通过单个 native command 并回传整份 manifest', async () => { + const savedManifest = { + ...MANIFEST, + projectVersion: 6, + } as unknown as GameCreationAppManifest; + const { invoke, calls } = installNativeInvoke(() => savedManifest); + + const updated = await saveGameDistributionProjectVersion( + { projectPath: '/tmp/project', projectVersion: 6 }, + invoke, + ); + + expect(updated).toEqual(savedManifest); + expect(calls).toEqual([ + { + command: 'update_game_distribution_project_version', + args: { projectPath: '/tmp/project', projectVersion: 6 }, + }, + ]); + }); + + test('项目版本写回在非法输入时失败关闭', async () => { + const invoke = vi.fn() as unknown as TauriInvoke; + await expect( + saveGameDistributionProjectVersion( + { projectPath: '/tmp/project', projectVersion: 0 }, + invoke, + ), + ).rejects.toThrow('项目版本必须是大于 0 的安全整数'); + await expect( + saveGameDistributionProjectVersion( + { projectPath: ' ', projectVersion: 2 }, + invoke, + ), + ).rejects.toThrow('发布需要绑定本地项目'); + expect(invoke).not.toHaveBeenCalled(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index 8fa36e44c..b39ad667c 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -674,7 +674,7 @@ liveTest( expect(second.gameId).toBe(first.gameId); expect(second.versionNumber).toBe(first.versionNumber + 1); - // 更新发布:AGC 带原 gameId + 用户目标版本号,跳过 create game,只新增版本。 + // 更新发布:AGC 带原 gameId + 用户项目版本,跳过 create game,只新增版本。 const ownerDetail = await unwrap<{ game: { publicationRevision: number }; }>( @@ -697,6 +697,50 @@ liveTest( expect(updated.gameId).toBe(second.gameId); expect(updated.versionNumber).toBe(targetVersionNumber); + // 同一个项目版本重复提交:服务端必须生成新的 versionId,而不是覆盖上一个提交实例。 + const afterUpdate = await unwrap<{ + game: { publicationRevision: number }; + }>( + await realFetch(apiUrl(`/api/game-distribution/my-games/${second.gameId}`), { + headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS }, + }), + ); + const repeated = await publishLocalProjectGame({ + invoke, + projectPath: '/data/dsk/Genarrative/tmp-agc-live-project', + packageRelativePath: 'dist/game.zip', + manifest, + metadata: { ...metadata, inputModes: [...metadata.inputModes] }, + gameId: second.gameId, + versionNumber: targetVersionNumber, + expectedPublicationRevision: afterUpdate.game.publicationRevision, + }); + expect(repeated.gameId).toBe(second.gameId); + expect(repeated.versionNumber).toBe(targetVersionNumber); + expect(repeated.versionId).not.toBe(updated.versionId); + + // 回退:项目版本可以低于线上最大版本号,服务端只校验它是正整数。 + const afterRepeat = await unwrap<{ + game: { publicationRevision: number }; + }>( + await realFetch(apiUrl(`/api/game-distribution/my-games/${second.gameId}`), { + headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS }, + }), + ); + const reverted = await publishLocalProjectGame({ + invoke, + projectPath: '/data/dsk/Genarrative/tmp-agc-live-project', + packageRelativePath: 'dist/game.zip', + manifest, + metadata: { ...metadata, inputModes: [...metadata.inputModes] }, + gameId: second.gameId, + versionNumber: 2, + expectedPublicationRevision: afterRepeat.game.publicationRevision, + }); + expect(reverted.gameId).toBe(second.gameId); + expect(reverted.versionNumber).toBe(2); + expect(reverted.versionId).not.toBe(repeated.versionId); + const myGames = await unwrap<{ games: Array<{ id: string; @@ -708,9 +752,12 @@ liveTest( }), ); const published = myGames.games.find((game) => game.id === first.gameId); - expect(published?.latestVersion?.versionId).toBe(updated.versionId); + // 「最新提交」按版本号降序取第一条:v7 有两条提交实例,回退的 v2 不会顶掉它们。 + expect([updated.versionId, repeated.versionId]).toContain( + published?.latestVersion?.versionId, + ); expect(published?.latestVersion?.status).toBe('pending_review'); - // 更新只增加版本,不重复创建作品:该 localProjectId 在作者名下仍然只有一款游戏。 + // 重复提交同一版本、回退到更小版本都只增加版本,不重复创建作品。 expect( myGames.games.filter( (game) => game.id === first.gameId || game.id === updated.gameId, diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index 19f0f046b..853832017 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -25,6 +25,7 @@ import { publishLocalProjectGame, readGameCoverGenerationPrice, readGameDistributionPublication, + saveGameDistributionProjectVersion, suggestGameDistributionPublishMetadata, } from '../src/services/gameDistributionPublish'; import { GAME_PACKAGE_UPLOAD_PROGRESS_EVENT } from '../src/services/gamePackageUploadProgress'; @@ -43,6 +44,7 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { publishLocalProjectGame: vi.fn(), readGameCoverGenerationPrice: vi.fn(), readGameDistributionPublication: vi.fn(), + saveGameDistributionProjectVersion: vi.fn(), suggestGameDistributionPublishMetadata: vi.fn(), }; }); @@ -145,6 +147,7 @@ afterEach(() => { vi.mocked(publishLocalProjectGame).mockReset(); vi.mocked(readGameCoverGenerationPrice).mockReset(); vi.mocked(readGameDistributionPublication).mockReset(); + vi.mocked(saveGameDistributionProjectVersion).mockReset(); vi.mocked(suggestGameDistributionPublishMetadata).mockReset(); vi.mocked(uploadPlatformMediaAsset).mockReset(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; @@ -335,7 +338,7 @@ describe('GameDistributionPublishPanel', () => { expect(invoke).toBeDefined(); expect(await screen.findByText('已提交审核')).not.toBeNull(); - expect(screen.getByText(/版本 1/u)).not.toBeNull(); + expect(screen.getByText(/项目版本/u)).not.toBeNull(); expect(onPublished).toHaveBeenCalledTimes(1); }); @@ -682,7 +685,7 @@ describe('GameDistributionPublishPanel', () => { expect(screen.queryByLabelText('发行包摘要')).toBeNull(); }); - test('已有绑定时进入更新模式,回填线上资料并提交原 gameId 与更高版本号', async () => { + test('已有绑定时进入更新模式,回填线上资料并按项目版本提交原 gameId', async () => { installTauriInvoke(async () => undefined); vi.mocked(readGameDistributionPublication).mockResolvedValue({ state: 'update', @@ -721,7 +724,7 @@ describe('GameDistributionPublishPanel', () => { vi.mocked(publishLocalProjectGame).mockResolvedValue({ gameId: 'game_9', versionId: 'gamever_3', - versionNumber: 3, + versionNumber: 2, status: 'pending_review', packageSha256: 'a'.repeat(64), packageSizeBytes: 3, @@ -729,7 +732,7 @@ describe('GameDistributionPublishPanel', () => { }); const { onPublished } = renderPanel(); - // 回填线上冻结资料,目标版本默认「最大版本号 + 1」。 + // 回填线上冻结资料;项目版本按绑定回填成线上最近提交的版本标签(旧清单缺字段时用它)。 await screen.findByRole('button', { name: '更新游戏' }); expect(screen.getByLabelText('游戏名称')).toHaveProperty( 'value', @@ -739,9 +742,13 @@ describe('GameDistributionPublishPanel', () => { 'value', '线上简介', ); - expect(screen.getByLabelText('目标版本号')).toHaveProperty('value', '3'); + expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2'); + expect(screen.getByText('v2')).not.toBeNull(); expect( - screen.getByText(/当前最大版本号 2,建议 3/u), + screen.getByText(/线上最近提交 v2 · 审核中/u), + ).not.toBeNull(); + expect( + screen.getByText(/版本标签不要求递增/u), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '更新游戏' })); @@ -750,7 +757,8 @@ describe('GameDistributionPublishPanel', () => { ); const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]; expect(args?.gameId).toBe('game_9'); - expect(args?.versionNumber).toBe(3); + // 同一个版本标签可以重复提交:不做递增改写,提交的就是清单里的 projectVersion。 + expect(args?.versionNumber).toBe(2); expect(args?.expectedPublicationRevision).toBe(7); // 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。 expect(args?.metadata?.coverAssetId).toBe('asset_cover_old'); @@ -767,7 +775,7 @@ describe('GameDistributionPublishPanel', () => { expect(onPublished).toHaveBeenCalledTimes(1); }); - test('目标版本号不大于当前最大版本号时本地拦截且不发起发布', async () => { + test('清单里的项目版本优先于线上版本,回退到更小版本也能提交', async () => { installTauriInvoke(async () => undefined); vi.mocked(readGameDistributionPublication).mockResolvedValue({ state: 'update', @@ -784,18 +792,79 @@ describe('GameDistributionPublishPanel', () => { draft: null, visibility: 'published', }); - renderPanel(); + vi.mocked(publishLocalProjectGame).mockResolvedValue({ + gameId: 'game_9', + versionId: 'gamever_6', + versionNumber: 2, + status: 'pending_review', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 3, + fileCount: 2, + }); + renderPanel({ + manifest: { + ...MANIFEST, + projectVersion: 2, + } as unknown as GameCreationAppManifest, + }); await screen.findByRole('button', { name: '更新游戏' }); - fireEvent.change(screen.getByLabelText('目标版本号'), { - target: { value: '5' }, - }); + // 清单写过的项目版本胜出:不被线上最大版本号悄悄改成 5 或 6。 + expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2'); + await selectCover(); fireEvent.click(screen.getByRole('button', { name: '更新游戏' })); + await waitFor(() => + expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), + ); expect( - await screen.findByText(/目标版本号必须大于当前最大版本号 5/u), + vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber, + ).toBe(2); + }); + + test('修改项目版本后失焦写回清单并回调工作台', async () => { + installTauriInvoke(async () => undefined); + const savedManifest = { + ...MANIFEST, + projectVersion: 4, + } as unknown as GameCreationAppManifest; + vi.mocked(saveGameDistributionProjectVersion).mockResolvedValue( + savedManifest, + ); + const onManifestUpdated = vi.fn(); + renderPanel({ onManifestUpdated }); + await screen.findByLabelText('项目版本'); + + const input = screen.getByLabelText('项目版本'); + fireEvent.change(input, { target: { value: '4' } }); + expect(screen.getByText('v4')).not.toBeNull(); + fireEvent.blur(input); + + await waitFor(() => + expect(saveGameDistributionProjectVersion).toHaveBeenCalledWith({ + projectPath: '/tmp/authorized-game', + projectVersion: 4, + }), + ); + expect(onManifestUpdated).toHaveBeenCalledWith(savedManifest); + }); + + test('项目版本非法时失焦回退到已保存值且不写盘', async () => { + installTauriInvoke(async () => undefined); + renderPanel(); + const input = await screen.findByLabelText('项目版本'); + expect(input).toHaveProperty('value', '1'); + + fireEvent.change(input, { target: { value: '0' } }); + fireEvent.blur(input); + + expect( + await screen.findByText('项目版本必须是大于 0 的整数'), ).not.toBeNull(); - expect(publishLocalProjectGame).not.toHaveBeenCalled(); + expect(saveGameDistributionProjectVersion).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '1'); + }); }); test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => { diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index a4525bad6..82ebdeb56 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -976,6 +976,42 @@ export interface GameCreationAppPublicationBinding { status?: string | null; } +/** 项目版本标签的唯一合法形状:正整数,且能安全走 JSON number。 */ +export function isGameCreationAppProjectVersion(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value > 0 + ); +} + +/** 项目版本标签的建议默认值:没有任何线上记录时的首个版本。 */ +export const GAME_CREATION_APP_FIRST_PROJECT_VERSION = 1; + +/** + * 项目版本标签的有效值。 + * + * 新清单直接用写下来的 `projectVersion`;旧清单缺这个字段时按发布绑定回填——线上已有版本号 + * (`latestVersionNumber > 0`)就用它,否则用首版 1。 + * + * `binding` 必须已经按**当前会话**(`accountId` + `apiBaseUrl`)与 `gameId` 隔离:调用方传 + * 别的账号 / 别的环境的绑定,就会把该项目的版本标签回填成别人的线上版本号。原生回读与发布 + * 写回都遵守这条隔离,渲染层只消费原生已经隔离过的绑定。 + */ +export function resolveGameCreationAppProjectVersion( + manifest: Pick, + binding?: GameCreationAppPublicationBinding | null, +): number { + if (isGameCreationAppProjectVersion(manifest.projectVersion)) { + return manifest.projectVersion; + } + const latest = binding?.latestVersionNumber; + if (isGameCreationAppProjectVersion(latest)) { + return latest; + } + return GAME_CREATION_APP_FIRST_PROJECT_VERSION; +} + export interface GameCreationAppManifest { schemaVersion: string; projectId: string; @@ -988,6 +1024,17 @@ export interface GameCreationAppManifest { versions?: GameIterationVersion[]; godotProjectRoot?: string | null; cocosProjectRoot?: string | null; + /** + * 唯一用户发行版本标签(`项目版本 vN`),正整数,不需要递增。 + * + * 同一个 `gameId` 的同一个版本标签可以反复提交:每次具体包/资料/审核提交由不可变 + * `versionId` 区分,因此这里允许重复与回退(低于线上当前版本)。它**不是**内部编辑迭代 + * `versions[].projectRevision`,也不是 semver 字符串。 + * + * 旧清单没有这个字段:读取方按当前账号 / origin / gameId 隔离后的发布绑定回填(见 + * `resolveGameCreationAppProjectVersion`),发布或改版本标签时由原生写回。 + */ + projectVersion?: number | null; /** 平台发布绑定;缺失即该项目尚未发布过。 */ publication?: GameCreationAppPublicationBinding | null; } diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index d30259902..a8610cb0c 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -169,8 +169,11 @@ export type GameDistributionCreateGameRequest = { export type GameDistributionCreateVersionRequest = { localProjectId?: string | null; /** - * 显式目标版本号;缺省时服务端按该游戏已有最大版本号自动 +1。 - * 必须严格大于当前最大版本号(允许跳号),否则返回 409。 + * 用户可见的正整数版本标签(AGC 里就是 `projectVersion`)。 + * + * 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的 + * `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。 + * 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。 */ versionNumber?: number | null; packageSha256: string; diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 6b1dbe3bf..4c7bc497b 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -881,7 +881,11 @@ fn release_package_asset_response( }; let content = normalize_release_asset_references(content, content_type); let content = inject_release_storage_bootstrap(content, content_type); - Ok(release_asset_response_with_cache(content, content_type, cache_control)) + Ok(release_asset_response_with_cache( + content, + content_type, + cache_control, + )) } fn normalize_release_asset_references(content: Vec, content_type: &str) -> Vec { @@ -951,7 +955,6 @@ async fn release_package_bytes( Ok(bytes) } - fn release_asset_response_with_cache( content: Vec, content_type: &'static str, @@ -2929,7 +2932,6 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value { }) } - /// 游戏分发写入开关。 /// /// 运营在灰度配置里把 `game-distribution:publish` 收紧后,作者写入与新版本激活会返回 @@ -4615,14 +4617,12 @@ mod tests { ); } - #[test] fn release_normalizes_legacy_root_asset_references() { - let source = br#""#; - let normalized = normalize_release_asset_references( - source.to_vec(), - "text/javascript; charset=utf-8", - ); + let source = + br#""#; + let normalized = + normalize_release_asset_references(source.to_vec(), "text/javascript; charset=utf-8"); let normalized = String::from_utf8(normalized).expect("normalized source"); assert!(normalized.contains("fetch('assets/hero.png')")); assert!(normalized.contains("url(ui/icon.svg)")); diff --git a/server-rs/crates/module-game-distribution/src/application.rs b/server-rs/crates/module-game-distribution/src/application.rs index 6b958a911..2b868bb2f 100644 --- a/server-rs/crates/module-game-distribution/src/application.rs +++ b/server-rs/crates/module-game-distribution/src/application.rs @@ -7,7 +7,7 @@ use crate::{ commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision}, domain::{ GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus, - GameVisibility, normalize_id, + GameVisibility, normalize_id, resolve_version_number, }, errors::{GameDistributionError, GameDistributionFieldError}, }; @@ -146,14 +146,16 @@ impl GameDistributionService { if state.versions.contains_key(&version_id) { return Err(GameDistributionError::VersionAlreadyExists); } - let version_number = state - .versions - .values() - .filter(|version| version.game_id == game_id) - .map(|version| version.version_number) - .max() - .unwrap_or(0) - .saturating_add(1); + let version_number = resolve_version_number( + state + .versions + .values() + .filter(|version| version.game_id == game_id) + .map(|version| version.version_number) + .max() + .unwrap_or(0), + input.version_number, + )?; let snapshot = GameVersionSnapshot { version_id: version_id.clone(), game_id, @@ -880,6 +882,7 @@ mod tests { game_id: "game-1".to_string(), owner_user_id: "owner-1".to_string(), version_id: "version-1".to_string(), + version_number: None, package_sha256: "a".repeat(64), package_bytes: 42, idempotency: idem("create-version-1", "version-1"), @@ -1159,6 +1162,7 @@ mod tests { game_id: "game-1".to_string(), owner_user_id: "owner-1".to_string(), version_id: "version-2".to_string(), + version_number: None, package_sha256: "b".repeat(64), package_bytes: 7, idempotency: idem("create-version-2", "version-2"), @@ -1270,4 +1274,61 @@ mod tests { assert_eq!(snapshot.status, GameVersionStatus::Uploaded); assert_eq!(repeated.snapshot, snapshot); } + + #[test] + fn explicit_version_number_accepts_repeats_and_reverts_of_the_same_label() { + assert_eq!(resolve_version_number(0, Some(1)).expect("首版显式 1"), 1); + // 重复标签:线上已有 3 也要能再提交一次 3,并生成新的版本记录。 + assert_eq!(resolve_version_number(3, Some(3)).expect("重复标签"), 3); + // 回退:线上最大 5,也允许提交 2。 + assert_eq!(resolve_version_number(5, Some(2)).expect("回退标签"), 2); + assert_eq!( + resolve_version_number(0, None).expect("旧客户端自动递增"), + 1 + ); + assert_eq!( + resolve_version_number(7, None).expect("旧客户端按最大值递增"), + 8 + ); + } + + #[test] + fn version_number_must_be_positive_and_auto_increment_fails_closed() { + assert_eq!( + resolve_version_number(0, Some(0)).expect_err("0 不是合法标签"), + GameDistributionFieldError::InvalidVersionNumber + ); + assert_eq!( + resolve_version_number(u64::MAX, None).expect_err("自动递增溢出必须失败关闭"), + GameDistributionFieldError::VersionNumberExhausted + ); + } + + #[test] + fn repeating_the_same_label_creates_a_new_version_record() { + let service = service(); + let first = create_version(&service); + assert_eq!(first.version_number, 1); + + // 同一个版本标签再提交一次:旧的已公开实例不原地修改,而是新增一条版本记录。 + let repeated = service + .create_version(CreateVersionInput { + game_id: "game-1".to_string(), + owner_user_id: "owner-1".to_string(), + version_id: "version-1-again".to_string(), + version_number: Some(first.version_number), + package_sha256: "b".repeat(64), + package_bytes: 9, + idempotency: idem("create-version-again", "version-again"), + created_at_micros: 12, + }) + .expect("同一个版本标签可以重复提交") + .snapshot; + assert_eq!(repeated.version_number, first.version_number); + assert_ne!(repeated.version_id, first.version_id); + assert_eq!( + service.get_version(&first.version_id).expect("旧版本仍在"), + first + ); + } } diff --git a/server-rs/crates/module-game-distribution/src/commands.rs b/server-rs/crates/module-game-distribution/src/commands.rs index 0cd47fb43..b9460264b 100644 --- a/server-rs/crates/module-game-distribution/src/commands.rs +++ b/server-rs/crates/module-game-distribution/src/commands.rs @@ -34,6 +34,10 @@ pub struct CreateVersionInput { pub game_id: String, pub owner_user_id: String, pub version_id: String, + /// 用户可见的正整数版本标签;`None` 时按该游戏已有最大版本号 +1(旧客户端兼容)。 + /// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次都是一条新版本记录。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_number: Option, pub package_sha256: String, pub package_bytes: u64, pub idempotency: IdempotencyRequest, diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index 4607a2013..e8d625a1d 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::errors::GameDistributionFieldError; use shared_kernel::{build_prefixed_seed_id, normalize_required_string}; pub const GAME_ID_PREFIX: &str = "game_"; @@ -17,6 +18,25 @@ pub fn generate_game_version_id(seed_micros: i64, version_number: u64) -> String ) } +/// 解析本次创建的版本号(用户可见的版本标签,不是不可变的 `version_id`)。 +/// +/// - `None`:旧客户端兼容路径,取该游戏已有最大版本号 +1;溢出时失败关闭,不回绕成 0。 +/// - `Some(requested)`:只要求正整数。同一个 gameId 的同一个版本号可以反复提交——每次提交都是 +/// 一条新的版本记录(新的 `version_id`),旧公开实例不原地修改——所以**不与已有最大值比较**, +/// 允许重复标签,也允许回退到更小的标签。 +pub fn resolve_version_number( + max_existing: u64, + requested: Option, +) -> Result { + match requested { + Some(0) => Err(GameDistributionFieldError::InvalidVersionNumber), + Some(requested) => Ok(requested), + None => max_existing + .checked_add(1) + .ok_or(GameDistributionFieldError::VersionNumberExhausted), + } +} + /// Computes the lowercase SHA-256 digest used by the idempotency contract. pub fn compute_request_digest(payload: impl AsRef<[u8]>) -> String { let digest = Sha256::digest(payload.as_ref()); diff --git a/server-rs/crates/module-game-distribution/src/errors.rs b/server-rs/crates/module-game-distribution/src/errors.rs index e3cb17a65..2228183c3 100644 --- a/server-rs/crates/module-game-distribution/src/errors.rs +++ b/server-rs/crates/module-game-distribution/src/errors.rs @@ -13,6 +13,8 @@ pub enum GameDistributionFieldError { InvalidPackageDigest, InvalidPackageBytes, MissingReviewReason, + InvalidVersionNumber, + VersionNumberExhausted, } impl fmt::Display for GameDistributionFieldError { @@ -27,6 +29,8 @@ impl fmt::Display for GameDistributionFieldError { Self::InvalidPackageDigest => "packageSha256 必须是 64 位十六进制摘要", Self::InvalidPackageBytes => "packageBytes 必须大于 0", Self::MissingReviewReason => "拒绝审核必须提供理由", + Self::InvalidVersionNumber => "版本号必须是大于 0 的整数", + Self::VersionNumberExhausted => "版本号已达上限,无法自动递增", }; formatter.write_str(message) } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 2fb820d17..0da10868a 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -21,7 +21,7 @@ pub use application::{ pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision}; pub use domain::{ GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility, - compute_request_digest, generate_game_id, generate_game_version_id, + compute_request_digest, generate_game_id, generate_game_version_id, resolve_version_number, }; pub use errors::{GameDistributionError, GameDistributionFieldError}; pub use events::GameDistributionEvent; diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index d11c615ac..c9dd96106 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -1087,6 +1087,36 @@ pub struct GameCreationAppPublicationBinding { pub status: Option, } +/// 项目版本标签的建议默认值:没有任何线上记录时的首个版本。 +pub const GAME_CREATION_APP_FIRST_PROJECT_VERSION: u64 = 1; + +/// 项目版本标签的唯一合法形状:正整数(`0` 永远是非法标签,不表示"未设置")。 +pub fn is_game_creation_app_project_version(value: u64) -> bool { + value > 0 +} + +/// 项目版本标签的有效值。 +/// +/// 新清单直接用写下来的 `project_version`;旧清单缺这个字段时按发布绑定回填——线上已有版本号 +/// (`latest_version_number > 0`)就用它,否则用首版 `1`。`0` 与缺失同义(历史上没有任何写入口 +/// 会落 0),所以两者都走回填分支。 +/// +/// `binding` 必须已经按**当前会话**(`account_id` + `api_base_url`)与 `game_id` 隔离:传别的 +/// 账号 / 别的环境的绑定,就会把该项目的版本标签回填成别人的线上版本号。原生回读与发布写回都 +/// 遵守这条隔离。 +pub fn resolve_game_creation_app_project_version( + project_version: Option, + binding: Option<&GameCreationAppPublicationBinding>, +) -> u64 { + if let Some(project_version) = project_version.filter(|value| *value > 0) { + return project_version; + } + binding + .and_then(|binding| binding.latest_version_number) + .filter(|value| *value > 0) + .unwrap_or(GAME_CREATION_APP_FIRST_PROJECT_VERSION) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct GameCreationAppManifest { @@ -1108,6 +1138,16 @@ pub struct GameCreationAppManifest { pub godot_project_root: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cocos_project_root: Option, + /// 唯一用户发行版本标签(`项目版本 vN`),正整数,不需要递增。 + /// + /// 同一个 gameId 的同一个版本标签可以反复提交:每次具体包/资料/审核提交由不可变 + /// `versionId` 区分,所以这里允许重复与回退(低于线上当前版本)。它不是内部编辑迭代 + /// `versions[].project_revision`,也不是 semver 字符串。 + /// + /// 旧清单没有这个字段:读取方用 [`resolve_game_creation_app_project_version`] 按当前会话 + /// 隔离后的发布绑定回填,发布或改版本标签时由原生写回。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub publication: Option, } @@ -1243,6 +1283,7 @@ pub fn new_game_creation_app_manifest( command_runs: Vec::new(), versions: Vec::new(), godot_project_root: None, + project_version: None, publication: None, cocos_project_root: None, } @@ -2182,6 +2223,67 @@ mod tests { assert_eq!(payload["godotProjectRoot"], json!("game-source")); } + /// 项目版本标签是清单里唯一的用户发行版本:线上线同一字段名(`projectVersion`), + /// 缺省时按已隔离的发布绑定回填,且绝不落 0。 + #[test] + fn manifest_project_version_round_trips_and_backfills_from_binding() { + let manifest = new_game_creation_app_manifest("project-1", "像素动作原型"); + let payload = serde_json::to_value(&manifest).expect("manifest should serialize"); + assert!( + payload.get("projectVersion").is_none(), + "旧清单语义:没有版本标签时不写字段,由发布/回读回填" + ); + assert_eq!( + resolve_game_creation_app_project_version(manifest.project_version, None), + GAME_CREATION_APP_FIRST_PROJECT_VERSION + ); + + let mut published = manifest.clone(); + published.project_version = Some(4); + published.publication = Some(GameCreationAppPublicationBinding { + account_id: "user-1".to_string(), + api_base_url: "https://dev.test".to_string(), + game_id: "game-1".to_string(), + revision: 3, + latest_version_id: Some("gamever-1".to_string()), + latest_version_number: Some(4), + status: Some("pending_review".to_string()), + }); + let payload = serde_json::to_value(&published).expect("published manifest serializes"); + assert_eq!(payload["projectVersion"], json!(4)); + assert_eq!(payload["publication"]["latestVersionNumber"], json!(4)); + assert_eq!( + serde_json::from_value::(payload) + .expect("project version must round-trip") + .project_version, + Some(4) + ); + + // 缺失或 0(不是合法标签)都走回填:线上有版本号就用它,否则首版 1。 + assert_eq!( + resolve_game_creation_app_project_version(None, published.publication.as_ref()), + 4 + ); + assert_eq!( + resolve_game_creation_app_project_version(Some(0), published.publication.as_ref()), + 4 + ); + let mut without_latest = published.publication.clone(); + without_latest + .as_mut() + .expect("binding") + .latest_version_number = Some(0); + assert_eq!( + resolve_game_creation_app_project_version(None, without_latest.as_ref()), + GAME_CREATION_APP_FIRST_PROJECT_VERSION + ); + // 清单里写过的值胜出:重开项目不能被线上版本号悄悄改写。 + assert_eq!( + resolve_game_creation_app_project_version(Some(2), published.publication.as_ref()), + 2 + ); + } + /// 未知顶层字段必须失败关闭。这条契约级断言钉住的是本结构体本身的取向,而不是某个调用方: /// AGC 客户端读写 manifest 的方式是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」, /// 一旦放行未知顶层字段,「读一次 + 任意一次写」就会静默抹掉未来版本新增的字段。 diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 289bf8ab9..dc3da2e9d 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -14,10 +14,12 @@ pub const GAME_DISTRIBUTION_CATEGORIES: [&str; 7] = /// 客户端要么放行服务端必拒的包(白等一次上传),要么拦掉服务端能收的包。 pub const GAME_DISTRIBUTION_MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; -/// 显式版本号与既有版本冲突时的统一错误前缀。 +/// 版本号无法分配时的统一错误前缀(只在自动递增溢出时使用)。 /// -/// SpacetimeDB 创建版本事务在 max 校验失败、或自动递增溢出时返回带此前缀的错误; -/// api-server 据此映射成 HTTP 409,让客户端区分"版本号必须严格更大"与参数格式错误。 +/// 旧的「必须严格大于当前最大版本号」限制已经取消:同一 gameId 的同一个版本号可以反复提交, +/// 每次生成新的 versionId,也允许回退到更小的标签。显式版本号只要求 `>= 1`,`0` 这类非法值 +/// 走普通 400;只有旧客户端缺省时按最大版本号自动递增、撞上 `u64::MAX` 才会带上此前缀, +/// api-server 据此映射成 HTTP 409。 pub const GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT: &str = "VERSION_NUMBER_CONFLICT"; /// 发布页免费生成简介与分类的输入。 @@ -230,10 +232,11 @@ pub struct GameDistributionCreateGameRequest { pub struct GameDistributionCreateVersionRequest { #[serde(default)] pub local_project_id: Option, - /// 显式目标版本号;缺省时服务端按该游戏已有最大版本号自动 +1。 + /// 用户可见的正整数版本标签(AGC 里就是 `projectVersion`)。 /// - /// 传入值必须严格大于该游戏已有最大版本号,允许跳号但不允许覆盖、重用或回退; - /// 冲突时服务端返回 409(`GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT`)。 + /// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的 + /// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端 + /// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。 /// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。 #[serde(default, skip_serializing_if = "Option::is_none")] pub version_number: Option, diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 2a46f27fb..d4a279d6f 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -88,7 +88,8 @@ pub struct GameDistributionCreateVersionRecordInput { pub game_id: String, pub owner_user_id: String, pub version_id: String, - /// 显式目标版本号;`None` 时由服务端按已有最大版本号自动 +1。 + /// 用户可见的正整数版本标签;`None` 时由服务端按已有最大版本号自动 +1(旧客户端兼容)。 + /// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次生成新的 version_id。 pub version_number: Option, /// api-server 校验并解析素材后生成的资料快照 JSON。 pub metadata_json: String, diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 9dffd2367..a243c841e 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -933,7 +933,9 @@ pub struct GameDistributionCreateVersionInput { pub game_id: String, pub owner_user_id: String, pub version_id: String, - /// 显式目标版本号;`None` 时由事务按该游戏最大版本号自动 +1(旧客户端兼容)。 + /// 用户可见的正整数版本标签;`None` 时由事务按该游戏最大版本号自动 +1(旧客户端兼容)。 + /// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交(每次生成新的 version_id), + /// 允许重复标签与回退,因此不再与已有最大值比较。 pub version_number: Option, /// 由 api-server 校验并解析素材后生成的资料快照 JSON。 pub metadata_json: String, @@ -1890,30 +1892,27 @@ fn create_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } -/// 解析本次创建的版本号。 +/// 解析本次创建的版本号(用户可见版本标签;具体提交实例由 `version_id` 区分)。 /// -/// - `None`:旧客户端兼容路径,取该游戏已有最大版本号 +1;溢出时失败关闭。 -/// - `Some(requested)`:必须严格大于已有最大版本号,允许跳号,但不允许覆盖或重用 -/// (`requested == 0` 在 `max_existing >= 0` 时必然落进拒绝分支)。 -/// -/// 事务串行化保证并发创建看到的是前一个事务提交后的 `max_existing`:两个并发请求传入 -/// 同一个显式版本号时,后执行的事务会读到已被占用的最大值并被这里拒绝。 +/// 规则本体在 `module-game-distribution` 的 `resolve_version_number`,这里只把领域错误映射成 +/// 传输层错误字符串: +/// - `None`:旧客户端兼容路径,取该游戏已有最大版本号 +1;溢出时失败关闭并带上冲突前缀。 +/// - `Some(requested)`:只要求正整数。同一个 gameId 的同一个版本号可以反复提交(每次生成新的 +/// `version_id`,旧公开实例不原地修改),所以**不再与已有最大值比较**,允许重复标签与回退。 +/// - `Some(0)` / 非法值:普通参数错误(不带冲突前缀,api-server 映射成 400)。 fn resolve_game_distribution_version_number( max_existing: u64, requested: Option, ) -> Result { - match requested { - Some(requested) if requested > max_existing => Ok(requested), - Some(requested) => Err(format!( - "{}: 版本号 {requested} 必须严格大于当前最大版本号 {max_existing}", - shared_contracts::game_distribution::GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT - )), - None => max_existing.checked_add(1).ok_or_else(|| { - format!( + match module_game_distribution::resolve_version_number(max_existing, requested) { + Ok(version_number) => Ok(version_number), + Err(module_game_distribution::GameDistributionFieldError::VersionNumberExhausted) => { + Err(format!( "{}: 版本号已达上限 {max_existing},无法自动递增", shared_contracts::game_distribution::GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT - ) - }), + )) + } + Err(error) => Err(error.to_string()), } } @@ -3804,9 +3803,9 @@ mod tests { } #[test] - fn explicit_version_number_must_exceed_current_max() { + fn explicit_version_number_only_requires_a_positive_integer() { assert_eq!( - resolve_game_distribution_version_number(2, Some(3)).expect("严格更大应接受"), + resolve_game_distribution_version_number(2, Some(3)).expect("更大版本号应接受"), 3 ); // 允许跳号:显式值可以从 0 直接跳到 5。 @@ -3814,10 +3813,15 @@ mod tests { resolve_game_distribution_version_number(0, Some(5)).expect("跳号应接受"), 5 ); - // 先发布 3、再发布 5:第二个显式值仍严格大于新的最大值。 + // 重复提交同一个版本标签:线上已有 3 也能再提交一次 3,生成新的 version_id。 assert_eq!( - resolve_game_distribution_version_number(3, Some(5)).expect("递增跳号应接受"), - 5 + resolve_game_distribution_version_number(3, Some(3)).expect("重复标签应接受"), + 3 + ); + // 回退:线上最大 5,也允许把项目版本改回 2。 + assert_eq!( + resolve_game_distribution_version_number(5, Some(2)).expect("回退标签应接受"), + 2 ); assert_eq!( resolve_game_distribution_version_number(u64::MAX - 1, Some(u64::MAX)) @@ -3827,34 +3831,12 @@ mod tests { } #[test] - fn explicit_version_number_at_or_below_max_is_rejected_as_conflict() { - // 0 严格不大于任何已有最大值(含空集时的 0),必须拒绝,不能落成版本号 0。 + fn version_number_zero_is_rejected_as_a_plain_bad_request() { + // 0 不是合法的版本标签;这类参数错误不带冲突前缀,api-server 映射成 400 而不是 409。 let zero = resolve_game_distribution_version_number(0, Some(0)).expect_err("版本号 0 应被拒绝"); - assert!(conflict(&zero), "应带冲突前缀:{zero}"); - - let equal = - resolve_game_distribution_version_number(4, Some(4)).expect_err("相同版本号应被拒绝"); - assert!(conflict(&equal), "应带冲突前缀:{equal}"); - - let lower = - resolve_game_distribution_version_number(4, Some(2)).expect_err("更小版本号应被拒绝"); - assert!(conflict(&lower), "应带冲突前缀:{lower}"); - - let max_taken = resolve_game_distribution_version_number(u64::MAX, Some(u64::MAX)) - .expect_err("已被占用的最大值应被拒绝"); - assert!(conflict(&max_taken), "应带冲突前缀:{max_taken}"); - } - - #[test] - fn concurrent_same_explicit_version_number_is_rejected_for_the_later_transaction() { - // 两个并发事务都传 5、当前最大值为 4:先提交的事务写入 5,后执行的事务重新读到 - // max=5 后必须拒绝,而不是覆盖已存在的版本。 - let first = resolve_game_distribution_version_number(4, Some(5)).expect("首个请求应成功"); - assert_eq!(first, 5); - let second = resolve_game_distribution_version_number(first, Some(5)) - .expect_err("第二个同名版本号应被拒绝"); - assert!(conflict(&second), "应带冲突前缀:{second}"); + assert!(!conflict(&zero), "参数错误不应带冲突前缀:{zero}"); + assert!(zero.contains("大于 0"), "应说明合法形状:{zero}"); } #[test]