Files
Genarrative/server-rs/crates/module-runtime/src/lib.rs
T

2416 lines
82 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 PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
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,
AssetOperationConsume,
AssetOperationRefund,
RedeemCodeReward,
PuzzleAuthorIncentiveClaim,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileRedeemCodeMode {
Public,
Unique,
Private,
}
#[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 RuntimeProfileWalletAdjustmentProcedureResult {
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 RuntimeProfileWalletLedgerListInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileWalletAdjustmentInput {
pub user_id: String,
pub amount: u64,
pub ledger_id: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemInput {
pub user_id: String,
pub code: String,
pub redeemed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
pub admin_user_id: String,
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub allowed_public_user_codes: Vec<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminDisableInput {
pub admin_user_id: String,
pub code: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeSnapshot {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: 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 RuntimeProfileRedeemCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: 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 RuntimeProfileInviteCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<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,
MissingLedgerId,
InvalidWalletAmount,
MissingInviteCode,
InvalidInviteCodeMetadata,
MissingRedeemCode,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
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 RuntimeProfileRewardCodeRedeemRecord {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRedeemCodeRecord {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileInviteCodeRecord {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[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_wallet_adjustment_input(
user_id: String,
amount: u64,
ledger_id: String,
created_at_micros: i64,
) -> Result<RuntimeProfileWalletAdjustmentInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let ledger_id =
normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?;
if amount == 0 || amount > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidWalletAmount);
}
Ok(RuntimeProfileWalletAdjustmentInput {
user_id,
amount,
ledger_id,
created_at_micros,
})
}
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_reward_code_redeem_input(
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
Ok(RuntimeProfileRewardCodeRedeemInput {
user_id,
code,
redeemed_at_micros,
})
}
pub fn build_runtime_profile_redeem_code_admin_upsert_input(
admin_user_id: String,
code: String,
mode: RuntimeProfileRedeemCodeMode,
reward_points: u64,
max_uses: u32,
enabled: bool,
allowed_user_ids: Vec<String>,
allowed_public_user_codes: Vec<String>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
if reward_points == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
}
if max_uses == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses);
}
Ok(RuntimeProfileRedeemCodeAdminUpsertInput {
admin_user_id,
code,
mode,
reward_points,
max_uses,
enabled,
allowed_user_ids: allowed_user_ids
.into_iter()
.filter_map(|value| normalize_optional_string(Some(value)))
.collect(),
allowed_public_user_codes: allowed_public_user_codes
.into_iter()
.filter_map(|value| normalize_optional_string(Some(value)))
.collect(),
updated_at_micros,
})
}
pub fn build_runtime_profile_redeem_code_admin_disable_input(
admin_user_id: String,
code: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
Ok(RuntimeProfileRedeemCodeAdminDisableInput {
admin_user_id,
code,
updated_at_micros,
})
}
pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileInviteCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
admin_user_id,
invite_code,
metadata_json,
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_reward_code_redeem_record(
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
) -> RuntimeProfileRewardCodeRedeemRecord {
RuntimeProfileRewardCodeRedeemRecord {
wallet_balance: snapshot.wallet_balance,
amount_granted: snapshot.amount_granted,
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
}
}
pub fn build_runtime_profile_redeem_code_record(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> RuntimeProfileRedeemCodeRecord {
RuntimeProfileRedeemCodeRecord {
code: snapshot.code,
mode: snapshot.mode,
reward_points: snapshot.reward_points,
max_uses: snapshot.max_uses,
global_used_count: snapshot.global_used_count,
enabled: snapshot.enabled,
allowed_user_ids: snapshot.allowed_user_ids,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_invite_code_record(
snapshot: RuntimeProfileInviteCodeSnapshot,
) -> RuntimeProfileInviteCodeRecord {
RuntimeProfileInviteCodeRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_played_world_record(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord {
RuntimeProfilePlayedWorldRecord {
played_world_id: snapshot.played_world_id,
user_id: snapshot.user_id,
world_key: snapshot.world_key,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_type: snapshot.world_type,
world_title: snapshot.world_title,
world_subtitle: snapshot.world_subtitle,
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
first_played_at_micros: snapshot.first_played_at_micros,
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
last_played_at_micros: snapshot.last_played_at_micros,
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
}
}
pub fn build_runtime_profile_play_stats_record(
snapshot: RuntimeProfilePlayStatsSnapshot,
) -> RuntimeProfilePlayStatsRecord {
RuntimeProfilePlayStatsRecord {
user_id: snapshot.user_id,
total_play_time_ms: snapshot.total_play_time_ms,
played_works: snapshot
.played_works
.into_iter()
.map(build_runtime_profile_played_world_record)
.collect(),
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_snapshot_record(
snapshot: RuntimeSnapshot,
) -> Result<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story = parse_optional_json_value(
snapshot.current_story_json.as_deref(),
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)?;
Ok(RuntimeSnapshotRecord {
user_id: snapshot.user_id,
version: snapshot.version,
saved_at: format_utc_micros(snapshot.saved_at_micros),
saved_at_micros: snapshot.saved_at_micros,
bottom_tab: snapshot.bottom_tab,
game_state,
current_story,
game_state_json: snapshot.game_state_json,
current_story_json: snapshot.current_story_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
})
}
pub fn build_runtime_profile_save_archive_record(
snapshot: RuntimeProfileSaveArchiveSnapshot,
) -> Result<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story = parse_optional_json_value(
snapshot.current_story_json.as_deref(),
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)?;
Ok(RuntimeProfileSaveArchiveRecord {
archive_id: snapshot.archive_id,
user_id: snapshot.user_id,
world_key: snapshot.world_key,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_type: snapshot.world_type,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
saved_at: format_utc_micros(snapshot.saved_at_micros),
saved_at_micros: snapshot.saved_at_micros,
bottom_tab: snapshot.bottom_tab,
game_state,
current_story,
game_state_json: snapshot.game_state_json,
current_story_json: snapshot.current_story_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
})
}
pub fn build_runtime_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",
Self::AssetOperationConsume => "asset_operation_consume",
Self::AssetOperationRefund => "asset_operation_refund",
Self::RedeemCodeReward => "redeem_code_reward",
Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim",
}
}
}
impl RuntimeProfileRedeemCodeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Public => "public",
Self::Unique => "unique",
Self::Private => "private",
}
}
}
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)
}
}
pub fn normalize_invite_code_metadata_json(
value: String,
) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string());
}
if trimmed.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
let parsed = serde_json::from_str::<Value>(trimmed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if !parsed.is_object() {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
pub fn normalize_redeem_code(value: String) -> Option<String> {
normalize_invite_code(value)
}
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::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是 JSON 对象且不超过 4096 bytes")
}
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
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 invite_code_metadata_defaults_to_empty_object() {
assert_eq!(
normalize_invite_code_metadata_json(" ".to_string()).expect("blank metadata defaults"),
"{}"
);
}
#[test]
fn invite_code_metadata_requires_json_object() {
assert_eq!(
normalize_invite_code_metadata_json("[]".to_string()).expect_err("array rejects"),
RuntimeProfileFieldError::InvalidInviteCodeMetadata
);
assert_eq!(
normalize_invite_code_metadata_json("{bad".to_string()).expect_err("bad json rejects"),
RuntimeProfileFieldError::InvalidInviteCodeMetadata
);
}
#[test]
fn build_admin_invite_code_input_normalizes_code_and_compacts_metadata() {
let input = build_runtime_profile_invite_code_admin_upsert_input(
" admin-user ".to_string(),
" spring-2026 ".to_string(),
r#"{ "channel": "spring", "batch": 1 }"#.to_string(),
1_776_000_000_000_000,
)
.expect("admin invite input should build");
assert_eq!(input.admin_user_id, "admin-user");
assert_eq!(input.invite_code, "SPRING2026");
assert_eq!(input.metadata_json, r#"{"batch":1,"channel":"spring"}"#);
}
#[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"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
"asset_operation_consume"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_operation_refund"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim.as_str(),
"puzzle_author_incentive_claim"
);
}
#[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);
}
}