feat(游戏共创): M2b 数据模型——版本行三列工程源包 + 契约与绑定(块 A)

- `server-rs/crates/spacetime-module/src/game_distribution.rs`:`game_distribution_version` 表尾追加三列(明确默认值,遵循既有追加规则)——`project_bundle_object_key: Option<String>`(`#[default(None)]`)、`project_bundle_bytes: u64`(`#[default(0)]` = 未上传)、`project_bundle_sha256: Option<String>`(`#[default(None)]`);三列同步进 `GameDistributionVersionSnapshot`、版本快照构造与建版本时的初始化
- 同文件:`GameDistributionForkSourceSnapshot` 与扁平结果类型追加 `project_bundle_sha256` / `project_bundle_bytes`,下行据此判断走 `project` 还是回落 `package`(字节数与摘要成对,缺一视为没有工程包)
- 同文件:新增 `GAME_DISTRIBUTION_ACTION_UPLOAD_PROJECT_BUNDLE` 动作常量、输入 `GameDistributionConfirmProjectBundleInput`、事务 `confirm_game_distribution_project_bundle_tx` 与 procedure `confirm_game_distribution_project_bundle_and_return`。语义:可选资产、**不驱动版本状态机**;owner 校验 → 阶段门(`awaiting_upload` / `upload_failed`,与发行包确认同口径,已公开/已取消/已撤回一律拒绝)→ 同内容幂等重放 / 换内容冲突 → 写三列 + 幂等收据
- `spacetime-client`:`GameDistributionVersionRecord` 与 `map_version` 同步三列(生成绑定把 `project_bundle_sha256` 折成 `project_bundle_sha_256`,与 `package_sha_256` 同约定);新增 `GameDistributionConfirmProjectBundleRecordInput` 与 `confirm_game_distribution_project_bundle`(沿用 `map_game_distribution_game_result` 再取 version 的既有写法)
- 契约:`shared-contracts` 的 `GameDistributionPrivateVersion` 追加 `project_bundle_bytes`(必需,`0` = 未上传)与 `project_bundle_sha256`(可选);`packages/shared` TS 镜像同步;api-server `private_version_payload` 追加同名键——**对象键不下发**,只回摘要与字节数
- 生成物:重跑 `npm run spacetime:generate -- --rust-only`,只保留 `module_bindings/game_distribution*`(含 2 个新文件)与 `module_bindings.rs`,其余 `module_bindings/**` 的 rustfmt 漂移已 `git checkout --` 还原
- 文档:数据契约表目录 `game_distribution_version` 小节补三列语义与对象键不出服务端的口径
- 顺手记下一条门禁陷阱:`scripts/check-game-distribution-dto-parity.mjs` 抓 `json!` 顶层键时要求「键前一个非空字符是 `{` 或 `,`」且**不剥注释**,在字面量里插注释行会让紧随其后的键被判成缺失(已写进 `private_version_payload` 的文档注释)
- 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` 69 passed;`cargo test -p api-server game_distribution` 54 passed;DTO parity 42 组 OK;`check:spacetime-schema` 92 tables OK;`check:encoding` 5301 files OK;`git diff --check` 0;`cargo fmt --all -- --check` 0
This commit is contained in:
2026-10-05 15:24:18 +08:00
parent 63e6c6345e
commit 604ce7f3ec
13 changed files with 400 additions and 1 deletions
@@ -536,6 +536,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图)并从素材记录派生对象键后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料,旧版本(无快照)保持原值。
- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。
- 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。
- 工程源包(M2b,2026-10-05):版本行末尾追加三个可空/零默认列 `project_bundle_object_key: Option<String>`、`project_bundle_bytes: u64`(`0` = 未上传)、`project_bundle_sha256: Option<String>`,保存作者**可选**上传的工程源码包(与发行包并列的第二份私有资产)。三列由 `confirm_game_distribution_project_bundle_and_return` 一次性写入(`upload_project_bundle` 幂等动作),只在版本尚未公开(`awaiting_upload` / `upload_failed`)时接受;同一版本已确认工程包后换内容按冲突拒绝,同内容按幂等重放。作品授权非 `forbidden` 时,工程包与发行包走同一道取件鉴权与校验,**平台不设独立的「源码可见性」开关**——作者不传即退化产物级改编。对象键只在服务端使用(投影里只回 `projectBundleBytes` / `projectBundleSha256`),且键名与发行包不同(`.project.zip`),避免同 (作品, 版本) 的两份资产互相覆盖或串缓存。
### 后台游戏管理读模型与恢复动作(2026-09-23)
@@ -373,6 +373,10 @@ export type GameDistributionPrivateVersion = {
reviewReason?: string | null;
/** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */
entryUrl: string | null;
/** 工程源包字节数;0 表示该版本未上传工程源包(作者上架时选择不给源码)。 */
projectBundleBytes: number;
/** 工程源包整包 SHA-256;未上传缺省。对象键不下发,只在服务端使用。 */
projectBundleSha256?: string | null;
createdAt: string;
updatedAt: string;
};
@@ -3432,6 +3432,11 @@ fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value {
})
}
/// 作者侧版本 payload。
///
/// 工程源包只回摘要与字节数(作者面板展示「已上传 / 未上传」),**对象键不出服务端**。
/// 注意:`scripts/check-game-distribution-dto-parity.mjs` 按「键前面必须是 `{` 或 `,`」抓顶层键,
/// 且不剥注释,因此 `json!` 字面量里不要插注释行——否则该键会被判成缺失。
fn private_version_payload(version: &GameDistributionVersionRecord) -> Value {
json!({
"versionId": version.version_id,
@@ -3444,6 +3449,8 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value {
"publicationRevision": version.publication_revision,
"reviewReason": version.review_reason,
"entryUrl": version.entry_url,
"projectBundleBytes": version.project_bundle_bytes,
"projectBundleSha256": version.project_bundle_sha256,
"createdAt": version.created_at,
"updatedAt": version.updated_at,
})
@@ -4695,6 +4702,9 @@ mod tests {
r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/game-cover.png","screenshots":[{"assetId":"asset_shot","objectKey":"generated/shot.png"}]}"#
.to_string(),
),
project_bundle_object_key: None,
project_bundle_bytes: 0,
project_bundle_sha256: None,
};
let payload = version_detail_payload(&version, &game);
@@ -4759,6 +4769,9 @@ mod tests {
created_at: "2026-09-20T00:00:00Z".to_string(),
updated_at: "2026-09-20T00:00:00Z".to_string(),
metadata_json: None,
project_bundle_object_key: None,
project_bundle_bytes: 0,
project_bundle_sha256: None,
};
let public = game_payload(&game);
@@ -439,6 +439,12 @@ pub struct GameDistributionPrivateVersion {
pub review_reason: Option<String>,
#[serde(default)]
pub entry_url: Option<String>,
/// 工程源包字节数;`0` 表示该版本未上传工程源包(作者上架时选择不给源码)。
#[serde(default)]
pub project_bundle_bytes: u64,
/// 工程源包整包 SHA-256;未上传为 `None`。**对象键不下发**,只在服务端使用。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_bundle_sha256: Option<String>,
pub created_at: String,
pub updated_at: String,
}
@@ -203,6 +203,12 @@ pub struct GameDistributionVersionRecord {
pub updated_at: String,
/// 创建版本时冻结的资料快照;历史版本可能为空,读取方必须容忍。
pub metadata_json: Option<String>,
/// 工程源包对象键;未上传为 `None`(只在服务端使用,不下发给客户端)。
pub project_bundle_object_key: Option<String>,
/// 工程源包字节数;`0` 表示未上传。
pub project_bundle_bytes: u64,
/// 工程源包整包 SHA-256;未上传为 `None`。
pub project_bundle_sha256: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -378,6 +384,10 @@ fn map_version(
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
metadata_json: value.metadata_json,
project_bundle_object_key: value.project_bundle_object_key,
project_bundle_bytes: value.project_bundle_bytes,
// 生成绑定把 `project_bundle_sha256` 折成 `project_bundle_sha_256`(同 `package_sha_256`)。
project_bundle_sha256: value.project_bundle_sha_256,
}
}
@@ -143,6 +143,19 @@ pub struct GameDistributionConfirmPackageRecordInput {
pub updated_at_micros: i64,
}
/// 作者确认某版本已上传的工程源包(可选资产);对象键由 api-server 派生后传入。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionConfirmProjectBundleRecordInput {
pub version_id: String,
pub owner_user_id: String,
pub project_bundle_object_key: String,
pub project_bundle_bytes: u64,
pub project_bundle_sha256: String,
pub idempotency_key: String,
pub request_digest: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionFailUploadRecordInput {
pub version_id: String,
@@ -958,6 +971,49 @@ impl SpacetimeClient {
.await
}
/// 确认某版本已上传的工程源包(可选资产)。
///
/// 与 `confirm_game_distribution_package` 分开:工程源包不驱动版本状态机,摘要与字节数随请求
/// 带上(没有创建版本时预登记的包摘要可比),阶段门由事务按「尚未公开」判定。
pub async fn confirm_game_distribution_project_bundle(
&self,
input: GameDistributionConfirmProjectBundleRecordInput,
) -> Result<(GameDistributionVersionRecord, bool), SpacetimeClientError> {
let input = crate::module_bindings::GameDistributionConfirmProjectBundleInput {
version_id: input.version_id,
owner_user_id: input.owner_user_id,
project_bundle_object_key: input.project_bundle_object_key,
project_bundle_bytes: input.project_bundle_bytes,
// 生成绑定把 `project_bundle_sha256` 折成 `project_bundle_sha_256`。
project_bundle_sha_256: input.project_bundle_sha256,
idempotency_key: input.idempotency_key,
request_digest: input.request_digest,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(
"confirm_game_distribution_project_bundle",
move |connection, sender| {
connection
.procedures()
.confirm_game_distribution_project_bundle_and_return_then(
input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_game_result)
.and_then(|(_, version, replayed)| {
version.map(|version| (version, replayed)).ok_or_else(|| {
SpacetimeClientError::missing_snapshot("工程源包确认结果")
})
});
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn fail_game_distribution_upload(
&self,
input: GameDistributionFailUploadRecordInput,
@@ -159,6 +159,7 @@ pub mod complete_profile_recharge_order_expiration_schedule_and_return_procedure
pub mod confirm_asset_object_and_return_procedure;
pub mod confirm_asset_object_reducer;
pub mod confirm_game_distribution_package_and_return_procedure;
pub mod confirm_game_distribution_project_bundle_and_return_procedure;
pub mod consume_profile_wallet_points_and_return_procedure;
pub mod create_admin_account_and_return_procedure;
pub mod create_ai_task_and_return_procedure;
@@ -418,6 +419,7 @@ pub mod game_distribution_admin_version_snapshot_type;
pub mod game_distribution_approve_input_type;
pub mod game_distribution_cancel_version_input_type;
pub mod game_distribution_confirm_package_input_type;
pub mod game_distribution_confirm_project_bundle_input_type;
pub mod game_distribution_create_game_input_type;
pub mod game_distribution_create_version_input_type;
pub mod game_distribution_delete_game_input_type;
@@ -1035,6 +1037,7 @@ pub use complete_profile_recharge_order_expiration_schedule_and_return_procedure
pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return;
pub use confirm_asset_object_reducer::confirm_asset_object;
pub use confirm_game_distribution_package_and_return_procedure::confirm_game_distribution_package_and_return;
pub use confirm_game_distribution_project_bundle_and_return_procedure::confirm_game_distribution_project_bundle_and_return;
pub use consume_profile_wallet_points_and_return_procedure::consume_profile_wallet_points_and_return;
pub use create_admin_account_and_return_procedure::create_admin_account_and_return;
pub use create_ai_task_and_return_procedure::create_ai_task_and_return;
@@ -1294,6 +1297,7 @@ pub use game_distribution_admin_version_snapshot_type::GameDistributionAdminVers
pub use game_distribution_approve_input_type::GameDistributionApproveInput;
pub use game_distribution_cancel_version_input_type::GameDistributionCancelVersionInput;
pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPackageInput;
pub use game_distribution_confirm_project_bundle_input_type::GameDistributionConfirmProjectBundleInput;
pub use game_distribution_create_game_input_type::GameDistributionCreateGameInput;
pub use game_distribution_create_version_input_type::GameDistributionCreateVersionInput;
pub use game_distribution_delete_game_input_type::GameDistributionDeleteGameInput;
@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_confirm_project_bundle_input_type::GameDistributionConfirmProjectBundleInput;
use super::game_distribution_procedure_result_type::GameDistributionProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ConfirmGameDistributionProjectBundleAndReturnArgs {
pub input: GameDistributionConfirmProjectBundleInput,
}
impl __sdk::InModule for ConfirmGameDistributionProjectBundleAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `confirm_game_distribution_project_bundle_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait confirm_game_distribution_project_bundle_and_return {
fn confirm_game_distribution_project_bundle_and_return(
&self,
input: GameDistributionConfirmProjectBundleInput,
) {
self.confirm_game_distribution_project_bundle_and_return_then(input, |_, _| {});
}
fn confirm_game_distribution_project_bundle_and_return_then(
&self,
input: GameDistributionConfirmProjectBundleInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl confirm_game_distribution_project_bundle_and_return for super::RemoteProcedures {
fn confirm_game_distribution_project_bundle_and_return_then(
&self,
input: GameDistributionConfirmProjectBundleInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionProcedureResult>(
"confirm_game_distribution_project_bundle_and_return",
ConfirmGameDistributionProjectBundleAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionConfirmProjectBundleInput {
pub version_id: String,
pub owner_user_id: String,
pub project_bundle_object_key: String,
pub project_bundle_bytes: u64,
pub project_bundle_sha_256: String,
pub idempotency_key: String,
pub request_digest: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for GameDistributionConfirmProjectBundleInput {
type Module = super::RemoteModule;
}
@@ -14,6 +14,8 @@ pub struct GameDistributionForkSourceResult {
pub version_id: Option<String>,
pub package_sha_256: Option<String>,
pub package_bytes: Option<u64>,
pub project_bundle_sha_256: Option<String>,
pub project_bundle_bytes: u64,
pub error_message: Option<String>,
}
@@ -22,6 +22,9 @@ pub struct GameDistributionVersionSnapshot {
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub metadata_json: Option<String>,
pub project_bundle_object_key: Option<String>,
pub project_bundle_bytes: u64,
pub project_bundle_sha_256: Option<String>,
}
impl __sdk::InModule for GameDistributionVersionSnapshot {
@@ -32,6 +32,9 @@ pub struct GameDistributionVersion {
pub last_error_code: Option<String>,
pub last_error_message: Option<String>,
pub metadata_json: Option<String>,
pub project_bundle_object_key: Option<String>,
pub project_bundle_bytes: u64,
pub project_bundle_sha_256: Option<String>,
}
impl __sdk::InModule for GameDistributionVersion {
@@ -69,6 +72,11 @@ pub struct GameDistributionVersionCols {
pub last_error_code: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
pub last_error_message: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
pub metadata_json: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
pub project_bundle_object_key:
__sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
pub project_bundle_bytes: __sdk::__query_builder::Col<GameDistributionVersion, u64>,
pub project_bundle_sha_256:
__sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
}
impl __sdk::__query_builder::HasCols for GameDistributionVersion {
@@ -106,6 +114,18 @@ impl __sdk::__query_builder::HasCols for GameDistributionVersion {
last_error_code: __sdk::__query_builder::Col::new(table_name, "last_error_code"),
last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"),
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
project_bundle_object_key: __sdk::__query_builder::Col::new(
table_name,
"project_bundle_object_key",
),
project_bundle_bytes: __sdk::__query_builder::Col::new(
table_name,
"project_bundle_bytes",
),
project_bundle_sha_256: __sdk::__query_builder::Col::new(
table_name,
"project_bundle_sha_256",
),
}
}
}
@@ -861,6 +861,19 @@ pub struct GameDistributionVersion {
/// 该版本冻结的作者资料快照(JSON,含封面与截图素材键)。审核通过时整体生效。
#[default(None::<String>)]
pub(crate) metadata_json: Option<String>,
/// 作者**可选**上传的工程源包(可编辑源码工程)对象键。三处同时为空表示该版本没有工程源包,
/// 作品在该版本上退化为「产物级改编」(只能试玩与复用素材)。
///
/// 「上传与否」就是作者唯一的开关:平台不设独立的「源码可见性」授权位——作品授权非
/// `forbidden` 时,工程源包与成品包走同一道鉴权与校验被他人取走;作者不想给源码,就不上传。
#[default(None::<String>)]
pub(crate) project_bundle_object_key: Option<String>,
/// 工程源包字节数;`0` 表示未上传(读侧只看它就能判断是否有工程源包)。
#[default(0)]
pub(crate) project_bundle_bytes: u64,
/// 工程源包整包 SHA-256;未上传为 `None`。与字节数成对写入,缺一即视为不可用。
#[default(None::<String>)]
pub(crate) project_bundle_sha256: Option<String>,
}
/// 创建、提交、审核、撤回和下架等写操作的幂等收据。
@@ -932,6 +945,8 @@ const GAME_DISTRIBUTION_ACTION_RESTORE: &str = "restore";
const GAME_DISTRIBUTION_ACTION_UPDATE_METADATA: &str = "update_metadata";
const GAME_DISTRIBUTION_ACTION_DELETE: &str = "delete";
const GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION: &str = "set_fork_authorization";
/// 作者确认某版本的工程源包(可选资产);与发行包确认分开,互不影响状态机。
const GAME_DISTRIBUTION_ACTION_UPLOAD_PROJECT_BUNDLE: &str = "upload_project_bundle";
/// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。
const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20;
/// 后台游戏管理页一次最多回传多少个游戏。
@@ -1023,6 +1038,24 @@ pub struct GameDistributionConfirmPackageInput {
pub updated_at_micros: i64,
}
/// 作者确认某版本已上传的工程源包。
///
/// 与发行包确认的关键差别:工程源包是**可选**资产,不参与版本状态机,因此对象键由
/// api-server 从(作品、版本)派生后传入,摘要与字节数也随请求带上(没有「创建版本时预登记
/// 的包摘要」可比)。事务只负责:owner 校验、阶段门(尚未公开)、同内容幂等 / 换内容冲突、
/// 写三个字段与幂等收据。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionConfirmProjectBundleInput {
pub version_id: String,
pub owner_user_id: String,
pub project_bundle_object_key: String,
pub project_bundle_bytes: u64,
pub project_bundle_sha256: String,
pub idempotency_key: String,
pub request_digest: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionFailUploadInput {
pub version_id: String,
@@ -1263,12 +1296,19 @@ pub struct GameDistributionVersionSnapshot {
pub package_entry_path: String,
pub status: String,
pub review_reason: Option<String>,
pub entry_url: Option<String>,
pub publication_revision: u64,
pub created_at_micros: i64,
pub updated_at_micros: i64,
/// 该版本冻结的资料快照 JSON;旧版本为 None。
pub metadata_json: Option<String>,
/// 工程源包对象键;未上传为 `None`(api-server 用它判断是否走源码级取件)。
pub project_bundle_object_key: Option<String>,
/// 工程源包字节数;`0` 表示未上传。
pub project_bundle_bytes: u64,
/// 工程源包整包 SHA-256;未上传为 `None`。
pub project_bundle_sha256: Option<String>,
}
/// 后台游戏管理页的版本历史条目:补上审核与公开时间,供运营判断下架与恢复影响。
@@ -1411,6 +1451,10 @@ pub struct GameDistributionForkSourceSnapshot {
/// 当前公开版本的发行包摘要;取自版本行,不重新计算。
pub package_sha256: Option<String>,
pub package_bytes: Option<u64>,
/// 当前公开版本的**工程源包**摘要;未上传时为 None / 0,api-server 据此把取件来源切成
/// `project`(优先)或回落 `package`。字节数与摘要成对,缺一即视为没有工程源包。
pub project_bundle_sha256: Option<String>,
pub project_bundle_bytes: u64,
}
/// Fork 取件读取结果。
@@ -1429,6 +1473,10 @@ pub struct GameDistributionForkSourceResult {
pub version_id: Option<String>,
pub package_sha256: Option<String>,
pub package_bytes: Option<u64>,
/// 当前公开版本的工程源包摘要;未上传时为 None / 0。api-server 据此把 `source` 切成
/// `project`(有工程包时优先)或 `package`。
pub project_bundle_sha256: Option<String>,
pub project_bundle_bytes: u64,
pub error_message: Option<String>,
}
@@ -1443,6 +1491,8 @@ impl GameDistributionForkSourceResult {
version_id: None,
package_sha256: None,
package_bytes: None,
project_bundle_sha256: None,
project_bundle_bytes: 0,
error_message: Some(error),
}
}
@@ -1665,6 +1715,22 @@ pub fn confirm_game_distribution_package_and_return(
}
}
/// 确认某版本已上传的工程源包(可选资产)。procedure 不读取、不执行上传内容,只登记事实。
#[spacetimedb::procedure]
pub fn confirm_game_distribution_project_bundle_and_return(
ctx: &mut ProcedureContext,
input: GameDistributionConfirmProjectBundleInput,
) -> GameDistributionProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_editor_generation_runtime_service_identity(tx, caller)?;
confirm_game_distribution_project_bundle_tx(tx, input.clone())
}) {
Ok((version, replayed)) => game_distribution_version_result(version, replayed),
Err(error) => game_distribution_result_error(error),
}
}
/// 记录上传或包校验失败,供客户端在原版本上恢复重试;不改变版本的冻结摘要。
#[spacetimedb::procedure]
pub fn fail_game_distribution_upload_and_return(
@@ -2095,6 +2161,8 @@ pub fn get_game_distribution_fork_source_and_return(
version_id: source.version_id,
package_sha256: source.package_sha256,
package_bytes: source.package_bytes,
project_bundle_sha256: source.project_bundle_sha256,
project_bundle_bytes: source.project_bundle_bytes,
error_message: None,
},
Err(error) => GameDistributionForkSourceResult::failed(error),
@@ -2558,6 +2626,10 @@ fn create_game_distribution_version_tx(
last_error_code: None,
last_error_message: None,
metadata_json: Some(metadata_json),
// 工程源包是可选资产:新建版本时一律没有,作者随后单独上传并确认。
project_bundle_object_key: None,
project_bundle_bytes: 0,
project_bundle_sha256: None,
});
insert_game_distribution_receipt(
ctx,
@@ -2694,6 +2766,116 @@ fn confirm_game_distribution_package_tx(
))
}
/// 确认某版本已上传的工程源包。
///
/// 与 `confirm_game_distribution_package_tx` 的差别:这是**可选**资产,不驱动版本状态机,
/// 因此摘要与字节数由请求带上(没有创建版本时预登记的包摘要可比),阶段门只看「尚未公开」。
/// 业务口径(产品已拍板):平台**不做**独立的「源码可见性」开关——作者上传即表示作品授权非
/// `forbidden` 时他人可取走源码;不想给源码就不上传,作品自然退化为产物级改编。
fn confirm_game_distribution_project_bundle_tx(
ctx: &ReducerContext,
input: GameDistributionConfirmProjectBundleInput,
) -> Result<(GameDistributionVersionSnapshot, bool), String> {
let owner_user_id = required_game_distribution_text(input.owner_user_id, "owner_user_id")?;
let version_id = required_game_distribution_text(input.version_id, "version_id")?;
let project_bundle_object_key = required_game_distribution_text(
input.project_bundle_object_key,
"project_bundle_object_key",
)?;
let project_bundle_sha256 =
required_game_distribution_text(input.project_bundle_sha256, "project_bundle_sha256")?;
if input.project_bundle_bytes == 0 {
return Err("工程源包字节数必须大于 0".to_string());
}
let idempotency_key =
required_game_distribution_text(input.idempotency_key, "idempotency_key")?;
let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?;
let receipt_id = game_distribution_receipt_id(
owner_user_id.as_str(),
GAME_DISTRIBUTION_ACTION_UPLOAD_PROJECT_BUNDLE,
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 version = ctx
.db
.game_distribution_version()
.version_id()
.find(&version_id)
.ok_or_else(|| "幂等收据对应的版本已不存在".to_string())?;
if version.owner_user_id != owner_user_id {
return Err("版本 owner 不匹配".to_string());
}
return Ok((
game_distribution_version_snapshot_for_game(ctx, &version),
true,
));
}
let mut version = ctx
.db
.game_distribution_version()
.version_id()
.find(&version_id)
.ok_or_else(|| "版本不存在".to_string())?;
if version.owner_user_id != owner_user_id {
return Err("版本 owner 不匹配".to_string());
}
// 已确认过工程源包:同内容按幂等重放,换内容按冲突拒绝(工程源包与发行包一样不可静默替换)。
if version.project_bundle_object_key.is_some() || version.project_bundle_bytes > 0 {
let same_as_before = version.project_bundle_object_key.as_deref()
== Some(project_bundle_object_key.as_str())
&& version.project_bundle_sha256.as_deref() == Some(project_bundle_sha256.as_str())
&& version.project_bundle_bytes == input.project_bundle_bytes;
if same_as_before {
return Ok((
game_distribution_version_snapshot_for_game(ctx, &version),
true,
));
}
return Err("同一版本已存在不同的工程源包".to_string());
}
// 阶段门与发行包确认同一口径:只有还没确认过发行包的版本能写。已公开、已撤回或已取消的
// 版本一律拒绝(版本不可变:公开后的内容不接受替换;历史版本也不做回溯补齐)。
if version.status != GAME_DISTRIBUTION_VERSION_AWAITING_UPLOAD
&& version.status != "upload_failed"
{
return Err(format!("版本状态 {} 不允许上传工程源包", version.status));
}
let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
version.project_bundle_object_key = Some(project_bundle_object_key);
version.project_bundle_bytes = input.project_bundle_bytes;
version.project_bundle_sha256 = Some(project_bundle_sha256);
// 不含 `uploaded_at`(那是发行包的字段),但行确实变了,`updated_at` 照常推进。
version.updated_at = now;
ctx.db
.game_distribution_version()
.version_id()
.update(version.clone());
insert_game_distribution_receipt(
ctx,
GameDistributionReceiptInput {
receipt_id,
owner_user_id,
action: GAME_DISTRIBUTION_ACTION_UPLOAD_PROJECT_BUNDLE.to_string(),
idempotency_key,
request_digest,
game_id: Some(version.game_id.clone()),
version_id: Some(version.version_id.clone()),
outcome_kind: "version".to_string(),
outcome_game_id: None,
outcome_version_id: Some(version.version_id.clone()),
outcome_json: None,
now_micros: input.updated_at_micros,
},
)?;
Ok((
game_distribution_version_snapshot_for_game(ctx, &version),
false,
))
}
fn fail_game_distribution_upload_tx(
ctx: &ReducerContext,
input: GameDistributionFailUploadInput,
@@ -4228,6 +4410,8 @@ fn get_game_distribution_fork_source_tx(
version_id: None,
package_sha256: None,
package_bytes: None,
project_bundle_sha256: None,
project_bundle_bytes: 0,
});
};
// 取件必须有「当前公开版本」,否则没有可下发的内容。
@@ -4244,7 +4428,16 @@ fn get_game_distribution_fork_source_tx(
package_sha256: version
.as_ref()
.map(|version| version.package_sha256.clone()),
package_bytes: version.map(|version| version.package_bytes),
package_bytes: version.as_ref().map(|version| version.package_bytes),
// 工程源包:字节数与摘要成对才有意义(缺一视为没有工程包,取件回落到成品包)。
project_bundle_sha256: version
.as_ref()
.filter(|version| version.project_bundle_bytes > 0)
.and_then(|version| version.project_bundle_sha256.clone()),
project_bundle_bytes: version
.as_ref()
.map(|version| version.project_bundle_bytes)
.unwrap_or_default(),
})
}
@@ -4644,6 +4837,9 @@ fn game_distribution_version_snapshot(
created_at_micros: version.created_at.to_micros_since_unix_epoch(),
updated_at_micros: version.updated_at.to_micros_since_unix_epoch(),
metadata_json: version.metadata_json.clone(),
project_bundle_object_key: version.project_bundle_object_key.clone(),
project_bundle_bytes: version.project_bundle_bytes,
project_bundle_sha256: version.project_bundle_sha256.clone(),
}
}