Files
Genarrative/server-rs/crates/module-runtime/src/lib.rs
2026-04-26 17:53:31 +08:00

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