Files
Genarrative/server-rs/crates/module-runtime/src/application.rs
2026-05-11 16:15:48 +08:00

1337 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 运行时应用编排。
//!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 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;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryStartCardResponse, CreationEntryTypeModalResponse,
CreationEntryTypeResponse,
};
pub fn build_creation_entry_config_response(
snapshot: CreationEntryConfigSnapshot,
) -> CreationEntryConfigResponse {
CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
title: snapshot.start_card.title,
description: snapshot.start_card.description,
idle_badge: snapshot.start_card.idle_badge,
busy_badge: snapshot.start_card.busy_badge,
},
type_modal: CreationEntryTypeModalResponse {
title: snapshot.type_modal.title,
description: snapshot.type_modal.description,
},
creation_types: snapshot
.creation_types
.into_iter()
.map(|item| CreationEntryTypeResponse {
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
image_src: item.image_src,
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
updated_at_micros: item.updated_at_micros,
})
.collect(),
}
}
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,
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),
}
}