//! 运行时应用编排。 //! //! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。 use serde_json::Value; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use std::collections::BTreeMap; use crate::domain::*; use crate::errors::RuntimeProfileFieldError; use crate::format_utc_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, } } 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_profile_feedback_submission_record( snapshot: RuntimeProfileFeedbackSubmissionSnapshot, ) -> Result { let evidence_items = serde_json::from_str::>( &snapshot.evidence_json, ) .map_err(|_| RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)? .into_iter() .map(|item| RuntimeProfileFeedbackEvidenceRecord { evidence_id: item.evidence_id, file_name: item.file_name, content_type: item.content_type, size_bytes: item.size_bytes, }) .collect(); Ok(RuntimeProfileFeedbackSubmissionRecord { feedback_id: snapshot.feedback_id, user_id: snapshot.user_id, description: snapshot.description, contact_phone: snapshot.contact_phone, evidence_items, status: snapshot.status, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_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, invited_users: snapshot .invited_users .into_iter() .map(|user| RuntimeReferralInvitedUserRecord { user_id: user.user_id, display_name: user.display_name, avatar_url: user.avatar_url, bound_at: format_utc_micros(user.bound_at_micros), bound_at_micros: user.bound_at_micros, }) .collect(), 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_reward_code_redeem_record( snapshot: RuntimeProfileRewardCodeRedeemSnapshot, ) -> RuntimeProfileRewardCodeRedeemRecord { RuntimeProfileRewardCodeRedeemRecord { wallet_balance: snapshot.wallet_balance, amount_granted: snapshot.amount_granted, ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry), } } pub fn runtime_profile_beijing_day_key(now_micros: i64) -> i64 { now_micros .saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS) .div_euclid(PROFILE_RUNTIME_DAY_MICROS) } /// 从 YYYY-MM-DD 解析分析业务日 date_key。 /// /// 这里故意不引入时区库:date_key 本身就是“北京时间日历日期自 Unix 纪元起的天数”。 pub fn parse_analytics_calendar_date_key( calendar_date: &str, ) -> Result { let (year, month, day) = parse_calendar_date_parts(calendar_date)?; validate_calendar_date(year, month, day)?; let date_key = days_from_civil(year, month, day); validate_analytics_date_dimension_date_key(date_key)?; Ok(date_key) } /// 校验分析日期维表 date_key 是否位于业务允许范围内。 /// /// 裸 i64 date_key 可由 reducer 直接传入,因此在进入日历算法前先限制范围,避免极端输入 /// 生成无意义日期或触发整数边界风险。 pub fn validate_analytics_date_dimension_date_key( date_key: i64, ) -> Result<(), RuntimeProfileFieldError> { let min_date_key = days_from_civil(2000, 1, 1); let max_date_key = days_from_civil(2100, 12, 31); if date_key < min_date_key || date_key > max_date_key { return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate); } Ok(()) } pub fn build_analytics_date_dimension_from_date_key( date_key: i64, ) -> AnalyticsDateDimensionSnapshot { let (year, month, day) = civil_from_days(date_key); let weekday = weekday_from_date_key(date_key); let iso_week_key = iso_week_key(year, month, day, weekday); let week_start_date_key = date_key - i64::from(weekday - 1); let week_end_date_key = week_start_date_key + 6; let month_start_date_key = days_from_civil(year, month, 1); let month_end_date_key = days_from_civil(year, month, days_in_month(year, month)); let quarter = (month - 1) / 3 + 1; let quarter_start_month = (quarter - 1) * 3 + 1; let quarter_end_month = quarter_start_month + 2; let quarter_start_date_key = days_from_civil(year, quarter_start_month, 1); let quarter_end_date_key = days_from_civil( year, quarter_end_month, days_in_month(year, quarter_end_month), ); let year_start_date_key = days_from_civil(year, 1, 1); let year_end_date_key = days_from_civil(year, 12, 31); AnalyticsDateDimensionSnapshot { date_key, calendar_date: format!("{year:04}-{month:02}-{day:02}"), weekday, iso_week_key, week_start_date_key, week_end_date_key, month_key: year * 100 + i32::from(month), month_start_date_key, month_end_date_key, quarter_key: year * 10 + i32::from(quarter), quarter_start_date_key, quarter_end_date_key, year_key: year, year_start_date_key, year_end_date_key, } } fn parse_calendar_date_parts( calendar_date: &str, ) -> Result<(i32, u8, u8), RuntimeProfileFieldError> { let mut parts = calendar_date.trim().split('-'); let year = parts .next() .and_then(|value| value.parse::().ok()) .ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?; let month = parts .next() .and_then(|value| value.parse::().ok()) .ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?; let day = parts .next() .and_then(|value| value.parse::().ok()) .ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?; if parts.next().is_some() { return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate); } Ok((year, month, day)) } fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimeProfileFieldError> { if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate); } Ok(()) } fn days_in_month(year: i32, month: u8) -> u8 { match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 if is_leap_year(year) => 29, 2 => 28, _ => 0, } } fn is_leap_year(year: i32) -> bool { (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } fn weekday_from_date_key(date_key: i64) -> u8 { // 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。 (date_key + 3).rem_euclid(7) as u8 + 1 } fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 { let ordinal = ordinal_day(year, month, day); let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7); let iso_year = if week < 1 { year - 1 } else if week > iso_weeks_in_year(year) { year + 1 } else { year }; let iso_week = if week < 1 { iso_weeks_in_year(year - 1) } else if week > iso_weeks_in_year(year) { 1 } else { week }; iso_year * 100 + iso_week } fn ordinal_day(year: i32, month: u8, day: u8) -> u16 { (1..month) .map(|current_month| u16::from(days_in_month(year, current_month))) .sum::() + u16::from(day) } fn iso_weeks_in_year(year: i32) -> i32 { let jan_first_weekday = weekday_from_date_key(days_from_civil(year, 1, 1)); if jan_first_weekday == 4 || (jan_first_weekday == 3 && is_leap_year(year)) { 53 } else { 52 } } fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { // 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。 let adjusted_year = year - if month <= 2 { 1 } else { 0 }; let era = adjusted_year.div_euclid(400); let year_of_era = adjusted_year - era * 400; let month = i32::from(month); let day = i32::from(day); let month_prime = month + if month > 2 { -3 } else { 9 }; let day_of_year = (153 * month_prime + 2) / 5 + day - 1; let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; i64::from(era * 146_097 + day_of_era - 719_468) } fn civil_from_days(date_key: i64) -> (i32, u8, u8) { // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 let z = date_key + 719_468; let era = z.div_euclid(146_097); let day_of_era = z - era * 146_097; let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) .div_euclid(365); let mut year = year_of_era + era * 400; let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); let month_prime = (5 * day_of_year + 2).div_euclid(153); let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1; let month = month_prime + if month_prime < 10 { 3 } else { -9 }; year += if month <= 2 { 1 } else { 0 }; (year as i32, month as u8, day as u8) } pub fn build_default_runtime_profile_task_config( updated_at_micros: i64, updated_by: String, ) -> RuntimeProfileTaskConfigSnapshot { RuntimeProfileTaskConfigSnapshot { task_id: PROFILE_TASK_ID_DAILY_LOGIN.to_string(), title: PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN.to_string(), description: String::new(), event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(), cycle: RuntimeProfileTaskCycle::Daily, scope_kind: RuntimeTrackingScopeKind::User, threshold: PROFILE_TASK_DEFAULT_THRESHOLD, reward_points: PROFILE_TASK_DEFAULT_REWARD_POINTS, enabled: true, sort_order: 10, created_by: updated_by.clone(), created_at_micros: updated_at_micros, updated_by, updated_at_micros, } } pub fn resolve_runtime_profile_task_status( enabled: bool, progress_count: u32, threshold: u32, claimed: bool, ) -> RuntimeProfileTaskStatus { if !enabled { return RuntimeProfileTaskStatus::Disabled; } if claimed { return RuntimeProfileTaskStatus::Claimed; } if progress_count >= threshold { RuntimeProfileTaskStatus::Claimable } else { RuntimeProfileTaskStatus::Incomplete } } pub fn build_runtime_profile_task_progress_id( user_id: &str, task_id: &str, day_key: i64, ) -> String { format!("{}:{}:{}", user_id.trim(), task_id.trim(), day_key) } pub fn build_runtime_profile_task_claim_id(user_id: &str, task_id: &str, day_key: i64) -> String { build_runtime_profile_task_progress_id(user_id, task_id, day_key) } pub fn build_runtime_profile_task_reward_ledger_id( user_id: &str, task_id: &str, day_key: i64, ) -> String { format!( "task-reward:{}:{}:{}", user_id.trim(), task_id.trim(), day_key ) } pub fn build_runtime_tracking_event_id( event_key: &str, scope_kind: RuntimeTrackingScopeKind, scope_id: &str, occurred_at_micros: i64, ) -> String { format!( "tracking:{}:{}:{}:{}", event_key.trim(), scope_kind.as_str(), scope_id.trim(), occurred_at_micros ) } pub fn build_runtime_tracking_daily_stat_id( event_key: &str, scope_kind: RuntimeTrackingScopeKind, scope_id: &str, day_key: i64, ) -> String { format!( "tracking-stat:{}:{}:{}:{}", event_key.trim(), scope_kind.as_str(), scope_id.trim(), day_key ) } pub fn aggregate_runtime_tracking_daily_stats( stats: Vec, event_key: &str, scope_kind: RuntimeTrackingScopeKind, scope_id: &str, granularity: AnalyticsGranularity, ) -> Vec { let mut buckets: BTreeMap<(String, i64, i64), u64> = BTreeMap::new(); let event_key = event_key.trim(); let scope_id = scope_id.trim(); for stat in stats { if stat.event_key.trim() != event_key || stat.scope_kind != scope_kind || stat.scope_id.trim() != scope_id { continue; } let dimension = build_analytics_date_dimension_from_date_key(stat.day_key); let (bucket_key, bucket_start_date_key, bucket_end_date_key) = analytics_bucket_for_dimension(&dimension, granularity); *buckets .entry((bucket_key, bucket_start_date_key, bucket_end_date_key)) .or_insert(0) += u64::from(stat.count); } buckets .into_iter() .map( |((bucket_key, bucket_start_date_key, bucket_end_date_key), value)| { AnalyticsBucketMetric { bucket_key, bucket_start_date_key, bucket_end_date_key, value, } }, ) .collect() } fn analytics_bucket_for_dimension( dimension: &AnalyticsDateDimensionSnapshot, granularity: AnalyticsGranularity, ) -> (String, i64, i64) { match granularity { AnalyticsGranularity::Day => ( dimension.calendar_date.clone(), dimension.date_key, dimension.date_key, ), AnalyticsGranularity::Week => ( dimension.iso_week_key.to_string(), dimension.week_start_date_key, dimension.week_end_date_key, ), AnalyticsGranularity::Month => ( dimension.month_key.to_string(), dimension.month_start_date_key, dimension.month_end_date_key, ), AnalyticsGranularity::Quarter => ( dimension.quarter_key.to_string(), dimension.quarter_start_date_key, dimension.quarter_end_date_key, ), AnalyticsGranularity::Year => ( dimension.year_key.to_string(), dimension.year_start_date_key, dimension.year_end_date_key, ), } } pub fn build_runtime_profile_task_config_record( snapshot: RuntimeProfileTaskConfigSnapshot, ) -> RuntimeProfileTaskConfigRecord { RuntimeProfileTaskConfigRecord { task_id: snapshot.task_id, title: snapshot.title, description: snapshot.description, event_key: snapshot.event_key, cycle: snapshot.cycle, scope_kind: snapshot.scope_kind, threshold: snapshot.threshold, reward_points: snapshot.reward_points, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, updated_by: snapshot.updated_by, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_task_item_record( snapshot: RuntimeProfileTaskItemSnapshot, ) -> RuntimeProfileTaskItemRecord { RuntimeProfileTaskItemRecord { task_id: snapshot.task_id, title: snapshot.title, description: snapshot.description, event_key: snapshot.event_key, cycle: snapshot.cycle, threshold: snapshot.threshold, progress_count: snapshot.progress_count, reward_points: snapshot.reward_points, status: snapshot.status, day_key: snapshot.day_key, claimed_at: snapshot.claimed_at_micros.map(format_utc_micros), claimed_at_micros: snapshot.claimed_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_task_center_record( snapshot: RuntimeProfileTaskCenterSnapshot, ) -> RuntimeProfileTaskCenterRecord { RuntimeProfileTaskCenterRecord { user_id: snapshot.user_id, day_key: snapshot.day_key, wallet_balance: snapshot.wallet_balance, tasks: snapshot .tasks .into_iter() .map(build_runtime_profile_task_item_record) .collect(), updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_task_claim_record( snapshot: RuntimeProfileTaskClaimSnapshot, ) -> RuntimeProfileTaskClaimRecord { RuntimeProfileTaskClaimRecord { user_id: snapshot.user_id, task_id: snapshot.task_id, day_key: snapshot.day_key, reward_points: snapshot.reward_points, wallet_balance: snapshot.wallet_balance, ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry), center: build_runtime_profile_task_center_record(snapshot.center), } } pub fn build_runtime_profile_redeem_code_record( snapshot: RuntimeProfileRedeemCodeSnapshot, ) -> RuntimeProfileRedeemCodeRecord { RuntimeProfileRedeemCodeRecord { code: snapshot.code, mode: snapshot.mode, reward_points: snapshot.reward_points, max_uses: snapshot.max_uses, global_used_count: snapshot.global_used_count, enabled: snapshot.enabled, allowed_user_ids: snapshot.allowed_user_ids, created_by: snapshot.created_by, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, } } pub fn build_runtime_profile_invite_code_record( snapshot: RuntimeProfileInviteCodeSnapshot, ) -> RuntimeProfileInviteCodeRecord { let status = crate::commands::resolve_runtime_profile_invite_code_status( snapshot.starts_at_micros, snapshot.expires_at_micros, snapshot.updated_at_micros, ); RuntimeProfileInviteCodeRecord { user_id: snapshot.user_id, invite_code: snapshot.invite_code, metadata_json: snapshot.metadata_json, granted_user_tags: snapshot.granted_user_tags, starts_at: snapshot.starts_at_micros.map(format_utc_micros), starts_at_micros: snapshot.starts_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), expires_at_micros: snapshot.expires_at_micros, status, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, } } 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_save_checkpoint_update( input: RuntimeSaveCheckpointInput, existing: RuntimeSnapshotRecord, ) -> Result { if is_non_persistent_runtime_snapshot(&existing.game_state) { return Err(RuntimeProfileFieldError::NonPersistentRuntimeSnapshot); } let persisted_session_id = read_runtime_json_string_field(&existing.game_state, "runtimeSessionId") .ok_or(RuntimeProfileFieldError::MissingRuntimeSessionId)?; if persisted_session_id != input.session_id { return Err(RuntimeProfileFieldError::RuntimeSessionMismatch { expected_session_id: persisted_session_id, actual_session_id: input.session_id, }); } Ok(RuntimeSaveCheckpointSnapshotUpdate { saved_at_micros: input.saved_at_micros, bottom_tab: input.bottom_tab, game_state: refresh_runtime_snapshot_play_time( existing.game_state, input.updated_at_micros, ), current_story: existing.current_story, updated_at_micros: input.updated_at_micros, }) } pub fn build_runtime_profile_played_world_id(user_id: &str, world_key: &str) -> String { format!("{}:{}", user_id.trim(), world_key.trim()) } pub fn build_runtime_profile_snapshot_wallet_ledger_id( user_id: &str, saved_at_micros: i64, next_wallet_balance: u64, ) -> String { format!( "{}:{}:{}", user_id.trim(), saved_at_micros, next_wallet_balance ) } pub fn build_runtime_profile_save_archive_id(user_id: &str, world_key: &str) -> String { format!("{}:{}", user_id.trim(), world_key.trim()) } pub fn build_runtime_profile_recharge_wallet_ledger_id( user_id: &str, created_at_micros: i64, product_id: &str, ) -> String { format!( "{}:{}:{}", user_id.trim(), created_at_micros, product_id.trim() ) } pub fn build_runtime_profile_recharge_order_id( user_id: &str, created_at_micros: i64, product_id: &str, ) -> String { format!( "recharge:{}", build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id) ) } pub fn resolve_runtime_profile_points_recharge_delta( product: &RuntimeProfileRechargeProductSnapshot, has_points_recharged: bool, ) -> u64 { let bonus_points = if has_points_recharged { 0 } else { product.bonus_points }; product.points_amount.saturating_add(bonus_points) } pub fn resolve_runtime_profile_membership_purchase_update( current_started_at_micros: Option, current_expires_at_micros: Option, purchased_at_micros: i64, duration_days: u32, ) -> RuntimeProfileMembershipPurchaseUpdate { let start_at_micros = current_expires_at_micros .filter(|expires_at_micros| *expires_at_micros > purchased_at_micros) .unwrap_or(purchased_at_micros); let expires_at_micros = start_at_micros .saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)); RuntimeProfileMembershipPurchaseUpdate { started_at_micros: current_started_at_micros.unwrap_or(purchased_at_micros), expires_at_micros, } } pub fn build_runtime_profile_invite_code(user_id: &str, salt: u32) -> String { let mut hash = 14_695_981_039_346_656_037u64; for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) { hash ^= byte as u64; hash = hash.wrapping_mul(1_099_511_628_211); } format!("SY{:08X}", hash as u32) } pub fn build_runtime_profile_invite_link_path(invite_code: &str) -> String { format!("/?inviteCode={}", invite_code.trim()) } pub fn runtime_profile_day_start_micros(now_micros: i64) -> i64 { now_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS) * PROFILE_RUNTIME_DAY_MICROS } pub fn should_grant_runtime_profile_inviter_reward(today_inviter_reward_count: u32) -> bool { today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT } pub fn build_runtime_profile_referral_invitee_ledger_id( invitee_user_id: &str, updated_at_micros: i64, ) -> String { format!("invitee:{}:{}", invitee_user_id.trim(), updated_at_micros) } pub fn build_runtime_profile_referral_inviter_ledger_id( inviter_user_id: &str, updated_at_micros: i64, ) -> String { format!("inviter:{}:{}", inviter_user_id.trim(), updated_at_micros) } pub fn validate_runtime_profile_redeem_code_usage( code: &RuntimeProfileRedeemCodeSnapshot, user_id: &str, user_used_count: u32, ) -> Result<(), RuntimeProfileFieldError> { if !code.enabled { return Err(RuntimeProfileFieldError::RedeemCodeDisabled); } if code.reward_points == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); } match code.mode { RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => { Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted) } RuntimeProfileRedeemCodeMode::Unique if code.global_used_count >= code.max_uses => { Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted) } RuntimeProfileRedeemCodeMode::Private => { if !code.allowed_user_ids.iter().any(|item| item == user_id) { return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser); } if code.global_used_count >= code.max_uses { return Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted); } Ok(()) } _ => Ok(()), } } pub fn build_runtime_profile_redeem_code_usage_id( code: &str, user_id: &str, redeemed_at_micros: i64, sequence: u32, ) -> String { format!( "redeem:{}:{}:{}:{}", code.trim(), user_id.trim(), redeemed_at_micros, sequence ) } pub fn build_runtime_profile_redeem_code_ledger_id(usage_id: &str) -> String { format!("{}:ledger", usage_id.trim()) } pub fn convert_runtime_profile_wallet_unsigned_delta( amount_delta: u64, ) -> Result { i64::try_from(amount_delta).map_err(|_| RuntimeProfileFieldError::WalletAmountOverflow) } pub fn calculate_runtime_profile_wallet_balance( previous_balance: u64, amount_delta: i64, ) -> Result { if amount_delta >= 0 { previous_balance .checked_add(amount_delta as u64) .ok_or(RuntimeProfileFieldError::WalletBalanceOverflow) } else { previous_balance .checked_sub(amount_delta.unsigned_abs()) .ok_or(RuntimeProfileFieldError::InsufficientWalletBalance) } } pub fn refresh_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value { let Some(game_state_object) = game_state.as_object_mut() else { return game_state; }; let now_text = format_utc_micros(now_micros); let Some(runtime_stats) = game_state_object .get_mut("runtimeStats") .and_then(Value::as_object_mut) else { game_state_object.insert( "runtimeStats".to_string(), serde_json::json!({ "playTimeMs": 0, "lastPlayTickAt": now_text, "hostileNpcsDefeated": 0, "questsAccepted": 0, "itemsUsed": 0, "scenesTraveled": 0, }), ); return game_state; }; let current_play_time = runtime_stats .get("playTimeMs") .and_then(Value::as_f64) .filter(|value| value.is_finite() && *value >= 0.0) .unwrap_or(0.0); let elapsed_ms = runtime_stats .get("lastPlayTickAt") .and_then(Value::as_str) .and_then(|last_tick| parse_rfc3339(last_tick).ok()) .map(offset_datetime_to_unix_micros) .map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0) .unwrap_or(0.0); let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0); // checkpoint 只刷新服务端已有 runtimeStats 的时间水位,不接收浏览器上传的剧情、背包或战斗真相。 runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64)); runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text)); game_state } pub fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool { let Some(game_state) = game_state.as_object() else { return false; }; if game_state .get("runtimePersistenceDisabled") .and_then(Value::as_bool) .unwrap_or(false) { return true; } matches!( read_runtime_json_string_field_from_map(game_state, "runtimeMode").as_deref(), Some("preview") | Some("test") ) } pub fn resolve_runtime_profile_world_snapshot_meta( game_state: Option<&serde_json::Map>, ) -> Option { let game_state = game_state?; let custom_world_profile = game_state .get("customWorldProfile") .and_then(Value::as_object); if let Some(custom_world_profile) = custom_world_profile { let profile_id = read_runtime_json_string_field_from_map(custom_world_profile, "id"); let world_title = read_runtime_json_string_field_from_map(custom_world_profile, "name") .or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title")); if profile_id.is_some() || world_title.is_some() { let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string()); return Some(RuntimeProfileWorldSnapshotMeta { world_key: profile_id .as_ref() .map(|profile_id| format!("custom:{profile_id}")) .unwrap_or_else(|| format!("custom:{world_title}")), owner_user_id: None, profile_id, world_type: Some("CUSTOM".to_string()), world_title, world_subtitle: read_runtime_json_string_field_from_map( custom_world_profile, "summary", ) .or_else(|| { read_runtime_json_string_field_from_map(custom_world_profile, "settingText") }) .unwrap_or_default(), }); } } let world_type = read_runtime_json_string_field_from_map(game_state, "worldType")?; let current_scene_preset = game_state .get("currentScenePreset") .and_then(Value::as_object); Some(RuntimeProfileWorldSnapshotMeta { world_key: format!("builtin:{world_type}"), owner_user_id: None, profile_id: None, world_type: Some(world_type.clone()), world_title: current_scene_preset .and_then(|preset| read_runtime_json_string_field_from_map(preset, "name")) .unwrap_or_else(|| build_runtime_builtin_world_title(&world_type)), world_subtitle: current_scene_preset .and_then(|preset| { read_runtime_json_string_field_from_map(preset, "summary") .or_else(|| read_runtime_json_string_field_from_map(preset, "description")) }) .unwrap_or_default(), }) } pub fn resolve_runtime_profile_save_archive_meta( game_state: &Value, current_story_json: Option<&str>, ) -> Option { if is_non_persistent_runtime_snapshot(game_state) { return None; } let game_state_object = game_state.as_object(); let world_meta = resolve_runtime_profile_world_snapshot_meta(game_state_object)?; let story_engine_memory = game_state_object .and_then(|state| state.get("storyEngineMemory")) .and_then(Value::as_object); let continue_game_digest = story_engine_memory .and_then(|memory| read_runtime_json_string_field_from_map(memory, "continueGameDigest")); let current_story_text = parse_optional_json_value( current_story_json, RuntimeProfileFieldError::InvalidCurrentStoryJson, ) .ok() .flatten() .and_then(|story| story.as_object().cloned()) .and_then(|story| read_runtime_json_string_field_from_map(&story, "text")); let custom_world_profile = game_state_object .and_then(|state| state.get("customWorldProfile")) .and_then(Value::as_object); if let Some(custom_world_profile) = custom_world_profile { let world_name = read_runtime_json_string_field_from_map(custom_world_profile, "name") .or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title")) .unwrap_or_else(|| world_meta.world_title.clone()); let subtitle = read_runtime_json_string_field_from_map(custom_world_profile, "summary") .or_else(|| { read_runtime_json_string_field_from_map(custom_world_profile, "settingText") }) .unwrap_or_else(|| world_meta.world_subtitle.clone()); let summary_text = continue_game_digest .or(current_story_text) .or_else(|| { if subtitle.is_empty() { None } else { Some(subtitle.clone()) } }) .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); return Some(RuntimeProfileSaveArchiveMeta { world_key: world_meta.world_key, owner_user_id: world_meta.owner_user_id, profile_id: world_meta.profile_id, world_type: world_meta.world_type, world_name, subtitle, summary_text, cover_image_src: read_runtime_json_string_field_from_map( custom_world_profile, "coverImageSrc", ), }); } let summary_text = continue_game_digest .or(current_story_text) .or_else(|| { if world_meta.world_subtitle.is_empty() { None } else { Some(world_meta.world_subtitle.clone()) } }) .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); let current_scene_preset = game_state_object .and_then(|state| state.get("currentScenePreset")) .and_then(Value::as_object); Some(RuntimeProfileSaveArchiveMeta { world_key: world_meta.world_key, owner_user_id: world_meta.owner_user_id, profile_id: world_meta.profile_id, world_type: world_meta.world_type, world_name: world_meta.world_title, subtitle: world_meta.world_subtitle, summary_text, cover_image_src: current_scene_preset .and_then(|preset| read_runtime_json_string_field_from_map(preset, "imageSrc")), }) } pub fn read_runtime_json_non_negative_u64(value: Option<&Value>) -> u64 { match value { Some(Value::Number(number)) => { if let Some(raw) = number.as_u64() { raw } else if let Some(raw) = number.as_i64() { raw.max(0) as u64 } else if let Some(raw) = number.as_f64() { if raw.is_finite() && raw > 0.0 { raw.floor() as u64 } else { 0 } } else { 0 } } Some(Value::String(raw)) => raw.trim().parse::().ok().unwrap_or(0), _ => 0, } } pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option { read_runtime_json_string_field_from_map(value.as_object()?, field) } pub fn read_runtime_json_string_field_from_map( value: &serde_json::Map, field: &str, ) -> Option { value .get(field)? .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } pub fn build_runtime_builtin_world_title(world_type: &str) -> String { match world_type { "WUXIA" => "武侠世界".to_string(), "XIANXIA" => "仙侠世界".to_string(), _ => "叙事世界".to_string(), } } 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), } }