2416 lines
82 KiB
Rust
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(¤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",
|
|
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);
|
|
}
|
|
}
|