2008 lines
68 KiB
Rust
2008 lines
68 KiB
Rust
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(¤t_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);
|
||
}
|
||
}
|