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 2a277f2ca..e6eb43f22 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 @@ -24,8 +24,8 @@ use shared_contracts::game_creation_app::{ GameCreationAppManifest, GameCreationAppPublicationBinding, }; use shared_contracts::game_distribution::{ - GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, - GameDistributionDeviceSupport, GameDistributionInputMode, GameDistributionOrientation, + GameDistributionCreateGameRequest, GameDistributionDeviceSupport, + GameDistributionInputMode, GameDistributionOrientation, }; use std::io::Read; use std::path::Path; @@ -153,8 +153,34 @@ struct CreatedVersion { version_number: u64, #[serde(default)] status: String, - #[serde(default)] - publication_revision: Option, +} + +/// 统一发布请求的 `metadata` part:首次发布带 `projectKey`,更新带 `gameId`。 +/// +/// `gameMetadata` 复用 `GameDistributionCreateGameRequest` 形状,服务端只在作品行不存在时用它 +/// bootstrap;更新既有作品时忽略。封面/截图仍作为 `cover` / `screenshot` 二进制 part 随行提交。 +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PublishVersionMetadata<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + game_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + project_key: Option<&'a str>, + version_number: u64, + price_mud_points: u64, + package_sha256: &'a str, + package_bytes: u64, + package_file_count: u32, + package_entry_path: &'a str, + game_metadata: &'a GameDistributionCreateGameRequest, +} + +/// 统一发布响应:一次拿到游戏身份(含 `publicationRevision`)与冻结版本。 +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PublishedGameVersion { + game: CreatedGame, + version: CreatedVersion, } /// 作者名下作品条目(`GET /my-games` 列表项与 `GET /my-games/{gameId}` 的 `game`)。 @@ -326,38 +352,6 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { server_error_detail(code, message, || "当前账号无权执行此操作".to_string()) ); } - // 版本号无法分配必须是可读且可区分的提示:只有旧客户端不传版本号、服务端自动递增撞上 - // 上限时才会走到这里,不能让作者看到笼统的「HTTP 409」。 - if status == StatusCode::CONFLICT - && (code.as_deref() == Some("VERSION_NUMBER_CONFLICT") - || message - .as_deref() - .is_some_and(|value| value.trim_start().starts_with("VERSION_NUMBER_CONFLICT"))) - { - let has_meaningful_message = message.as_deref().is_some_and(|value| { - let trimmed = value.trim(); - !trimmed.is_empty() && !trimmed.starts_with('{') - }); - let detail = message - .as_deref() - .map(|value| { - value - .trim() - .trim_start_matches("VERSION_NUMBER_CONFLICT") - .trim_start_matches([':', ':']) - .trim() - }) - .filter(|value| has_meaningful_message && !value.is_empty()) - .unwrap_or("服务端无法为这次发布分配版本号"); - let code_suffix = if !has_meaningful_message { - code.as_deref() - .map(|value| format!("(错误码:{value})")) - .unwrap_or_default() - } else { - String::new() - }; - return format!("版本号冲突:{detail}{code_suffix}"); - } let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } @@ -1465,66 +1459,41 @@ pub(crate) async fn publish_local_project_game( 0, 0, ); - let (game_id, mut publication_revision) = match target_game_id.as_deref() { - Some(game_id) => { - let revision = expected_publication_revision - .ok_or_else(|| "缺少公开修订号,请重新打开发布面板后再试".to_string())?; - (game_id.to_string(), revision) - } - None => { - let game_value = request_multipart( - &client, - &snapshot, - Method::POST, - &["api", "game-distribution", "games"], - publish_multipart_form(&metadata, &media)?, - Some(&format!("{root_key}:game")), - "创建平台游戏失败", - ) - .await?; - let game: CreatedGame = serde_json::from_value(game_value) - .map_err(|error| format!("创建平台游戏响应无效:{error}"))?; - if game.id.trim().is_empty() { - return Err("创建平台游戏未返回游戏 ID".to_string()); - } - (game.id, game.publication_revision) - } - }; - let version_request = GameDistributionCreateVersionRequest { - project_key: Some(local_project_id.clone()), - package_sha256: staged.package_sha256.clone(), - package_bytes: staged.package_size_bytes, - package_file_count: staged.package_file_count, - package_entry_path: "index.html".to_string(), + // 首次发布与更新合并成一次 `POST /versions`:作品行由同一事务按 `(owner, projectKey)` + // get-or-create,封面/截图只解析一次、只上传一遍。`gameId` 有则带(更新),无则带 projectKey。 + let publish_request = PublishVersionMetadata { + game_id: target_game_id.as_deref(), + project_key: Some(local_project_id.as_str()), version_number, // 买断价随版本冻结;面板已本地校验,这里透传服务端口径的整数泥点。 price_mud_points, - game_metadata: metadata, + package_sha256: staged.package_sha256.as_str(), + package_bytes: staged.package_size_bytes, + package_file_count: staged.package_file_count, + package_entry_path: "index.html", + game_metadata: &metadata, }; - let version_value = request_multipart( + let publish_value = request_multipart( &client, &snapshot, Method::POST, - &[ - "api", - "game-distribution", - "games", - game_id.as_str(), - "versions", - ], - publish_multipart_form(&version_request, &media)?, - Some(&format!("{root_key}:version")), - "创建游戏发行版本失败", + &["api", "game-distribution", "versions"], + publish_multipart_form(&publish_request, &media)?, + None, + "发布游戏失败", ) .await?; - let version: CreatedVersion = serde_json::from_value(version_value) - .map_err(|error| format!("创建发行版本响应无效:{error}"))?; - if version.version_id.trim().is_empty() { - return Err("创建发行版本未返回版本 ID".to_string()); + let published: PublishedGameVersion = serde_json::from_value(publish_value) + .map_err(|error| format!("发布游戏响应无效:{error}"))?; + if published.game.id.trim().is_empty() { + return Err("发布游戏未返回游戏 ID".to_string()); } - if let Some(revision) = version.publication_revision { - publication_revision = revision; + if published.version.version_id.trim().is_empty() { + return Err("发布游戏未返回版本 ID".to_string()); } + let game_id = published.game.id; + let publication_revision = published.game.publication_revision; + let version = published.version; validate_session(&snapshot)?; let progress_version_id = version.version_id.clone(); let verify_version_id = version.version_id.clone(); @@ -1765,29 +1734,6 @@ mod tests { ); } - #[test] - fn version_number_conflict_is_a_readable_distinct_message() { - // 只有旧客户端不传版本号、服务端自动递增撞上上限时才可能走到这里;必须让作者看到 - // 「版本号冲突」而不是笼统 HTTP 409。 - assert_eq!( - map_http_error( - StatusCode::CONFLICT, - r#"{"error":{"code":"VERSION_NUMBER_CONFLICT","message":"VERSION_NUMBER_CONFLICT: 版本号已达上限 18446744073709551615,无法自动递增"}}"#, - "创建游戏发行版本失败" - ), - "版本号冲突:版本号已达上限 18446744073709551615,无法自动递增" - ); - // 没有 message 时同时给出可读提示和服务端错误码,不能只剩一个编号。 - assert_eq!( - map_http_error( - StatusCode::CONFLICT, - r#"{"error":{"code":"VERSION_NUMBER_CONFLICT"}}"#, - "创建游戏发行版本失败" - ), - "版本号冲突:服务端无法为这次发布分配版本号(错误码:VERSION_NUMBER_CONFLICT)" - ); - } - #[test] fn repeated_or_lower_project_version_is_not_a_client_side_conflict() { // 重复提交同一版本、回退到更小版本都由服务端接受,客户端不得在本地把它当成冲突。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md b/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md index 362e26ba3..02e3c8345 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md +++ b/docs/project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md @@ -135,6 +135,7 @@ ## 7. 风险与回滚 - **确定性 gameId 改变 ID 形态**:模块不在线、无历史数据,硬切;回滚 = 撤销 M1/M2 提交,schema 随代码回滚。旧随机 gameId 若已写入本地验收库需重建。 +- **软删后重发的身份**:确定性 gameId 用 `(owner, projectKey)` 派生,一旦该作品被软删,同一 projectKey 再次发布仍会派生到被占用的 gameId;当前合并事务按「游戏已删除」失败关闭,尚未实现「删除后重新发布得到新身份」。模块不在线、无线上数据,先记为已知边界,待确认是否需要在 api-server 侧为软删命中换发随机后缀。 - **媒体只解析一次**:唯一一次 `resolve_publish_media` 同时服务游戏行与版本;失败时 `cleanup_uncommitted_publish_media` 仍覆盖两条写出之前的上传。以「同键重放不新增 objectKey」测试锁定。 - **客户端冻结版本号**:网页若在重试时重读 max 会跳号生成重复版本;draft 预持久化是硬要求,用「响应丢失后重发同键命中」用例覆盖。 - **大文件抽模块**:抽出 `game_distribution_publish.rs` 会移动大量代码,与并发改动冲突风险高;先做零行为变化的重构提交,再改行为。 diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 76d607eea..c8df514c9 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -29,7 +29,8 @@ pub use game_distribution::{ GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput, GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionPurchaseRecordInput, + GameDistributionPublicGameListRecordInput, GameDistributionPublishVersionRecordInput, + GameDistributionPurchaseRecordInput, GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 7004000a7..a40a7ad6f 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -114,6 +114,40 @@ pub struct GameDistributionCreateVersionRecordInput { pub now_micros: i64, } +/// 统一发布输入:一次 procedure 调用的记录形态。`game_id` 始终给出(首次发布由调用方按 +/// `(owner, project_key)` 确定性派生),`project_key` 是首次发布的身份锚与版本冻结字段。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionPublishVersionRecordInput { + pub game_id: String, + pub owner_user_id: String, + pub project_key: Option, + pub version_id: String, + pub version_number: u64, + pub metadata_json: String, + pub package_sha256: String, + pub package_bytes: u64, + pub package_file_count: u32, + pub package_entry_path: String, + /// 首次发布时 bootstrap 作品行;`game_id` 命中既有行时忽略。 + pub title: String, + pub summary: String, + pub description: Option, + pub category: String, + pub tags_json: String, + pub author_name: Option, + pub author_avatar_url: Option, + pub device_support_desktop: bool, + pub device_support_mobile: bool, + pub device_support_touch: bool, + pub input_modes_json: String, + pub orientation: String, + pub cover_object_key: Option, + pub screenshots_json: Option, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct GameDistributionConfirmPackageRecordInput { pub version_id: String, @@ -790,6 +824,66 @@ impl SpacetimeClient { .await } + pub async fn publish_game_distribution_version( + &self, + input: GameDistributionPublishVersionRecordInput, + ) -> Result< + ( + GameDistributionGameRecord, + GameDistributionVersionRecord, + bool, + ), + SpacetimeClientError, + > { + let input = crate::module_bindings::GameDistributionPublishVersionInput { + game_id: input.game_id, + owner_user_id: input.owner_user_id, + project_key: input.project_key, + version_id: input.version_id, + version_number: input.version_number, + metadata_json: input.metadata_json, + package_sha_256: input.package_sha256, + package_bytes: input.package_bytes, + package_file_count: input.package_file_count, + package_entry_path: input.package_entry_path, + title: input.title, + summary: input.summary, + description: input.description, + category: input.category, + tags_json: input.tags_json, + author_name: input.author_name, + author_avatar_url: input.author_avatar_url, + device_support_desktop: input.device_support_desktop, + device_support_mobile: input.device_support_mobile, + device_support_touch: input.device_support_touch, + input_modes_json: input.input_modes_json, + orientation: input.orientation, + cover_object_key: input.cover_object_key, + screenshots_json: input.screenshots_json, + idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + now_micros: input.now_micros, + }; + self.call_after_connect( + "publish_game_distribution_version", + move |connection, sender| { + connection + .procedures() + .publish_game_distribution_version_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_game_result) + .and_then(|(game, version, replayed)| match (game, version) { + (Some(game), Some(version)) => Ok((game, version, replayed)), + _ => Err(SpacetimeClientError::missing_snapshot("游戏发布结果")), + }); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn confirm_game_distribution_package( &self, input: GameDistributionConfirmPackageRecordInput, diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 8552fd521..1d302ba4a 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -990,6 +990,43 @@ pub struct GameDistributionCreateVersionInput { pub now_micros: i64, } +/// 统一发布输入:一次事务内完成「按身份 get-or-create 作品行 + 写版本 + 写自然幂等收据」。 +/// +/// `game_id` 始终由 api-server 给出:更新时是既有作品 ID,首次发布时用 +/// `derive_game_distribution_game_id(owner, project_key)` 确定性派生。`project_key` 是首次发布 +/// 的身份锚;作品行已存在时那几个 bootstrap 字段被忽略,版本冻结资料始终以 `metadata_json` 为准。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionPublishVersionInput { + pub game_id: String, + pub owner_user_id: String, + pub project_key: Option, + pub version_id: String, + pub version_number: u64, + pub metadata_json: String, + pub package_sha256: String, + pub package_bytes: u64, + pub package_file_count: u32, + pub package_entry_path: String, + /// 首次发布时 bootstrap 作品行;`game_id` 命中既有行时忽略。 + pub title: String, + pub summary: String, + pub description: Option, + pub category: String, + pub tags_json: String, + pub author_name: Option, + pub author_avatar_url: Option, + pub device_support_desktop: bool, + pub device_support_mobile: bool, + pub device_support_touch: bool, + pub input_modes_json: String, + pub orientation: String, + pub cover_object_key: Option, + pub screenshots_json: Option, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionConfirmPackageInput { pub version_id: String, @@ -1458,6 +1495,24 @@ fn game_distribution_game_result( } } +/// 统一发布结果:同时携带作品行与版本行,供客户端一次拿到 `{ game, version, replayed }`。 +fn game_distribution_publish_result( + game: GameDistributionGameSnapshot, + version: GameDistributionVersionSnapshot, + replayed: bool, +) -> GameDistributionProcedureResult { + GameDistributionProcedureResult { + ok: true, + replayed, + game: Some(game), + games: Vec::new(), + version: Some(version), + versions: Vec::new(), + next_cursor: None, + error_message: None, + } +} + /// 创建作者名下的游戏身份并在同一事务内写入幂等收据。 #[spacetimedb::procedure] pub fn create_game_distribution_game_and_return( @@ -1490,6 +1545,24 @@ pub fn create_game_distribution_version_and_return( } } +/// 统一发布:首次发布按 `(owner, project_key)` get-or-create 作品行,再把版本与自然幂等收据 +/// 写进同一事务;更新既有作品时复用 `game_id`。同键同摘要返回 `game + version` 并标记 `replayed`, +/// 不同摘要返回冲突错误(api-server 映射 409)。 +#[spacetimedb::procedure] +pub fn publish_game_distribution_version_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionPublishVersionInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + publish_game_distribution_version_tx(tx, input.clone()) + }) { + Ok((game, version, replayed)) => game_distribution_publish_result(game, version, replayed), + Err(error) => game_distribution_result_error(error), + } +} + /// 确认对象存储中的包摘要和校验清单。procedure 不读取、不执行上传内容。 #[spacetimedb::procedure] pub fn confirm_game_distribution_package_and_return( @@ -2259,6 +2332,230 @@ fn create_game_distribution_version_tx( )) } +/// 发布路径的作品 get-or-create:不写收据,由发布事务按统一语义写一条。 +/// +/// `game_id` 已存在则复用(校验 owner / 未软删 / 未暂停);否则按 `(owner, project_key)` +/// 命中既有作品(首次发布重试);仍无则用 bootstrap 字段插入新行。 +fn get_or_create_game_distribution_game_for_publish_tx( + ctx: &ReducerContext, + input: &GameDistributionPublishVersionInput, + owner_user_id: &str, +) -> Result { + if let Some(game) = ctx + .db + .game_distribution_game() + .game_id() + .find(&input.game_id) + { + if game.owner_user_id != owner_user_id { + return Err("游戏 owner 不匹配".to_string()); + } + ensure_game_distribution_game_not_deleted(&game)?; + if game.visibility == GAME_DISTRIBUTION_VISIBILITY_SUSPENDED { + return Err("游戏已被暂停,不能创建版本".to_string()); + } + return Ok(game); + } + + let project_key = input + .project_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + 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) + }) + { + return Ok(game); + } + } + + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .game_distribution_game() + .insert(GameDistributionGame { + game_id: input.game_id.clone(), + owner_user_id: owner_user_id.to_string(), + title: input.title.clone(), + summary: input.summary.clone(), + description: input.description.clone().unwrap_or_default(), + category: input.category.clone(), + tags_json: input.tags_json.clone(), + cover_asset_id: None, + author_name: input.author_name.clone(), + author_avatar_url: input.author_avatar_url.clone(), + device_support_desktop: input.device_support_desktop, + device_support_mobile: input.device_support_mobile, + device_support_touch: input.device_support_touch, + input_modes_json: input.input_modes_json.clone(), + orientation: input.orientation.clone(), + publication_revision: 0, + active_version_id: None, + visibility: GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED.to_string(), + play_count: 0, + created_at: now, + updated_at: now, + project_key: project_key.map(str::to_string), + cover_object_key: input.cover_object_key.clone(), + screenshots_json: input.screenshots_json.clone(), + deleted_at: None, + price_mud_points: 0, + }); + ctx.db + .game_distribution_game() + .game_id() + .find(&input.game_id) + .ok_or_else(|| "创建游戏后读取失败".to_string()) +} + +/// 统一发布事务:收据命中即重放,否则 get-or-create 作品行、写版本,再写 `create_version` 收据, +/// 收据同时落 `outcome_game_id` 与 `outcome_version_id`。整个过程由 procedure 的 `try_with_tx` 保证原子。 +fn publish_game_distribution_version_tx( + ctx: &ReducerContext, + input: GameDistributionPublishVersionInput, +) -> Result< + ( + GameDistributionGameSnapshot, + GameDistributionVersionSnapshot, + bool, + ), + String, +> { + 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")?; + 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")?; + let metadata_json = + required_game_distribution_text(input.metadata_json.clone(), "metadata_json")?; + let package_entry_path = + required_game_distribution_text(input.package_entry_path.clone(), "package_entry_path")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key.clone(), "idempotency_key")?; + let request_digest = + required_game_distribution_text(input.request_digest.clone(), "request_digest")?; + let version_number = resolve_game_distribution_version_number(input.version_number)?; + let receipt_id = game_distribution_receipt_id( + owner_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_CREATE_VERSION, + idempotency_key.as_str(), + ); + + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; + let existing_game_id = receipt + .outcome_game_id + .ok_or_else(|| "幂等收据缺少游戏 ID".to_string())?; + let existing_version_id = receipt + .outcome_version_id + .ok_or_else(|| "幂等收据缺少版本 ID".to_string())?; + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&existing_game_id) + .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; + let version = ctx + .db + .game_distribution_version() + .version_id() + .find(&existing_version_id) + .ok_or_else(|| "幂等收据对应的版本已不存在".to_string())?; + if game.owner_user_id != owner_user_id || version.owner_user_id != owner_user_id { + return Err("游戏 owner 不匹配".to_string()); + } + return Ok(( + game_distribution_game_snapshot(&game), + game_distribution_version_snapshot_for_game(ctx, &version), + true, + )); + } + + let game = + get_or_create_game_distribution_game_for_publish_tx(ctx, &input, owner_user_id.as_str())?; + if ctx + .db + .game_distribution_version() + .version_id() + .find(&version_id) + .is_some() + { + return Err("版本 ID 已存在".to_string()); + } + + let project_key = input + .project_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .game_distribution_version() + .insert(GameDistributionVersion { + version_id: version_id.clone(), + game_id: game.game_id.clone(), + owner_user_id: owner_user_id.clone(), + version_number, + package_sha256, + package_bytes: input.package_bytes, + package_file_count: input.package_file_count, + package_entry_path, + status: GAME_DISTRIBUTION_VERSION_AWAITING_UPLOAD.to_string(), + review_reason: None, + created_at: now, + updated_at: now, + project_key, + package_object_key: None, + package_manifest_json: None, + entry_url: None, + reviewed_by_user_id: None, + uploaded_at: None, + submitted_at: None, + reviewed_at: None, + published_at: None, + revoked_at: None, + last_error_code: None, + last_error_message: None, + metadata_json: Some(metadata_json), + }); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id, + action: GAME_DISTRIBUTION_ACTION_CREATE_VERSION.to_string(), + idempotency_key, + request_digest, + game_id: Some(game.game_id.clone()), + version_id: Some(version_id.clone()), + outcome_kind: "version".to_string(), + outcome_game_id: Some(game.game_id.clone()), + outcome_version_id: Some(version_id.clone()), + outcome_json: None, + now_micros: input.now_micros, + }, + )?; + let version = ctx + .db + .game_distribution_version() + .version_id() + .find(&version_id) + .ok_or_else(|| "创建版本后读取失败".to_string())?; + Ok(( + game_distribution_game_snapshot(&game), + game_distribution_version_snapshot_for_game(ctx, &version), + false, + )) +} + fn confirm_game_distribution_package_tx( ctx: &ReducerContext, input: GameDistributionConfirmPackageInput,