use std::collections::HashSet; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared_kernel::{ format_rfc3339 as format_shared_rfc3339, normalize_optional_string, normalize_required_string, parse_rfc3339 as parse_shared_rfc3339, }; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; use time::OffsetDateTime; pub const DEFAULT_MUSIC_VOLUME: f32 = 0.42; pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::Light; pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家"; pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100; pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50; pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30; pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10; pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; // 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimePlatformTheme { Light, Dark, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeSettings { pub music_volume: f32, pub platform_theme: RuntimePlatformTheme, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSnapshot { pub user_id: String, pub version: u32, pub saved_at_micros: i64, pub bottom_tab: String, pub game_state_json: String, pub current_story_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSnapshotProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSnapshotGetInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSnapshotUpsertInput { pub user_id: String, pub saved_at_micros: i64, pub bottom_tab: String, pub game_state_json: String, pub current_story_json: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSnapshotDeleteInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileSaveArchiveSnapshot { pub archive_id: String, pub user_id: String, pub world_key: String, pub owner_user_id: Option, pub profile_id: Option, pub world_type: Option, pub world_name: String, pub subtitle: String, pub summary_text: String, pub cover_image_src: Option, pub saved_at_micros: i64, pub bottom_tab: String, pub game_state_json: String, pub current_story_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileSaveArchiveProcedureResult { pub ok: bool, pub entries: Vec, pub record: Option, pub current_snapshot: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileSaveArchiveListInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileSaveArchiveResumeInput { pub user_id: String, pub world_key: String, } // 浏览历史沿用平台已有的六种世界主题,但独立冻结在 runtime 领域内,避免反向耦合创作域 crate。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeBrowseHistoryThemeMode { Martial, Arcane, Machina, Tide, Rift, Mythic, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSettingSnapshot { pub user_id: String, pub music_volume: f32, pub platform_theme: RuntimePlatformTheme, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSettingProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSettingGetInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeSettingUpsertInput { pub user_id: String, pub music_volume: f32, pub platform_theme: RuntimePlatformTheme, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeBrowseHistorySnapshot { pub browse_history_id: String, pub user_id: String, pub owner_user_id: String, pub profile_id: String, pub world_name: String, pub subtitle: String, pub summary_text: String, pub cover_image_src: Option, pub theme_mode: RuntimeBrowseHistoryThemeMode, pub author_display_name: String, pub visited_at_micros: i64, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeBrowseHistoryProcedureResult { pub ok: bool, pub entries: Vec, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeBrowseHistoryListInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeBrowseHistoryClearInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeBrowseHistoryWriteInput { pub owner_user_id: String, pub profile_id: String, pub world_name: String, pub subtitle: Option, pub summary_text: Option, pub cover_image_src: Option, pub theme_mode: Option, pub author_display_name: Option, pub visited_at: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeBrowseHistorySyncInput { pub user_id: String, pub entries: Vec, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileDashboardSnapshot { pub user_id: String, pub wallet_balance: u64, pub total_play_time_ms: u64, pub played_world_count: u32, pub updated_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileDashboardProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileDashboardGetInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileWalletLedgerSourceType { SnapshotSync, InviteInviterReward, InviteInviteeReward, PointsRecharge, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeProductKind { Points, Membership, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileMembershipStatus { Normal, Active, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileMembershipTier { Normal, Month, Season, Year, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { Paid, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeProductSnapshot { pub product_id: String, pub title: String, pub price_cents: u64, pub kind: RuntimeProfileRechargeProductKind, pub points_amount: u64, pub bonus_points: u64, pub duration_days: u32, pub badge_label: String, pub description: String, pub tier: RuntimeProfileMembershipTier, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipBenefitSnapshot { pub benefit_name: String, pub normal_value: String, pub month_value: String, pub season_value: String, pub year_value: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, pub tier: RuntimeProfileMembershipTier, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderSnapshot { pub order_id: String, pub user_id: String, pub product_id: String, pub product_title: String, pub kind: RuntimeProfileRechargeProductKind, pub amount_cents: u64, pub status: RuntimeProfileRechargeOrderStatus, pub payment_channel: String, pub paid_at_micros: i64, pub created_at_micros: i64, pub points_delta: i64, pub membership_expires_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterSnapshot { pub user_id: String, pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, pub membership_products: Vec, pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterProcedureResult { pub ok: bool, pub record: Option, pub order: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterGetInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderCreateInput { pub user_id: String, pub product_id: String, pub payment_channel: String, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerEntrySnapshot { pub wallet_ledger_id: String, pub user_id: String, pub amount_delta: i64, pub balance_after: u64, pub source_type: RuntimeProfileWalletLedgerSourceType, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerProcedureResult { pub ok: bool, pub entries: Vec, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerListInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { pub user_id: String, pub invite_code: String, pub invite_link_path: String, pub invited_count: u32, pub rewarded_invite_count: u32, pub today_inviter_reward_count: u32, pub today_inviter_reward_remaining: u32, pub reward_points: u64, pub has_redeemed_code: bool, pub bound_inviter_user_id: Option, pub bound_at_micros: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterGetInput { pub user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralRedeemInput { pub user_id: String, pub invite_code: String, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralRedeemSnapshot { pub center: RuntimeReferralInviteCenterSnapshot, pub invitee_reward_granted: bool, pub inviter_reward_granted: bool, pub invitee_balance_after: u64, pub inviter_balance_after: u64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfilePlayedWorldSnapshot { pub played_world_id: String, pub user_id: String, pub world_key: String, pub owner_user_id: Option, pub profile_id: Option, pub world_type: Option, pub world_title: String, pub world_subtitle: String, pub first_played_at_micros: i64, pub last_played_at_micros: i64, pub last_observed_play_time_ms: u64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfilePlayStatsSnapshot { pub user_id: String, pub total_play_time_ms: u64, pub played_works: Vec, pub updated_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfilePlayStatsProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfilePlayStatsGetInput { pub user_id: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeSettingsFieldError { MissingUserId, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeBrowseHistoryFieldError { MissingUserId, TooManyEntries, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeProfileFieldError { MissingUserId, MissingInviteCode, MissingProductId, MissingWorldKey, MissingBottomTab, UnknownRechargeProduct, InvalidGameStateJson, InvalidCurrentStoryJson, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeBrowseHistoryPreparedEntry { pub browse_history_id: String, pub user_id: String, pub owner_user_id: String, pub profile_id: String, pub world_name: String, pub subtitle: String, pub summary_text: String, pub cover_image_src: Option, pub theme_mode: RuntimeBrowseHistoryThemeMode, pub author_display_name: String, pub visited_at_micros: i64, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeBrowseHistoryRecord { pub browse_history_id: String, pub user_id: String, pub owner_user_id: String, pub profile_id: String, pub world_name: String, pub subtitle: String, pub summary_text: String, pub cover_image_src: Option, pub theme_mode: RuntimeBrowseHistoryThemeMode, pub author_display_name: String, pub visited_at: String, pub visited_at_micros: i64, pub created_at_micros: i64, pub updated_at_micros: i64, } impl RuntimePlatformTheme { pub fn as_str(&self) -> &'static str { match self { Self::Light => "light", Self::Dark => "dark", } } pub fn from_client_str(value: &str) -> Self { if value.trim().eq_ignore_ascii_case("dark") { Self::Dark } else { Self::Light } } } impl RuntimeBrowseHistoryThemeMode { pub fn as_str(&self) -> &'static str { match self { Self::Martial => "martial", Self::Arcane => "arcane", Self::Machina => "machina", Self::Tide => "tide", Self::Rift => "rift", Self::Mythic => "mythic", } } // 浏览历史主题沿用旧 Node 逻辑:不做严格校验,未知值统一回退到 mythic。 pub fn from_client_str(value: &str) -> Self { match value.trim().to_ascii_lowercase().as_str() { "martial" => Self::Martial, "arcane" => Self::Arcane, "machina" => Self::Machina, "tide" => Self::Tide, "rift" => Self::Rift, _ => Self::Mythic, } } } impl RuntimeSettings { pub fn defaults() -> Self { Self { music_volume: DEFAULT_MUSIC_VOLUME, platform_theme: DEFAULT_PLATFORM_THEME, } } // 与旧 Node 仓储保持一致:音量 clamp 到 0~1,主题除 dark 外统一回退到 light。 pub fn normalized(music_volume: f32, platform_theme: RuntimePlatformTheme) -> Self { Self { music_volume: music_volume.clamp(0.0, 1.0), platform_theme, } } } // 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。 fn normalize_runtime_settings_user_id( user_id: String, ) -> Result { normalize_required_string(user_id).ok_or(RuntimeSettingsFieldError::MissingUserId) } fn normalize_runtime_browse_history_user_id( user_id: String, ) -> Result { normalize_required_string(user_id).ok_or(RuntimeBrowseHistoryFieldError::MissingUserId) } fn normalize_runtime_profile_user_id(user_id: String) -> Result { normalize_required_string(user_id).ok_or(RuntimeProfileFieldError::MissingUserId) } pub fn build_runtime_setting_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_settings_user_id(user_id)?; Ok(RuntimeSettingGetInput { user_id }) } pub fn build_runtime_setting_upsert_input( user_id: String, music_volume: f32, platform_theme: RuntimePlatformTheme, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_settings_user_id(user_id)?; let normalized = RuntimeSettings::normalized(music_volume, platform_theme); Ok(RuntimeSettingUpsertInput { user_id, music_volume: normalized.music_volume, platform_theme: normalized.platform_theme, updated_at_micros, }) } pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord { RuntimeSettingsRecord { user_id: snapshot.user_id, music_volume: snapshot.music_volume, platform_theme: snapshot.platform_theme, created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, } } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeSettingsRecord { pub user_id: String, pub music_volume: f32, pub platform_theme: RuntimePlatformTheme, pub created_at_micros: i64, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileDashboardRecord { pub user_id: String, pub wallet_balance: u64, pub total_play_time_ms: u64, pub played_world_count: u32, pub updated_at: Option, pub updated_at_micros: Option, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileWalletLedgerEntryRecord { pub wallet_ledger_id: String, pub user_id: String, pub amount_delta: i64, pub balance_after: u64, pub source_type: RuntimeProfileWalletLedgerSourceType, pub created_at: String, pub created_at_micros: i64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfilePlayedWorldRecord { pub played_world_id: String, pub user_id: String, pub world_key: String, pub owner_user_id: Option, pub profile_id: Option, pub world_type: Option, pub world_title: String, pub world_subtitle: String, pub first_played_at: String, pub first_played_at_micros: i64, pub last_played_at: String, pub last_played_at_micros: i64, pub last_observed_play_time_ms: u64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfilePlayStatsRecord { pub user_id: String, pub total_play_time_ms: u64, pub played_works: Vec, pub updated_at: Option, pub updated_at_micros: Option, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileRechargeProductRecord { pub product_id: String, pub title: String, pub price_cents: u64, pub kind: RuntimeProfileRechargeProductKind, pub points_amount: u64, pub bonus_points: u64, pub duration_days: u32, pub badge_label: String, pub description: String, pub tier: RuntimeProfileMembershipTier, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipBenefitRecord { pub benefit_name: String, pub normal_value: String, pub month_value: String, pub season_value: String, pub year_value: String, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipRecord { pub user_id: String, pub status: RuntimeProfileMembershipStatus, pub tier: RuntimeProfileMembershipTier, pub started_at: Option, pub started_at_micros: Option, pub expires_at: Option, pub expires_at_micros: Option, pub updated_at: Option, pub updated_at_micros: Option, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileRechargeOrderRecord { pub order_id: String, pub user_id: String, pub product_id: String, pub product_title: String, pub kind: RuntimeProfileRechargeProductKind, pub amount_cents: u64, pub status: RuntimeProfileRechargeOrderStatus, pub payment_channel: String, pub paid_at: String, pub paid_at_micros: i64, pub created_at: String, pub created_at_micros: i64, pub points_delta: i64, pub membership_expires_at: Option, pub membership_expires_at_micros: Option, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileRechargeCenterRecord { pub user_id: String, pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, pub membership_products: Vec, pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeReferralInviteCenterRecord { pub user_id: String, pub invite_code: String, pub invite_link_path: String, pub invited_count: u32, pub rewarded_invite_count: u32, pub today_inviter_reward_count: u32, pub today_inviter_reward_remaining: u32, pub reward_points: u64, pub has_redeemed_code: bool, pub bound_inviter_user_id: Option, pub bound_at: Option, pub bound_at_micros: Option, pub updated_at: String, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeReferralRedeemRecord { pub center: RuntimeReferralInviteCenterRecord, pub invitee_reward_granted: bool, pub inviter_reward_granted: bool, pub invitee_balance_after: u64, pub inviter_balance_after: u64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeSnapshotRecord { pub user_id: String, pub version: u32, pub saved_at: String, pub saved_at_micros: i64, pub bottom_tab: String, pub game_state: Value, pub current_story: Option, pub game_state_json: String, pub current_story_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileSaveArchiveRecord { pub archive_id: String, pub user_id: String, pub world_key: String, pub owner_user_id: Option, pub profile_id: Option, pub world_type: Option, pub world_name: String, pub subtitle: String, pub summary_text: String, pub cover_image_src: Option, pub saved_at: String, pub saved_at_micros: i64, pub bottom_tab: String, pub game_state: Value, pub current_story: Option, pub game_state_json: String, pub current_story_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } pub fn build_runtime_browse_history_list_input( user_id: String, ) -> Result { let user_id = normalize_runtime_browse_history_user_id(user_id)?; Ok(RuntimeBrowseHistoryListInput { user_id }) } pub fn build_runtime_profile_dashboard_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileDashboardGetInput { user_id }) } pub fn build_runtime_profile_wallet_ledger_list_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileWalletLedgerListInput { user_id }) } pub fn build_runtime_profile_recharge_center_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileRechargeCenterGetInput { user_id }) } pub fn build_runtime_profile_recharge_order_create_input( user_id: String, product_id: String, payment_channel: String, created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let product_id = normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?; if runtime_profile_recharge_product_by_id(&product_id).is_none() { return Err(RuntimeProfileFieldError::UnknownRechargeProduct); } let payment_channel = normalize_required_string(payment_channel) .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); Ok(RuntimeProfileRechargeOrderCreateInput { user_id, product_id, payment_channel, created_at_micros, }) } pub fn build_runtime_referral_invite_center_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeReferralInviteCenterGetInput { user_id }) } pub fn build_runtime_referral_redeem_input( user_id: String, invite_code: String, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let invite_code = normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?; Ok(RuntimeReferralRedeemInput { user_id, invite_code, updated_at_micros, }) } pub fn build_runtime_profile_play_stats_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfilePlayStatsGetInput { user_id }) } pub fn build_runtime_snapshot_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeSnapshotGetInput { user_id }) } pub fn build_runtime_snapshot_delete_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeSnapshotDeleteInput { user_id }) } pub fn build_runtime_profile_save_archive_list_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileSaveArchiveListInput { user_id }) } pub fn build_runtime_profile_save_archive_resume_input( user_id: String, world_key: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let world_key = normalize_required_string(world_key).ok_or(RuntimeProfileFieldError::MissingWorldKey)?; Ok(RuntimeProfileSaveArchiveResumeInput { user_id, world_key }) } pub fn build_runtime_browse_history_clear_input( user_id: String, ) -> Result { let user_id = normalize_runtime_browse_history_user_id(user_id)?; Ok(RuntimeBrowseHistoryClearInput { user_id }) } pub fn build_runtime_snapshot_upsert_input( user_id: String, saved_at_micros: i64, bottom_tab: String, game_state: Value, current_story: Option, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let bottom_tab = normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?; let game_state_json = serde_json::to_string(&game_state) .map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?; let current_story_json = normalize_current_story_json(current_story)?; Ok(RuntimeSnapshotUpsertInput { user_id, saved_at_micros, bottom_tab, game_state_json, current_story_json, updated_at_micros, }) } pub fn build_runtime_browse_history_sync_input( user_id: String, entries: Vec, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_browse_history_user_id(user_id)?; if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE { return Err(RuntimeBrowseHistoryFieldError::TooManyEntries); } let mut normalized_entries = Vec::with_capacity(entries.len()); for entry in entries { let Some(owner_user_id) = normalize_required_string(entry.owner_user_id) else { continue; }; let Some(profile_id) = normalize_required_string(entry.profile_id) else { continue; }; let Some(world_name) = normalize_required_string(entry.world_name) else { continue; }; // 与旧 Node 仓储保持一致:单条缺少关键字段时静默过滤,不让整批请求失败。 let visited_at_micros = entry .visited_at .as_deref() .and_then(parse_utc_rfc3339_to_micros) .unwrap_or(updated_at_micros); normalized_entries.push(RuntimeBrowseHistoryWriteInput { owner_user_id, profile_id, world_name, subtitle: normalize_optional_string(entry.subtitle), summary_text: normalize_optional_string(entry.summary_text), cover_image_src: normalize_optional_string(entry.cover_image_src), theme_mode: normalize_optional_string(entry.theme_mode), author_display_name: normalize_optional_string(entry.author_display_name), // 统一把 visitedAt 收口成 RFC3339,避免后续排序与回包格式继续漂移。 visited_at: Some(format_utc_micros(visited_at_micros)), }); } Ok(RuntimeBrowseHistorySyncInput { user_id, entries: normalized_entries, updated_at_micros, }) } pub fn prepare_runtime_browse_history_entries( input: RuntimeBrowseHistorySyncInput, ) -> Result, RuntimeBrowseHistoryFieldError> { let validated_input = build_runtime_browse_history_sync_input( input.user_id, input.entries, input.updated_at_micros, )?; let mut prepared_entries = validated_input .entries .into_iter() .map(|entry| { let visited_at_micros = entry .visited_at .as_deref() .and_then(parse_utc_rfc3339_to_micros) .unwrap_or(validated_input.updated_at_micros); RuntimeBrowseHistoryPreparedEntry { browse_history_id: build_runtime_browse_history_id( &validated_input.user_id, &entry.owner_user_id, &entry.profile_id, ), user_id: validated_input.user_id.clone(), owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, world_name: entry.world_name, subtitle: entry.subtitle.unwrap_or_default(), summary_text: entry.summary_text.unwrap_or_default(), cover_image_src: entry.cover_image_src, theme_mode: RuntimeBrowseHistoryThemeMode::from_client_str( entry.theme_mode.as_deref().unwrap_or("mythic"), ), author_display_name: entry .author_display_name .unwrap_or_else(|| DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME.to_string()), visited_at_micros, updated_at_micros: validated_input.updated_at_micros, } }) .collect::>(); // 与旧 Node 仓储保持一致:先按 visitedAt 倒序,再按 owner/profile 去重,只保留最近一次访问。 prepared_entries.sort_by(|left, right| { right .visited_at_micros .cmp(&left.visited_at_micros) .then_with(|| left.browse_history_id.cmp(&right.browse_history_id)) }); let mut seen_ids = HashSet::new(); prepared_entries.retain(|entry| seen_ids.insert(entry.browse_history_id.clone())); Ok(prepared_entries) } pub fn build_runtime_browse_history_record( snapshot: RuntimeBrowseHistorySnapshot, ) -> RuntimeBrowseHistoryRecord { RuntimeBrowseHistoryRecord { browse_history_id: snapshot.browse_history_id, user_id: snapshot.user_id, owner_user_id: snapshot.owner_user_id, profile_id: snapshot.profile_id, world_name: snapshot.world_name, subtitle: snapshot.subtitle, summary_text: snapshot.summary_text, cover_image_src: snapshot.cover_image_src, theme_mode: snapshot.theme_mode, author_display_name: snapshot.author_display_name, visited_at: format_utc_micros(snapshot.visited_at_micros), visited_at_micros: snapshot.visited_at_micros, created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_dashboard_record( snapshot: RuntimeProfileDashboardSnapshot, ) -> RuntimeProfileDashboardRecord { RuntimeProfileDashboardRecord { user_id: snapshot.user_id, wallet_balance: snapshot.wallet_balance, total_play_time_ms: snapshot.total_play_time_ms, played_world_count: snapshot.played_world_count, updated_at: snapshot.updated_at_micros.map(format_utc_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_wallet_ledger_entry_record( snapshot: RuntimeProfileWalletLedgerEntrySnapshot, ) -> RuntimeProfileWalletLedgerEntryRecord { RuntimeProfileWalletLedgerEntryRecord { wallet_ledger_id: snapshot.wallet_ledger_id, user_id: snapshot.user_id, amount_delta: snapshot.amount_delta, balance_after: snapshot.balance_after, source_type: snapshot.source_type, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, } } pub fn build_runtime_profile_recharge_center_record( snapshot: RuntimeProfileRechargeCenterSnapshot, ) -> RuntimeProfileRechargeCenterRecord { RuntimeProfileRechargeCenterRecord { user_id: snapshot.user_id, wallet_balance: snapshot.wallet_balance, membership: build_runtime_profile_membership_record(snapshot.membership), point_products: snapshot .point_products .into_iter() .map(build_runtime_profile_recharge_product_record) .collect(), membership_products: snapshot .membership_products .into_iter() .map(build_runtime_profile_recharge_product_record) .collect(), benefits: snapshot .benefits .into_iter() .map(build_runtime_profile_membership_benefit_record) .collect(), latest_order: snapshot .latest_order .map(build_runtime_profile_recharge_order_record), has_points_recharged: snapshot.has_points_recharged, } } pub fn build_runtime_profile_recharge_product_record( snapshot: RuntimeProfileRechargeProductSnapshot, ) -> RuntimeProfileRechargeProductRecord { RuntimeProfileRechargeProductRecord { product_id: snapshot.product_id, title: snapshot.title, price_cents: snapshot.price_cents, kind: snapshot.kind, points_amount: snapshot.points_amount, bonus_points: snapshot.bonus_points, duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, tier: snapshot.tier, } } pub fn build_runtime_profile_membership_benefit_record( snapshot: RuntimeProfileMembershipBenefitSnapshot, ) -> RuntimeProfileMembershipBenefitRecord { RuntimeProfileMembershipBenefitRecord { benefit_name: snapshot.benefit_name, normal_value: snapshot.normal_value, month_value: snapshot.month_value, season_value: snapshot.season_value, year_value: snapshot.year_value, } } pub fn build_runtime_profile_membership_record( snapshot: RuntimeProfileMembershipSnapshot, ) -> RuntimeProfileMembershipRecord { RuntimeProfileMembershipRecord { user_id: snapshot.user_id, status: snapshot.status, tier: snapshot.tier, started_at: snapshot.started_at_micros.map(format_utc_micros), started_at_micros: snapshot.started_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), expires_at_micros: snapshot.expires_at_micros, updated_at: snapshot.updated_at_micros.map(format_utc_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_recharge_order_record( snapshot: RuntimeProfileRechargeOrderSnapshot, ) -> RuntimeProfileRechargeOrderRecord { RuntimeProfileRechargeOrderRecord { order_id: snapshot.order_id, user_id: snapshot.user_id, product_id: snapshot.product_id, product_title: snapshot.product_title, kind: snapshot.kind, amount_cents: snapshot.amount_cents, status: snapshot.status, payment_channel: snapshot.payment_channel, paid_at: format_utc_micros(snapshot.paid_at_micros), paid_at_micros: snapshot.paid_at_micros, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, points_delta: snapshot.points_delta, membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros), membership_expires_at_micros: snapshot.membership_expires_at_micros, } } pub fn build_runtime_referral_invite_center_record( snapshot: RuntimeReferralInviteCenterSnapshot, ) -> RuntimeReferralInviteCenterRecord { RuntimeReferralInviteCenterRecord { user_id: snapshot.user_id, invite_code: snapshot.invite_code, invite_link_path: snapshot.invite_link_path, invited_count: snapshot.invited_count, rewarded_invite_count: snapshot.rewarded_invite_count, today_inviter_reward_count: snapshot.today_inviter_reward_count, today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining, reward_points: snapshot.reward_points, has_redeemed_code: snapshot.has_redeemed_code, bound_inviter_user_id: snapshot.bound_inviter_user_id, bound_at: snapshot.bound_at_micros.map(format_utc_micros), bound_at_micros: snapshot.bound_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_referral_redeem_record( snapshot: RuntimeReferralRedeemSnapshot, ) -> RuntimeReferralRedeemRecord { RuntimeReferralRedeemRecord { center: build_runtime_referral_invite_center_record(snapshot.center), invitee_reward_granted: snapshot.invitee_reward_granted, inviter_reward_granted: snapshot.inviter_reward_granted, invitee_balance_after: snapshot.invitee_balance_after, inviter_balance_after: snapshot.inviter_balance_after, } } pub fn build_runtime_profile_played_world_record( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> RuntimeProfilePlayedWorldRecord { RuntimeProfilePlayedWorldRecord { played_world_id: snapshot.played_world_id, user_id: snapshot.user_id, world_key: snapshot.world_key, owner_user_id: snapshot.owner_user_id, profile_id: snapshot.profile_id, world_type: snapshot.world_type, world_title: snapshot.world_title, world_subtitle: snapshot.world_subtitle, first_played_at: format_utc_micros(snapshot.first_played_at_micros), first_played_at_micros: snapshot.first_played_at_micros, last_played_at: format_utc_micros(snapshot.last_played_at_micros), last_played_at_micros: snapshot.last_played_at_micros, last_observed_play_time_ms: snapshot.last_observed_play_time_ms, } } pub fn build_runtime_profile_play_stats_record( snapshot: RuntimeProfilePlayStatsSnapshot, ) -> RuntimeProfilePlayStatsRecord { RuntimeProfilePlayStatsRecord { user_id: snapshot.user_id, total_play_time_ms: snapshot.total_play_time_ms, played_works: snapshot .played_works .into_iter() .map(build_runtime_profile_played_world_record) .collect(), updated_at: snapshot.updated_at_micros.map(format_utc_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_snapshot_record( snapshot: RuntimeSnapshot, ) -> Result { let game_state = serde_json::from_str::(&snapshot.game_state_json) .map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?; let current_story = parse_optional_json_value( snapshot.current_story_json.as_deref(), RuntimeProfileFieldError::InvalidCurrentStoryJson, )?; Ok(RuntimeSnapshotRecord { user_id: snapshot.user_id, version: snapshot.version, saved_at: format_utc_micros(snapshot.saved_at_micros), saved_at_micros: snapshot.saved_at_micros, bottom_tab: snapshot.bottom_tab, game_state, current_story, game_state_json: snapshot.game_state_json, current_story_json: snapshot.current_story_json, created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, }) } pub fn build_runtime_profile_save_archive_record( snapshot: RuntimeProfileSaveArchiveSnapshot, ) -> Result { let game_state = serde_json::from_str::(&snapshot.game_state_json) .map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?; let current_story = parse_optional_json_value( snapshot.current_story_json.as_deref(), RuntimeProfileFieldError::InvalidCurrentStoryJson, )?; Ok(RuntimeProfileSaveArchiveRecord { archive_id: snapshot.archive_id, user_id: snapshot.user_id, world_key: snapshot.world_key, owner_user_id: snapshot.owner_user_id, profile_id: snapshot.profile_id, world_type: snapshot.world_type, world_name: snapshot.world_name, subtitle: snapshot.subtitle, summary_text: snapshot.summary_text, cover_image_src: snapshot.cover_image_src, saved_at: format_utc_micros(snapshot.saved_at_micros), saved_at_micros: snapshot.saved_at_micros, bottom_tab: snapshot.bottom_tab, game_state, current_story, game_state_json: snapshot.game_state_json, current_story_json: snapshot.current_story_json, created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, }) } pub fn build_runtime_browse_history_id( user_id: &str, owner_user_id: &str, profile_id: &str, ) -> String { format!("{user_id}:{owner_user_id}:{profile_id}") } pub fn format_utc_micros(micros: i64) -> String { let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) .unwrap_or(OffsetDateTime::UNIX_EPOCH); format_shared_rfc3339(timestamp).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } fn parse_utc_rfc3339_to_micros(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { return None; } let nanos = parse_shared_rfc3339(trimmed).ok()?.unix_timestamp_nanos(); i64::try_from(nanos / 1_000).ok() } fn normalize_bottom_tab(value: String) -> Option { let trimmed = normalize_required_string(value)?; let normalized = match trimmed.as_str() { "character" | "inventory" => trimmed, _ => "adventure".to_string(), }; Some(normalized) } fn normalize_current_story_json( current_story: Option, ) -> Result, RuntimeProfileFieldError> { let Some(current_story) = current_story else { return Ok(None); }; if !current_story.is_object() { return Ok(None); } serde_json::to_string(¤t_story) .map(Some) .map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson) } fn parse_optional_json_value( raw: Option<&str>, error: RuntimeProfileFieldError, ) -> Result, RuntimeProfileFieldError> { match raw.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_json::from_str::(value) .map(Some) .map_err(|_| error), None => Ok(None), } } impl std::fmt::Display for RuntimeSettingsFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingUserId => f.write_str("runtime_setting.user_id 不能为空"), } } } impl std::fmt::Display for RuntimeBrowseHistoryFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingUserId => f.write_str("browse_history.user_id 不能为空"), Self::TooManyEntries => write!( f, "browse_history.entries 单次最多只允许 {} 条", MAX_BROWSE_HISTORY_BATCH_SIZE ), } } } impl RuntimeProfileWalletLedgerSourceType { pub fn as_str(&self) -> &'static str { match self { Self::SnapshotSync => "snapshot_sync", Self::InviteInviterReward => "invite_inviter_reward", Self::InviteInviteeReward => "invite_invitee_reward", Self::PointsRecharge => "points_recharge", } } } impl RuntimeProfileRechargeProductKind { pub fn as_str(&self) -> &'static str { match self { Self::Points => "points", Self::Membership => "membership", } } } impl RuntimeProfileMembershipStatus { pub fn as_str(&self) -> &'static str { match self { Self::Normal => "normal", Self::Active => "active", } } } impl RuntimeProfileMembershipTier { pub fn as_str(&self) -> &'static str { match self { Self::Normal => "normal", Self::Month => "month", Self::Season => "season", Self::Year => "year", } } } impl RuntimeProfileRechargeOrderStatus { pub fn as_str(&self) -> &'static str { match self { Self::Paid => "paid", } } } pub fn runtime_profile_recharge_point_products() -> Vec { vec![ build_points_recharge_product( "points_60", "60叙世币", 600, 60, 60, "首充双倍", "首充送60叙世币", ), build_points_recharge_product( "points_180", "180叙世币", 1800, 180, 180, "首充双倍", "首充送180叙世币", ), build_points_recharge_product( "points_300", "300叙世币", 3000, 300, 300, "首充双倍", "首充送300叙世币", ), build_points_recharge_product( "points_680", "680叙世币", 6800, 680, 680, "首充双倍", "首充送680叙世币", ), build_points_recharge_product( "points_1280", "1280叙世币", 12800, 1280, 1280, "首充双倍", "首充送1280叙世币", ), build_points_recharge_product( "points_3280", "3280叙世币", 32800, 3280, 3280, "首充双倍", "首充送3280叙世币", ), ] } pub fn runtime_profile_recharge_membership_products() -> Vec { vec![ build_membership_recharge_product( "member_month", "月卡", 2800, 30, RuntimeProfileMembershipTier::Month, ), build_membership_recharge_product( "member_season", "季卡", 7800, 90, RuntimeProfileMembershipTier::Season, ), build_membership_recharge_product( "member_year", "年卡", 24800, 365, RuntimeProfileMembershipTier::Year, ), ] } pub fn runtime_profile_membership_benefits() -> Vec { vec![ RuntimeProfileMembershipBenefitSnapshot { benefit_name: "特权名称".to_string(), normal_value: "普通".to_string(), month_value: "月卡".to_string(), season_value: "季卡".to_string(), year_value: "年卡".to_string(), }, RuntimeProfileMembershipBenefitSnapshot { benefit_name: "免费".to_string(), normal_value: "免费".to_string(), month_value: "¥28".to_string(), season_value: "¥78".to_string(), year_value: "¥248".to_string(), }, RuntimeProfileMembershipBenefitSnapshot { benefit_name: "免叙世币回合数".to_string(), normal_value: "30".to_string(), month_value: "100".to_string(), season_value: "100".to_string(), year_value: "100".to_string(), }, RuntimeProfileMembershipBenefitSnapshot { benefit_name: "每日签到加成".to_string(), normal_value: "0%".to_string(), month_value: "0%".to_string(), season_value: "+100%".to_string(), year_value: "+210%".to_string(), }, ] } pub fn runtime_profile_recharge_product_by_id( product_id: &str, ) -> Option { runtime_profile_recharge_point_products() .into_iter() .chain(runtime_profile_recharge_membership_products()) .find(|product| product.product_id == product_id) } fn build_points_recharge_product( product_id: &str, title: &str, price_cents: u64, points_amount: u64, bonus_points: u64, badge_label: &str, description: &str, ) -> RuntimeProfileRechargeProductSnapshot { RuntimeProfileRechargeProductSnapshot { product_id: product_id.to_string(), title: title.to_string(), price_cents, kind: RuntimeProfileRechargeProductKind::Points, points_amount, bonus_points, duration_days: 0, badge_label: badge_label.to_string(), description: description.to_string(), tier: RuntimeProfileMembershipTier::Normal, } } fn build_membership_recharge_product( product_id: &str, title: &str, price_cents: u64, duration_days: u32, tier: RuntimeProfileMembershipTier, ) -> RuntimeProfileRechargeProductSnapshot { RuntimeProfileRechargeProductSnapshot { product_id: product_id.to_string(), title: title.to_string(), price_cents, kind: RuntimeProfileRechargeProductKind::Membership, points_amount: 0, bonus_points: 0, duration_days, badge_label: String::new(), description: format!("{}天会员", duration_days), tier, } } pub fn normalize_invite_code(value: String) -> Option { let normalized = value .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .map(|character| character.to_ascii_uppercase()) .collect::(); if normalized.is_empty() { None } else { Some(normalized) } } impl std::fmt::Display for RuntimeProfileFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingUserId => f.write_str("profile.user_id 不能为空"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::UnknownRechargeProduct => f.write_str("recharge.product_id 不存在"), Self::InvalidGameStateJson => { f.write_str("runtime_snapshot.game_state 必须是合法 JSON") } Self::InvalidCurrentStoryJson => { f.write_str("runtime_snapshot.current_story 必须是合法 JSON object 或 null") } } } } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_match_shared_contract_baseline() { let settings = RuntimeSettings::defaults(); assert!((settings.music_volume - DEFAULT_MUSIC_VOLUME).abs() < f32::EPSILON); assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light); } #[test] fn normalized_clamps_music_volume_into_valid_range() { let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); let high = RuntimeSettings::normalized(3.5, RuntimePlatformTheme::Dark); assert_eq!(low.music_volume, 0.0); assert_eq!(high.music_volume, 1.0); assert_eq!(high.platform_theme, RuntimePlatformTheme::Dark); } #[test] fn theme_from_client_string_falls_back_to_light() { assert_eq!( RuntimePlatformTheme::from_client_str("dark"), RuntimePlatformTheme::Dark ); assert_eq!( RuntimePlatformTheme::from_client_str("LIGHT"), RuntimePlatformTheme::Light ); assert_eq!( RuntimePlatformTheme::from_client_str("mythic"), RuntimePlatformTheme::Light ); } #[test] fn build_upsert_input_rejects_blank_user_id() { let error = build_runtime_setting_upsert_input( " ".to_string(), DEFAULT_MUSIC_VOLUME, RuntimePlatformTheme::Light, 1, ) .expect_err("blank user id should fail"); assert_eq!(error, RuntimeSettingsFieldError::MissingUserId); } #[test] fn browse_history_theme_from_client_string_falls_back_to_mythic() { assert_eq!( RuntimeBrowseHistoryThemeMode::from_client_str("martial"), RuntimeBrowseHistoryThemeMode::Martial ); assert_eq!( RuntimeBrowseHistoryThemeMode::from_client_str("RIFT"), RuntimeBrowseHistoryThemeMode::Rift ); assert_eq!( RuntimeBrowseHistoryThemeMode::from_client_str("unknown"), RuntimeBrowseHistoryThemeMode::Mythic ); } #[test] fn build_browse_history_sync_input_normalizes_optionals_and_visited_at() { let input = build_runtime_browse_history_sync_input( " user-1 ".to_string(), vec![RuntimeBrowseHistoryWriteInput { owner_user_id: " owner-a ".to_string(), profile_id: " profile-a ".to_string(), world_name: " 世界A ".to_string(), subtitle: Some(" ".to_string()), summary_text: Some(" 简介 ".to_string()), cover_image_src: Some(" /cover.png ".to_string()), theme_mode: Some(" arcane ".to_string()), author_display_name: Some(" ".to_string()), visited_at: None, }], 1_713_680_000_000_000, ) .expect("sync input should build"); assert_eq!(input.user_id, "user-1"); assert_eq!(input.entries.len(), 1); assert_eq!(input.entries[0].owner_user_id, "owner-a"); assert_eq!(input.entries[0].profile_id, "profile-a"); assert_eq!(input.entries[0].world_name, "世界A"); assert_eq!(input.entries[0].subtitle, None); assert_eq!(input.entries[0].summary_text, Some("简介".to_string())); assert_eq!( input.entries[0].cover_image_src, Some("/cover.png".to_string()) ); assert_eq!(input.entries[0].theme_mode, Some("arcane".to_string())); assert_eq!(input.entries[0].author_display_name, None); assert_eq!( input.entries[0].visited_at, Some("2024-04-21T06:13:20Z".to_string()) ); } #[test] fn prepare_browse_history_entries_sorts_desc_and_dedups_by_owner_profile() { let entries = prepare_runtime_browse_history_entries(RuntimeBrowseHistorySyncInput { user_id: "user-1".to_string(), entries: vec![ RuntimeBrowseHistoryWriteInput { owner_user_id: "owner-a".to_string(), profile_id: "profile-a".to_string(), world_name: "世界旧".to_string(), subtitle: None, summary_text: None, cover_image_src: None, theme_mode: Some("martial".to_string()), author_display_name: None, visited_at: Some("2026-04-20T10:00:00Z".to_string()), }, RuntimeBrowseHistoryWriteInput { owner_user_id: "owner-b".to_string(), profile_id: "profile-b".to_string(), world_name: "世界B".to_string(), subtitle: None, summary_text: None, cover_image_src: None, theme_mode: Some("rift".to_string()), author_display_name: Some("作者B".to_string()), visited_at: Some("2026-04-21T10:00:00Z".to_string()), }, RuntimeBrowseHistoryWriteInput { owner_user_id: "owner-a".to_string(), profile_id: "profile-a".to_string(), world_name: "世界新".to_string(), subtitle: None, summary_text: None, cover_image_src: None, theme_mode: Some("unknown".to_string()), author_display_name: Some("".to_string()), visited_at: Some("2026-04-21T11:00:00Z".to_string()), }, ], updated_at_micros: 1_776_000_000_000_000, }) .expect("entries should prepare"); assert_eq!(entries.len(), 2); assert_eq!(entries[0].world_name, "世界新"); assert_eq!(entries[0].theme_mode, RuntimeBrowseHistoryThemeMode::Mythic); assert_eq!( entries[0].author_display_name, DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME ); assert_eq!(entries[1].world_name, "世界B"); assert!(entries[0].visited_at_micros > entries[1].visited_at_micros); } #[test] fn build_browse_history_sync_input_silently_filters_invalid_entries() { let input = build_runtime_browse_history_sync_input( "user-1".to_string(), vec![ RuntimeBrowseHistoryWriteInput { owner_user_id: " ".to_string(), profile_id: "profile-a".to_string(), world_name: "世界A".to_string(), subtitle: None, summary_text: None, cover_image_src: None, theme_mode: None, author_display_name: None, visited_at: None, }, RuntimeBrowseHistoryWriteInput { owner_user_id: "owner-b".to_string(), profile_id: "profile-b".to_string(), world_name: " 世界B ".to_string(), subtitle: None, summary_text: None, cover_image_src: None, theme_mode: None, author_display_name: None, visited_at: None, }, RuntimeBrowseHistoryWriteInput { owner_user_id: "owner-c".to_string(), profile_id: "".to_string(), world_name: "世界C".to_string(), subtitle: None, summary_text: None, cover_image_src: None, theme_mode: None, author_display_name: None, visited_at: None, }, ], 1_776_000_000_000_000, ) .expect("sync input should build"); assert_eq!(input.entries.len(), 1); assert_eq!(input.entries[0].owner_user_id, "owner-b"); assert_eq!(input.entries[0].profile_id, "profile-b"); assert_eq!(input.entries[0].world_name, "世界B"); } #[test] fn build_profile_inputs_reject_blank_user_id() { assert_eq!( build_runtime_profile_dashboard_get_input(" ".to_string()) .expect_err("dashboard input should fail"), RuntimeProfileFieldError::MissingUserId ); assert_eq!( build_runtime_profile_wallet_ledger_list_input(" ".to_string()) .expect_err("wallet ledger input should fail"), RuntimeProfileFieldError::MissingUserId ); assert_eq!( build_runtime_profile_play_stats_get_input(" ".to_string()) .expect_err("play stats input should fail"), RuntimeProfileFieldError::MissingUserId ); } #[test] fn profile_dashboard_record_formats_optional_timestamp() { let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot { user_id: "user-1".to_string(), wallet_balance: 8, total_play_time_ms: 12, played_world_count: 2, updated_at_micros: Some(1_713_680_000_000_000), }); assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string())); } #[test] fn profile_wallet_ledger_source_type_formats_to_snapshot_sync() { assert_eq!( RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(), "snapshot_sync" ); assert_eq!( RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(), "points_recharge" ); } #[test] fn recharge_product_catalog_matches_reference_prices() { let point_products = runtime_profile_recharge_point_products(); let membership_products = runtime_profile_recharge_membership_products(); assert_eq!(point_products.len(), 6); assert_eq!(point_products[0].product_id, "points_60"); assert_eq!(point_products[0].title, "60叙世币"); assert_eq!(point_products[0].price_cents, 600); assert_eq!(point_products[0].bonus_points, 60); assert_eq!(point_products[0].description, "首充送60叙世币"); assert_eq!(point_products[5].product_id, "points_3280"); assert_eq!(point_products[5].price_cents, 32800); assert_eq!(point_products[5].bonus_points, 3280); assert_eq!(point_products[5].description, "首充送3280叙世币"); assert_eq!(membership_products.len(), 3); assert_eq!(membership_products[0].title, "月卡"); assert_eq!(membership_products[0].price_cents, 2800); assert_eq!(membership_products[2].duration_days, 365); let benefits = runtime_profile_membership_benefits(); assert!( benefits .iter() .any(|benefit| benefit.benefit_name == "免叙世币回合数") ); } #[test] fn build_recharge_order_input_rejects_unknown_product() { let error = build_runtime_profile_recharge_order_create_input( "user-1".to_string(), "bad-product".to_string(), "mock".to_string(), 1, ) .expect_err("unknown product should fail"); assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct); } }