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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user