//! 运行时写入命令过渡落位。 //! //! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。 use std::collections::HashSet; use serde_json::Value; use shared_kernel::{ normalize_optional_string, normalize_required_string, parse_rfc3339 as parse_shared_rfc3339, }; use crate::domain::*; use crate::errors::*; use crate::{format_utc_micros, runtime_profile_recharge_product_by_id}; // 统一把共享必填字符串归一化映射到 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_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_wallet_adjustment_input( user_id: String, amount: u64, ledger_id: String, created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let ledger_id = normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; if amount == 0 || amount > i64::MAX as u64 { return Err(RuntimeProfileFieldError::InvalidWalletAmount); } Ok(RuntimeProfileWalletAdjustmentInput { user_id, amount, ledger_id, created_at_micros, }) } 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_reward_code_redeem_input( user_id: String, code: String, redeemed_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; Ok(RuntimeProfileRewardCodeRedeemInput { user_id, code, redeemed_at_micros, }) } pub fn build_runtime_profile_redeem_code_admin_upsert_input( admin_user_id: String, code: String, mode: RuntimeProfileRedeemCodeMode, reward_points: u64, max_uses: u32, enabled: bool, allowed_user_ids: Vec, allowed_public_user_codes: Vec, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; if reward_points == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); } if max_uses == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); } Ok(RuntimeProfileRedeemCodeAdminUpsertInput { admin_user_id, code, mode, reward_points, max_uses, enabled, allowed_user_ids: allowed_user_ids .into_iter() .filter_map(|value| normalize_optional_string(Some(value))) .collect(), allowed_public_user_codes: allowed_public_user_codes .into_iter() .filter_map(|value| normalize_optional_string(Some(value))) .collect(), updated_at_micros, }) } pub fn build_runtime_profile_redeem_code_admin_disable_input( admin_user_id: String, code: String, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; Ok(RuntimeProfileRedeemCodeAdminDisableInput { admin_user_id, 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_save_checkpoint_input( session_id: String, bottom_tab: String, saved_at_micros: i64, updated_at_micros: i64, ) -> Result { let session_id = normalize_required_string(session_id) .ok_or(RuntimeProfileFieldError::MissingCheckpointSessionId)?; let bottom_tab = normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?; Ok(RuntimeSaveCheckpointInput { session_id, bottom_tab, saved_at_micros, updated_at_micros, }) } 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_id( user_id: &str, owner_user_id: &str, profile_id: &str, ) -> String { format!("{user_id}:{owner_user_id}:{profile_id}") } 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) } 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) } } pub fn normalize_redeem_code(value: String) -> Option { normalize_invite_code(value) }