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:
@@ -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;
|
||||
|
||||
+62
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+2
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+20
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user