feat(游戏分发): 新增统一发布 procedure 与 AGC 单次发布调用

- spacetime-module 新增 publish_game_distribution_version_and_return:一次 try_with_tx 内按 (owner, projectKey) get-or-create 作品行、写版本、写 create_version 收据,收据同时落 outcome_game_id/outcome_version_id
- 新增 GameDistributionPublishVersionInput 与 game_distribution_publish_result 复合结果
- spacetime-client 新增 publish_game_distribution_version 方法与记录输入,重新生成 module_bindings
- AGC publish_local_project_game 合并两次 POST 为一次 POST /api/game-distribution/versions,封面/截图只提交一遍
- AGC 移除已退役的 VERSION_NUMBER_CONFLICT 错误映射与专属测试
- 实施计划风险节记录确定性 gameId 下软删重发的已知边界
This commit is contained in:
2026-10-07 18:00:08 +08:00
parent a5a5237d19
commit c7c99567b6
5 changed files with 448 additions and 109 deletions
@@ -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<u64>,
}
/// 统一发布请求的 `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() {
// 重复提交同一版本、回退到更小版本都由服务端接受,客户端不得在本地把它当成冲突。
@@ -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` 会移动大量代码,与并发改动冲突风险高;先做零行为变化的重构提交,再改行为。
@@ -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,
@@ -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<String>,
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<String>,
pub category: String,
pub tags_json: String,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
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<String>,
pub screenshots_json: Option<String>,
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,
@@ -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<String>,
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<String>,
pub category: String,
pub tags_json: String,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
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<String>,
pub screenshots_json: Option<String>,
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<GameDistributionGame, String> {
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,