1302 lines
46 KiB
Rust
1302 lines
46 KiB
Rust
//! 运行时应用编排。
|
||
//!
|
||
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 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<RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileFieldError> {
|
||
let evidence_items = serde_json::from_str::<Vec<RuntimeProfileFeedbackEvidenceSnapshot>>(
|
||
&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<i64, RuntimeProfileFieldError> {
|
||
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::<i32>().ok())
|
||
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
|
||
let month = parts
|
||
.next()
|
||
.and_then(|value| value.parse::<u8>().ok())
|
||
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
|
||
let day = parts
|
||
.next()
|
||
.and_then(|value| value.parse::<u8>().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>()
|
||
+ 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<RuntimeAnalyticsDailyStatSnapshot>,
|
||
event_key: &str,
|
||
scope_kind: RuntimeTrackingScopeKind,
|
||
scope_id: &str,
|
||
granularity: AnalyticsGranularity,
|
||
) -> Vec<AnalyticsBucketMetric> {
|
||
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<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
|
||
let game_state = serde_json::from_str::<Value>(&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<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
|
||
let game_state = serde_json::from_str::<Value>(&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<RuntimeSaveCheckpointSnapshotUpdate, RuntimeProfileFieldError> {
|
||
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<i64>,
|
||
current_expires_at_micros: Option<i64>,
|
||
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, RuntimeProfileFieldError> {
|
||
i64::try_from(amount_delta).map_err(|_| RuntimeProfileFieldError::WalletAmountOverflow)
|
||
}
|
||
|
||
pub fn calculate_runtime_profile_wallet_balance(
|
||
previous_balance: u64,
|
||
amount_delta: i64,
|
||
) -> Result<u64, RuntimeProfileFieldError> {
|
||
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<String, Value>>,
|
||
) -> Option<RuntimeProfileWorldSnapshotMeta> {
|
||
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<RuntimeProfileSaveArchiveMeta> {
|
||
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::<u64>().ok().unwrap_or(0),
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option<String> {
|
||
read_runtime_json_string_field_from_map(value.as_object()?, field)
|
||
}
|
||
|
||
pub fn read_runtime_json_string_field_from_map(
|
||
value: &serde_json::Map<String, Value>,
|
||
field: &str,
|
||
) -> Option<String> {
|
||
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<Option<Value>, RuntimeProfileFieldError> {
|
||
match raw.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(value) => serde_json::from_str::<Value>(value)
|
||
.map(Some)
|
||
.map_err(|_| error),
|
||
None => Ok(None),
|
||
}
|
||
}
|