diff --git a/server-rs/crates/module-game-distribution/src/collection.rs b/server-rs/crates/module-game-distribution/src/collection.rs new file mode 100644 index 000000000..e52b46367 --- /dev/null +++ b/server-rs/crates/module-game-distribution/src/collection.rs @@ -0,0 +1,82 @@ +//! 收藏(收录)的纯函数规则。 +//! +//! 表与事务在 `spacetime-module::game_distribution`;这里只放能被不起 SpacetimeDB 的单测钉住的 +//! 两条规则:确定性主键的构造,以及「收藏行是否出现在列表投影里」的可见性判定。 + +/// 收藏主键:`{user_id}:{game_id}`。 +/// +/// 与 `profile_save_archive.archive_id`(`spacetime-module` 的 `runtime/profile.rs`)同一写法: +/// 平台签发的两个标识用冒号拼成确定性 ID。`user_id` 由平台签发(形如 `usr_*` / 微信侧标识), +/// `game_id` 由本领域生成(`game-*`),两者都不含 `:`,因此组合是单射。 +/// +/// 用确定性主键而不是随机 ID,是为了让**去重由结构保证,而不是由流程保证**:同一 +/// (用户, 作品) 只可能有一行,这一点由主键约束本身成立。相对地,「事务里先查后写」只在 +/// 单写者假设下成立——将来分片或并发写同一对 (用户, 作品) 时,两个事务都可能先查到 +/// 「不存在」,然后各写一行。也正因为主键已经是那条约束,这张表**不需要**再建 +/// `(user_id, game_id)` 唯一索引。 +pub fn game_distribution_collection_id(user_id: &str, game_id: &str) -> String { + format!("{user_id}:{game_id}") +} + +/// 收藏行是否应出现在「我的收藏(收录)」列表里。 +/// +/// 只看作品**当前**是否公开可读:未软删除、可见性为 `published`、且存在当前公开版本 +/// (`has_public_version` 由调用方按 `public_game_distribution_version` 命中给出)。 +/// +/// 判定只在**投影时**发生,收藏行本身不因此被删除:作品下架后该行只是不再出现在响应里, +/// 作品重新公开后同一行会再次出现。把它做成纯函数,就能用「同一行、不同可见性」直接钉住 +/// 「已收藏 → 下架 → 列表不含 → 重新公开 → 列表又含」这条链路,不必起真库。 +pub fn game_distribution_collection_visible( + is_deleted: bool, + is_published: bool, + has_public_version: bool, +) -> bool { + !is_deleted && is_published && has_public_version +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collection_id_is_deterministic_and_pair_specific() { + assert_eq!( + game_distribution_collection_id("usr_1", "game_a"), + "usr_1:game_a" + ); + // 同一对组合永远得到同一个键(幂等重放与「重复收藏只留一行」都依赖这一点)。 + assert_eq!( + game_distribution_collection_id("usr_1", "game_a"), + game_distribution_collection_id("usr_1", "game_a") + ); + // 换用户或换作品必须换键,否则不同用户/作品的收藏会互相覆盖。 + assert_ne!( + game_distribution_collection_id("usr_1", "game_a"), + game_distribution_collection_id("usr_2", "game_a") + ); + assert_ne!( + game_distribution_collection_id("usr_1", "game_a"), + game_distribution_collection_id("usr_1", "game_b") + ); + } + + /// 「已收藏 → 作品下架 → 列表不含 → 重新公开 → 列表又含」。 + /// + /// 收藏行在整条链路里都存在;变化的只是投影判定,因此同一行在两次判定间翻转, + /// 不需要删除再重建行。 + #[test] + fn collection_row_survives_unpublish_and_returns_after_republish() { + // 已收藏且作品公开可读:出现在列表里。 + assert!(game_distribution_collection_visible(false, true, true)); + // 作者下架:行还在,但投影时被过滤。 + assert!(!game_distribution_collection_visible(false, false, true)); + // 管理员暂停 / 幽灵可见性同样不算公开可读。 + assert!(!game_distribution_collection_visible(false, false, true)); + // 软删除:作品不再公开可读。 + assert!(!game_distribution_collection_visible(true, true, true)); + // 可见性仍是 published 但没有当前公开版本(例如版本被撤回)也按不可读处理。 + assert!(!game_distribution_collection_visible(false, true, false)); + // 重新公开:同一行再次可见。 + assert!(game_distribution_collection_visible(false, true, true)); + } +} diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 60e4918ad..dc9dde519 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -1,4 +1,5 @@ mod application; +mod collection; mod commands; mod domain; mod errors; @@ -20,6 +21,7 @@ pub use application::{ GameDistributionService, GameOperationResult, InMemoryGameDistributionStore, PublicationOperationResult, }; +pub use collection::{game_distribution_collection_id, game_distribution_collection_visible}; pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision}; pub use domain::{ FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL, diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index eaf30d46f..6350262ec 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -25,15 +25,16 @@ pub mod game_distribution; pub use game_distribution::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminUserReviewListRecordInput, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, - GameDistributionConfirmPackageRecordInput, GameDistributionConfirmProjectBundleRecordInput, - GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput, - GameDistributionDeleteGameRecordInput, GameDistributionFailUploadRecordInput, - GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, - GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput, - GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, - GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, - GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, + GameDistributionCollectGameRecordInput, GameDistributionConfirmPackageRecordInput, + GameDistributionConfirmProjectBundleRecordInput, GameDistributionCreateGameRecordInput, + GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput, + GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, + GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, + GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, + GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, + GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionUncollectGameRecordInput, GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, }; #[path = "active/external_generation.rs"] diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 055a763c1..a50547920 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -98,9 +98,9 @@ pub use self::external_generation::{ pub use self::game_distribution::{ GameDistributionAdminGameRecord, GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord, GameDistributionAdminUserReviewRecord, - GameDistributionAdminVersionRecord, GameDistributionDerivedGamesRecord, - GameDistributionForkSourceRecord, GameDistributionGameRecord, - GameDistributionLineageNodeRecord, GameDistributionLineageRecord, + GameDistributionAdminVersionRecord, GameDistributionCollectionMutationRecord, + GameDistributionDerivedGamesRecord, GameDistributionForkSourceRecord, + GameDistributionGameRecord, GameDistributionLineageNodeRecord, GameDistributionLineageRecord, GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord, @@ -171,7 +171,9 @@ pub(crate) use self::external_generation::{ }; pub(crate) use self::game_distribution::{ ensure_admin_user_review_result, ensure_user_review_result, map_admin_user_review, - map_game_distribution_admin_game_list_result, map_game_distribution_derived_games_result, + map_game_distribution_admin_game_list_result, map_game_distribution_collection_list_result, + map_game_distribution_collection_mutation_result, + map_game_distribution_collection_state_result, map_game_distribution_derived_games_result, map_game_distribution_fork_source_result, map_game_distribution_game_result, map_game_distribution_lineage_result, map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result, map_game_distribution_public_game_result, 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 0f5773819..1232374d0 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 @@ -341,6 +341,16 @@ pub struct GameDistributionForkSourceRecord { pub project_bundle_bytes: u64, } +/// 收藏(收录)写入 / 取消后的权威投影值。 +/// +/// `collected` 是**调用后**的收藏事实(不是前端本地状态);`replayed` 只在带幂等键的「收藏」 +/// 上有意义(取消按确定性主键删除,天然幂等,恒为 `false`)。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionCollectionMutationRecord { + pub collected: bool, + pub replayed: bool, +} + fn map_game( value: crate::module_bindings::GameDistributionGameSnapshot, ) -> GameDistributionGameRecord { @@ -533,6 +543,39 @@ pub(crate) fn map_game_distribution_fork_source_result( }) } +/// 收藏 / 取消收藏结果:`ok == false` 是服务端失败(走既有错误映射,含 404 / 409)。 +pub(crate) fn map_game_distribution_collection_mutation_result( + result: crate::module_bindings::GameDistributionCollectionMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(GameDistributionCollectionMutationRecord { + collected: result.collected, + replayed: result.replayed, + }) +} + +/// 公开详情的收藏态:只回传布尔,`ok == false` 走既有错误映射。 +pub(crate) fn map_game_distribution_collection_state_result( + result: crate::module_bindings::GameDistributionCollectionStateResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.collected) +} + +/// 「我的收藏(收录)」列表:逐条用公开目录同一份公开投影映射,形状因此与公开目录一致。 +pub(crate) fn map_game_distribution_collection_list_result( + result: crate::module_bindings::GameDistributionCollectionListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.games.into_iter().map(map_public_game).collect()) +} + fn map_public_game( value: crate::module_bindings::GameDistributionPublicGameSnapshot, ) -> GameDistributionPublicGameRecord { diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 380f29b2b..686b0443f 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -276,6 +276,23 @@ pub struct GameDistributionDeleteGameRecordInput { pub now_micros: i64, } +/// 收藏(收录)公开作品;`user_id` 来自 api-server 的认证主体,调用方不能指定他人。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionCollectGameRecordInput { + pub game_id: String, + pub user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +/// 取消收藏(收录)。按确定性主键删除,天然幂等,因此不带幂等键。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionUncollectGameRecordInput { + pub game_id: String, + pub user_id: String, +} + impl SpacetimeClient { pub async fn list_admin_game_distribution_user_reviews( &self, @@ -717,6 +734,105 @@ impl SpacetimeClient { .await } + /// 收藏(收录)某公开作品;命中同键幂等收据时回 `replayed = true`。 + pub async fn set_game_distribution_collection( + &self, + input: GameDistributionCollectGameRecordInput, + ) -> Result { + let input = crate::module_bindings::GameDistributionCollectGameInput { + game_id: input.game_id, + user_id: input.user_id, + idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + now_micros: input.now_micros, + }; + self.call_after_connect( + "set_game_distribution_collection", + move |connection, sender| { + connection + .procedures() + .set_game_distribution_collection_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_collection_mutation_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 取消收藏(收录);按确定性主键删除,重复调用结果相同。 + pub async fn unset_game_distribution_collection( + &self, + input: GameDistributionUncollectGameRecordInput, + ) -> Result { + let input = crate::module_bindings::GameDistributionUncollectGameInput { + game_id: input.game_id, + user_id: input.user_id, + }; + self.call_after_connect( + "unset_game_distribution_collection", + move |connection, sender| { + connection + .procedures() + .unset_game_distribution_collection_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_collection_mutation_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 「我的收藏(收录)」列表;只返回当前公开可读的作品,形状与公开目录一致。 + pub async fn list_game_distribution_collections( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionCollectionListInput { user_id }; + self.call_after_connect( + "list_game_distribution_collections", + move |connection, sender| { + connection + .procedures() + .list_game_distribution_collections_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_collection_list_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 某用户是否已收藏某作品;公开详情用它计算 `collected`。 + pub async fn is_game_distribution_collected( + &self, + game_id: String, + user_id: String, + ) -> Result { + let input = + crate::module_bindings::GameDistributionCollectionStateInput { game_id, user_id }; + self.call_after_connect( + "is_game_distribution_collected", + move |connection, sender| { + connection + .procedures() + .is_game_distribution_collected_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_collection_state_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 pub async fn increment_game_distribution_game_play_counts( &self, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 595df3be8..5381fb118 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -430,6 +430,14 @@ pub mod game_distribution_admin_user_review_snapshot_type; 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_collect_game_input_type; +pub mod game_distribution_collection_list_input_type; +pub mod game_distribution_collection_list_result_type; +pub mod game_distribution_collection_mutation_result_type; +pub mod game_distribution_collection_state_input_type; +pub mod game_distribution_collection_state_result_type; +pub mod game_distribution_collection_table; +pub mod game_distribution_collection_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; @@ -479,6 +487,7 @@ pub mod game_distribution_review_type; pub mod game_distribution_set_fork_authorization_input_type; pub mod game_distribution_submit_review_input_type; pub mod game_distribution_suspend_input_type; +pub mod game_distribution_uncollect_game_input_type; pub mod game_distribution_unpublish_input_type; pub mod game_distribution_update_metadata_input_type; pub mod game_distribution_user_review_input_type; @@ -537,6 +546,7 @@ pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; pub mod increment_game_distribution_game_play_counts_and_return_procedure; pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure; +pub mod is_game_distribution_collected_and_return_procedure; pub mod list_admin_accounts_and_return_procedure; pub mod list_admin_game_distribution_games_and_return_procedure; pub mod list_admin_game_distribution_user_reviews_and_return_procedure; @@ -549,6 +559,7 @@ pub mod list_error_reports_and_return_procedure; pub mod list_external_api_keys_and_return_procedure; pub mod list_external_generation_job_summaries_and_return_procedure; pub mod list_external_generation_jobs_and_return_procedure; +pub mod list_game_distribution_collections_and_return_procedure; pub mod list_game_distribution_derived_games_and_return_procedure; pub mod list_game_distribution_games_and_return_procedure; pub mod list_game_distribution_review_games_and_return_procedure; @@ -863,6 +874,7 @@ pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod save_game_distribution_my_review_and_return_procedure; pub mod seed_analytics_date_dimensions_reducer; pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure; +pub mod set_game_distribution_collection_and_return_procedure; pub mod set_game_distribution_fork_authorization_and_return_procedure; pub mod set_payment_order_provider_code_and_return_procedure; pub mod settle_llm_router_quota_and_return_procedure; @@ -880,6 +892,7 @@ pub mod tracking_event_table; pub mod tracking_event_type; pub mod unfollow_creator_and_return_procedure; pub mod unpublish_game_distribution_game_and_return_procedure; +pub mod unset_game_distribution_collection_and_return_procedure; pub mod update_admin_account_and_return_procedure; pub mod update_editor_asset_and_return_procedure; pub mod update_editor_asset_folder_and_return_procedure; @@ -1327,6 +1340,14 @@ pub use game_distribution_admin_user_review_snapshot_type::GameDistributionAdmin pub use game_distribution_admin_version_snapshot_type::GameDistributionAdminVersionSnapshot; pub use game_distribution_approve_input_type::GameDistributionApproveInput; pub use game_distribution_cancel_version_input_type::GameDistributionCancelVersionInput; +pub use game_distribution_collect_game_input_type::GameDistributionCollectGameInput; +pub use game_distribution_collection_list_input_type::GameDistributionCollectionListInput; +pub use game_distribution_collection_list_result_type::GameDistributionCollectionListResult; +pub use game_distribution_collection_mutation_result_type::GameDistributionCollectionMutationResult; +pub use game_distribution_collection_state_input_type::GameDistributionCollectionStateInput; +pub use game_distribution_collection_state_result_type::GameDistributionCollectionStateResult; +pub use game_distribution_collection_table::*; +pub use game_distribution_collection_type::GameDistributionCollection; 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; @@ -1376,6 +1397,7 @@ pub use game_distribution_review_type::GameDistributionReview; pub use game_distribution_set_fork_authorization_input_type::GameDistributionSetForkAuthorizationInput; pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput; pub use game_distribution_suspend_input_type::GameDistributionSuspendInput; +pub use game_distribution_uncollect_game_input_type::GameDistributionUncollectGameInput; pub use game_distribution_unpublish_input_type::GameDistributionUnpublishInput; pub use game_distribution_update_metadata_input_type::GameDistributionUpdateMetadataInput; pub use game_distribution_user_review_input_type::GameDistributionUserReviewInput; @@ -1434,6 +1456,7 @@ pub use import_database_migration_incremental_from_chunks_procedure::import_data pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; pub use increment_game_distribution_game_play_counts_and_return_procedure::increment_game_distribution_game_play_counts_and_return; pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return; +pub use is_game_distribution_collected_and_return_procedure::is_game_distribution_collected_and_return; pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return; pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return; pub use list_admin_game_distribution_user_reviews_and_return_procedure::list_admin_game_distribution_user_reviews_and_return; @@ -1446,6 +1469,7 @@ pub use list_error_reports_and_return_procedure::list_error_reports_and_return; pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return; pub use list_external_generation_job_summaries_and_return_procedure::list_external_generation_job_summaries_and_return; pub use list_external_generation_jobs_and_return_procedure::list_external_generation_jobs_and_return; +pub use list_game_distribution_collections_and_return_procedure::list_game_distribution_collections_and_return; pub use list_game_distribution_derived_games_and_return_procedure::list_game_distribution_derived_games_and_return; pub use list_game_distribution_games_and_return_procedure::list_game_distribution_games_and_return; pub use list_game_distribution_review_games_and_return_procedure::list_game_distribution_review_games_and_return; @@ -1760,6 +1784,7 @@ pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project pub use save_game_distribution_my_review_and_return_procedure::save_game_distribution_my_review_and_return; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return; +pub use set_game_distribution_collection_and_return_procedure::set_game_distribution_collection_and_return; pub use set_game_distribution_fork_authorization_and_return_procedure::set_game_distribution_fork_authorization_and_return; pub use set_payment_order_provider_code_and_return_procedure::set_payment_order_provider_code_and_return; pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return; @@ -1777,6 +1802,7 @@ pub use tracking_event_table::*; pub use tracking_event_type::TrackingEvent; pub use unfollow_creator_and_return_procedure::unfollow_creator_and_return; pub use unpublish_game_distribution_game_and_return_procedure::unpublish_game_distribution_game_and_return; +pub use unset_game_distribution_collection_and_return_procedure::unset_game_distribution_collection_and_return; pub use update_admin_account_and_return_procedure::update_admin_account_and_return; pub use update_editor_asset_and_return_procedure::update_editor_asset_and_return; pub use update_editor_asset_folder_and_return_procedure::update_editor_asset_folder_and_return; @@ -1952,6 +1978,7 @@ pub struct DbUpdate { external_generation_job_event: __sdk::TableUpdate, external_generation_job_summary: __sdk::TableUpdate, feature_gate_config: __sdk::TableUpdate, + game_distribution_collection: __sdk::TableUpdate, game_distribution_game: __sdk::TableUpdate, game_distribution_idempotency_receipt: __sdk::TableUpdate, game_distribution_lineage: __sdk::TableUpdate, @@ -2168,6 +2195,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "feature_gate_config" => db_update .feature_gate_config .append(feature_gate_config_table::parse_table_update(table_update)?), + "game_distribution_collection" => db_update.game_distribution_collection.append( + game_distribution_collection_table::parse_table_update(table_update)?, + ), "game_distribution_game" => db_update.game_distribution_game.append( game_distribution_game_table::parse_table_update(table_update)?, ), @@ -2587,6 +2617,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.feature_gate_config, ) .with_updates_by_pk(|row| &row.gate_key); + diff.game_distribution_collection = cache + .apply_diff_to_table::( + "game_distribution_collection", + &self.game_distribution_collection, + ) + .with_updates_by_pk(|row| &row.collection_id); diff.game_distribution_game = cache .apply_diff_to_table::( "game_distribution_game", @@ -2990,6 +3026,9 @@ impl __sdk::DbUpdate for DbUpdate { "feature_gate_config" => db_update .feature_gate_config .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_distribution_collection" => db_update + .game_distribution_collection + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_distribution_game" => db_update .game_distribution_game .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3282,6 +3321,9 @@ impl __sdk::DbUpdate for DbUpdate { "feature_gate_config" => db_update .feature_gate_config .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_distribution_collection" => db_update + .game_distribution_collection + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_distribution_game" => db_update .game_distribution_game .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3492,6 +3534,7 @@ pub struct AppliedDiff<'r> { external_generation_job_event: __sdk::TableAppliedDiff<'r, ExternalGenerationJobEvent>, external_generation_job_summary: __sdk::TableAppliedDiff<'r, ExternalGenerationJobSummary>, feature_gate_config: __sdk::TableAppliedDiff<'r, FeatureGateConfig>, + game_distribution_collection: __sdk::TableAppliedDiff<'r, GameDistributionCollection>, game_distribution_game: __sdk::TableAppliedDiff<'r, GameDistributionGame>, game_distribution_idempotency_receipt: __sdk::TableAppliedDiff<'r, GameDistributionIdempotencyReceipt>, @@ -3769,6 +3812,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.feature_gate_config, event, ); + callbacks.invoke_table_row_callbacks::( + "game_distribution_collection", + &self.game_distribution_collection, + event, + ); callbacks.invoke_table_row_callbacks::( "game_distribution_game", &self.game_distribution_game, @@ -4714,6 +4762,7 @@ impl __sdk::SpacetimeModule for RemoteModule { external_generation_job_event_table::register_table(client_cache); external_generation_job_summary_table::register_table(client_cache); feature_gate_config_table::register_table(client_cache); + game_distribution_collection_table::register_table(client_cache); game_distribution_game_table::register_table(client_cache); game_distribution_idempotency_receipt_table::register_table(client_cache); game_distribution_lineage_table::register_table(client_cache); @@ -4809,6 +4858,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "external_generation_job_event", "external_generation_job_summary", "feature_gate_config", + "game_distribution_collection", "game_distribution_game", "game_distribution_idempotency_receipt", "game_distribution_lineage", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collect_game_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collect_game_input_type.rs new file mode 100644 index 000000000..d4be5592a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collect_game_input_type.rs @@ -0,0 +1,19 @@ +// 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 GameDistributionCollectGameInput { + pub game_id: String, + pub user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +impl __sdk::InModule for GameDistributionCollectGameInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs new file mode 100644 index 000000000..015967173 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs @@ -0,0 +1,15 @@ +// 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 GameDistributionCollectionListInput { + pub user_id: String, +} + +impl __sdk::InModule for GameDistributionCollectionListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs new file mode 100644 index 000000000..84f231a1e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs @@ -0,0 +1,19 @@ +// 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_public_game_snapshot_type::GameDistributionPublicGameSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionCollectionListResult { + pub ok: bool, + pub games: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionCollectionListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_mutation_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_mutation_result_type.rs new file mode 100644 index 000000000..45c0553d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_mutation_result_type.rs @@ -0,0 +1,18 @@ +// 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 GameDistributionCollectionMutationResult { + pub ok: bool, + pub collected: bool, + pub replayed: bool, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionCollectionMutationResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_state_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_state_input_type.rs new file mode 100644 index 000000000..779e9bc31 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_state_input_type.rs @@ -0,0 +1,16 @@ +// 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 GameDistributionCollectionStateInput { + pub game_id: String, + pub user_id: String, +} + +impl __sdk::InModule for GameDistributionCollectionStateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_state_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_state_result_type.rs new file mode 100644 index 000000000..6c30e2f91 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_state_result_type.rs @@ -0,0 +1,17 @@ +// 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 GameDistributionCollectionStateResult { + pub ok: bool, + pub collected: bool, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionCollectionStateResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_table.rs new file mode 100644 index 000000000..c7416eebf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_table.rs @@ -0,0 +1,235 @@ +// 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 super::game_distribution_collection_type::GameDistributionCollection; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_distribution_collection`. +/// +/// Obtain a handle from the [`GameDistributionCollectionTableAccess::game_distribution_collection`] method on [`super::RemoteTables`], +/// like `ctx.db.game_distribution_collection()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_collection().on_insert(...)`. +pub struct GameDistributionCollectionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_distribution_collection`. +pub struct GameDistributionCollectionTableAccessor; + +impl __sdk::TableAccessor for GameDistributionCollectionTableAccessor { + type Row = GameDistributionCollection; + type Handle<'db> = GameDistributionCollectionTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_distribution_collection() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_distribution_collection`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameDistributionCollectionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameDistributionCollectionTableHandle`], which mediates access to the table `game_distribution_collection`. + fn game_distribution_collection(&self) -> GameDistributionCollectionTableHandle<'_>; +} + +impl GameDistributionCollectionTableAccess for super::RemoteTables { + fn game_distribution_collection(&self) -> GameDistributionCollectionTableHandle<'_> { + GameDistributionCollectionTableHandle { + imp: self + .imp + .get_table::("game_distribution_collection"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameDistributionCollectionInsertCallbackId(__sdk::CallbackId); +pub struct GameDistributionCollectionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameDistributionCollectionTableHandle<'ctx> { + type Row = GameDistributionCollection; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameDistributionCollectionTableHandle<'ctx> { + type Row = GameDistributionCollection; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameDistributionCollectionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionCollectionInsertCallbackId { + GameDistributionCollectionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionCollectionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameDistributionCollectionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionCollectionDeleteCallbackId { + GameDistributionCollectionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionCollectionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameDistributionCollectionTableHandle<'ctx> { + type InsertCallbackId = GameDistributionCollectionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionCollectionInsertCallbackId { + GameDistributionCollectionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionCollectionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameDistributionCollectionTableHandle<'ctx> { + type DeleteCallbackId = GameDistributionCollectionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionCollectionDeleteCallbackId { + GameDistributionCollectionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionCollectionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameDistributionCollectionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameDistributionCollectionTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionCollectionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionCollectionUpdateCallbackId { + GameDistributionCollectionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionCollectionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameDistributionCollectionTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionCollectionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionCollectionUpdateCallbackId { + GameDistributionCollectionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionCollectionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `collection_id` unique index on the table `game_distribution_collection`, +/// which allows point queries on the field of the same name +/// via the [`GameDistributionCollectionCollectionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_collection().collection_id().find(...)`. +pub struct GameDistributionCollectionCollectionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameDistributionCollectionTableHandle<'ctx> { + /// Get a handle on the `collection_id` unique index on the table `game_distribution_collection`. + pub fn collection_id(&self) -> GameDistributionCollectionCollectionIdUnique<'ctx> { + GameDistributionCollectionCollectionIdUnique { + imp: self.imp.get_unique_constraint::("collection_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameDistributionCollectionCollectionIdUnique<'ctx> { + /// Find the subscribed row whose `collection_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("game_distribution_collection"); + _table.add_unique_constraint::("collection_id", |row| &row.collection_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `GameDistributionCollection`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_distribution_collectionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `GameDistributionCollection`. + fn game_distribution_collection( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl game_distribution_collectionQueryTableAccess for __sdk::QueryTableAccessor { + fn game_distribution_collection( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_distribution_collection") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_type.rs new file mode 100644 index 000000000..f94dd5035 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_type.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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionCollection { + pub collection_id: String, + pub user_id: String, + pub game_id: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for GameDistributionCollection { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `GameDistributionCollection`. +/// +/// Provides typed access to columns for query building. +pub struct GameDistributionCollectionCols { + pub collection_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub game_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for GameDistributionCollection { + type Cols = GameDistributionCollectionCols; + fn cols(table_name: &'static str) -> Self::Cols { + GameDistributionCollectionCols { + collection_id: __sdk::__query_builder::Col::new(table_name, "collection_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + game_id: __sdk::__query_builder::Col::new(table_name, "game_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `GameDistributionCollection`. +/// +/// Provides typed access to indexed columns for query building. +pub struct GameDistributionCollectionIxCols { + pub collection_id: __sdk::__query_builder::IxCol, + pub game_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for GameDistributionCollection { + type IxCols = GameDistributionCollectionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + GameDistributionCollectionIxCols { + collection_id: __sdk::__query_builder::IxCol::new(table_name, "collection_id"), + game_id: __sdk::__query_builder::IxCol::new(table_name, "game_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for GameDistributionCollection {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_uncollect_game_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_uncollect_game_input_type.rs new file mode 100644 index 000000000..09ffd71de --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_uncollect_game_input_type.rs @@ -0,0 +1,16 @@ +// 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 GameDistributionUncollectGameInput { + pub game_id: String, + pub user_id: String, +} + +impl __sdk::InModule for GameDistributionUncollectGameInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/is_game_distribution_collected_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/is_game_distribution_collected_and_return_procedure.rs new file mode 100644 index 000000000..7a916d327 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/is_game_distribution_collected_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_collection_state_input_type::GameDistributionCollectionStateInput; +use super::game_distribution_collection_state_result_type::GameDistributionCollectionStateResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct IsGameDistributionCollectedAndReturnArgs { + pub input: GameDistributionCollectionStateInput, +} + +impl __sdk::InModule for IsGameDistributionCollectedAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `is_game_distribution_collected_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait is_game_distribution_collected_and_return { + fn is_game_distribution_collected_and_return( + &self, + input: GameDistributionCollectionStateInput, + ) { + self.is_game_distribution_collected_and_return_then(input, |_, _| {}); + } + + fn is_game_distribution_collected_and_return_then( + &self, + input: GameDistributionCollectionStateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl is_game_distribution_collected_and_return for super::RemoteProcedures { + fn is_game_distribution_collected_and_return_then( + &self, + input: GameDistributionCollectionStateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionCollectionStateResult>( + "is_game_distribution_collected_and_return", + IsGameDistributionCollectedAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_collections_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_collections_and_return_procedure.rs new file mode 100644 index 000000000..d5d61b41a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_collections_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_collection_list_input_type::GameDistributionCollectionListInput; +use super::game_distribution_collection_list_result_type::GameDistributionCollectionListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListGameDistributionCollectionsAndReturnArgs { + pub input: GameDistributionCollectionListInput, +} + +impl __sdk::InModule for ListGameDistributionCollectionsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_game_distribution_collections_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_game_distribution_collections_and_return { + fn list_game_distribution_collections_and_return( + &self, + input: GameDistributionCollectionListInput, + ) { + self.list_game_distribution_collections_and_return_then(input, |_, _| {}); + } + + fn list_game_distribution_collections_and_return_then( + &self, + input: GameDistributionCollectionListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_game_distribution_collections_and_return for super::RemoteProcedures { + fn list_game_distribution_collections_and_return_then( + &self, + input: GameDistributionCollectionListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionCollectionListResult>( + "list_game_distribution_collections_and_return", + ListGameDistributionCollectionsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_collection_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_collection_and_return_procedure.rs new file mode 100644 index 000000000..1e8647397 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_collection_and_return_procedure.rs @@ -0,0 +1,59 @@ +// 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_collect_game_input_type::GameDistributionCollectGameInput; +use super::game_distribution_collection_mutation_result_type::GameDistributionCollectionMutationResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SetGameDistributionCollectionAndReturnArgs { + pub input: GameDistributionCollectGameInput, +} + +impl __sdk::InModule for SetGameDistributionCollectionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `set_game_distribution_collection_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait set_game_distribution_collection_and_return { + fn set_game_distribution_collection_and_return(&self, input: GameDistributionCollectGameInput) { + self.set_game_distribution_collection_and_return_then(input, |_, _| {}); + } + + fn set_game_distribution_collection_and_return_then( + &self, + input: GameDistributionCollectGameInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl set_game_distribution_collection_and_return for super::RemoteProcedures { + fn set_game_distribution_collection_and_return_then( + &self, + input: GameDistributionCollectGameInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionCollectionMutationResult>( + "set_game_distribution_collection_and_return", + SetGameDistributionCollectionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/unset_game_distribution_collection_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/unset_game_distribution_collection_and_return_procedure.rs new file mode 100644 index 000000000..3cdf1ece0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/unset_game_distribution_collection_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_collection_mutation_result_type::GameDistributionCollectionMutationResult; +use super::game_distribution_uncollect_game_input_type::GameDistributionUncollectGameInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UnsetGameDistributionCollectionAndReturnArgs { + pub input: GameDistributionUncollectGameInput, +} + +impl __sdk::InModule for UnsetGameDistributionCollectionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `unset_game_distribution_collection_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait unset_game_distribution_collection_and_return { + fn unset_game_distribution_collection_and_return( + &self, + input: GameDistributionUncollectGameInput, + ) { + self.unset_game_distribution_collection_and_return_then(input, |_, _| {}); + } + + fn unset_game_distribution_collection_and_return_then( + &self, + input: GameDistributionUncollectGameInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl unset_game_distribution_collection_and_return for super::RemoteProcedures { + fn unset_game_distribution_collection_and_return_then( + &self, + input: GameDistributionUncollectGameInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionCollectionMutationResult>( + "unset_game_distribution_collection_and_return", + UnsetGameDistributionCollectionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 21ac92a72..0708865e4 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -806,6 +806,36 @@ pub struct GameDistributionLineage { pub(crate) created_at: Timestamp, } +/// 用户对公开作品的收藏(收录)事实:**真实用户态投影**,不是前端本地状态。 +/// +/// 主键是确定性构造的 `{user_id}:{game_id}`(`module_game_distribution::game_distribution_collection_id`): +/// 同一 (用户, 作品) 在**结构上**不可能出现第二行,去重由主键约束本身承担,而不是靠 +/// 「事务里先查后写」——那种写法只在单写者假设下成立,将来分片 / 并发写同一对组合时, +/// 两个事务都可能先查到「不存在」再各写一行。也正因为主键已经是那条约束,这里**不需要** +/// 再建 `(user_id, game_id)` 唯一索引;两个索引只服务两个读取方向(「我的收藏」/ 按作品)。 +/// +/// 作品下架(未公开 / 软删除)**不删除**本表行:收藏是用户态事实,读侧投影时才按当前公开性 +/// 过滤,作品重新公开后收藏自动回到列表。`created_at` 只用于列表稳定排序。 +#[spacetimedb::table( + accessor = game_distribution_collection, + index( + accessor = by_game_distribution_collection_user_id, + btree(columns = [user_id]) + ), + index( + accessor = by_game_distribution_collection_game_id, + btree(columns = [game_id]) + ) +)] +#[derive(Clone)] +pub struct GameDistributionCollection { + #[primary_key] + pub(crate) collection_id: String, + pub(crate) user_id: String, + pub(crate) game_id: String, + pub(crate) created_at: Timestamp, +} + /// 游戏发行版本。包摘要、确认字节数和送审资料在创建后作为不可变快照保留;状态和 /// 审核/对象安装时间由后续事务推进。 #[spacetimedb::table( @@ -947,6 +977,8 @@ 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_ACTION_COLLECT_GAME: &str = "collect_game"; /// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。 const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20; /// 后台游戏管理页一次最多回传多少个游戏。 @@ -1231,6 +1263,37 @@ pub struct GameDistributionForkSourceInput { pub game_id: String, } +/// 收藏(收录)公开作品;`idempotency_key` 由 api-server 从 `Idempotency-Key` 头传入。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionCollectGameInput { + pub game_id: String, + pub user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +/// 取消收藏。按确定性主键删除,天然幂等,因此**不带** `idempotency_key` +/// (幂等键只在「重放与新手势无法从请求本身区分」时才有意义)。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionUncollectGameInput { + pub game_id: String, + pub user_id: String, +} + +/// 读取某用户的收藏(收录)列表。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionCollectionListInput { + pub user_id: String, +} + +/// 读取「某用户是否已收藏某作品」;公开详情用它算 `collected`。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionCollectionStateInput { + pub game_id: String, + pub user_id: String, +} + /// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPlayCountIncrement { @@ -1499,6 +1562,56 @@ impl GameDistributionForkSourceResult { } } +/// 收藏 / 取消收藏的结果。 +/// +/// `collected` 是**调用后的权威投影值**(该用户对该作品的收藏事实),不是前端本地状态: +/// 客户端不需要按「动词」推断结果,也不用自己维护收藏集合。`replayed == true` 表示本次命中 +/// 了同键幂等收据、没有产生新事实;取消不带幂等键,因此恒为 `false`。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionCollectionMutationResult { + pub ok: bool, + pub collected: bool, + pub replayed: bool, + pub error_message: Option, +} + +impl GameDistributionCollectionMutationResult { + fn failed(error: String) -> Self { + Self { + ok: false, + collected: false, + replayed: false, + error_message: Some(error), + } + } +} + +/// 「我的收藏(收录)」列表结果;逐条用**公开游戏快照**投影,因此形状与公开目录逐字一致。 +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct GameDistributionCollectionListResult { + pub ok: bool, + pub games: Vec, + pub error_message: Option, +} + +/// 公开详情的收藏态;只回传布尔,不暴露收藏时间,也不暴露其它用户。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionCollectionStateResult { + pub ok: bool, + pub collected: bool, + pub error_message: Option, +} + +impl GameDistributionCollectionStateResult { + fn failed(error: String) -> Self { + Self { + ok: false, + collected: false, + error_message: Some(error), + } + } +} + impl GameDistributionLineageResult { /// 目标作品不存在或已软删除:`ok` 仍为 true,由 api-server 映射成 404。 fn not_found() -> Self { @@ -2170,7 +2283,104 @@ pub fn get_game_distribution_fork_source_and_return( } } -/// 管理员读取任意版本状态;admin 权限由 api-server 在调用前校验,procedure 只接受受信服务身份。 +/// 收藏(收录)某公开作品;受信服务身份只读之外,api-server 侧要求 Bearer 登录。 +/// +/// 幂等:同一 `Idempotency-Key` 重放返回同一结果并带 `replayed = true`;同键不同摘要冲突。 +/// 重复收藏(不同键)不产生第二行(确定性主键),仍然成功。 +#[spacetimedb::procedure] +pub fn set_game_distribution_collection_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionCollectGameInput, +) -> GameDistributionCollectionMutationResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + set_game_distribution_collection_tx(tx, input.clone()) + }) { + Ok((collected, replayed)) => GameDistributionCollectionMutationResult { + ok: true, + collected, + replayed, + error_message: None, + }, + Err(error) => GameDistributionCollectionMutationResult::failed(error), + } +} + +/// 取消收藏(收录)。 +/// +/// **不要求幂等键、也不要求作品仍公开**:按确定性主键删除,重复调用结果相同(不存在也算成功); +/// 作品已下架时若拒绝取消,只会在库里留下用户清理不掉的脏行。 +#[spacetimedb::procedure] +pub fn unset_game_distribution_collection_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionUncollectGameInput, +) -> GameDistributionCollectionMutationResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + unset_game_distribution_collection_tx(tx, input.clone()) + }) { + Ok(collected) => GameDistributionCollectionMutationResult { + ok: true, + collected, + replayed: false, + error_message: None, + }, + Err(error) => GameDistributionCollectionMutationResult::failed(error), + } +} + +/// 「我的收藏(收录)」列表;逐条用公开游戏快照投影,只返回当前公开可读的作品。 +#[spacetimedb::procedure] +pub fn list_game_distribution_collections_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionCollectionListInput, +) -> GameDistributionCollectionListResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + list_game_distribution_collections_tx(tx, input.clone()) + }) { + Ok(games) => GameDistributionCollectionListResult { + ok: true, + games, + error_message: None, + }, + Err(error) => GameDistributionCollectionListResult { + ok: false, + games: Vec::new(), + error_message: Some(error), + }, + } +} + +/// 公开详情的收藏态查询:给 `collected` 一个真实投影来源,**不改动既有公开快照契约**。 +#[spacetimedb::procedure] +pub fn is_game_distribution_collected_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionCollectionStateInput, +) -> GameDistributionCollectionStateResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + let game_id = required_game_distribution_text(input.game_id.clone(), "game_id")?; + let user_id = required_game_distribution_text(input.user_id.clone(), "user_id")?; + Ok(game_distribution_collected( + tx, + user_id.as_str(), + game_id.as_str(), + )) + }) { + Ok(collected) => GameDistributionCollectionStateResult { + ok: true, + collected, + error_message: None, + }, + Err(error) => GameDistributionCollectionStateResult::failed(error), + } +} + #[spacetimedb::procedure] pub fn get_game_distribution_version_and_return( ctx: &mut ProcedureContext, @@ -4453,7 +4663,191 @@ fn get_game_distribution_fork_source_tx( }) } -/// 折叠族谱树的输入行:根作品自身 + 所有以该根为祖先的血缘行。 +/// 收藏的前置:作品必须存在,且当前公开可读。 +/// +/// 与 `get_game_distribution_fork_source_tx` 同口径折叠行状态(未软删除 + 已公开 + 存在当前 +/// 公开版本),但这里**失败关闭**:不存在返回「作品不存在」(api-server → 404),存在但不可读 +/// 返回 `GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT`(→ 409)。该文案刻意命中 api-server +/// 错误映射里的 `状态` 子串,且**不含**「不存在」/「已被删除」——否则会先命中 404 分支,等于用 +/// 错误码对外确认未公开作品的存在性。 +fn ensure_game_distribution_collectable(ctx: &ReducerContext, game_id: &str) -> Result<(), String> { + let Some(game) = ctx + .db + .game_distribution_game() + .game_id() + .find(game_id.to_string()) + else { + return Err("作品不存在".to_string()); + }; + if game.deleted_at.is_some() + || game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + || public_game_distribution_version(ctx, &game).is_none() + { + return Err( + shared_contracts::game_distribution::GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT + .to_string(), + ); + } + Ok(()) +} + +/// 某用户是否已收藏某作品:按确定性主键直查,不扫描、不依赖游戏行状态。 +fn game_distribution_collected(ctx: &ReducerContext, user_id: &str, game_id: &str) -> bool { + let collection_id = module_game_distribution::game_distribution_collection_id(user_id, game_id); + ctx.db + .game_distribution_collection() + .collection_id() + .find(&collection_id) + .is_some() +} + +/// 收藏(收录)某公开作品。 +/// +/// 幂等:`(user_id, action, idempotency_key)` 命中收据时先校验 `request_digest`,通过即 +/// `replayed = true` 返回,不再写库;不同摘要冲突。摘要由 api-server 从 `(user_id, game_id)` +/// 派生,因此不同用户 / 不同作品即使共用同一个 `Idempotency-Key` 也不会互相命中。 +/// +/// 防重:写入前只判「主键是否已存在」,存在即跳过写入——它只是省一次写入,不是去重依据; +/// 真正保证「同一 (用户, 作品) 只有一行」的是确定性主键本身。 +fn set_game_distribution_collection_tx( + ctx: &ReducerContext, + input: GameDistributionCollectGameInput, +) -> Result<(bool, bool), String> { + let user_id = required_game_distribution_text(input.user_id, "user_id")?; + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + 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( + user_id.as_str(), + GAME_DISTRIBUTION_ACTION_COLLECT_GAME, + 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())?; + // 收据只可能由一次成功的收藏写入,因此重放直接回「已收藏」,不再复核当前可见性 + // (下发时的可见性可能与首次调用时不同,重放必须回同一结果)。 + return Ok((true, true)); + } + ensure_game_distribution_collectable(ctx, game_id.as_str())?; + let collection_id = module_game_distribution::game_distribution_collection_id( + user_id.as_str(), + game_id.as_str(), + ); + if ctx + .db + .game_distribution_collection() + .collection_id() + .find(&collection_id) + .is_none() + { + ctx.db + .game_distribution_collection() + .insert(GameDistributionCollection { + collection_id, + user_id: user_id.clone(), + game_id: game_id.clone(), + created_at: Timestamp::from_micros_since_unix_epoch(input.now_micros), + }); + } + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id: user_id, + action: GAME_DISTRIBUTION_ACTION_COLLECT_GAME.to_string(), + idempotency_key, + request_digest, + game_id: Some(game_id.clone()), + version_id: None, + outcome_kind: "collection".to_string(), + outcome_game_id: Some(game_id), + outcome_version_id: None, + outcome_json: None, + now_micros: input.now_micros, + }, + )?; + Ok((true, false)) +} + +/// 取消收藏(收录):按确定性主键删除,**不存在也算成功**(幂等)。 +/// +/// 这里刻意**不要求作品仍公开**:作品下架后若拒绝取消,用户就没法清理自己的收藏,库里会留下 +/// 永远删不掉的脏行;而「取消」这个意图与作品当前状态无关。返回值是调用后的投影值 +/// (`false` = 未收藏),客户端不需要按动词推断。 +fn unset_game_distribution_collection_tx( + ctx: &ReducerContext, + input: GameDistributionUncollectGameInput, +) -> Result { + let user_id = required_game_distribution_text(input.user_id, "user_id")?; + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let collection_id = module_game_distribution::game_distribution_collection_id( + user_id.as_str(), + game_id.as_str(), + ); + if ctx + .db + .game_distribution_collection() + .collection_id() + .find(&collection_id) + .is_some() + { + ctx.db + .game_distribution_collection() + .collection_id() + .delete(&collection_id); + } + Ok(false) +} + +/// 「我的收藏(收录)」列表。 +/// +/// 按 `user_id` 索引取该用户的全部收藏行,逐条用**既有** `public_game_distribution_snapshot` +/// 投影(与公开目录 / 公开详情同一份),因此形状与公开目录逐字一致。 +/// +/// 只返回当前公开可读的作品:未公开 / 已软删除 / 没有当前公开版本的行在**投影时**被跳过, +/// 但**不删除行**(`module_game_distribution::game_distribution_collection_visible` 就是这条 +/// 判定的纯函数形式)。作品重新公开后,同一行会自动回到列表里。 +fn list_game_distribution_collections_tx( + ctx: &ReducerContext, + input: GameDistributionCollectionListInput, +) -> Result, String> { + let user_id = required_game_distribution_text(input.user_id, "user_id")?; + let mut collections = ctx + .db + .game_distribution_collection() + .by_game_distribution_collection_user_id() + .filter(&user_id) + .collect::>(); + // 最近收藏在前;同一时刻按 game_id 升序,保证顺序稳定(不依赖表的物理顺序)。 + collections.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.game_id.cmp(&right.game_id)) + }); + Ok(collections + .into_iter() + .filter_map(|collection| { + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&collection.game_id)?; + let has_public_version = public_game_distribution_version(ctx, &game).is_some(); + let visible = module_game_distribution::game_distribution_collection_visible( + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + has_public_version, + ); + if !visible { + return None; + } + public_game_distribution_snapshot(ctx, &game) + }) + .collect()) +} + /// /// 这里只负责取行(根游戏行、按根索引命中的血缘行、以及每行引用的游戏行), /// 「家族归属校验、根自身入集合、游戏行缺失跳过、重复行去重」全部交给 @@ -5080,4 +5474,107 @@ mod tests { // 溢出不能回绕成 0 或静默改写已有版本。 assert!(!overflow.contains("版本号 0")); } + + /// 取 `fn ` 的函数体文本(按花括号配平),供「事务结构」类断言使用。 + /// + /// 这些事务需要 `ReducerContext`,host 测试起不了真库;因此与 + /// `editor_project_storage` 的同类测试一样,用源码文本钉住「必须走哪几步、 + /// 绝不能出现哪一步」这类结构性不变量,行为证据留给 dev 栈端到端。 + fn function_body<'a>(source: &'a str, marker: &str) -> &'a str { + let start = source + .find(marker) + .unwrap_or_else(|| panic!("{marker} 必须存在")); + let open = source[start..] + .find('{') + .map(|offset| start + offset) + .expect("函数必须有函数体"); + let mut depth = 0usize; + for (index, character) in source[open..].char_indices() { + match character { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return &source[open..=open + index]; + } + } + _ => {} + } + } + panic!("{marker} 的函数体不闭合"); + } + + /// 收藏事务:收据命中先校验摘要(重放不写库)、写入前只判主键是否存在(**不是**去重依据, + /// 去重由确定性主键承担),且同一次调用只可能插入一行。 + #[test] + fn collection_collect_is_idempotent_and_cannot_write_a_second_row() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn set_game_distribution_collection_tx("); + for required in [ + "GAME_DISTRIBUTION_ACTION_COLLECT_GAME", + "find_game_distribution_receipt", + "ensure_game_distribution_receipt_digest", + "module_game_distribution::game_distribution_collection_id", + "insert_game_distribution_receipt", + "ensure_game_distribution_collectable", + ] { + assert!(body.contains(required), "收藏事务必须包含 {required}"); + } + // 「已存在就不写」的守卫 + 一次插入:重复收藏不会产生第二行。 + assert!(body.contains(".find(&collection_id)"), "必须先按主键查行"); + assert!(body.contains(".is_none()"), "存在时必须跳过写入"); + assert_eq!( + body.matches(".insert(GameDistributionCollection {").count(), + 1, + "同一次调用只能有一处收藏行插入" + ); + // 收据只有在成功路径末尾才写:命中收据时提前 `return Ok((true, true))`。 + assert!(body.contains("return Ok((true, true))")); + } + + /// 取消事务:按确定性主键删除、不存在也算成功,**不**要求作品仍公开(下架后拒绝取消会留下脏行)。 + #[test] + fn collection_unset_is_idempotent_and_ignores_current_visibility() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn unset_game_distribution_collection_tx("); + assert!(body.contains("module_game_distribution::game_distribution_collection_id")); + assert!(body.contains(".delete(&collection_id)"), "必须按主键删除"); + assert!(body.contains("Ok(false)"), "取消后的权威值恒为 false"); + assert!( + !body.contains("ensure_game_distribution_collectable"), + "取消不得要求作品仍公开:作品下架后用户仍必须能清理自己的收藏" + ); + } + + /// 列表事务:按 `user_id` 索引取行,只投影当前公开可读的作品,且不删除任何行。 + #[test] + fn collection_list_projects_only_currently_public_games() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn list_game_distribution_collections_tx("); + assert!(body.contains("by_game_distribution_collection_user_id()")); + assert!(body.contains("module_game_distribution::game_distribution_collection_visible")); + assert!(body.contains("public_game_distribution_snapshot")); + assert!( + !body.contains(".delete("), + "投影阶段不得删除收藏行:作品重新公开后同一行必须自动回来" + ); + } + + /// 收藏前置失败关闭:作品不存在与「状态不允许收藏」必须用不同文案,后者命中 api-server 的 + /// 409 分支(`状态` 子串),且不得含「不存在」/「已被删除」(会先命中 404 分支)。 + #[test] + fn collection_precondition_keeps_404_and_409_messages_distinct() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn ensure_game_distribution_collectable("); + assert!(body.contains("\"作品不存在\""), "行不存在必须是 404 文案"); + assert!( + body.contains("GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT"), + "状态不允许必须复用跨端常量,避免只改一边让映射漂移" + ); + let conflict_message = + shared_contracts::game_distribution::GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT; + assert!(conflict_message.contains("状态")); + assert!(!conflict_message.contains("不存在")); + assert!(!conflict_message.contains("已被删除")); + } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 20a4ae4fb..a82ebd555 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -222,6 +222,9 @@ macro_rules! migration_tables { game_distribution_idempotency_receipt, // 作品之间的改编血缘:业务事实,随迁移导出/导入;父作品下架不删除历史行。 game_distribution_lineage, + // 用户对公开作品的收藏(收录):真实用户态投影,随迁移导出/导入; + // 作品下架不删除行(读取时按当前公开性过滤,重新公开后自动回来)。 + game_distribution_collection, llm_router_account, llm_router_billing_checkpoint, agc_model_catalog,