From 604ce7f3ec6968e67d4a86d1889c2b11c321f5a2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:24:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20M?= =?UTF-8?q?2b=20=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=A1=8C=E4=B8=89=E5=88=97=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E6=BA=90=E5=8C=85=20+=20=E5=A5=91=E7=BA=A6=E4=B8=8E=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=EF=BC=88=E5=9D=97=20A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `server-rs/crates/spacetime-module/src/game_distribution.rs`:`game_distribution_version` 表尾追加三列(明确默认值,遵循既有追加规则)——`project_bundle_object_key: Option`(`#[default(None)]`)、`project_bundle_bytes: u64`(`#[default(0)]` = 未上传)、`project_bundle_sha256: Option`(`#[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 --- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + .../shared/src/contracts/gameDistribution.ts | 4 + .../src/modules/game_distribution.rs | 13 ++ .../shared-contracts/src/game_distribution.rs | 6 + .../src/active/mapper/game_distribution.rs | 10 + .../spacetime-client/src/game_distribution.rs | 56 +++++ .../spacetime-client/src/module_bindings.rs | 4 + ...ion_project_bundle_and_return_procedure.rs | 62 ++++++ ...ution_confirm_project_bundle_input_type.rs | 22 ++ ...me_distribution_fork_source_result_type.rs | 2 + ...game_distribution_version_snapshot_type.rs | 3 + .../game_distribution_version_type.rs | 20 ++ .../spacetime-module/src/game_distribution.rs | 198 +++++++++++++++++- 13 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/confirm_game_distribution_project_bundle_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_confirm_project_bundle_input_type.rs diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 84c7cc365..16e31d2db 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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`、`project_bundle_bytes: u64`(`0` = 未上传)、`project_bundle_sha256: Option`,保存作者**可选**上传的工程源码包(与发行包并列的第二份私有资产)。三列由 `confirm_game_distribution_project_bundle_and_return` 一次性写入(`upload_project_bundle` 幂等动作),只在版本尚未公开(`awaiting_upload` / `upload_failed`)时接受;同一版本已确认工程包后换内容按冲突拒绝,同内容按幂等重放。作品授权非 `forbidden` 时,工程包与发行包走同一道取件鉴权与校验,**平台不设独立的「源码可见性」开关**——作者不传即退化产物级改编。对象键只在服务端使用(投影里只回 `projectBundleBytes` / `projectBundleSha256`),且键名与发行包不同(`.project.zip`),避免同 (作品, 版本) 的两份资产互相覆盖或串缓存。 ### 后台游戏管理读模型与恢复动作(2026-09-23) diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index e30e722c0..3aa521519 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -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; }; diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 58e03ce1d..fcda94723 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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); diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 64803eb9d..4885c2b06 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -439,6 +439,12 @@ pub struct GameDistributionPrivateVersion { pub review_reason: Option, #[serde(default)] pub entry_url: Option, + /// 工程源包字节数;`0` 表示该版本未上传工程源包(作者上架时选择不给源码)。 + #[serde(default)] + pub project_bundle_bytes: u64, + /// 工程源包整包 SHA-256;未上传为 `None`。**对象键不下发**,只在服务端使用。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_bundle_sha256: Option, pub created_at: String, pub updated_at: String, } diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index ddd899736..bd08c5da3 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -203,6 +203,12 @@ pub struct GameDistributionVersionRecord { pub updated_at: String, /// 创建版本时冻结的资料快照;历史版本可能为空,读取方必须容忍。 pub metadata_json: Option, + /// 工程源包对象键;未上传为 `None`(只在服务端使用,不下发给客户端)。 + pub project_bundle_object_key: Option, + /// 工程源包字节数;`0` 表示未上传。 + pub project_bundle_bytes: u64, + /// 工程源包整包 SHA-256;未上传为 `None`。 + pub project_bundle_sha256: Option, } #[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, } } diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 907317de5..31ee718d9 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 455c70850..a872260f4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/confirm_game_distribution_project_bundle_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/confirm_game_distribution_project_bundle_and_return_procedure.rs new file mode 100644 index 000000000..0c1ce5bbd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/confirm_game_distribution_project_bundle_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "confirm_game_distribution_project_bundle_and_return", + ConfirmGameDistributionProjectBundleAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_confirm_project_bundle_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_confirm_project_bundle_input_type.rs new file mode 100644 index 000000000..4b3a8d378 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_confirm_project_bundle_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs index 7d4f39408..d65126e5b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs @@ -14,6 +14,8 @@ pub struct GameDistributionForkSourceResult { pub version_id: Option, pub package_sha_256: Option, pub package_bytes: Option, + pub project_bundle_sha_256: Option, + pub project_bundle_bytes: u64, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs index 7acae2d7c..222cc7cc7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs @@ -22,6 +22,9 @@ pub struct GameDistributionVersionSnapshot { pub created_at_micros: i64, pub updated_at_micros: i64, pub metadata_json: Option, + pub project_bundle_object_key: Option, + pub project_bundle_bytes: u64, + pub project_bundle_sha_256: Option, } impl __sdk::InModule for GameDistributionVersionSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs index 05d3c3cdb..7fbe9a32d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs @@ -32,6 +32,9 @@ pub struct GameDistributionVersion { pub last_error_code: Option, pub last_error_message: Option, pub metadata_json: Option, + pub project_bundle_object_key: Option, + pub project_bundle_bytes: u64, + pub project_bundle_sha_256: Option, } impl __sdk::InModule for GameDistributionVersion { @@ -69,6 +72,11 @@ pub struct GameDistributionVersionCols { pub last_error_code: __sdk::__query_builder::Col>, pub last_error_message: __sdk::__query_builder::Col>, pub metadata_json: __sdk::__query_builder::Col>, + pub project_bundle_object_key: + __sdk::__query_builder::Col>, + pub project_bundle_bytes: __sdk::__query_builder::Col, + pub project_bundle_sha_256: + __sdk::__query_builder::Col>, } 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", + ), } } } diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index aac2a8182..712daa9e6 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -861,6 +861,19 @@ pub struct GameDistributionVersion { /// 该版本冻结的作者资料快照(JSON,含封面与截图素材键)。审核通过时整体生效。 #[default(None::)] pub(crate) metadata_json: Option, + /// 作者**可选**上传的工程源包(可编辑源码工程)对象键。三处同时为空表示该版本没有工程源包, + /// 作品在该版本上退化为「产物级改编」(只能试玩与复用素材)。 + /// + /// 「上传与否」就是作者唯一的开关:平台不设独立的「源码可见性」授权位——作品授权非 + /// `forbidden` 时,工程源包与成品包走同一道鉴权与校验被他人取走;作者不想给源码,就不上传。 + #[default(None::)] + pub(crate) project_bundle_object_key: Option, + /// 工程源包字节数;`0` 表示未上传(读侧只看它就能判断是否有工程源包)。 + #[default(0)] + pub(crate) project_bundle_bytes: u64, + /// 工程源包整包 SHA-256;未上传为 `None`。与字节数成对写入,缺一即视为不可用。 + #[default(None::)] + pub(crate) project_bundle_sha256: Option, } /// 创建、提交、审核、撤回和下架等写操作的幂等收据。 @@ -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, + pub entry_url: Option, pub publication_revision: u64, pub created_at_micros: i64, pub updated_at_micros: i64, /// 该版本冻结的资料快照 JSON;旧版本为 None。 pub metadata_json: Option, + /// 工程源包对象键;未上传为 `None`(api-server 用它判断是否走源码级取件)。 + pub project_bundle_object_key: Option, + /// 工程源包字节数;`0` 表示未上传。 + pub project_bundle_bytes: u64, + /// 工程源包整包 SHA-256;未上传为 `None`。 + pub project_bundle_sha256: Option, } /// 后台游戏管理页的版本历史条目:补上审核与公开时间,供运营判断下架与恢复影响。 @@ -1411,6 +1451,10 @@ pub struct GameDistributionForkSourceSnapshot { /// 当前公开版本的发行包摘要;取自版本行,不重新计算。 pub package_sha256: Option, pub package_bytes: Option, + /// 当前公开版本的**工程源包**摘要;未上传时为 None / 0,api-server 据此把取件来源切成 + /// `project`(优先)或回落 `package`。字节数与摘要成对,缺一即视为没有工程源包。 + pub project_bundle_sha256: Option, + pub project_bundle_bytes: u64, } /// Fork 取件读取结果。 @@ -1429,6 +1473,10 @@ pub struct GameDistributionForkSourceResult { pub version_id: Option, pub package_sha256: Option, pub package_bytes: Option, + /// 当前公开版本的工程源包摘要;未上传时为 None / 0。api-server 据此把 `source` 切成 + /// `project`(有工程包时优先)或 `package`。 + pub project_bundle_sha256: Option, + pub project_bundle_bytes: u64, pub error_message: Option, } @@ -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(), } }