Files
Genarrative/server-rs/crates/spacetime-module/src/runtime/profile.rs
T
kdletters 8d1c640eb5 修复精选图片读取与审核返还
为已通过、已展示且返还完成的精选资产补充精确匿名读取授权

将精选批准、泥点返还和返还完成标记收进同一SpacetimeDB事务

保留确定性返还流水的幂等语义并支持修复历史半完成记录

补充精选公开门禁测试并同步后端与创作主页文档
2026-07-15 16:58:18 +08:00

10139 lines
349 KiB
Rust

use crate::*;
use std::collections::{HashMap, HashSet, VecDeque};
const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task";
const PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID: &str = "system:recharge-product";
const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
const PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_DEFAULT: u32 = 20;
const PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_MAX: u32 = 50;
const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT: u32 = 50;
const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX: u32 = 200;
const ASSET_OPERATION_CONSUME_LEDGER_PREFIX: &str = "asset_operation_consume:";
const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:";
#[spacetimedb::table(accessor = profile_dashboard_state)]
pub struct ProfileDashboardState {
#[primary_key]
pub(crate) user_id: String,
pub(crate) wallet_balance: u64,
pub(crate) total_play_time_ms: u64,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_daily_free_points)]
#[derive(Clone)]
pub struct ProfileDailyFreePoints {
#[primary_key]
pub(crate) user_id: String,
pub(crate) day_key: i64,
pub(crate) granted_points: u64,
pub(crate) remaining_points: u64,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_wallet_ledger,
index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_wallet_ledger_user_created_at,
btree(columns = [user_id, created_at])
)
)]
pub struct ProfileWalletLedger {
#[primary_key]
pub(crate) wallet_ledger_id: String,
pub(crate) user_id: String,
pub(crate) amount_delta: i64,
pub(crate) balance_after: u64,
pub(crate) source_type: RuntimeProfileWalletLedgerSourceType,
pub(crate) created_at: Timestamp,
#[default(None::<String>)]
pub(crate) metadata_json: Option<String>,
}
#[spacetimedb::table(accessor = asset_operation_wallet_settlement)]
#[derive(Clone)]
pub struct AssetOperationWalletSettlement {
#[primary_key]
pub(crate) consume_ledger_id: String,
pub(crate) refund_ledger_id: String,
pub(crate) user_id: String,
pub(crate) amount: u64,
pub(crate) settled_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_wallet_config)]
#[derive(Clone)]
pub struct ProfileWalletConfig {
#[primary_key]
pub(crate) config_id: String,
pub(crate) initial_mud_points: u64,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = tracking_event,
index(accessor = by_tracking_event_event_key, btree(columns = [event_key])),
index(
accessor = by_tracking_event_scope,
btree(columns = [scope_kind, scope_id])
),
index(
accessor = by_tracking_event_user,
btree(columns = [user_id, occurred_at])
)
)]
pub struct TrackingEvent {
#[primary_key]
pub(crate) event_id: String,
pub(crate) event_key: String,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) scope_id: String,
pub(crate) day_key: i64,
pub(crate) user_id: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) module_key: Option<String>,
pub(crate) metadata_json: String,
pub(crate) occurred_at: Timestamp,
}
#[spacetimedb::table(
accessor = tracking_daily_stat,
index(
accessor = by_tracking_daily_stat_event_day,
btree(columns = [event_key, day_key])
),
index(
accessor = by_tracking_daily_stat_scope_day,
btree(columns = [scope_kind, scope_id, day_key])
)
)]
pub struct TrackingDailyStat {
#[primary_key]
pub(crate) stat_id: String,
pub(crate) event_key: String,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) scope_id: String,
pub(crate) day_key: i64,
pub(crate) count: u32,
pub(crate) first_occurred_at: Timestamp,
pub(crate) last_occurred_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_task_config)]
pub struct ProfileTaskConfig {
#[primary_key]
pub(crate) task_id: String,
pub(crate) title: String,
pub(crate) description: String,
pub(crate) event_key: String,
pub(crate) cycle: RuntimeProfileTaskCycle,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) threshold: u32,
pub(crate) reward_points: u64,
pub(crate) enabled: bool,
pub(crate) sort_order: i32,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_task_progress,
index(accessor = by_profile_task_progress_user, btree(columns = [user_id])),
index(
accessor = by_profile_task_progress_user_task,
btree(columns = [user_id, task_id])
)
)]
pub struct ProfileTaskProgress {
#[primary_key]
pub(crate) progress_id: String,
pub(crate) user_id: String,
pub(crate) task_id: String,
pub(crate) day_key: i64,
pub(crate) progress_count: u32,
pub(crate) threshold: u32,
pub(crate) status: RuntimeProfileTaskStatus,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_task_reward_claim,
index(accessor = by_profile_task_claim_user, btree(columns = [user_id])),
index(
accessor = by_profile_task_claim_user_task,
btree(columns = [user_id, task_id])
)
)]
pub struct ProfileTaskRewardClaim {
#[primary_key]
pub(crate) claim_id: String,
pub(crate) user_id: String,
pub(crate) task_id: String,
pub(crate) day_key: i64,
pub(crate) reward_points: u64,
pub(crate) wallet_ledger_id: String,
pub(crate) claimed_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_redeem_code)]
pub struct ProfileRedeemCode {
#[primary_key]
pub(crate) code: String,
pub(crate) mode: RuntimeProfileRedeemCodeMode,
pub(crate) reward_points: u64,
pub(crate) max_uses: u32,
pub(crate) global_used_count: u32,
pub(crate) enabled: bool,
pub(crate) allowed_user_ids: Vec<String>,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
#[default(None::<Timestamp>)]
pub(crate) starts_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expires_at: Option<Timestamp>,
}
#[spacetimedb::table(
accessor = profile_redeem_code_usage,
index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])),
index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_redeem_code_usage_code_user_id,
btree(columns = [code, user_id])
)
)]
pub struct ProfileRedeemCodeUsage {
#[primary_key]
pub(crate) usage_id: String,
pub(crate) code: String,
pub(crate) user_id: String,
pub(crate) amount_granted: u64,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_code_operation,
index(
accessor = by_profile_code_operation_code_kind,
btree(columns = [code_kind])
),
index(
accessor = by_profile_code_operation_kind_code,
btree(columns = [code_kind, code])
)
)]
pub struct ProfileCodeOperation {
#[primary_key]
pub(crate) operation_id: String,
pub(crate) code_kind: String,
pub(crate) code: String,
pub(crate) action: String,
pub(crate) operator_user_id: String,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_invite_code)]
pub struct ProfileInviteCode {
#[primary_key]
pub(crate) user_id: String,
#[unique]
pub(crate) invite_code: String,
pub(crate) metadata_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
#[default(None::<Timestamp>)]
pub(crate) starts_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expires_at: Option<Timestamp>,
}
#[spacetimedb::table(
accessor = profile_referral_relation,
index(accessor = by_profile_referral_inviter_user_id, btree(columns = [inviter_user_id])),
index(
accessor = by_profile_referral_inviter_bound_at,
btree(columns = [inviter_user_id, bound_at])
)
)]
pub struct ProfileReferralRelation {
#[primary_key]
pub(crate) invitee_user_id: String,
pub(crate) inviter_user_id: String,
pub(crate) invite_code: String,
pub(crate) inviter_reward_granted: bool,
pub(crate) invitee_reward_granted: bool,
pub(crate) bound_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_played_world,
index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_played_world_user_world_key,
btree(columns = [user_id, world_key])
),
index(
accessor = by_profile_played_world_user_last_played_at,
btree(columns = [user_id, last_played_at])
)
)]
pub struct ProfilePlayedWorld {
#[primary_key]
pub(crate) played_world_id: String,
pub(crate) user_id: String,
pub(crate) world_key: String,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) world_type: Option<String>,
pub(crate) world_title: String,
pub(crate) world_subtitle: String,
pub(crate) first_played_at: Timestamp,
pub(crate) last_played_at: Timestamp,
pub(crate) last_observed_play_time_ms: u64,
}
#[spacetimedb::table(
accessor = public_work_play_daily_stat,
index(
accessor = by_public_work_play_daily_stat_work_day,
btree(columns = [source_type, profile_id, played_day])
)
)]
pub struct PublicWorkPlayDailyStat {
#[primary_key]
pub(crate) stat_id: String,
// 中文注释:source_type 区分 custom-world / puzzle / big-fish,避免不同玩法 profile_id 撞桶。
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
// 中文注释:UTC 自 Unix 纪元起的自然日桶,用于快速聚合近 7 日新增游玩次数。
pub(crate) played_day: i64,
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = public_work_like,
index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])),
index(accessor = by_public_work_like_user, btree(columns = [user_id]))
)]
pub struct PublicWorkLike {
#[primary_key]
pub(crate) like_id: String,
// 中文注释:source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
pub(crate) user_id: String,
pub(crate) liked_at: Timestamp,
}
pub(crate) struct ProfilePlayedWorkUpsertInput {
pub(crate) user_id: String,
pub(crate) world_key: String,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) world_type: Option<String>,
pub(crate) world_title: String,
pub(crate) world_subtitle: String,
pub(crate) played_at_micros: i64,
}
pub(crate) struct PublicWorkPlayRecordInput {
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
pub(crate) played_at_micros: i64,
}
pub(crate) struct PublicWorkLikeRecordInput {
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
pub(crate) user_id: String,
pub(crate) liked_at_micros: i64,
}
pub(crate) struct ProfileSaveArchiveUpsertInput {
pub(crate) user_id: String,
pub(crate) world_key: String,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) world_type: Option<String>,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) bottom_tab: String,
pub(crate) game_state_json: String,
pub(crate) current_story_json: Option<String>,
pub(crate) saved_at_micros: i64,
}
#[spacetimedb::table(accessor = profile_membership)]
pub struct ProfileMembership {
#[primary_key]
pub(crate) user_id: String,
pub(crate) status: RuntimeProfileMembershipStatus,
pub(crate) tier: RuntimeProfileMembershipTier,
pub(crate) started_at: Timestamp,
pub(crate) expires_at: Timestamp,
pub(crate) updated_at: Timestamp,
#[default(None::<Timestamp>)]
pub(crate) cycle_started_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) cycle_resets_at: Option<Timestamp>,
#[default(0u64)]
pub(crate) cycle_granted_points: u64,
#[default(0u64)]
pub(crate) cycle_remaining_points: u64,
#[default(30u32)]
pub(crate) cycle_period_days: u32,
}
#[spacetimedb::table(accessor = profile_recharge_product_config)]
#[derive(Clone)]
pub struct ProfileRechargeProductConfig {
#[primary_key]
pub(crate) product_id: String,
pub(crate) title: String,
pub(crate) price_cents: u64,
pub(crate) kind: RuntimeProfileRechargeProductKind,
pub(crate) points_amount: u64,
pub(crate) bonus_points: u64,
pub(crate) duration_days: u32,
pub(crate) badge_label: String,
pub(crate) description: String,
pub(crate) tier: RuntimeProfileMembershipTier,
pub(crate) enabled: bool,
pub(crate) sort_order: i32,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by: String,
pub(crate) updated_at: Timestamp,
#[default(0u64)]
pub(crate) membership_period_points: u64,
#[default(0u32)]
pub(crate) membership_period_days: u32,
#[default(0u32)]
pub(crate) membership_queue_limit: u32,
#[default(0u32)]
pub(crate) membership_discount_bps: u32,
}
#[spacetimedb::table(
accessor = profile_recharge_order,
index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_recharge_order_user_created_at,
btree(columns = [user_id, created_at])
)
)]
#[derive(Clone)]
pub struct ProfileRechargeOrder {
#[primary_key]
pub(crate) order_id: String,
pub(crate) user_id: String,
pub(crate) product_id: String,
pub(crate) product_title: String,
pub(crate) kind: RuntimeProfileRechargeProductKind,
pub(crate) amount_cents: u64,
pub(crate) status: RuntimeProfileRechargeOrderStatus,
pub(crate) payment_channel: String,
#[default(None::<Timestamp>)]
pub(crate) paid_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) provider_transaction_id: Option<String>,
pub(crate) created_at: Timestamp,
pub(crate) points_delta: i64,
pub(crate) membership_expires_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expired_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expiration_checked_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) expiration_provider_state: Option<String>,
#[default(None::<String>)]
pub(crate) expiration_last_error: Option<String>,
}
#[spacetimedb::table(
accessor = profile_recharge_refund,
index(accessor = by_profile_recharge_refund_order_id, btree(columns = [order_id])),
index(
accessor = by_profile_recharge_refund_status_updated_at,
btree(columns = [provider_status, updated_at])
)
)]
#[derive(Clone)]
pub struct ProfileRechargeRefund {
#[primary_key]
pub(crate) out_refund_no: String,
#[unique]
pub(crate) provider_refund_id: String,
pub(crate) order_id: String,
pub(crate) provider_transaction_id: String,
pub(crate) user_id: Option<String>,
pub(crate) provider_status: RuntimeProfileRechargeRefundStatus,
pub(crate) total_cents: u64,
pub(crate) refund_cents: u64,
pub(crate) payer_total_cents: u64,
pub(crate) payer_refund_cents: u64,
pub(crate) success_at: Option<Timestamp>,
pub(crate) first_observed_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) last_observation_source: RuntimeProfileRechargeRefundObservationSource,
pub(crate) last_observation_id: String,
pub(crate) order_settled_at: Option<Timestamp>,
pub(crate) target_recovery_points: u64,
pub(crate) recovered_points: u64,
pub(crate) unrecovered_points: u64,
pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
pub(crate) last_recovery_ledger_id: Option<String>,
pub(crate) last_error_code: Option<String>,
#[default(None::<String>)]
pub(crate) manual_review_resolved_by_admin_user_id: Option<String>,
#[default(None::<String>)]
pub(crate) manual_review_resolution_reason: Option<String>,
#[default(None::<Timestamp>)]
pub(crate) manual_review_resolved_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) manual_review_resolved_error_code: Option<String>,
}
#[spacetimedb::table(
accessor = profile_recharge_refund_observation,
index(
accessor = by_profile_recharge_refund_observation_refund,
btree(columns = [out_refund_no, observed_at])
)
)]
#[derive(Clone)]
pub struct ProfileRechargeRefundObservation {
#[primary_key]
pub(crate) observation_id: String,
pub(crate) out_refund_no: String,
pub(crate) provider_refund_id: String,
pub(crate) order_id: String,
pub(crate) provider_transaction_id: String,
pub(crate) source: RuntimeProfileRechargeRefundObservationSource,
pub(crate) provider_status: RuntimeProfileRechargeRefundStatus,
pub(crate) total_cents: u64,
pub(crate) refund_cents: u64,
pub(crate) payer_total_cents: u64,
pub(crate) payer_refund_cents: u64,
pub(crate) success_at: Option<Timestamp>,
pub(crate) notification_ref: Option<String>,
pub(crate) payload_fingerprint: String,
pub(crate) resolution_code: String,
pub(crate) observed_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_recharge_order_refund_settlement,
index(
accessor = by_profile_recharge_order_refund_settlement_user_id,
btree(columns = [user_id])
)
)]
#[derive(Clone)]
pub struct ProfileRechargeOrderRefundSettlement {
#[primary_key]
pub(crate) order_id: String,
pub(crate) user_id: String,
pub(crate) successful_refund_count: u32,
pub(crate) cumulative_success_refund_cents: u64,
pub(crate) target_recovery_points: u64,
pub(crate) recovered_points: u64,
pub(crate) unrecovered_points: u64,
pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
pub(crate) wallet_frozen: bool,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_recharge_refund_hold,
index(accessor = by_profile_recharge_refund_hold_order_id, btree(columns = [order_id])),
index(accessor = by_profile_recharge_refund_hold_user_id, btree(columns = [user_id])),
index(accessor = by_profile_recharge_refund_hold_status, btree(columns = [status]))
)]
#[derive(Clone)]
pub struct ProfileRechargeRefundHold {
#[primary_key]
pub(crate) out_refund_no: String,
pub(crate) order_id: String,
pub(crate) user_id: String,
pub(crate) refund_cents: u64,
pub(crate) held_points: u64,
pub(crate) status: RuntimeProfileRechargeRefundHoldStatus,
pub(crate) admin_user_id: String,
pub(crate) reason: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) settled_at: Option<Timestamp>,
pub(crate) released_at: Option<Timestamp>,
pub(crate) released_by_admin_user_id: Option<String>,
pub(crate) release_reason: Option<String>,
}
#[spacetimedb::table(accessor = profile_wallet_manual_restriction)]
#[derive(Clone)]
pub struct ProfileWalletManualRestriction {
#[primary_key]
pub(crate) user_id: String,
pub(crate) frozen: bool,
pub(crate) reason: String,
pub(crate) created_by_admin_user_id: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by_admin_user_id: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_recharge_refund_bill_checkpoint)]
#[derive(Clone)]
pub struct ProfileRechargeRefundBillCheckpoint {
#[primary_key]
pub(crate) checkpoint_id: String,
pub(crate) bill_date: String,
pub(crate) bill_hash: String,
pub(crate) processed_refund_count: u32,
pub(crate) completed_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_recharge_order_expiration_schedule,
index(
accessor = by_profile_recharge_order_expiration_scheduled_at,
btree(columns = [scheduled_at])
)
)]
#[derive(Clone)]
pub struct ProfileRechargeOrderExpirationSchedule {
#[primary_key]
pub(crate) order_id: String,
pub(crate) user_id: String,
pub(crate) scheduled_at: Timestamp,
#[default(None::<String>)]
pub(crate) lease_owner: Option<String>,
#[default(None::<Timestamp>)]
pub(crate) lease_expires_at: Option<Timestamp>,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_recharge_order_expiration_timer,
scheduled(expire_profile_recharge_order_timer)
)]
#[derive(Clone)]
pub struct ProfileRechargeOrderExpirationTimer {
#[primary_key]
#[auto_inc]
pub(crate) scheduled_id: u64,
#[unique]
pub(crate) order_id: String,
pub(crate) user_id: String,
pub(crate) scheduled_at: ScheduleAt,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_feedback_submission,
index(accessor = by_profile_feedback_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_feedback_user_created_at,
btree(columns = [user_id, created_at])
)
)]
pub struct ProfileFeedbackSubmission {
#[primary_key]
pub(crate) feedback_id: String,
pub(crate) user_id: String,
pub(crate) description: String,
pub(crate) contact_phone: Option<String>,
// 中文注释:首版凭证以 Data URL 写入私有表,HTTP 回包只返回元数据,后续迁 OSS 不改变外部契约。
pub(crate) evidence_json: String,
pub(crate) status: RuntimeProfileFeedbackStatus,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_save_archive,
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_save_archive_user_world_key,
btree(columns = [user_id, world_key])
),
index(
accessor = by_profile_save_archive_user_saved_at,
btree(columns = [user_id, saved_at])
)
)]
pub struct ProfileSaveArchive {
#[primary_key]
pub(crate) archive_id: String,
pub(crate) user_id: String,
pub(crate) world_key: String,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) world_type: Option<String>,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) saved_at: Timestamp,
pub(crate) bottom_tab: String,
pub(crate) game_state_json: String,
pub(crate) current_story_json: Option<String>,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。
#[spacetimedb::procedure]
pub fn list_profile_save_archives(
ctx: &mut ProcedureContext,
input: RuntimeProfileSaveArchiveListInput,
) -> RuntimeProfileSaveArchiveProcedureResult {
match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) {
Ok(entries) => RuntimeProfileSaveArchiveProcedureResult {
ok: true,
entries,
record: None,
current_snapshot: None,
error_message: None,
},
Err(message) => RuntimeProfileSaveArchiveProcedureResult {
ok: false,
entries: Vec::new(),
record: None,
current_snapshot: None,
error_message: Some(message),
},
}
}
// resume 会把指定 archive 回填到当前 snapshot,并同步返回 entry + 当前 snapshot。
#[spacetimedb::procedure]
pub fn resume_profile_save_archive_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileSaveArchiveResumeInput,
) -> RuntimeProfileSaveArchiveProcedureResult {
match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) {
Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult {
ok: true,
entries: Vec::new(),
record: Some(record),
current_snapshot: Some(current_snapshot),
error_message: None,
},
Err(message) => RuntimeProfileSaveArchiveProcedureResult {
ok: false,
entries: Vec::new(),
record: None,
current_snapshot: None,
error_message: Some(message),
},
}
}
// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。
#[spacetimedb::procedure]
pub fn get_profile_dashboard(
ctx: &mut ProcedureContext,
input: RuntimeProfileDashboardGetInput,
) -> RuntimeProfileDashboardProcedureResult {
match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfileDashboardProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileDashboardProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。
#[spacetimedb::procedure]
pub fn list_profile_wallet_ledger(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletLedgerListInput,
) -> RuntimeProfileWalletLedgerProcedureResult {
match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) {
Ok(entries) => RuntimeProfileWalletLedgerProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileWalletLedgerProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
// analytics metric 查询直接聚合 tracking_daily_stat,避免 API 层订阅全量表后自行汇总。
#[spacetimedb::procedure]
pub fn query_analytics_metric(
ctx: &mut ProcedureContext,
input: AnalyticsMetricQueryInput,
) -> AnalyticsMetricQueryProcedureResult {
match ctx.try_with_tx(|tx| query_analytics_metric_buckets(tx, input.clone())) {
Ok(buckets) => AnalyticsMetricQueryProcedureResult {
ok: true,
buckets,
error_message: None,
},
Err(message) => AnalyticsMetricQueryProcedureResult {
ok: false,
buckets: Vec::new(),
error_message: Some(message),
},
}
}
// 通用埋点入口开放给 Axum 调用;具体入口仍在业务 handler 成功后显式触发。
#[spacetimedb::procedure]
pub fn record_tracking_event_and_return(
ctx: &mut ProcedureContext,
input: RuntimeTrackingEventInput,
) -> RuntimeTrackingEventProcedureResult {
match ctx.try_with_tx(|tx| record_tracking_event(tx, input.clone())) {
Ok(()) => RuntimeTrackingEventProcedureResult {
ok: true,
error_message: None,
},
Err(message) => RuntimeTrackingEventProcedureResult {
ok: false,
error_message: Some(message),
},
}
}
// 高频 route tracking 由 api-server 本机 outbox 批量写入,减少公开列表热路径上的 procedure 调用次数。
#[spacetimedb::procedure]
pub fn record_tracking_events_and_return(
ctx: &mut ProcedureContext,
inputs: Vec<RuntimeTrackingEventInput>,
) -> RuntimeTrackingEventBatchProcedureResult {
match ctx.try_with_tx(|tx| {
let mut accepted_count = 0u32;
for input in &inputs {
record_tracking_event(tx, input.clone())?;
accepted_count = accepted_count.saturating_add(1);
}
Ok(accepted_count)
}) {
Ok(accepted_count) => RuntimeTrackingEventBatchProcedureResult {
ok: true,
accepted_count,
error_message: None,
},
Err(message) => RuntimeTrackingEventBatchProcedureResult {
ok: false,
accepted_count: 0,
error_message: Some(message),
},
}
}
// 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。
#[spacetimedb::procedure]
pub fn record_daily_login_tracking_event_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeTrackingEventProcedureResult {
match ctx.try_with_tx(|tx| {
let validated_input = build_runtime_profile_task_center_get_input(input.user_id.clone())
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(tx);
record_daily_login_tracking_event(tx, &validated_input.user_id)
}) {
Ok(()) => RuntimeTrackingEventProcedureResult {
ok: true,
error_message: None,
},
Err(message) => RuntimeTrackingEventProcedureResult {
ok: false,
error_message: Some(message),
},
}
}
// 任务中心读取会刷新进度;每日登录埋点应由登录成功链路提前记录。
#[spacetimedb::procedure]
pub fn get_profile_task_center(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeProfileTaskCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), false)) {
Ok(record) => RuntimeProfileTaskCenterProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskCenterProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 领奖记录与泥点流水在同一事务内写入,避免任务状态和钱包余额漂移。
#[spacetimedb::procedure]
pub fn claim_profile_task_reward_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskClaimInput,
) -> RuntimeProfileTaskClaimProcedureResult {
match ctx.try_with_tx(|tx| claim_profile_task_reward_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskClaimProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskClaimProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_task_configs(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminListInput,
) -> RuntimeProfileTaskConfigAdminListProcedureResult {
match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) {
Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_task_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminUpsertInput,
) -> RuntimeProfileTaskConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_disable_profile_task_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminDisableInput,
) -> RuntimeProfileTaskConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_get_profile_wallet_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletConfigAdminGetInput,
) -> RuntimeProfileWalletConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| get_profile_wallet_config_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_wallet_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletConfigAdminUpsertInput,
) -> RuntimeProfileWalletConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| upsert_profile_wallet_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_recharge_products(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeProductAdminListInput,
) -> RuntimeProfileRechargeProductAdminListProcedureResult {
match ctx.try_with_tx(|tx| list_profile_recharge_product_config_snapshots(tx, input.clone())) {
Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeProductAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_recharge_product(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeProductAdminUpsertInput,
) -> RuntimeProfileRechargeProductAdminProcedureResult {
match ctx.try_with_tx(|tx| upsert_profile_recharge_product_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRechargeProductAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。
#[spacetimedb::procedure]
pub fn grant_new_user_registration_wallet_reward(
ctx: &mut ProcedureContext,
input: RuntimeProfileDashboardGetInput,
) -> RuntimeProfileWalletAdjustmentProcedureResult {
match ctx.try_with_tx(|tx| grant_new_user_registration_wallet_reward_tx(tx, input.clone())) {
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。
#[spacetimedb::procedure]
pub fn consume_profile_wallet_points_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletAdjustmentInput,
) -> RuntimeProfileWalletAdjustmentProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
apply_profile_wallet_adjustment(
tx,
input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
true,
)
}) {
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 生成链路失败时由 Axum 调用退款,ledger_id 幂等保证重复补偿不会重复加钱。
#[spacetimedb::procedure]
pub fn refund_profile_wallet_points_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletAdjustmentInput,
) -> RuntimeProfileWalletAdjustmentProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
apply_profile_wallet_adjustment(
tx,
input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
false,
)
}) {
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。
#[spacetimedb::procedure]
pub fn get_profile_play_stats(
ctx: &mut ProcedureContext,
input: RuntimeProfilePlayStatsGetInput,
) -> RuntimeProfilePlayStatsProcedureResult {
match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfilePlayStatsProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfilePlayStatsProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 账户充值中心只读快照,套餐和权益由后端返回,前端不保存业务价格表。
#[spacetimedb::procedure]
pub fn get_profile_recharge_center(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeCenterGetInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: None,
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_profile_recharge_order_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderGetInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) {
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn create_profile_recharge_order_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderCreateInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| create_profile_recharge_order_record(tx, input.clone())) {
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn mark_profile_recharge_order_paid_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderPaidInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
mark_profile_recharge_order_paid_record(tx, input.clone())
}) {
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn record_profile_recharge_refund_observation_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundObservationInput,
) -> RuntimeProfileRechargeRefundProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
record_profile_recharge_refund_observation(tx, input.clone())
}) {
Ok((record, settlement, duplicate, resolution_code)) => {
RuntimeProfileRechargeRefundProcedureResult {
ok: true,
record: Some(record),
settlement,
duplicate,
resolution_code,
error_message: None,
}
}
Err(message) => RuntimeProfileRechargeRefundProcedureResult {
ok: false,
record: None,
settlement: None,
duplicate: false,
resolution_code: "failed".to_string(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_profile_recharge_refund_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundGetInput,
) -> RuntimeProfileRechargeRefundProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let validated =
build_runtime_profile_recharge_refund_get_input(input.out_refund_no.clone())?;
let row = tx
.db
.profile_recharge_refund()
.out_refund_no()
.find(&validated.out_refund_no)
.ok_or_else(|| "profile_recharge_refund 不存在".to_string())?;
let settlement = tx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&row.order_id)
.map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value));
Ok((
build_profile_recharge_refund_snapshot_from_row(&row),
settlement,
))
}) {
Ok((record, settlement)) => RuntimeProfileRechargeRefundProcedureResult {
ok: true,
record: Some(record),
settlement,
duplicate: false,
resolution_code: "loaded".to_string(),
error_message: None,
},
Err(message) => RuntimeProfileRechargeRefundProcedureResult {
ok: false,
record: None,
settlement: None,
duplicate: false,
resolution_code: "failed".to_string(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_profile_recharge_refunds_for_reconciliation(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundReconciliationListInput,
) -> RuntimeProfileRechargeRefundListProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
Ok(list_profile_recharge_refund_reconciliation_rows(
tx,
input.clone(),
))
}) {
Ok(entries) => RuntimeProfileRechargeRefundListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeRefundListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_recharge_orders_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderAdminListInput,
) -> RuntimeProfileRechargeOrderAdminListProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
admin_list_profile_recharge_order_entries(tx, input.clone())
}) {
Ok(entries) => RuntimeProfileRechargeOrderAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeOrderAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn preview_profile_recharge_refund_hold_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundHoldPreviewInput,
) -> RuntimeProfileRechargeRefundHoldProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
preview_profile_recharge_refund_hold(tx, input.clone())
}) {
Ok((record, order, settlement, wallet)) => {
RuntimeProfileRechargeRefundHoldProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
settlement,
wallet: Some(wallet),
error_message: None,
}
}
Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult {
ok: false,
record: None,
order: None,
settlement: None,
wallet: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn prepare_profile_recharge_refund_hold_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundHoldPrepareInput,
) -> RuntimeProfileRechargeRefundHoldProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
prepare_profile_recharge_refund_hold(tx, input.clone())
}) {
Ok((record, order, settlement, wallet)) => {
RuntimeProfileRechargeRefundHoldProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
settlement,
wallet: Some(wallet),
error_message: None,
}
}
Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult {
ok: false,
record: None,
order: None,
settlement: None,
wallet: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn release_profile_recharge_refund_hold_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundHoldReleaseInput,
) -> RuntimeProfileRechargeRefundHoldProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
release_profile_recharge_refund_hold(tx, input.clone())
}) {
Ok((record, order, settlement, wallet)) => {
RuntimeProfileRechargeRefundHoldProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
settlement,
wallet: Some(wallet),
error_message: None,
}
}
Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult {
ok: false,
record: None,
order: None,
settlement: None,
wallet: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn resolve_profile_recharge_refund_manual_review_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
) -> RuntimeProfileRechargeRefundProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
resolve_profile_recharge_refund_manual_review(tx, input.clone())
}) {
Ok((record, settlement, duplicate, resolution_code)) => {
RuntimeProfileRechargeRefundProcedureResult {
ok: true,
record: Some(record),
settlement,
duplicate,
resolution_code,
error_message: None,
}
}
Err(message) => RuntimeProfileRechargeRefundProcedureResult {
ok: false,
record: None,
settlement: None,
duplicate: false,
resolution_code: "manual_review_resolution_failed".to_string(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_profile_recharge_refund_holds_for_reconciliation(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundHoldListInput,
) -> RuntimeProfileRechargeRefundHoldListProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let validated = build_runtime_profile_recharge_refund_hold_list_input(input.limit);
let mut rows = tx
.db
.profile_recharge_refund_hold()
.by_profile_recharge_refund_hold_status()
.filter(RuntimeProfileRechargeRefundHoldStatus::Active)
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.updated_at
.to_micros_since_unix_epoch()
.cmp(&right.updated_at.to_micros_since_unix_epoch())
.then_with(|| left.out_refund_no.cmp(&right.out_refund_no))
});
let rotation_slot = tx
.timestamp
.to_micros_since_unix_epoch()
.div_euclid(60 * 1_000_000)
.unsigned_abs();
Ok(select_profile_recharge_refund_hold_reconciliation_page(
rows,
validated.limit as usize,
rotation_slot,
)
.into_iter()
.map(|row| build_profile_recharge_refund_hold_snapshot_from_row(&row))
.collect())
}) {
Ok(entries) => RuntimeProfileRechargeRefundHoldListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeRefundHoldListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_get_profile_wallet_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileAdminWalletGetInput,
) -> RuntimeProfileAdminWalletProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let validated = build_runtime_profile_admin_wallet_get_input(input.user_id.clone())?;
Ok(build_profile_admin_wallet_snapshot(tx, &validated.user_id))
}) {
Ok(record) => RuntimeProfileAdminWalletProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileAdminWalletProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_wallet_manual_restriction_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletManualRestrictionUpsertInput,
) -> RuntimeProfileAdminWalletProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let validated = build_runtime_profile_wallet_manual_restriction_upsert_input(
input.user_id.clone(),
input.frozen,
input.reason.clone(),
input.admin_user_id.clone(),
)?;
upsert_profile_wallet_manual_restriction(tx, validated);
Ok(build_profile_admin_wallet_snapshot(tx, &input.user_id))
}) {
Ok(record) => RuntimeProfileAdminWalletProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileAdminWalletProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_profile_recharge_refund_bill_checkpoint_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundBillCheckpointGetInput,
) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let validated = build_runtime_profile_recharge_refund_bill_checkpoint_get_input(
input.checkpoint_id.clone(),
)?;
Ok(tx
.db
.profile_recharge_refund_bill_checkpoint()
.checkpoint_id()
.find(&validated.checkpoint_id)
.map(|row| build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row)))
}) {
Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
ok: true,
record,
error_message: None,
},
Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn advance_profile_recharge_refund_bill_checkpoint_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput,
) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
advance_profile_recharge_refund_bill_checkpoint(tx, input.clone())
}) {
Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn claim_profile_recharge_order_expiration_schedule_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderExpirationClaimInput,
) -> RuntimeProfileRechargeOrderExpirationClaimProcedureResult {
match ctx.try_with_tx(|tx| claim_profile_recharge_order_expiration_schedules(tx, input.clone()))
{
Ok(entries) => RuntimeProfileRechargeOrderExpirationClaimProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeOrderExpirationClaimProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn complete_profile_recharge_order_expiration_schedule_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderExpirationCompleteInput,
) -> RuntimeProfileRechargeOrderExpirationCompleteProcedureResult {
match ctx
.try_with_tx(|tx| complete_profile_recharge_order_expiration_schedule(tx, input.clone()))
{
Ok(()) => RuntimeProfileRechargeOrderExpirationCompleteProcedureResult {
ok: true,
error_message: None,
},
Err(message) => RuntimeProfileRechargeOrderExpirationCompleteProcedureResult {
ok: false,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_unchecked_expired_profile_recharge_orders(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderExpirationCheckListInput,
) -> RuntimeProfileRechargeOrderExpirationCheckListProcedureResult {
match ctx
.try_with_tx(|tx| list_unchecked_expired_profile_recharge_order_rows(tx, input.clone()))
{
Ok(entries) => RuntimeProfileRechargeOrderExpirationCheckListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeOrderExpirationCheckListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn mark_profile_recharge_order_expiration_checked(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderExpirationCheckInput,
) -> RuntimeProfileRechargeOrderExpirationCheckProcedureResult {
match ctx
.try_with_tx(|tx| mark_profile_recharge_order_expiration_checked_record(tx, input.clone()))
{
Ok(record) => RuntimeProfileRechargeOrderExpirationCheckProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRechargeOrderExpirationCheckProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::reducer]
pub fn expire_profile_recharge_order_timer(
ctx: &ReducerContext,
timer: ProfileRechargeOrderExpirationTimer,
) -> Result<(), String> {
if !ctx.sender_auth().is_internal() {
return Err("profile_recharge_order_expiration_timer scheduler-only".to_string());
}
let Some(mut order) = ctx
.db
.profile_recharge_order()
.order_id()
.find(&timer.order_id)
else {
delete_profile_recharge_order_expiration_timer(ctx, &timer.order_id);
return Ok(());
};
if order.status == RuntimeProfileRechargeOrderStatus::Pending {
ctx.db
.profile_recharge_order()
.order_id()
.delete(&order.order_id);
order.status = RuntimeProfileRechargeOrderStatus::Expired;
order.expired_at = Some(ctx.timestamp);
ctx.db.profile_recharge_order().insert(order);
}
delete_profile_recharge_order_expiration_timer(ctx, &timer.order_id);
Ok(())
}
#[spacetimedb::procedure]
pub fn close_profile_recharge_order_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderCloseInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| close_profile_recharge_order_record(tx, input.clone())) {
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_profile_feedback_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileFeedbackSubmissionInput,
) -> RuntimeProfileFeedbackSubmissionProcedureResult {
match ctx.try_with_tx(|tx| submit_profile_feedback_record(tx, input.clone())) {
Ok(record) => RuntimeProfileFeedbackSubmissionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileFeedbackSubmissionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 邀请中心会在首次打开时为账号创建稳定邀请码,前端只展示这里返回的后端状态。
#[spacetimedb::procedure]
pub fn get_profile_referral_invite_center(
ctx: &mut ProcedureContext,
input: RuntimeReferralInviteCenterGetInput,
) -> RuntimeReferralInviteCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_referral_invite_center_snapshot(tx, input.clone())) {
Ok(record) => RuntimeReferralInviteCenterProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeReferralInviteCenterProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 填码绑定、每日邀请者奖励上限和双方泥点发放都在同一事务内完成。
#[spacetimedb::procedure]
pub fn redeem_profile_referral_invite_code(
ctx: &mut ProcedureContext,
input: RuntimeReferralRedeemInput,
) -> RuntimeReferralRedeemProcedureResult {
match ctx.try_with_tx(|tx| redeem_profile_referral_invite_code_record(tx, input.clone())) {
Ok(record) => RuntimeReferralRedeemProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeReferralRedeemProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。
#[spacetimedb::procedure]
pub fn redeem_profile_reward_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRewardCodeRedeemInput,
) -> RuntimeProfileRewardCodeRedeemProcedureResult {
match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_redeem_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_disable_profile_redeem_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminDisableInput,
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_redeem_codes(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminListInput,
) -> RuntimeProfileRedeemCodeAdminListProcedureResult {
match ctx.try_with_tx(|tx| admin_list_profile_redeem_code_records(tx, input.clone())) {
Ok((entries, operations)) => RuntimeProfileRedeemCodeAdminListProcedureResult {
ok: true,
entries,
operations,
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult {
ok: false,
entries: Vec::new(),
operations: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_invite_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileInviteCodeAdminUpsertInput,
) -> RuntimeProfileInviteCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_upsert_profile_invite_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileInviteCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileInviteCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_invite_codes(
ctx: &mut ProcedureContext,
input: RuntimeProfileInviteCodeAdminListInput,
) -> RuntimeProfileInviteCodeAdminListProcedureResult {
match ctx.try_with_tx(|tx| admin_list_profile_invite_code_records(tx, input.clone())) {
Ok((entries, operations)) => RuntimeProfileInviteCodeAdminListProcedureResult {
ok: true,
entries,
operations,
error_message: None,
},
Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult {
ok: false,
entries: Vec::new(),
operations: Vec::new(),
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
) -> Result<Vec<RuntimeProfileSaveArchiveSnapshot>, String> {
let validated_input = build_runtime_profile_save_archive_list_input(input.user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_save_archive()
.by_profile_save_archive_user_id()
.filter(&validated_input.user_id)
.map(|row| build_profile_save_archive_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.saved_at_micros
.cmp(&left.saved_at_micros)
.then_with(|| left.archive_id.cmp(&right.archive_id))
});
Ok(entries)
}
pub(crate) fn resume_profile_save_archive_record(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveResumeInput,
) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> {
let validated_input =
build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key)
.map_err(|error| error.to_string())?;
let archive = ctx
.db
.profile_save_archive()
.by_profile_save_archive_user_world_key()
.filter((
validated_input.user_id.as_str(),
validated_input.world_key.as_str(),
))
.next()
.ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?;
let existing_snapshot = ctx
.db
.runtime_snapshot()
.user_id()
.find(&validated_input.user_id);
let created_at = existing_snapshot
.as_ref()
.map(|row| row.created_at)
.unwrap_or(archive.saved_at);
if let Some(existing) = existing_snapshot {
ctx.db
.runtime_snapshot()
.user_id()
.delete(&existing.user_id);
}
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
user_id: archive.user_id.clone(),
version: SAVE_SNAPSHOT_VERSION,
saved_at: archive.saved_at,
bottom_tab: archive.bottom_tab.clone(),
game_state_json: archive.game_state_json.clone(),
current_story_json: archive.current_story_json.clone(),
created_at,
updated_at: archive.saved_at,
});
Ok((
build_profile_save_archive_snapshot_from_row(&archive),
RuntimeSnapshot {
user_id: archive.user_id.clone(),
version: SAVE_SNAPSHOT_VERSION,
saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(),
bottom_tab: archive.bottom_tab.clone(),
game_state_json: archive.game_state_json.clone(),
current_story_json: archive.current_story_json.clone(),
created_at_micros: created_at.to_micros_since_unix_epoch(),
updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(),
},
))
}
pub(crate) fn sync_profile_projections_from_snapshot(
ctx: &ReducerContext,
snapshot: &RuntimeSnapshot,
) -> Result<(), String> {
let game_state = parse_json_str(&snapshot.game_state_json)?;
let game_state_object = game_state.as_object();
let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros);
if module_runtime::is_non_persistent_runtime_snapshot(&game_state) {
return Ok(());
}
sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at);
sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?;
Ok(())
}
pub(crate) fn upsert_profile_played_work(
ctx: &ReducerContext,
input: ProfilePlayedWorkUpsertInput,
) -> Result<(), String> {
let user_id = input.user_id.trim();
let world_key = input.world_key.trim();
if user_id.is_empty() {
return Err("profile_played_world.user_id 不能为空".to_string());
}
if world_key.is_empty() {
return Err("profile_played_world.world_key 不能为空".to_string());
}
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
let played_world_id = build_runtime_profile_played_world_id(user_id, world_key);
let existing = ctx
.db
.profile_played_world()
.played_world_id()
.find(&played_world_id);
if let Some(existing) = existing {
ctx.db
.profile_played_world()
.played_world_id()
.delete(&existing.played_world_id);
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
played_world_id,
user_id: user_id.to_string(),
world_key: world_key.to_string(),
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
world_type: input.world_type,
world_title: input.world_title,
world_subtitle: input.world_subtitle,
first_played_at: existing.first_played_at,
last_played_at: played_at,
last_observed_play_time_ms: existing.last_observed_play_time_ms,
});
} else {
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
played_world_id,
user_id: user_id.to_string(),
world_key: world_key.to_string(),
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
world_type: input.world_type,
world_title: input.world_title,
world_subtitle: input.world_subtitle,
first_played_at: played_at,
last_played_at: played_at,
last_observed_play_time_ms: 0,
});
}
ensure_profile_dashboard_state(ctx, user_id, played_at);
Ok(())
}
pub(crate) fn add_profile_observed_play_time(
ctx: &ReducerContext,
user_id: &str,
world_key: &str,
elapsed_ms: u64,
observed_at_micros: i64,
) -> Result<(), String> {
let user_id = user_id.trim();
let world_key = world_key.trim();
if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 {
return Ok(());
}
let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros);
let played_world_id = build_runtime_profile_played_world_id(user_id, world_key);
if let Some(existing) = ctx
.db
.profile_played_world()
.played_world_id()
.find(&played_world_id)
{
ctx.db
.profile_played_world()
.played_world_id()
.delete(&existing.played_world_id);
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
played_world_id,
user_id: existing.user_id,
world_key: existing.world_key,
owner_user_id: existing.owner_user_id,
profile_id: existing.profile_id,
world_type: existing.world_type,
world_title: existing.world_title,
world_subtitle: existing.world_subtitle,
first_played_at: existing.first_played_at,
last_played_at: observed_at,
last_observed_play_time_ms: existing
.last_observed_play_time_ms
.saturating_add(elapsed_ms),
});
}
add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at);
Ok(())
}
pub(crate) fn upsert_profile_save_archive(
ctx: &ReducerContext,
input: ProfileSaveArchiveUpsertInput,
) -> Result<(), String> {
let user_id = input.user_id.trim();
let world_key = input.world_key.trim();
if user_id.is_empty() || world_key.is_empty() {
return Err("profile_save_archive 参数不能为空".to_string());
}
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let archive_id = format!("{user_id}:{world_key}");
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(saved_at);
if let Some(existing) = existing {
ctx.db
.profile_save_archive()
.archive_id()
.delete(&existing.archive_id);
}
ctx.db.profile_save_archive().insert(ProfileSaveArchive {
archive_id,
user_id: user_id.to_string(),
world_key: world_key.to_string(),
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
world_type: input.world_type,
world_name: input.world_name,
subtitle: input.subtitle,
summary_text: input.summary_text,
cover_image_src: input.cover_image_src,
saved_at,
bottom_tab: input.bottom_tab,
game_state_json: input.game_state_json,
current_story_json: input.current_story_json,
created_at,
updated_at: saved_at,
});
Ok(())
}
pub(crate) fn record_public_work_play(
ctx: &ReducerContext,
input: PublicWorkPlayRecordInput,
) -> Result<(), String> {
let source_type = input.source_type.trim();
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
if source_type.is_empty() || owner_user_id.is_empty() || profile_id.is_empty() {
return Err("public_work_play_daily_stat 参数不能为空".to_string());
}
let played_day = public_work_play_day_from_micros(input.played_at_micros);
let stat_id = build_public_work_play_daily_stat_id(source_type, profile_id, played_day);
let updated_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
let next_count = ctx
.db
.public_work_play_daily_stat()
.stat_id()
.find(&stat_id)
.map(|existing| {
ctx.db
.public_work_play_daily_stat()
.stat_id()
.delete(&existing.stat_id);
existing.play_count.saturating_add(1)
})
.unwrap_or(1);
ctx.db
.public_work_play_daily_stat()
.insert(PublicWorkPlayDailyStat {
stat_id,
source_type: source_type.to_string(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
played_day,
play_count: next_count,
updated_at,
});
Ok(())
}
pub(crate) fn record_public_work_like(
ctx: &ReducerContext,
input: PublicWorkLikeRecordInput,
) -> Result<bool, String> {
let source_type = input.source_type.trim();
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
let user_id = input.user_id.trim();
if source_type.is_empty()
|| owner_user_id.is_empty()
|| profile_id.is_empty()
|| user_id.is_empty()
{
return Err("public_work_like 参数不能为空".to_string());
}
let like_id = build_public_work_like_id(source_type, profile_id, user_id);
if ctx.db.public_work_like().like_id().find(&like_id).is_some() {
return Ok(false);
}
ctx.db.public_work_like().insert(PublicWorkLike {
like_id,
source_type: source_type.to_string(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
user_id: user_id.to_string(),
liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros),
});
Ok(true)
}
pub(crate) fn count_recent_public_work_plays(
ctx: &ReducerContext,
source_type: &str,
profile_id: &str,
now_micros: i64,
) -> u32 {
count_recent_public_work_plays_for_profiles(
ctx,
source_type,
&[profile_id.to_string()],
now_micros,
)
.remove(profile_id.trim())
.unwrap_or(0)
}
pub(crate) fn count_recent_public_work_plays_for_profiles(
ctx: &ReducerContext,
source_type: &str,
profile_ids: &[String],
now_micros: i64,
) -> HashMap<String, u32> {
let source_type = source_type.trim();
if source_type.is_empty() || profile_ids.is_empty() {
return HashMap::new();
}
let current_day = public_work_play_day_from_micros(now_micros);
let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
let requested_profile_ids = profile_ids
.iter()
.map(|profile_id| profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.collect::<HashSet<_>>();
let mut counts = HashMap::new();
for profile_id in requested_profile_ids {
let mut total = 0u32;
for played_day in first_day..=current_day {
let day_total = ctx
.db
.public_work_play_daily_stat()
.by_public_work_play_daily_stat_work_day()
.filter((source_type, profile_id, played_day))
.fold(0u32, |sum, row| sum.saturating_add(row.play_count));
total = total.saturating_add(day_total);
}
if total > 0 {
counts.insert(profile_id.to_string(), total);
}
}
counts
}
#[cfg(test)]
fn build_recent_public_work_play_counts(
rows: impl IntoIterator<Item = PublicWorkPlayDailyStat>,
source_type: &str,
profile_ids: &[String],
now_micros: i64,
) -> HashMap<String, u32> {
let source_type = source_type.trim();
if source_type.is_empty() || profile_ids.is_empty() {
return HashMap::new();
}
let requested_profile_ids = profile_ids
.iter()
.map(|profile_id| profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.collect::<HashSet<_>>();
if requested_profile_ids.is_empty() {
return HashMap::new();
}
let current_day = public_work_play_day_from_micros(now_micros);
let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
let mut counts = HashMap::new();
for row in rows {
if row.source_type != source_type
|| !requested_profile_ids.contains(row.profile_id.as_str())
|| row.played_day < first_day
|| row.played_day > current_day
{
continue;
}
let entry = counts.entry(row.profile_id.clone()).or_insert(0u32);
*entry = entry.saturating_add(row.play_count);
}
counts
}
fn public_work_play_day_from_micros(value: i64) -> i64 {
value.div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS)
}
fn build_public_work_play_daily_stat_id(
source_type: &str,
profile_id: &str,
played_day: i64,
) -> String {
format!("{source_type}:{profile_id}:{played_day}")
}
fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String {
format!("{source_type}:{profile_id}:{user_id}")
}
#[cfg(test)]
mod tests {
use super::*;
fn wallet_ledger_snapshot(
ledger_id: &str,
amount_delta: i64,
balance_after: u64,
created_at_micros: i64,
) -> RuntimeProfileWalletLedgerEntrySnapshot {
RuntimeProfileWalletLedgerEntrySnapshot {
wallet_ledger_id: ledger_id.to_string(),
user_id: "user-1".to_string(),
amount_delta,
balance_after,
source_type: RuntimeProfileWalletLedgerSourceType::PointsRecharge,
created_at_micros,
metadata_json: "{}".to_string(),
}
}
#[test]
fn wallet_ledger_sort_follows_balance_settlement_chain_when_payment_time_is_delayed() {
let mut entries = vec![
wallet_ledger_snapshot("daily-free", 20, 97, 1),
wallet_ledger_snapshot("recharge-180-delayed", 180, 607, 2),
wallet_ledger_snapshot("recharge-60", 60, 157, 3),
wallet_ledger_snapshot("recharge-270", 270, 427, 4),
];
sort_profile_wallet_ledger_entries(&mut entries, 607);
assert_eq!(
entries
.iter()
.map(|entry| entry.wallet_ledger_id.as_str())
.collect::<Vec<_>>(),
vec![
"recharge-180-delayed",
"recharge-270",
"recharge-60",
"daily-free",
]
);
}
#[test]
fn wallet_ledger_records_settlement_time_instead_of_delayed_business_event_time() {
let paid_at = Timestamp::from_micros_since_unix_epoch(100);
let settled_at = Timestamp::from_micros_since_unix_epoch(200);
assert_eq!(
profile_wallet_ledger_recorded_at(paid_at, settled_at),
settled_at
);
}
fn asset_operation_wallet_ledger(
ledger_id: &str,
user_id: &str,
amount_delta: i64,
source_type: RuntimeProfileWalletLedgerSourceType,
) -> ProfileWalletLedger {
ProfileWalletLedger {
wallet_ledger_id: ledger_id.to_string(),
user_id: user_id.to_string(),
amount_delta,
balance_after: 100,
source_type,
created_at: Timestamp::from_micros_since_unix_epoch(1),
metadata_json: None,
}
}
#[test]
fn asset_operation_refund_without_matching_consume_records_settlement_intent() {
assert_eq!(
resolve_asset_operation_refund_disposition(
"user-1",
37,
"asset_operation_refund:external_generation_job:job-1:attempt:1",
None,
None,
None,
),
Ok(AssetOperationRefundDisposition::RecordIntent)
);
}
#[test]
fn asset_operation_refund_is_idempotent_and_requires_matching_consume() {
let consume = asset_operation_wallet_ledger(
"asset_operation_consume:external_generation_job:job-1:attempt:1",
"user-1",
-37,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
);
let refund = asset_operation_wallet_ledger(
"asset_operation_refund:external_generation_job:job-1:attempt:1",
"user-1",
37,
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
);
assert_eq!(
resolve_asset_operation_refund_disposition(
"user-1",
37,
&refund.wallet_ledger_id,
None,
None,
Some(&consume),
),
Ok(AssetOperationRefundDisposition::Apply)
);
assert_eq!(
resolve_asset_operation_refund_disposition(
"user-1",
37,
&refund.wallet_ledger_id,
None,
Some(&refund),
Some(&consume),
),
Ok(AssetOperationRefundDisposition::Noop)
);
assert!(
resolve_asset_operation_refund_disposition(
"user-2",
37,
&refund.wallet_ledger_id,
None,
None,
Some(&consume),
)
.is_err()
);
assert!(
resolve_asset_operation_refund_disposition(
"user-1",
38,
&refund.wallet_ledger_id,
None,
None,
Some(&consume),
)
.is_err()
);
}
#[test]
fn asset_operation_settlement_intent_rejects_conflicting_late_operations() {
let consume_ledger_id = "asset_operation_consume:external_generation_job:job-1:attempt:1";
let refund_ledger_id = "asset_operation_refund:external_generation_job:job-1:attempt:1";
let settlement = AssetOperationWalletSettlement {
consume_ledger_id: consume_ledger_id.to_string(),
refund_ledger_id: refund_ledger_id.to_string(),
user_id: "user-1".to_string(),
amount: 37,
settled_at: Timestamp::from_micros_since_unix_epoch(1),
};
assert_eq!(
resolve_asset_operation_refund_disposition(
"user-1",
37,
refund_ledger_id,
Some(&settlement),
None,
None,
),
Ok(AssetOperationRefundDisposition::Noop)
);
assert!(
validate_asset_operation_wallet_settlement(
&settlement,
consume_ledger_id,
refund_ledger_id,
"user-2",
37,
)
.is_err()
);
assert!(
validate_asset_operation_wallet_settlement(
&settlement,
consume_ledger_id,
refund_ledger_id,
"user-1",
38,
)
.is_err()
);
}
#[test]
fn wallet_idempotent_replay_requires_matching_user_amount_and_source() {
let existing = asset_operation_wallet_ledger(
"asset_operation_consume:external_generation_job:job-1:attempt:1",
"user-1",
-37,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
);
assert!(
validate_idempotent_profile_wallet_ledger(
&existing,
"user-1",
-37,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
)
.is_ok()
);
assert!(
validate_idempotent_profile_wallet_ledger(
&existing,
"user-2",
-37,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
)
.is_err()
);
assert!(
validate_idempotent_profile_wallet_ledger(
&existing,
"user-1",
-38,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
)
.is_err()
);
assert!(
validate_idempotent_profile_wallet_ledger(
&existing,
"user-1",
-37,
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
)
.is_err()
);
}
fn recharge_order_for_refund(
status: RuntimeProfileRechargeOrderStatus,
) -> ProfileRechargeOrder {
ProfileRechargeOrder {
order_id: "rcg-test".to_string(),
user_id: "user-1".to_string(),
product_id: "points_60".to_string(),
product_title: "60泥点".to_string(),
kind: RuntimeProfileRechargeProductKind::Points,
amount_cents: 600,
status,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE.to_string(),
paid_at: Some(Timestamp::from_micros_since_unix_epoch(100)),
provider_transaction_id: Some("wx-transaction-1".to_string()),
created_at: Timestamp::from_micros_since_unix_epoch(1),
points_delta: 60,
membership_expires_at: None,
expired_at: None,
expiration_checked_at: None,
expiration_provider_state: None,
expiration_last_error: None,
}
}
fn recharge_refund_for_order(order: &ProfileRechargeOrder) -> ProfileRechargeRefund {
ProfileRechargeRefund {
out_refund_no: "refund-test".to_string(),
provider_refund_id: "wx-refund-1".to_string(),
order_id: order.order_id.clone(),
provider_transaction_id: order.provider_transaction_id.clone().unwrap(),
user_id: Some(order.user_id.clone()),
provider_status: RuntimeProfileRechargeRefundStatus::Success,
total_cents: order.amount_cents,
refund_cents: order.amount_cents,
payer_total_cents: order.amount_cents,
payer_refund_cents: order.amount_cents,
success_at: Some(Timestamp::from_micros_since_unix_epoch(200)),
first_observed_at: Timestamp::from_micros_since_unix_epoch(200),
updated_at: Timestamp::from_micros_since_unix_epoch(200),
last_observation_source: RuntimeProfileRechargeRefundObservationSource::Callback,
last_observation_id: "observation-1".to_string(),
order_settled_at: None,
target_recovery_points: 0,
recovered_points: 0,
unrecovered_points: 0,
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending,
last_recovery_ledger_id: None,
last_error_code: None,
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at: None,
manual_review_resolved_error_code: None,
}
}
#[test]
fn refunded_order_accepts_only_exact_payment_transaction_replay() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded);
assert!(
validate_profile_recharge_order_paid_replay_transaction_id(
&order,
&Some("wx-transaction-1".to_string()),
)
.is_ok()
);
assert!(
validate_profile_recharge_order_paid_replay_transaction_id(
&order,
&Some("wx-transaction-other".to_string()),
)
.is_err()
);
assert!(validate_profile_recharge_order_paid_replay_transaction_id(&order, &None).is_err());
}
#[test]
fn confirmed_manual_review_suppresses_only_the_approved_order_match_conflict() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let mut refund = recharge_refund_for_order(&order);
refund.provider_transaction_id = "wx-transaction-other".to_string();
assert_eq!(
unresolved_profile_recharge_refund_order_match_error(&order, &refund),
Some("provider_transaction_id_mismatch".to_string())
);
refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300));
refund.manual_review_resolved_error_code =
Some("provider_transaction_id_mismatch".to_string());
assert_eq!(
unresolved_profile_recharge_refund_order_match_error(&order, &refund),
None
);
assert_eq!(
validate_profile_recharge_refund_order_match(&order, &refund),
Err("provider_transaction_id_mismatch".to_string())
);
refund.total_cents = order.amount_cents + 1;
assert_eq!(
unresolved_profile_recharge_refund_order_match_error(&order, &refund),
Some("order_total_mismatch".to_string())
);
refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string());
assert_eq!(
unresolved_profile_recharge_refund_order_match_error(&order, &refund),
Some("provider_transaction_id_mismatch".to_string())
);
}
#[test]
fn manual_review_request_matches_current_and_resolved_state() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let mut refund = recharge_refund_for_order(&order);
refund.last_error_code = Some("provider_transaction_id_mismatch".to_string());
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"provider_transaction_id_mismatch",
"admin-1",
"已核对商户平台",
)
.is_ok()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-1",
"已核对商户平台",
)
.is_err()
);
refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300));
refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string());
refund.manual_review_resolved_by_admin_user_id = Some("admin-1".to_string());
refund.manual_review_resolution_reason = Some("已核对商户平台".to_string());
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-1",
"已核对商户平台",
)
.is_ok()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"provider_transaction_id_mismatch",
"admin-1",
"已核对商户平台",
)
.is_err()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-2",
"已核对商户平台",
)
.is_err()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-1",
"另一份核对意见",
)
.is_err()
);
}
#[test]
fn refunded_order_keeps_first_recharge_qualification_consumed() {
let refunded = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded);
assert!(profile_recharge_order_counts_as_paid_purchase(&refunded));
let mut unpaid = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending);
unpaid.paid_at = None;
assert!(!profile_recharge_order_counts_as_paid_purchase(&unpaid));
}
#[test]
fn v3_refund_order_match_rejects_virtual_channel_transaction_and_total_mismatch() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let refund = recharge_refund_for_order(&order);
assert!(validate_profile_recharge_refund_order_match(&order, &refund).is_ok());
let mut wrong_channel = order.clone();
wrong_channel.payment_channel =
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL.to_string();
assert_eq!(
validate_profile_recharge_refund_order_match(&wrong_channel, &refund),
Err("payment_channel_not_v3".to_string())
);
let mut wrong_transaction = refund.clone();
wrong_transaction.provider_transaction_id = "wx-transaction-other".to_string();
assert_eq!(
validate_profile_recharge_refund_order_match(&order, &wrong_transaction),
Err("provider_transaction_id_mismatch".to_string())
);
let mut wrong_total = refund;
wrong_total.total_cents = 599;
assert_eq!(
validate_profile_recharge_refund_order_match(&order, &wrong_total),
Err("order_total_mismatch".to_string())
);
}
#[test]
fn successful_point_refund_conflict_freezes_ordinary_wallet_debits() {
for code in [
"provider_transaction_id_mismatch",
"order_total_mismatch",
"refund_settlement_plan_invalid",
] {
assert!(profile_recharge_refund_manual_review_freezes_wallet(
RuntimeProfileRechargeProductKind::Points,
code,
));
assert!(!profile_recharge_refund_manual_review_freezes_wallet(
RuntimeProfileRechargeProductKind::Membership,
code,
));
}
let settlement = ProfileRechargeOrderRefundSettlement {
order_id: "rcg-test".to_string(),
user_id: "user-1".to_string(),
successful_refund_count: 0,
cumulative_success_refund_cents: 0,
target_recovery_points: 0,
recovered_points: 0,
unrecovered_points: 0,
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::ManualReview,
wallet_frozen: true,
updated_at: Timestamp::from_micros_since_unix_epoch(200),
};
let refund_frozen = profile_recharge_refund_settlement_freezes_wallet(&settlement);
assert!(refund_frozen);
assert!(
validate_runtime_profile_wallet_debit_restrictions(
-1,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
false,
refund_frozen,
)
.is_err()
);
}
#[test]
fn refund_observation_replay_requires_identical_financial_facts() {
let input = build_runtime_profile_recharge_refund_observation_input(
"observation-1".to_string(),
RuntimeProfileRechargeRefundObservationSource::Callback,
Some("notification-ref".to_string()),
"payload-ref".to_string(),
"refund-test".to_string(),
"wx-refund-1".to_string(),
"rcg-test".to_string(),
"wx-transaction-1".to_string(),
RuntimeProfileRechargeRefundStatus::Success,
600,
600,
600,
600,
Some(200),
300,
)
.unwrap();
let row = ProfileRechargeRefundObservation {
observation_id: input.observation_id.clone(),
out_refund_no: input.out_refund_no.clone(),
provider_refund_id: input.provider_refund_id.clone(),
order_id: input.order_id.clone(),
provider_transaction_id: input.provider_transaction_id.clone(),
source: input.source,
provider_status: input.provider_status,
total_cents: input.total_cents,
refund_cents: input.refund_cents,
payer_total_cents: input.payer_total_cents,
payer_refund_cents: input.payer_refund_cents,
success_at: input
.success_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
notification_ref: input.notification_ref.clone(),
payload_fingerprint: input.payload_fingerprint.clone(),
resolution_code: "settled".to_string(),
observed_at: Timestamp::from_micros_since_unix_epoch(input.observed_at_micros),
};
assert!(validate_profile_recharge_refund_observation_replay(&row, &input).is_ok());
let mut conflicting = input;
conflicting.refund_cents = 599;
assert!(validate_profile_recharge_refund_observation_replay(&row, &conflicting).is_err());
let snapshot = build_profile_recharge_refund_observation_snapshot_from_row(&row);
assert_eq!(snapshot.resolution_code, "settled");
}
#[test]
fn refund_reconciliation_retries_only_late_order_manual_review() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let mut refund = recharge_refund_for_order(&order);
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some("order_missing".to_string());
assert!(profile_recharge_refund_needs_reconciliation(&refund));
refund.last_error_code = Some("order_not_paid".to_string());
assert!(profile_recharge_refund_needs_reconciliation(&refund));
refund.last_error_code = Some("membership_manual_review".to_string());
assert!(!profile_recharge_refund_needs_reconciliation(&refund));
refund.last_error_code = Some("order_total_mismatch".to_string());
assert!(!profile_recharge_refund_needs_reconciliation(&refund));
}
#[test]
fn refund_reconciliation_rotates_across_more_than_one_batch() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let rows = (0..250)
.map(|index| {
let mut refund = recharge_refund_for_order(&order);
refund.out_refund_no = format!("refund-{index:03}");
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall;
refund
})
.collect::<Vec<_>>();
let first = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 0);
let second = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 1);
let third = select_profile_recharge_refund_reconciliation_page(rows, 100, 2);
assert_eq!((first.len(), second.len(), third.len()), (100, 100, 50));
let observed = first
.into_iter()
.chain(second)
.chain(third)
.map(|row| row.out_refund_no)
.collect::<HashSet<_>>();
assert_eq!(observed.len(), 250);
}
#[test]
fn point_recharge_display_is_resolved_per_product() {
let products = runtime_profile_recharge_point_products();
let repeated = resolve_profile_recharge_product_display(products[0].clone(), true);
let untouched = resolve_profile_recharge_product_display(products[1].clone(), false);
assert_eq!(repeated.product_id, "points_60");
assert_eq!(repeated.bonus_points, 0);
assert_eq!(repeated.badge_label, "");
assert_eq!(repeated.description, "60泥点");
assert_eq!(untouched.product_id, "points_180");
assert_eq!(untouched.bonus_points, 90);
assert_eq!(untouched.badge_label, "首充加赠");
assert_eq!(untouched.description, "首充加赠90泥点");
}
#[test]
fn legacy_default_recharge_products_migrate_without_overwriting_admin_config() {
let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000);
let build_row = |points_amount: u64| ProfileRechargeProductConfig {
product_id: format!("points_{points_amount}"),
title: format!("{points_amount}泥点"),
price_cents: points_amount * 10,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount,
bonus_points: points_amount,
duration_days: 0,
badge_label: "首充双倍".to_string(),
description: format!("首充送{points_amount}泥点"),
tier: RuntimeProfileMembershipTier::Normal,
enabled: true,
sort_order: 0,
created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
created_at: timestamp,
updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
updated_at: timestamp,
membership_period_points: 0,
membership_period_days: 0,
membership_queue_limit: 0,
membership_discount_bps: 0,
};
assert_eq!(
resolve_default_point_product_migration(&build_row(180)),
Some(DefaultPointProductMigration {
bonus_points: 90,
badge_label: "首充加赠",
description: "首充加赠90泥点",
enabled: true,
}),
);
assert_eq!(
resolve_default_point_product_migration(&build_row(1_280)),
Some(DefaultPointProductMigration {
bonus_points: 1_280,
badge_label: "首充双倍",
description: "",
enabled: false,
}),
);
let mut admin_row = build_row(300);
admin_row.updated_by = "admin-1".to_string();
assert_eq!(resolve_default_point_product_migration(&admin_row), None);
}
#[test]
fn membership_wallet_split_metadata_keeps_cycle_reset_for_refund_restore() {
let metadata = metadata_with_profile_wallet_consumption_split(
r#"{"externalGenerationJobId":"job-1"}"#,
150,
DailyFreePointMutation::none(),
MembershipCyclePointMutation {
points: 120,
cycle_resets_at_micros: Some(123_000),
},
);
let parsed = serde_json::from_str::<JsonValue>(&metadata).expect("metadata json");
assert_eq!(
parsed
.get("externalGenerationJobId")
.and_then(JsonValue::as_str),
Some("job-1"),
);
assert_eq!(
parsed
.get("dailyFreePointsDelta")
.and_then(JsonValue::as_i64),
Some(0),
);
assert_eq!(
parsed
.get("membershipPeriodPointsDelta")
.and_then(JsonValue::as_i64),
Some(-120),
);
assert_eq!(
parsed
.get("permanentPointsDelta")
.and_then(JsonValue::as_i64),
Some(-30),
);
assert_eq!(
parsed
.get("cycleResetsAtMicros")
.and_then(JsonValue::as_i64),
Some(123_000),
);
assert_eq!(
membership_refund_restore_candidate_from_consume_metadata(&metadata),
MembershipCyclePointMutation {
points: 120,
cycle_resets_at_micros: Some(123_000),
},
);
}
#[test]
fn wallet_split_consumes_daily_free_before_membership_and_permanent_points() {
let metadata = metadata_with_profile_wallet_consumption_split(
"{}",
150,
DailyFreePointMutation {
points: 20,
day_key: Some(20_280),
},
MembershipCyclePointMutation {
points: 120,
cycle_resets_at_micros: Some(456_000),
},
);
let parsed = serde_json::from_str::<JsonValue>(&metadata).expect("metadata json");
assert_eq!(parsed["dailyFreePointsDelta"], json!(-20));
assert_eq!(parsed["dailyFreeDayKey"], json!(20_280));
assert_eq!(parsed["membershipPeriodPointsDelta"], json!(-120));
assert_eq!(parsed["permanentPointsDelta"], json!(-10));
assert_eq!(
daily_free_refund_restore_candidate_from_consume_metadata(&metadata),
DailyFreePointMutation {
points: 20,
day_key: Some(20_280),
},
);
}
#[test]
fn cross_day_daily_free_refund_keeps_the_original_permanent_remainder_permanent() {
let metadata = metadata_with_profile_wallet_refund_split(
"{}",
30,
DailyFreePointMutation {
points: 20,
day_key: Some(20_281),
},
MembershipCyclePointMutation::none(),
);
let parsed = serde_json::from_str::<JsonValue>(&metadata).expect("metadata json");
assert_eq!(parsed["dailyFreePointsDelta"], json!(20));
assert_eq!(parsed["dailyFreeDayKey"], json!(20_281));
assert_eq!(parsed["membershipPeriodPointsDelta"], json!(0));
assert_eq!(parsed["permanentPointsDelta"], json!(10));
}
#[test]
fn daily_free_refund_restore_candidate_requires_a_negative_delta_and_day_key() {
assert_eq!(
daily_free_refund_restore_candidate_from_consume_metadata(
r#"{"dailyFreePointsDelta":-20,"dailyFreeDayKey":20280}"#,
),
DailyFreePointMutation {
points: 20,
day_key: Some(20_280),
},
);
assert_eq!(
daily_free_refund_restore_candidate_from_consume_metadata(
r#"{"dailyFreePointsDelta":0,"dailyFreeDayKey":20280}"#,
),
DailyFreePointMutation::none(),
);
assert_eq!(
daily_free_refund_restore_candidate_from_consume_metadata("not-json"),
DailyFreePointMutation::none(),
);
}
#[test]
fn daily_free_refund_restores_same_day_and_stacks_after_cross_day() {
assert_eq!(
resolve_daily_free_refund_restore_plan(20_280, 20_280, 20_280, 20, 5, 20, 20),
Some(DailyFreeRefundRestorePlan {
restored_points: 15,
target_day_key: 20_280,
granted_points_delta: 0,
}),
);
assert_eq!(
resolve_daily_free_refund_restore_plan(20_280, 20_281, 20_281, 20, 20, 20, 20),
Some(DailyFreeRefundRestorePlan {
restored_points: 20,
target_day_key: 20_281,
granted_points_delta: 20,
}),
);
assert_eq!(
resolve_daily_free_refund_restore_plan(20_279, 20_281, 20_281, 40, 40, 20, 20),
Some(DailyFreeRefundRestorePlan {
restored_points: 20,
target_day_key: 20_281,
granted_points_delta: 20,
}),
);
assert_eq!(
resolve_daily_free_refund_restore_plan(20_281, 20_281, 20_281, 60, 50, 10, 10),
Some(DailyFreeRefundRestorePlan {
restored_points: 10,
target_day_key: 20_281,
granted_points_delta: 0,
}),
);
assert_eq!(
resolve_daily_free_refresh_plan(Some(20_281), 60, 20_282),
Some(DailyFreeRefreshPlan {
expired_points: 60,
granted_points: 20,
reset: true,
}),
);
assert_eq!(
resolve_daily_free_refund_restore_plan(20_282, 20_281, 20_281, 20, 20, 20, 20),
None,
);
}
#[test]
fn daily_free_reset_boundary_is_beijing_midnight() {
let day_key = 20_280;
let resets_at_micros = profile_daily_free_points_resets_at_micros(day_key);
assert_eq!(PROFILE_DAILY_FREE_POINTS_PER_DAY, 20);
assert_eq!(
runtime_profile_beijing_day_key(resets_at_micros.saturating_sub(1)),
day_key,
);
assert_eq!(
runtime_profile_beijing_day_key(resets_at_micros),
day_key + 1,
);
}
#[test]
fn daily_free_refresh_plan_grants_once_and_replaces_cross_day_remainder() {
assert_eq!(
resolve_daily_free_refresh_plan(None, 0, 20_280),
Some(DailyFreeRefreshPlan {
expired_points: 0,
granted_points: 20,
reset: false,
}),
);
assert_eq!(
resolve_daily_free_refresh_plan(Some(20_280), 7, 20_280),
None,
);
assert_eq!(
resolve_daily_free_refresh_plan(Some(20_280), 7, 20_281),
Some(DailyFreeRefreshPlan {
expired_points: 7,
granted_points: 20,
reset: true,
}),
);
assert_eq!(
resolve_daily_free_refresh_plan(Some(20_281), 7, 20_280),
None,
);
}
#[test]
fn legacy_wallet_snapshot_keeps_current_daily_free_points() {
assert_eq!(
merge_legacy_wallet_balance_with_daily_free_points(100, 20),
120,
);
assert_eq!(
merge_legacy_wallet_balance_with_daily_free_points(100, 0),
100,
);
assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync(
RuntimeProfileWalletLedgerSourceType::DailyFreeGrant,
));
assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync(
RuntimeProfileWalletLedgerSourceType::DailyFreeReset,
));
assert!(profile_wallet_ledger_source_blocks_legacy_snapshot_sync(
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
));
}
#[test]
fn membership_refund_restore_candidate_requires_negative_cycle_delta() {
assert_eq!(
membership_refund_restore_candidate_from_consume_metadata(
r#"{"membershipPeriodPointsDelta":0,"cycleResetsAtMicros":123}"#,
),
MembershipCyclePointMutation::none(),
);
assert_eq!(
membership_refund_restore_candidate_from_consume_metadata(
r#"{"membershipPeriodPointsDelta":50,"cycleResetsAtMicros":123}"#,
),
MembershipCyclePointMutation::none(),
);
assert_eq!(
membership_refund_restore_candidate_from_consume_metadata("not-json"),
MembershipCyclePointMutation::none(),
);
}
#[test]
fn membership_upgrade_amount_only_charges_price_difference() {
assert_eq!(membership_upgrade_amount_cents(6_990, 1_990), 5_000);
assert_eq!(membership_upgrade_amount_cents(19_990, 6_990), 13_000);
}
#[test]
fn legacy_membership_tiers_share_new_pricing_ranks() {
assert_eq!(
canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Month),
RuntimeProfileMembershipTier::Starter,
);
assert_eq!(
canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Season),
RuntimeProfileMembershipTier::Basic,
);
assert_eq!(
canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Year),
RuntimeProfileMembershipTier::Pro,
);
}
#[test]
fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() {
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Month,
RuntimeProfileMembershipTier::Starter,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Season,
RuntimeProfileMembershipTier::Basic,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Year,
RuntimeProfileMembershipTier::Pro,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Starter,
RuntimeProfileMembershipTier::Starter,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Month,
RuntimeProfileMembershipTier::Basic,
),
Ok(ActiveMembershipPurchaseMode::Upgrade),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Season,
RuntimeProfileMembershipTier::Pro,
),
Ok(ActiveMembershipPurchaseMode::Upgrade),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Year,
RuntimeProfileMembershipTier::Ultimate,
),
Ok(ActiveMembershipPurchaseMode::Upgrade),
);
assert!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Basic,
RuntimeProfileMembershipTier::Starter,
)
.is_err()
);
assert!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Year,
RuntimeProfileMembershipTier::Basic,
)
.is_err()
);
assert!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Basic,
RuntimeProfileMembershipTier::Season,
)
.is_err()
);
}
#[test]
fn legacy_membership_migration_renews_and_preserves_cycle_timing() {
let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS);
let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS);
let current_reset_at =
Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS);
let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS);
let row = apply_active_membership_renew_row(
ProfileMembership {
user_id: "user-1".to_string(),
status: RuntimeProfileMembershipStatus::Active,
tier: RuntimeProfileMembershipTier::Month,
started_at,
expires_at,
updated_at: started_at,
cycle_started_at: Some(started_at),
cycle_resets_at: Some(current_reset_at),
cycle_granted_points: 0,
cycle_remaining_points: 0,
cycle_period_days: 90,
},
RuntimeProfileMembershipTier::Starter,
30,
purchased_at,
);
assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter);
assert_eq!(
row.expires_at,
Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS),
);
assert_eq!(row.cycle_resets_at, Some(current_reset_at));
assert_eq!(row.cycle_period_days, 90);
assert_eq!(row.cycle_granted_points, 0);
assert_eq!(row.cycle_remaining_points, 0);
assert_eq!(row.updated_at, purchased_at);
}
#[test]
fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() {
let started_at = Timestamp::from_micros_since_unix_epoch(1_000);
let expires_at = Timestamp::from_micros_since_unix_epoch(90_000);
let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000);
let updated_at = Timestamp::from_micros_since_unix_epoch(2_000);
let (row, period_points_delta) = apply_active_membership_upgrade_row(
ProfileMembership {
user_id: "user-1".to_string(),
status: RuntimeProfileMembershipStatus::Active,
tier: RuntimeProfileMembershipTier::Basic,
started_at,
expires_at,
updated_at: started_at,
cycle_started_at: Some(started_at),
cycle_resets_at: Some(current_reset_at),
cycle_granted_points: 800,
cycle_remaining_points: 120,
cycle_period_days: 90,
},
RuntimeProfileMembershipTier::Pro,
7,
2_500,
updated_at,
);
assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro);
assert_eq!(row.expires_at, expires_at);
assert_eq!(row.cycle_resets_at, Some(current_reset_at));
assert_eq!(row.cycle_period_days, 7);
assert_eq!(row.cycle_granted_points, 2_500);
assert_eq!(row.cycle_remaining_points, 1_820);
assert_eq!(row.updated_at, updated_at);
assert_eq!(period_points_delta, 1_700);
}
#[test]
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() {
assert!(should_skip_existing_tracking_event_id(true));
assert!(!should_skip_existing_tracking_event_id(false));
}
#[test]
fn tracking_batch_result_reports_accepted_count() {
let result = RuntimeTrackingEventBatchProcedureResult {
ok: true,
accepted_count: 2,
error_message: None,
};
assert!(result.ok);
assert_eq!(result.accepted_count, 2);
assert!(result.error_message.is_none());
}
#[test]
fn invite_code_metadata_tags_are_user_tags() {
let tags = profile_invite_code_metadata_user_tags(
r#"{"tags":["系统"," beta ","系统"],"channel":"spring"}"#,
)
.expect("metadata tags should parse");
assert_eq!(tags, vec!["系统".to_string(), "beta".to_string()]);
}
#[test]
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10;
let updated_at = Timestamp::from_micros_since_unix_epoch(now_micros);
let rows = vec![
PublicWorkPlayDailyStat {
stat_id: "puzzle:profile-a:10".to_string(),
source_type: "puzzle".to_string(),
owner_user_id: "user-a".to_string(),
profile_id: "profile-a".to_string(),
played_day: 10,
play_count: 3,
updated_at,
},
PublicWorkPlayDailyStat {
stat_id: "puzzle:profile-a:4".to_string(),
source_type: "puzzle".to_string(),
owner_user_id: "user-a".to_string(),
profile_id: "profile-a".to_string(),
played_day: 4,
play_count: 5,
updated_at,
},
PublicWorkPlayDailyStat {
stat_id: "puzzle:profile-a:3".to_string(),
source_type: "puzzle".to_string(),
owner_user_id: "user-a".to_string(),
profile_id: "profile-a".to_string(),
played_day: 3,
play_count: 99,
updated_at,
},
PublicWorkPlayDailyStat {
stat_id: "custom-world:profile-a:10".to_string(),
source_type: "custom-world".to_string(),
owner_user_id: "user-a".to_string(),
profile_id: "profile-a".to_string(),
played_day: 10,
play_count: 7,
updated_at,
},
PublicWorkPlayDailyStat {
stat_id: "puzzle:profile-b:9".to_string(),
source_type: "puzzle".to_string(),
owner_user_id: "user-b".to_string(),
profile_id: "profile-b".to_string(),
played_day: 9,
play_count: 11,
updated_at,
},
];
let counts = build_recent_public_work_play_counts(
rows,
"puzzle",
&["profile-a".to_string(), "profile-b".to_string()],
now_micros,
);
assert_eq!(counts.get("profile-a"), Some(&8));
assert_eq!(counts.get("profile-b"), Some(&11));
assert_eq!(counts.get("profile-c"), None);
}
}
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
if ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string())
.is_some()
{
return;
}
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: 0,
total_play_time_ms: 0,
created_at: updated_at,
updated_at,
});
}
fn add_profile_dashboard_play_time(
ctx: &ReducerContext,
user_id: &str,
elapsed_ms: u64,
updated_at: Timestamp,
) {
let current = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string());
if let Some(existing) = current {
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: existing.wallet_balance,
total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms),
created_at: existing.created_at,
updated_at,
});
} else {
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: 0,
total_play_time_ms: elapsed_ms,
created_at: updated_at,
updated_at,
});
}
}
fn sync_profile_dashboard_from_snapshot(
ctx: &ReducerContext,
snapshot: &RuntimeSnapshot,
game_state: Option<&serde_json::Map<String, JsonValue>>,
saved_at: Timestamp,
) {
refresh_profile_daily_free_points(ctx, &snapshot.user_id, ctx.timestamp);
let current_state = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&snapshot.user_id);
let previous_wallet_balance = current_state
.as_ref()
.map(|row| row.wallet_balance)
.unwrap_or(0);
let previous_total_play_time_ms = current_state
.as_ref()
.map(|row| row.total_play_time_ms)
.unwrap_or(0);
let daily_free_remaining_points = ctx
.db
.profile_daily_free_points()
.user_id()
.find(&snapshot.user_id)
.map(|row| row.remaining_points)
.unwrap_or(0);
let has_business_wallet_ledger = has_profile_business_wallet_ledger(ctx, &snapshot.user_id);
let synced_wallet_balance = if has_business_wallet_ledger {
None
} else {
game_state
.and_then(|state| state.get("playerCurrency"))
.map(|value| module_runtime::read_runtime_json_non_negative_u64(Some(value)))
.map(|legacy_balance| {
merge_legacy_wallet_balance_with_daily_free_points(
legacy_balance,
daily_free_remaining_points,
)
})
};
let next_wallet_balance = synced_wallet_balance.unwrap_or(previous_wallet_balance);
let mut next_total_play_time_ms = previous_total_play_time_ms;
if let Some(next_wallet_balance) = synced_wallet_balance
&& next_wallet_balance != previous_wallet_balance
{
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: build_runtime_profile_snapshot_wallet_ledger_id(
&snapshot.user_id,
snapshot.saved_at_micros,
next_wallet_balance,
),
user_id: snapshot.user_id.clone(),
amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64,
balance_after: next_wallet_balance,
source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync,
created_at: saved_at,
metadata_json: Some(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()),
});
}
if let Some(world_meta) =
module_runtime::resolve_runtime_profile_world_snapshot_meta(game_state)
{
let current_play_time_ms = module_runtime::read_runtime_json_non_negative_u64(
game_state
.and_then(|state| state.get("runtimeStats"))
.and_then(JsonValue::as_object)
.and_then(|stats| stats.get("playTimeMs")),
);
let played_world_id =
build_runtime_profile_played_world_id(&snapshot.user_id, &world_meta.world_key);
let existing = ctx
.db
.profile_played_world()
.played_world_id()
.find(&played_world_id);
let previous_observed_play_time_ms = existing
.as_ref()
.map(|row| row.last_observed_play_time_ms)
.unwrap_or(0);
let incremental_play_time_ms =
current_play_time_ms.saturating_sub(previous_observed_play_time_ms);
next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms);
if let Some(existing) = existing {
ctx.db
.profile_played_world()
.played_world_id()
.delete(&existing.played_world_id);
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
played_world_id,
user_id: snapshot.user_id.clone(),
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_title: world_meta.world_title,
world_subtitle: world_meta.world_subtitle,
first_played_at: existing.first_played_at,
last_played_at: saved_at,
last_observed_play_time_ms: current_play_time_ms
.max(existing.last_observed_play_time_ms),
});
} else {
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
played_world_id,
user_id: snapshot.user_id.clone(),
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_title: world_meta.world_title,
world_subtitle: world_meta.world_subtitle,
first_played_at: saved_at,
last_played_at: saved_at,
last_observed_play_time_ms: current_play_time_ms,
});
}
}
if let Some(existing) = current_state {
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: existing.created_at,
updated_at: saved_at,
});
} else {
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: saved_at,
updated_at: saved_at,
});
}
}
fn sync_profile_save_archive_from_snapshot(
ctx: &ReducerContext,
snapshot: &RuntimeSnapshot,
game_state: &JsonValue,
saved_at: Timestamp,
) -> Result<(), String> {
let Some(archive_meta) = module_runtime::resolve_runtime_profile_save_archive_meta(
game_state,
snapshot.current_story_json.as_deref(),
) else {
return Ok(());
};
let archive_id =
build_runtime_profile_save_archive_id(&snapshot.user_id, &archive_meta.world_key);
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(saved_at);
if let Some(existing) = existing {
ctx.db
.profile_save_archive()
.archive_id()
.delete(&existing.archive_id);
}
ctx.db.profile_save_archive().insert(ProfileSaveArchive {
archive_id,
user_id: snapshot.user_id.clone(),
world_key: archive_meta.world_key,
owner_user_id: archive_meta.owner_user_id,
profile_id: archive_meta.profile_id,
world_type: archive_meta.world_type,
world_name: archive_meta.world_name,
subtitle: archive_meta.subtitle,
summary_text: archive_meta.summary_text,
cover_image_src: archive_meta.cover_image_src,
saved_at,
bottom_tab: snapshot.bottom_tab.clone(),
game_state_json: snapshot.game_state_json.clone(),
current_story_json: snapshot.current_story_json.clone(),
created_at,
updated_at: saved_at,
});
Ok(())
}
pub(crate) fn build_profile_save_archive_snapshot_from_row(
row: &ProfileSaveArchive,
) -> RuntimeProfileSaveArchiveSnapshot {
RuntimeProfileSaveArchiveSnapshot {
archive_id: row.archive_id.clone(),
user_id: row.user_id.clone(),
world_key: row.world_key.clone(),
owner_user_id: row.owner_user_id.clone(),
profile_id: row.profile_id.clone(),
world_type: row.world_type.clone(),
world_name: row.world_name.clone(),
subtitle: row.subtitle.clone(),
summary_text: row.summary_text.clone(),
cover_image_src: row.cover_image_src.clone(),
saved_at_micros: row.saved_at.to_micros_since_unix_epoch(),
bottom_tab: row.bottom_tab.clone(),
game_state_json: row.game_state_json.clone(),
current_story_json: row.current_story_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn get_profile_dashboard_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileDashboardGetInput,
) -> Result<RuntimeProfileDashboardSnapshot, String> {
let validated_input = build_runtime_profile_dashboard_get_input(input.user_id)
.map_err(|error| error.to_string())?;
refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp);
let state = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&validated_input.user_id);
let played_world_count = ctx
.db
.profile_played_world()
.by_profile_played_world_user_id()
.filter(&validated_input.user_id)
.count() as u32;
let daily_free_points =
build_profile_daily_free_points_snapshot(ctx, &validated_input.user_id, ctx.timestamp);
Ok(match state {
Some(existing) => RuntimeProfileDashboardSnapshot {
user_id: existing.user_id,
wallet_balance: existing.wallet_balance,
total_play_time_ms: existing.total_play_time_ms,
played_world_count,
updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()),
daily_free_points,
},
None => RuntimeProfileDashboardSnapshot {
user_id: validated_input.user_id,
wallet_balance: 0,
total_play_time_ms: 0,
played_world_count,
updated_at_micros: None,
daily_free_points,
},
})
}
fn list_profile_wallet_ledger_entries(
ctx: &ReducerContext,
input: RuntimeProfileWalletLedgerListInput,
) -> Result<Vec<RuntimeProfileWalletLedgerEntrySnapshot>, String> {
let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id)
.map_err(|error| error.to_string())?;
refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp);
let mut entries = ctx
.db
.profile_wallet_ledger()
.by_profile_wallet_ledger_user_id()
.filter(&validated_input.user_id)
.map(|row| build_profile_wallet_ledger_snapshot_from_row(&row))
.collect::<Vec<_>>();
let current_balance = profile_wallet_balance(ctx, &validated_input.user_id);
sort_profile_wallet_ledger_entries(&mut entries, current_balance);
entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT);
Ok(entries)
}
fn sort_profile_wallet_ledger_entries(
entries: &mut Vec<RuntimeProfileWalletLedgerEntrySnapshot>,
current_balance: u64,
) {
entries.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id))
});
let mut positions_by_balance = HashMap::<u64, VecDeque<usize>>::new();
for (position, entry) in entries.iter().enumerate() {
positions_by_balance
.entry(entry.balance_after)
.or_default()
.push_back(position);
}
let mut remaining = std::mem::take(entries)
.into_iter()
.map(Some)
.collect::<Vec<_>>();
let mut ordered = Vec::with_capacity(remaining.len());
let mut expected_balance = current_balance;
while ordered.len() < remaining.len() {
let Some(position) = positions_by_balance
.get_mut(&expected_balance)
.and_then(VecDeque::pop_front)
else {
break;
};
let Some(entry) = remaining[position].take() else {
break;
};
let previous_balance = i128::from(entry.balance_after) - i128::from(entry.amount_delta);
let Ok(previous_balance) = u64::try_from(previous_balance) else {
ordered.push(entry);
break;
};
ordered.push(entry);
expected_balance = previous_balance;
}
ordered.extend(remaining.into_iter().flatten());
*entries = ordered;
}
fn get_profile_play_stats_snapshot(
ctx: &ReducerContext,
input: RuntimeProfilePlayStatsGetInput,
) -> Result<RuntimeProfilePlayStatsSnapshot, String> {
let validated_input = build_runtime_profile_play_stats_get_input(input.user_id)
.map_err(|error| error.to_string())?;
let dashboard_state = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&validated_input.user_id);
let mut played_works = ctx
.db
.profile_played_world()
.by_profile_played_world_user_id()
.filter(&validated_input.user_id)
.map(|row| build_profile_played_world_snapshot_from_row(&row))
.collect::<Vec<_>>();
played_works.sort_by(|left, right| {
right
.last_played_at_micros
.cmp(&left.last_played_at_micros)
.then_with(|| left.played_world_id.cmp(&right.played_world_id))
});
Ok(RuntimeProfilePlayStatsSnapshot {
user_id: validated_input.user_id,
total_play_time_ms: dashboard_state
.as_ref()
.map(|row| row.total_play_time_ms)
.unwrap_or(0),
played_works,
updated_at_micros: dashboard_state
.as_ref()
.map(|row| row.updated_at.to_micros_since_unix_epoch()),
})
}
fn get_profile_recharge_center_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileRechargeCenterGetInput,
) -> Result<RuntimeProfileRechargeCenterSnapshot, String> {
let validated_input = build_runtime_profile_recharge_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
Ok(build_profile_recharge_center_snapshot(
ctx,
&validated_input.user_id,
))
}
fn create_profile_recharge_order_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderCreateInput,
) -> Result<
(
RuntimeProfileRechargeCenterSnapshot,
RuntimeProfileRechargeOrderSnapshot,
),
String,
> {
let validated_input = build_runtime_profile_recharge_order_create_input(
input.user_id,
input.product_id,
input.payment_channel,
input.created_at_micros,
)
.map_err(|error| error.to_string())?;
let product = enabled_profile_recharge_product_by_id(ctx, &validated_input.product_id)
.ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let should_settle_immediately =
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
let amount_cents = resolve_profile_recharge_order_amount_cents(
ctx,
&validated_input.user_id,
&product,
created_at,
)?;
let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately {
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
ctx,
&validated_input.user_id,
&product,
validated_input.created_at_micros,
created_at,
)?;
(
RuntimeProfileRechargeOrderStatus::Paid,
Some(created_at),
points_delta,
membership_expires_at,
)
} else {
(RuntimeProfileRechargeOrderStatus::Pending, None, 0, None)
};
let order = ProfileRechargeOrder {
order_id: build_runtime_profile_recharge_order_id(
&validated_input.user_id,
validated_input.created_at_micros,
&product.product_id,
),
user_id: validated_input.user_id.clone(),
product_id: product.product_id.clone(),
product_title: product.title.clone(),
kind: product.kind,
amount_cents,
status,
payment_channel: validated_input.payment_channel,
paid_at,
provider_transaction_id: None,
created_at,
points_delta,
membership_expires_at,
expired_at: None,
expiration_checked_at: None,
expiration_provider_state: None,
expiration_last_error: None,
};
ctx.db.profile_recharge_order().insert(order.clone());
if order.status == RuntimeProfileRechargeOrderStatus::Pending
&& should_schedule_profile_recharge_order_expiration(&order.payment_channel)
{
let scheduled_at = created_at
+ std::time::Duration::from_secs(PROFILE_RECHARGE_ORDER_EXPIRATION_SECONDS as u64);
ctx.db.profile_recharge_order_expiration_timer().insert(
ProfileRechargeOrderExpirationTimer {
scheduled_id: 0,
order_id: order.order_id.clone(),
user_id: order.user_id.clone(),
scheduled_at: scheduled_at.into(),
created_at,
},
);
}
let latest_order = latest_profile_recharge_order(ctx, &validated_input.user_id)
.ok_or_else(|| "profile_recharge_order 写入后未能读取".to_string())?;
Ok((
build_profile_recharge_center_snapshot(ctx, &validated_input.user_id),
build_profile_recharge_order_snapshot_from_row(&latest_order),
))
}
fn get_profile_recharge_order_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderGetInput,
) -> Result<
(
RuntimeProfileRechargeCenterSnapshot,
RuntimeProfileRechargeOrderSnapshot,
),
String,
> {
let validated_input = build_runtime_profile_recharge_order_get_input(input.order_id)
.map_err(|error| error.to_string())?;
let order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&validated_input.order_id)
.ok_or_else(|| "profile_recharge_order missing".to_string())?;
Ok((
build_profile_recharge_center_snapshot(ctx, &order.user_id),
build_profile_recharge_order_snapshot_from_row(&order),
))
}
fn mark_profile_recharge_order_paid_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderPaidInput,
) -> Result<
(
RuntimeProfileRechargeCenterSnapshot,
RuntimeProfileRechargeOrderSnapshot,
),
String,
> {
let validated_input = build_runtime_profile_recharge_order_paid_input(
input.order_id,
input.paid_at_micros,
input.provider_transaction_id,
)
.map_err(|error| error.to_string())?;
let mut order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&validated_input.order_id)
.ok_or_else(|| "profile_recharge_order 不存在".to_string())?;
if matches!(
order.status,
RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded
) {
validate_profile_recharge_order_paid_replay_transaction_id(
&order,
&validated_input.provider_transaction_id,
)?;
delete_profile_recharge_order_expiration_task(ctx, &order.order_id);
return Ok((
build_profile_recharge_center_snapshot(ctx, &order.user_id),
build_profile_recharge_order_snapshot_from_row(&order),
));
}
if !matches!(
order.status,
RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired
) {
return Err("profile_recharge_order 当前状态不能确认支付".to_string());
}
let product = profile_recharge_product_by_id(ctx, &order.product_id)
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros);
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
ctx,
&order.user_id,
&product,
order.created_at.to_micros_since_unix_epoch(),
paid_at,
)?;
ctx.db
.profile_recharge_order()
.order_id()
.delete(&order.order_id);
order.status = RuntimeProfileRechargeOrderStatus::Paid;
order.paid_at = Some(paid_at);
order.provider_transaction_id = validated_input.provider_transaction_id;
order.points_delta = points_delta;
order.membership_expires_at = membership_expires_at;
ctx.db.profile_recharge_order().insert(order.clone());
delete_profile_recharge_order_expiration_task(ctx, &order.order_id);
Ok((
build_profile_recharge_center_snapshot(ctx, &order.user_id),
build_profile_recharge_order_snapshot_from_row(&order),
))
}
fn validate_profile_recharge_order_paid_replay_transaction_id(
order: &ProfileRechargeOrder,
provider_transaction_id: &Option<String>,
) -> Result<(), String> {
if &order.provider_transaction_id != provider_transaction_id {
return Err("profile_recharge_order provider_transaction_id 重放不匹配".to_string());
}
Ok(())
}
fn admin_list_profile_recharge_order_entries(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderAdminListInput,
) -> Result<Vec<RuntimeProfileRechargeOrderAdminEntrySnapshot>, String> {
let validated = build_runtime_profile_recharge_order_admin_list_input(
input.order_id,
input.user_id,
input.provider_transaction_id,
input.payment_channel,
input.status,
input.created_after_micros,
input.created_before_micros,
input.limit,
)?;
let mut rows = ctx
.db
.profile_recharge_order()
.iter()
.filter(|row| profile_recharge_order_matches_admin_query(row, &validated))
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
right
.created_at
.to_micros_since_unix_epoch()
.cmp(&left.created_at.to_micros_since_unix_epoch())
.then_with(|| right.order_id.cmp(&left.order_id))
});
Ok(rows
.into_iter()
.take(validated.limit as usize)
.map(|row| build_profile_recharge_order_admin_entry_snapshot(ctx, &row))
.collect())
}
fn profile_recharge_order_matches_admin_query(
row: &ProfileRechargeOrder,
query: &RuntimeProfileRechargeOrderAdminListInput,
) -> bool {
query
.order_id
.as_deref()
.is_none_or(|value| row.order_id == value)
&& query
.user_id
.as_deref()
.is_none_or(|value| row.user_id == value)
&& query
.provider_transaction_id
.as_deref()
.is_none_or(|value| row.provider_transaction_id.as_deref() == Some(value))
&& query
.payment_channel
.as_deref()
.is_none_or(|value| row.payment_channel == value)
&& query.status.is_none_or(|value| row.status == value)
&& query
.created_after_micros
.is_none_or(|value| row.created_at.to_micros_since_unix_epoch() >= value)
&& query
.created_before_micros
.is_none_or(|value| row.created_at.to_micros_since_unix_epoch() <= value)
}
fn build_profile_recharge_order_admin_entry_snapshot(
ctx: &ReducerContext,
row: &ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderAdminEntrySnapshot {
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&row.order_id)
.map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value));
let mut refunds = ctx
.db
.profile_recharge_refund()
.by_profile_recharge_refund_order_id()
.filter(&row.order_id)
.map(|value| build_profile_recharge_refund_snapshot_from_row(&value))
.collect::<Vec<_>>();
refunds.sort_by(|left, right| {
right
.first_observed_at_micros
.cmp(&left.first_observed_at_micros)
.then_with(|| left.out_refund_no.cmp(&right.out_refund_no))
});
RuntimeProfileRechargeOrderAdminEntrySnapshot {
order: build_profile_recharge_order_snapshot_from_row(row),
settlement,
refunds,
active_hold: active_profile_recharge_refund_hold_for_order(ctx, &row.order_id)
.map(|value| build_profile_recharge_refund_hold_snapshot_from_row(&value)),
wallet: build_profile_admin_wallet_snapshot(ctx, &row.user_id),
}
}
fn preview_profile_recharge_refund_hold(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundHoldPreviewInput,
) -> Result<
(
RuntimeProfileRechargeRefundHoldSnapshot,
RuntimeProfileRechargeOrderSnapshot,
Option<RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
RuntimeProfileAdminWalletSnapshot,
),
String,
> {
let validated = build_runtime_profile_recharge_refund_hold_preview_input(
input.order_id,
input.refund_cents,
)?;
let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility(
ctx,
&validated.order_id,
validated.refund_cents,
)?;
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
Ok((
RuntimeProfileRechargeRefundHoldSnapshot {
out_refund_no: String::new(),
order_id: order.order_id.clone(),
user_id: order.user_id.clone(),
refund_cents: validated.refund_cents,
held_points,
status: RuntimeProfileRechargeRefundHoldStatus::Active,
admin_user_id: String::new(),
reason: String::new(),
created_at_micros: now_micros,
updated_at_micros: now_micros,
settled_at_micros: None,
released_at_micros: None,
released_by_admin_user_id: None,
release_reason: None,
},
build_profile_recharge_order_snapshot_from_row(&order),
settlement
.as_ref()
.map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)),
build_profile_admin_wallet_snapshot(ctx, &order.user_id),
))
}
fn prepare_profile_recharge_refund_hold(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundHoldPrepareInput,
) -> Result<
(
RuntimeProfileRechargeRefundHoldSnapshot,
RuntimeProfileRechargeOrderSnapshot,
Option<RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
RuntimeProfileAdminWalletSnapshot,
),
String,
> {
let validated = build_runtime_profile_recharge_refund_hold_prepare_input(
input.order_id,
input.out_refund_no,
input.refund_cents,
input.admin_user_id,
input.reason,
)?;
if let Some(existing) = ctx
.db
.profile_recharge_refund_hold()
.out_refund_no()
.find(&validated.out_refund_no)
{
if existing.order_id != validated.order_id
|| existing.refund_cents != validated.refund_cents
|| existing.admin_user_id != validated.admin_user_id
|| existing.reason != validated.reason
{
return Err("refund_hold 幂等重放内容冲突".to_string());
}
let order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&existing.order_id)
.ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?;
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&order.order_id)
.map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value));
return Ok((
build_profile_recharge_refund_hold_snapshot_from_row(&existing),
build_profile_recharge_order_snapshot_from_row(&order),
settlement,
build_profile_admin_wallet_snapshot(ctx, &order.user_id),
));
}
if ctx
.db
.profile_recharge_refund()
.out_refund_no()
.find(&validated.out_refund_no)
.is_some()
{
return Err("refund_hold 对应退款单已经存在".to_string());
}
let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility(
ctx,
&validated.order_id,
validated.refund_cents,
)?;
let row = ProfileRechargeRefundHold {
out_refund_no: validated.out_refund_no,
order_id: order.order_id.clone(),
user_id: order.user_id.clone(),
refund_cents: validated.refund_cents,
held_points,
status: RuntimeProfileRechargeRefundHoldStatus::Active,
admin_user_id: validated.admin_user_id,
reason: validated.reason,
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
settled_at: None,
released_at: None,
released_by_admin_user_id: None,
release_reason: None,
};
ctx.db.profile_recharge_refund_hold().insert(row.clone());
Ok((
build_profile_recharge_refund_hold_snapshot_from_row(&row),
build_profile_recharge_order_snapshot_from_row(&order),
settlement
.as_ref()
.map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)),
build_profile_admin_wallet_snapshot(ctx, &order.user_id),
))
}
fn validate_profile_recharge_refund_hold_eligibility(
ctx: &ReducerContext,
order_id: &str,
refund_cents: u64,
) -> Result<
(
ProfileRechargeOrder,
Option<ProfileRechargeOrderRefundSettlement>,
u64,
),
String,
> {
let order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&order_id.to_string())
.ok_or_else(|| "充值订单不存在".to_string())?;
if order.kind != RuntimeProfileRechargeProductKind::Points {
return Err("首期后台退款只支持泥点充值订单".to_string());
}
if order.status != RuntimeProfileRechargeOrderStatus::Paid || order.paid_at.is_none() {
return Err("充值订单不是可退款的已支付状态".to_string());
}
if !matches!(
order.payment_channel.as_str(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
) {
return Err("首期后台退款只支持普通微信 V3 订单".to_string());
}
if order.provider_transaction_id.is_none() || order.points_delta <= 0 {
return Err("充值订单缺少可退款的支付或泥点结算事实".to_string());
}
if active_profile_recharge_refund_hold_for_order(ctx, &order.order_id).is_some() {
return Err("充值订单已有退款占用,需先完成对账".to_string());
}
if ctx
.db
.profile_recharge_refund()
.by_profile_recharge_refund_order_id()
.filter(&order.order_id)
.any(|row| {
matches!(
row.provider_status,
RuntimeProfileRechargeRefundStatus::Processing
| RuntimeProfileRechargeRefundStatus::Abnormal
)
})
{
return Err("充值订单存在未完成退款,需先完成对账".to_string());
}
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&order.order_id);
if settlement
.as_ref()
.is_some_and(|value| value.unrecovered_points > 0)
{
return Err("账户存在退款异常欠账,不能继续发起退款".to_string());
}
if has_profile_wallet_manual_restriction(ctx, &order.user_id) {
return Err("账户已被人工冻结,不能继续发起退款".to_string());
}
let plan = build_runtime_profile_recharge_refund_settlement_plan(
settlement
.as_ref()
.map(|value| value.successful_refund_count)
.unwrap_or(0),
settlement
.as_ref()
.map(|value| value.cumulative_success_refund_cents)
.unwrap_or(0),
settlement
.as_ref()
.map(|value| value.target_recovery_points)
.unwrap_or(0),
refund_cents,
order.amount_cents,
order.points_delta,
)?;
let remaining_refundable_cents = order.amount_cents.saturating_sub(
settlement
.as_ref()
.map(|value| value.cumulative_success_refund_cents)
.unwrap_or(0),
);
let held_points = resolve_runtime_profile_recharge_refund_hold_points(
plan.incremental_target_recovery_points,
refund_cents,
remaining_refundable_cents,
);
let wallet = build_profile_admin_wallet_snapshot(ctx, &order.user_id);
validate_runtime_profile_recharge_refund_hold_capacity(
held_points,
wallet.permanent_points,
wallet.held_points,
)?;
Ok((order, settlement, held_points))
}
fn release_profile_recharge_refund_hold(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundHoldReleaseInput,
) -> Result<
(
RuntimeProfileRechargeRefundHoldSnapshot,
RuntimeProfileRechargeOrderSnapshot,
Option<RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
RuntimeProfileAdminWalletSnapshot,
),
String,
> {
let validated = build_runtime_profile_recharge_refund_hold_release_input(
input.out_refund_no,
input.admin_user_id,
input.release_reason,
)?;
let mut row = ctx
.db
.profile_recharge_refund_hold()
.out_refund_no()
.find(&validated.out_refund_no)
.ok_or_else(|| "refund_hold 不存在".to_string())?;
if row.status == RuntimeProfileRechargeRefundHoldStatus::Settled {
return Err("已结算退款占用不能释放".to_string());
}
if row.status == RuntimeProfileRechargeRefundHoldStatus::Active {
row.status = RuntimeProfileRechargeRefundHoldStatus::Released;
row.updated_at = ctx.timestamp;
row.released_at = Some(ctx.timestamp);
row.released_by_admin_user_id = Some(validated.admin_user_id);
row.release_reason = Some(validated.release_reason);
upsert_profile_recharge_refund_hold_row(ctx, row.clone());
}
let order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&row.order_id)
.ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?;
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&order.order_id)
.map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value));
Ok((
build_profile_recharge_refund_hold_snapshot_from_row(&row),
build_profile_recharge_order_snapshot_from_row(&order),
settlement,
build_profile_admin_wallet_snapshot(ctx, &order.user_id),
))
}
fn resolve_profile_recharge_refund_manual_review(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
) -> Result<
(
RuntimeProfileRechargeRefundSnapshot,
Option<RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
bool,
String,
),
String,
> {
let validated = build_runtime_profile_recharge_refund_manual_review_resolve_input(
input.out_refund_no,
input.admin_user_id,
input.reason,
input.expected_error_code,
)?;
let mut refund = ctx
.db
.profile_recharge_refund()
.out_refund_no()
.find(&validated.out_refund_no)
.ok_or_else(|| "退款记录不存在".to_string())?;
validate_profile_recharge_refund_manual_review_request(
&refund,
&validated.expected_error_code,
&validated.admin_user_id,
&validated.reason,
)?;
if refund.manual_review_resolved_at.is_some() {
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&refund.order_id)
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row));
return Ok((
build_profile_recharge_refund_snapshot_from_row(&refund),
settlement,
true,
"manual_review_already_resolved".to_string(),
));
}
if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success
|| refund.recovery_status != RuntimeProfileRechargeRefundRecoveryStatus::ManualReview
{
return Err("只有已成功且待人工复核的退款可以确认归属".to_string());
}
let review_code = refund
.last_error_code
.clone()
.ok_or_else(|| "退款缺少人工复核原因".to_string())?;
if !matches!(
review_code.as_str(),
"provider_transaction_id_mismatch" | "order_total_mismatch"
) {
return Err(format!(
"当前人工复核原因不能通过确认订单归属收口: {review_code}"
));
}
let order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&refund.order_id)
.ok_or_else(|| "退款对应充值订单不存在".to_string())?;
if order.kind != RuntimeProfileRechargeProductKind::Points {
return Err("会员退款不能使用泥点钱包人工复核入口".to_string());
}
if let Some(remaining_code) = profile_recharge_refund_order_match_error_except(
&order,
&refund,
Some(review_code.as_str()),
) {
return Err(format!(
"确认当前退款冲突后仍存在未获批准的订单不匹配: {remaining_code}"
));
}
refund.manual_review_resolved_by_admin_user_id = Some(validated.admin_user_id);
refund.manual_review_resolution_reason = Some(validated.reason);
refund.manual_review_resolved_at = Some(ctx.timestamp);
refund.manual_review_resolved_error_code = Some(review_code);
refund.updated_at = ctx.timestamp;
let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund);
if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview {
return Err(format!(
"确认订单归属后权益结算仍需人工复核: {}",
refund
.last_error_code
.as_deref()
.unwrap_or(resolution_code.as_str())
));
}
upsert_profile_recharge_refund_row(ctx, refund.clone());
Ok((
build_profile_recharge_refund_snapshot_from_row(&refund),
settlement
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)),
false,
format!("manual_review_resolved:{resolution_code}"),
))
}
fn validate_profile_recharge_refund_manual_review_request(
refund: &ProfileRechargeRefund,
expected_error_code: &str,
admin_user_id: &str,
reason: &str,
) -> Result<(), String> {
let current_error_code = if refund.manual_review_resolved_at.is_some() {
refund.manual_review_resolved_error_code.as_deref()
} else {
refund.last_error_code.as_deref()
};
if current_error_code != Some(expected_error_code) {
return Err(format!(
"退款人工复核状态已变化,当前错误码为 {}",
current_error_code.unwrap_or("-")
));
}
if refund.manual_review_resolved_at.is_some()
&& (refund.manual_review_resolved_by_admin_user_id.as_deref() != Some(admin_user_id)
|| refund.manual_review_resolution_reason.as_deref() != Some(reason))
{
return Err("退款人工复核已由其他审计内容处理".to_string());
}
Ok(())
}
fn build_profile_admin_wallet_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeProfileAdminWalletSnapshot {
refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp);
let total_balance = profile_wallet_balance(ctx, user_id);
let daily_free_points = ctx
.db
.profile_daily_free_points()
.user_id()
.find(&user_id.to_string())
.map(|row| row.remaining_points)
.unwrap_or(0);
let membership_limited_points = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
.filter(|row| active_membership_row_at(row, ctx.timestamp))
.map(|row| row.cycle_remaining_points)
.unwrap_or(0);
let permanent_points = total_balance
.saturating_sub(daily_free_points)
.saturating_sub(membership_limited_points);
let held_points = active_profile_recharge_refund_hold_points(ctx, user_id);
let (refund_debt_points, refund_debt_frozen) = ctx
.db
.profile_recharge_order_refund_settlement()
.by_profile_recharge_order_refund_settlement_user_id()
.filter(user_id)
.fold((0_u64, false), |(total, frozen), row| {
(
total.saturating_add(row.unrecovered_points),
frozen || profile_recharge_refund_settlement_freezes_wallet(&row),
)
});
let manual_restriction = ctx
.db
.profile_wallet_manual_restriction()
.user_id()
.find(&user_id.to_string());
let manual_frozen = manual_restriction.as_ref().is_some_and(|row| row.frozen);
let wallet_frozen = manual_frozen || refund_debt_frozen;
RuntimeProfileAdminWalletSnapshot {
user_id: user_id.to_string(),
total_balance,
spendable_balance: if wallet_frozen {
0
} else {
total_balance.saturating_sub(held_points)
},
daily_free_points,
membership_limited_points,
permanent_points,
held_points,
refund_debt_points,
manual_frozen,
refund_debt_frozen,
wallet_frozen,
manual_restriction: manual_restriction
.as_ref()
.map(build_profile_wallet_manual_restriction_snapshot_from_row),
}
}
fn upsert_profile_wallet_manual_restriction(
ctx: &ReducerContext,
input: RuntimeProfileWalletManualRestrictionUpsertInput,
) {
let existing = ctx
.db
.profile_wallet_manual_restriction()
.user_id()
.find(&input.user_id);
let row = ProfileWalletManualRestriction {
user_id: input.user_id,
frozen: input.frozen,
reason: input.reason,
created_by_admin_user_id: existing
.as_ref()
.map(|value| value.created_by_admin_user_id.clone())
.unwrap_or_else(|| input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|value| value.created_at)
.unwrap_or(ctx.timestamp),
updated_by_admin_user_id: input.admin_user_id,
updated_at: ctx.timestamp,
};
ctx.db
.profile_wallet_manual_restriction()
.user_id()
.delete(&row.user_id);
ctx.db.profile_wallet_manual_restriction().insert(row);
}
fn active_profile_recharge_refund_hold_for_order(
ctx: &ReducerContext,
order_id: &str,
) -> Option<ProfileRechargeRefundHold> {
ctx.db
.profile_recharge_refund_hold()
.by_profile_recharge_refund_hold_order_id()
.filter(order_id)
.find(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active)
}
fn active_profile_recharge_refund_hold_points(ctx: &ReducerContext, user_id: &str) -> u64 {
ctx.db
.profile_recharge_refund_hold()
.by_profile_recharge_refund_hold_user_id()
.filter(user_id)
.filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active)
.fold(0_u64, |total, row| total.saturating_add(row.held_points))
}
fn active_profile_recharge_refund_unrelated_hold_points(
ctx: &ReducerContext,
user_id: &str,
refund: &ProfileRechargeRefund,
) -> u64 {
ctx.db
.profile_recharge_refund_hold()
.by_profile_recharge_refund_hold_user_id()
.filter(user_id)
.filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active)
.filter(|row| {
row.out_refund_no != refund.out_refund_no
|| row.order_id != refund.order_id
|| row.refund_cents != refund.refund_cents
})
.fold(0_u64, |total, row| total.saturating_add(row.held_points))
}
fn has_profile_wallet_manual_restriction(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db
.profile_wallet_manual_restriction()
.user_id()
.find(&user_id.to_string())
.is_some_and(|row| row.frozen)
}
fn upsert_profile_recharge_refund_hold_row(ctx: &ReducerContext, row: ProfileRechargeRefundHold) {
ctx.db
.profile_recharge_refund_hold()
.out_refund_no()
.delete(&row.out_refund_no);
ctx.db.profile_recharge_refund_hold().insert(row);
}
fn sync_profile_recharge_refund_hold_with_provider_status(
ctx: &ReducerContext,
refund: &ProfileRechargeRefund,
) {
let Some(mut hold) = ctx
.db
.profile_recharge_refund_hold()
.out_refund_no()
.find(&refund.out_refund_no)
else {
return;
};
if hold.order_id != refund.order_id || hold.refund_cents != refund.refund_cents {
return;
}
let next_status =
resolve_runtime_profile_recharge_refund_hold_status(hold.status, refund.provider_status);
if next_status == hold.status {
return;
}
hold.status = next_status;
hold.updated_at = ctx.timestamp;
match next_status {
RuntimeProfileRechargeRefundHoldStatus::Settled => {
hold.settled_at = Some(ctx.timestamp);
}
RuntimeProfileRechargeRefundHoldStatus::Released => {
hold.released_at = Some(ctx.timestamp);
hold.released_by_admin_user_id = None;
hold.release_reason = Some("provider_closed".to_string());
}
RuntimeProfileRechargeRefundHoldStatus::Active => {}
}
upsert_profile_recharge_refund_hold_row(ctx, hold);
}
fn record_profile_recharge_refund_observation(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundObservationInput,
) -> Result<
(
RuntimeProfileRechargeRefundSnapshot,
Option<RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
bool,
String,
),
String,
> {
let validated = build_runtime_profile_recharge_refund_observation_input(
input.observation_id,
input.source,
input.notification_ref,
input.payload_fingerprint,
input.out_refund_no,
input.provider_refund_id,
input.order_id,
input.provider_transaction_id,
input.provider_status,
input.total_cents,
input.refund_cents,
input.payer_total_cents,
input.payer_refund_cents,
input.success_at_micros,
input.observed_at_micros,
)?;
let observed_at = Timestamp::from_micros_since_unix_epoch(validated.observed_at_micros);
let success_at = validated
.success_at_micros
.map(Timestamp::from_micros_since_unix_epoch);
if let Some(existing_observation) = ctx
.db
.profile_recharge_refund_observation()
.observation_id()
.find(&validated.observation_id)
{
validate_profile_recharge_refund_observation_replay(&existing_observation, &validated)?;
let mut refund = ctx
.db
.profile_recharge_refund()
.out_refund_no()
.find(&validated.out_refund_no)
.ok_or_else(|| "退款 observation 已存在但退款单缺失".to_string())?;
if observed_at.to_micros_since_unix_epoch() > refund.updated_at.to_micros_since_unix_epoch()
{
refund.updated_at = observed_at;
refund.last_observation_source = validated.source;
refund.last_observation_id = validated.observation_id.clone();
}
let (settlement, resolution_code) =
reconcile_profile_recharge_refund_success(ctx, &mut refund);
sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund);
upsert_profile_recharge_refund_row(ctx, refund.clone());
return Ok((
build_profile_recharge_refund_snapshot_from_row(&refund),
settlement
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)),
true,
resolution_code,
));
}
if let Some(provider_collision) = ctx
.db
.profile_recharge_refund()
.provider_refund_id()
.find(&validated.provider_refund_id)
.filter(|row| row.out_refund_no != validated.out_refund_no)
{
insert_profile_recharge_refund_observation(
ctx,
&validated,
observed_at,
success_at,
"provider_refund_id_conflict",
);
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&provider_collision.order_id)
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row));
return Ok((
build_profile_recharge_refund_snapshot_from_row(&provider_collision),
settlement,
false,
"provider_refund_id_conflict".to_string(),
));
}
let existing = ctx
.db
.profile_recharge_refund()
.out_refund_no()
.find(&validated.out_refund_no);
let mut refund = match existing {
Some(mut row) => {
if !profile_recharge_refund_matches_observation(&row, &validated) {
insert_profile_recharge_refund_observation(
ctx,
&validated,
observed_at,
success_at,
"immutable_conflict",
);
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&row.order_id)
.map(|value| {
build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)
});
return Ok((
build_profile_recharge_refund_snapshot_from_row(&row),
settlement,
false,
"immutable_conflict".to_string(),
));
}
let transition = resolve_runtime_profile_recharge_refund_status_transition(
row.provider_status,
validated.provider_status,
);
if transition == RuntimeProfileRechargeRefundStatusTransition::Conflict {
insert_profile_recharge_refund_observation(
ctx,
&validated,
observed_at,
success_at,
"status_conflict",
);
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&row.order_id)
.map(|value| {
build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)
});
return Ok((
build_profile_recharge_refund_snapshot_from_row(&row),
settlement,
false,
"status_conflict".to_string(),
));
}
if transition == RuntimeProfileRechargeRefundStatusTransition::Advance {
row.provider_status = validated.provider_status;
row.success_at = success_at;
}
row.updated_at = latest_profile_recharge_refund_timestamp(row.updated_at, observed_at);
row.last_observation_source = validated.source;
row.last_observation_id = validated.observation_id.clone();
row
}
None => ProfileRechargeRefund {
out_refund_no: validated.out_refund_no.clone(),
provider_refund_id: validated.provider_refund_id.clone(),
order_id: validated.order_id.clone(),
provider_transaction_id: validated.provider_transaction_id.clone(),
user_id: None,
provider_status: validated.provider_status,
total_cents: validated.total_cents,
refund_cents: validated.refund_cents,
payer_total_cents: validated.payer_total_cents,
payer_refund_cents: validated.payer_refund_cents,
success_at,
first_observed_at: observed_at,
updated_at: observed_at,
last_observation_source: validated.source,
last_observation_id: validated.observation_id.clone(),
order_settled_at: None,
target_recovery_points: 0,
recovered_points: 0,
unrecovered_points: 0,
recovery_status: if validated.provider_status
== RuntimeProfileRechargeRefundStatus::Success
{
RuntimeProfileRechargeRefundRecoveryStatus::Pending
} else {
RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable
},
last_recovery_ledger_id: None,
last_error_code: None,
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at: None,
manual_review_resolved_error_code: None,
},
};
let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund);
sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund);
upsert_profile_recharge_refund_row(ctx, refund.clone());
insert_profile_recharge_refund_observation(
ctx,
&validated,
observed_at,
success_at,
&resolution_code,
);
Ok((
build_profile_recharge_refund_snapshot_from_row(&refund),
settlement
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)),
false,
resolution_code,
))
}
fn reconcile_profile_recharge_refund_success(
ctx: &ReducerContext,
refund: &mut ProfileRechargeRefund,
) -> (Option<ProfileRechargeOrderRefundSettlement>, String) {
if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable;
refund.last_error_code = None;
return (None, "recorded_non_success".to_string());
}
let Some(mut order) = ctx
.db
.profile_recharge_order()
.order_id()
.find(&refund.order_id)
else {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some("order_missing".to_string());
return (None, "order_missing".to_string());
};
refund.user_id = Some(order.user_id.clone());
let mismatch_code = unresolved_profile_recharge_refund_order_match_error(&order, refund);
if let Some(code) = mismatch_code {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some(code.clone());
let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order);
settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
settlement.wallet_frozen |=
profile_recharge_refund_manual_review_freezes_wallet(order.kind, code.as_str());
settlement.updated_at = ctx.timestamp;
upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone());
return (Some(settlement), code);
}
let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order);
if refund.order_settled_at.is_none() {
let order_points_delta = if order.kind == RuntimeProfileRechargeProductKind::Points {
order.points_delta
} else {
0
};
let plan = match build_runtime_profile_recharge_refund_settlement_plan(
settlement.successful_refund_count,
settlement.cumulative_success_refund_cents,
settlement.target_recovery_points,
refund.refund_cents,
order.amount_cents,
order_points_delta,
) {
Ok(plan) => plan,
Err(_) => {
return mark_profile_recharge_refund_manual_review(
ctx,
refund,
settlement,
order.kind,
"refund_settlement_plan_invalid",
);
}
};
settlement.successful_refund_count = plan.successful_refund_count;
settlement.cumulative_success_refund_cents = plan.cumulative_success_refund_cents;
refund.order_settled_at = Some(ctx.timestamp);
if plan.order_fully_refunded {
order.status = RuntimeProfileRechargeOrderStatus::Refunded;
upsert_profile_recharge_order_row(ctx, order.clone());
}
match order.kind {
RuntimeProfileRechargeProductKind::Points => {
refund.target_recovery_points = plan.incremental_target_recovery_points;
refund.unrecovered_points = refund
.target_recovery_points
.saturating_sub(refund.recovered_points);
settlement.target_recovery_points = plan.target_recovery_points;
settlement.unrecovered_points = settlement
.target_recovery_points
.saturating_sub(settlement.recovered_points);
}
RuntimeProfileRechargeProductKind::Membership => {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some("membership_manual_review".to_string());
settlement.recovery_status =
RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
}
}
}
if order.kind == RuntimeProfileRechargeProductKind::Membership {
settlement.updated_at = ctx.timestamp;
upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone());
return (Some(settlement), "membership_manual_review".to_string());
}
let outstanding = refund
.target_recovery_points
.saturating_sub(refund.recovered_points);
if outstanding > 0 {
match apply_profile_recharge_refund_permanent_points_recovery(ctx, refund, outstanding) {
Ok((recovered, ledger_id)) => {
refund.recovered_points = refund.recovered_points.saturating_add(recovered);
refund.unrecovered_points = refund
.target_recovery_points
.saturating_sub(refund.recovered_points);
refund.last_recovery_ledger_id = ledger_id;
settlement.recovered_points = settlement.recovered_points.saturating_add(recovered);
settlement.unrecovered_points = settlement
.target_recovery_points
.saturating_sub(settlement.recovered_points);
}
Err(code) => {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some(code.clone());
settlement.recovery_status =
RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
settlement.updated_at = ctx.timestamp;
upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone());
return (Some(settlement), code);
}
}
}
if refund.unrecovered_points == 0 {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Applied;
refund.last_error_code = None;
} else {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall;
refund.last_error_code = Some("permanent_points_shortfall".to_string());
}
settlement.recovery_status = if settlement.unrecovered_points == 0 {
RuntimeProfileRechargeRefundRecoveryStatus::Applied
} else {
RuntimeProfileRechargeRefundRecoveryStatus::Shortfall
};
settlement.wallet_frozen = settlement.unrecovered_points > 0;
settlement.updated_at = ctx.timestamp;
upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone());
let resolution_code = if refund.unrecovered_points == 0 {
"settled"
} else {
"settled_shortfall"
};
(Some(settlement), resolution_code.to_string())
}
#[cfg(test)]
fn validate_profile_recharge_refund_order_match(
order: &ProfileRechargeOrder,
refund: &ProfileRechargeRefund,
) -> Result<(), String> {
profile_recharge_refund_order_match_error_except(order, refund, None).map_or(Ok(()), Err)
}
fn profile_recharge_refund_order_match_error_except(
order: &ProfileRechargeOrder,
refund: &ProfileRechargeRefund,
approved_error_code: Option<&str>,
) -> Option<String> {
if !matches!(
order.payment_channel.as_str(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
) {
return Some("payment_channel_not_v3".to_string());
}
if order.paid_at.is_none()
|| !matches!(
order.status,
RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded
)
{
return Some("order_not_paid".to_string());
}
if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str())
&& approved_error_code != Some("provider_transaction_id_mismatch")
{
return Some("provider_transaction_id_mismatch".to_string());
}
if order.amount_cents != refund.total_cents
&& approved_error_code != Some("order_total_mismatch")
{
return Some("order_total_mismatch".to_string());
}
None
}
fn unresolved_profile_recharge_refund_order_match_error(
order: &ProfileRechargeOrder,
refund: &ProfileRechargeRefund,
) -> Option<String> {
let approved_error_code = refund
.manual_review_resolved_at
.is_some()
.then_some(refund.manual_review_resolved_error_code.as_deref())
.flatten();
profile_recharge_refund_order_match_error_except(order, refund, approved_error_code)
}
fn mark_profile_recharge_refund_manual_review(
ctx: &ReducerContext,
refund: &mut ProfileRechargeRefund,
mut settlement: ProfileRechargeOrderRefundSettlement,
order_kind: RuntimeProfileRechargeProductKind,
code: &str,
) -> (Option<ProfileRechargeOrderRefundSettlement>, String) {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some(code.to_string());
settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
settlement.wallet_frozen |=
profile_recharge_refund_manual_review_freezes_wallet(order_kind, code);
settlement.updated_at = ctx.timestamp;
upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone());
(Some(settlement), code.to_string())
}
fn profile_recharge_refund_manual_review_freezes_wallet(
order_kind: RuntimeProfileRechargeProductKind,
code: &str,
) -> bool {
order_kind == RuntimeProfileRechargeProductKind::Points
&& matches!(
code,
"provider_transaction_id_mismatch"
| "order_total_mismatch"
| "refund_settlement_plan_invalid"
)
}
fn apply_profile_recharge_refund_permanent_points_recovery(
ctx: &ReducerContext,
refund: &ProfileRechargeRefund,
outstanding_points: u64,
) -> Result<(u64, Option<String>), String> {
let user_id = refund
.user_id
.as_deref()
.ok_or_else(|| "refund_user_missing".to_string())?;
refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp);
let current = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string());
let wallet_total = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
let daily_free = ctx
.db
.profile_daily_free_points()
.user_id()
.find(&user_id.to_string())
.map(|row| row.remaining_points)
.unwrap_or(0);
let membership_limited = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
.filter(|row| active_membership_row_at(row, ctx.timestamp))
.map(|row| row.cycle_remaining_points)
.unwrap_or(0);
let unrelated_held_points =
active_profile_recharge_refund_unrelated_hold_points(ctx, user_id, refund);
let (recoverable, _) = resolve_runtime_profile_recharge_refund_recovery_with_holds(
outstanding_points,
wallet_total,
daily_free,
membership_limited,
unrelated_held_points,
);
if recoverable == 0 {
return Ok((0, None));
}
let amount_delta =
-i64::try_from(recoverable).map_err(|_| "refund_recovery_points_overflow".to_string())?;
let next_recovered = refund.recovered_points.saturating_add(recoverable);
let ledger_id = format!(
"recharge-refund-recovery:{}:{}",
refund.out_refund_no, next_recovered
);
if ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id)
.is_some()
{
return Err("refund_recovery_ledger_conflict".to_string());
}
let Some(existing) = current else {
return Err("refund_wallet_state_missing".to_string());
};
let next_balance = existing
.wallet_balance
.checked_sub(recoverable)
.ok_or_else(|| "refund_wallet_balance_underflow".to_string())?;
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: existing.user_id.clone(),
wallet_balance: next_balance,
total_play_time_ms: existing.total_play_time_ms,
created_at: existing.created_at,
updated_at: ctx.timestamp,
});
let metadata_json = metadata_with_profile_wallet_delta_split(
&json!({
"rechargeOrderId": refund.order_id,
"outRefundNo": refund.out_refund_no,
"providerRefundId": refund.provider_refund_id,
})
.to_string(),
0,
0,
amount_delta,
None,
None,
);
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: ledger_id.clone(),
user_id: existing.user_id,
amount_delta,
balance_after: next_balance,
source_type: RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery,
created_at: ctx.timestamp,
metadata_json: Some(metadata_json),
});
Ok((recoverable, Some(ledger_id)))
}
fn repay_profile_recharge_refund_debt_from_permanent_points(ctx: &ReducerContext, user_id: &str) {
let mut candidates = ctx
.db
.profile_recharge_refund()
.iter()
.filter(|row| {
row.user_id.as_deref() == Some(user_id)
&& row.provider_status == RuntimeProfileRechargeRefundStatus::Success
&& row.unrecovered_points > 0
&& matches!(
row.recovery_status,
RuntimeProfileRechargeRefundRecoveryStatus::Pending
| RuntimeProfileRechargeRefundRecoveryStatus::Shortfall
)
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
left.first_observed_at
.to_micros_since_unix_epoch()
.cmp(&right.first_observed_at.to_micros_since_unix_epoch())
.then_with(|| left.out_refund_no.cmp(&right.out_refund_no))
});
for mut refund in candidates {
reconcile_profile_recharge_refund_success(ctx, &mut refund);
upsert_profile_recharge_refund_row(ctx, refund);
}
}
fn profile_recharge_order_refund_settlement_row(
ctx: &ReducerContext,
order: &ProfileRechargeOrder,
) -> ProfileRechargeOrderRefundSettlement {
ctx.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&order.order_id)
.unwrap_or_else(|| ProfileRechargeOrderRefundSettlement {
order_id: order.order_id.clone(),
user_id: order.user_id.clone(),
successful_refund_count: 0,
cumulative_success_refund_cents: 0,
target_recovery_points: 0,
recovered_points: 0,
unrecovered_points: 0,
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending,
wallet_frozen: false,
updated_at: ctx.timestamp,
})
}
fn profile_recharge_refund_matches_observation(
row: &ProfileRechargeRefund,
input: &RuntimeProfileRechargeRefundObservationInput,
) -> bool {
row.provider_refund_id == input.provider_refund_id
&& row.order_id == input.order_id
&& row.provider_transaction_id == input.provider_transaction_id
&& row.total_cents == input.total_cents
&& row.refund_cents == input.refund_cents
&& row.payer_total_cents == input.payer_total_cents
&& row.payer_refund_cents == input.payer_refund_cents
&& (row.provider_status != RuntimeProfileRechargeRefundStatus::Success
|| row
.success_at
.map(|value| value.to_micros_since_unix_epoch())
== input.success_at_micros)
}
fn validate_profile_recharge_refund_observation_replay(
row: &ProfileRechargeRefundObservation,
input: &RuntimeProfileRechargeRefundObservationInput,
) -> Result<(), String> {
let success_at_micros = row
.success_at
.map(|value| value.to_micros_since_unix_epoch());
if row.out_refund_no != input.out_refund_no
|| row.provider_refund_id != input.provider_refund_id
|| row.order_id != input.order_id
|| row.provider_transaction_id != input.provider_transaction_id
|| row.source != input.source
|| row.provider_status != input.provider_status
|| row.total_cents != input.total_cents
|| row.refund_cents != input.refund_cents
|| row.payer_total_cents != input.payer_total_cents
|| row.payer_refund_cents != input.payer_refund_cents
|| success_at_micros != input.success_at_micros
|| row.notification_ref != input.notification_ref
|| row.payload_fingerprint != input.payload_fingerprint
{
return Err("退款 observation_id 重放内容冲突".to_string());
}
Ok(())
}
fn insert_profile_recharge_refund_observation(
ctx: &ReducerContext,
input: &RuntimeProfileRechargeRefundObservationInput,
observed_at: Timestamp,
success_at: Option<Timestamp>,
resolution_code: &str,
) {
ctx.db
.profile_recharge_refund_observation()
.insert(ProfileRechargeRefundObservation {
observation_id: input.observation_id.clone(),
out_refund_no: input.out_refund_no.clone(),
provider_refund_id: input.provider_refund_id.clone(),
order_id: input.order_id.clone(),
provider_transaction_id: input.provider_transaction_id.clone(),
source: input.source,
provider_status: input.provider_status,
total_cents: input.total_cents,
refund_cents: input.refund_cents,
payer_total_cents: input.payer_total_cents,
payer_refund_cents: input.payer_refund_cents,
success_at,
notification_ref: input.notification_ref.clone(),
payload_fingerprint: input.payload_fingerprint.clone(),
resolution_code: resolution_code.to_string(),
observed_at,
});
}
fn upsert_profile_recharge_refund_row(ctx: &ReducerContext, row: ProfileRechargeRefund) {
ctx.db
.profile_recharge_refund()
.out_refund_no()
.delete(&row.out_refund_no);
ctx.db.profile_recharge_refund().insert(row);
}
fn upsert_profile_recharge_order_refund_settlement_row(
ctx: &ReducerContext,
row: ProfileRechargeOrderRefundSettlement,
) {
ctx.db
.profile_recharge_order_refund_settlement()
.order_id()
.delete(&row.order_id);
ctx.db
.profile_recharge_order_refund_settlement()
.insert(row);
}
fn upsert_profile_recharge_order_row(ctx: &ReducerContext, row: ProfileRechargeOrder) {
ctx.db
.profile_recharge_order()
.order_id()
.delete(&row.order_id);
ctx.db.profile_recharge_order().insert(row);
}
fn latest_profile_recharge_refund_timestamp(current: Timestamp, observed: Timestamp) -> Timestamp {
if observed.to_micros_since_unix_epoch() > current.to_micros_since_unix_epoch() {
observed
} else {
current
}
}
fn list_profile_recharge_refund_reconciliation_rows(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundReconciliationListInput,
) -> Vec<RuntimeProfileRechargeRefundSnapshot> {
let validated = build_runtime_profile_recharge_refund_reconciliation_list_input(input.limit);
let mut candidates = HashMap::<String, ProfileRechargeRefund>::new();
for status in [
RuntimeProfileRechargeRefundStatus::Processing,
RuntimeProfileRechargeRefundStatus::Abnormal,
RuntimeProfileRechargeRefundStatus::Success,
] {
for row in ctx
.db
.profile_recharge_refund()
.by_profile_recharge_refund_status_updated_at()
.filter(status)
{
let needs_reconciliation = profile_recharge_refund_needs_reconciliation(&row);
if needs_reconciliation {
candidates.insert(row.out_refund_no.clone(), row);
}
}
}
let mut rows = candidates.into_values().collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.updated_at
.to_micros_since_unix_epoch()
.cmp(&right.updated_at.to_micros_since_unix_epoch())
.then_with(|| left.out_refund_no.cmp(&right.out_refund_no))
});
let rotation_slot = ctx
.timestamp
.to_micros_since_unix_epoch()
.div_euclid(60 * 1_000_000)
.unsigned_abs();
select_profile_recharge_refund_reconciliation_page(
rows,
validated.limit as usize,
rotation_slot,
)
.into_iter()
.map(|row| build_profile_recharge_refund_snapshot_from_row(&row))
.collect()
}
fn profile_recharge_refund_needs_reconciliation(row: &ProfileRechargeRefund) -> bool {
if row.provider_status != RuntimeProfileRechargeRefundStatus::Success {
return row.provider_status != RuntimeProfileRechargeRefundStatus::Closed;
}
match row.recovery_status {
RuntimeProfileRechargeRefundRecoveryStatus::Pending
| RuntimeProfileRechargeRefundRecoveryStatus::Shortfall => true,
RuntimeProfileRechargeRefundRecoveryStatus::ManualReview => matches!(
row.last_error_code.as_deref(),
Some("order_missing" | "order_not_paid")
),
RuntimeProfileRechargeRefundRecoveryStatus::Applied
| RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable => false,
}
}
fn select_profile_recharge_refund_reconciliation_page(
rows: Vec<ProfileRechargeRefund>,
limit: usize,
rotation_slot: u64,
) -> Vec<ProfileRechargeRefund> {
select_rotating_reconciliation_page(rows, limit, rotation_slot)
}
fn select_profile_recharge_refund_hold_reconciliation_page(
rows: Vec<ProfileRechargeRefundHold>,
limit: usize,
rotation_slot: u64,
) -> Vec<ProfileRechargeRefundHold> {
select_rotating_reconciliation_page(rows, limit, rotation_slot)
}
fn select_rotating_reconciliation_page<T>(
rows: Vec<T>,
limit: usize,
rotation_slot: u64,
) -> Vec<T> {
if rows.len() <= limit || limit == 0 {
return rows;
}
let page_count = rows.len().div_ceil(limit);
let page_index = usize::try_from(rotation_slot % page_count as u64).unwrap_or(0);
rows.into_iter()
.skip(page_index.saturating_mul(limit))
.take(limit)
.collect()
}
fn advance_profile_recharge_refund_bill_checkpoint(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput,
) -> Result<RuntimeProfileRechargeRefundBillCheckpointSnapshot, String> {
let validated = build_runtime_profile_recharge_refund_bill_checkpoint_advance_input(
input.checkpoint_id,
input.bill_date,
input.bill_hash,
input.processed_refund_count,
input.completed_at_micros,
)?;
let completed_at = Timestamp::from_micros_since_unix_epoch(validated.completed_at_micros);
if let Some(existing) = ctx
.db
.profile_recharge_refund_bill_checkpoint()
.checkpoint_id()
.find(&validated.checkpoint_id)
{
if validated.bill_date < existing.bill_date {
return Err("退款账单 checkpoint 不能回退".to_string());
}
if validated.bill_date == existing.bill_date {
if validated.bill_hash != existing.bill_hash
|| validated.processed_refund_count != existing.processed_refund_count
{
return Err("同一退款账单日期的 hash 或退款条数冲突".to_string());
}
return Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&existing));
}
ctx.db
.profile_recharge_refund_bill_checkpoint()
.checkpoint_id()
.delete(&existing.checkpoint_id);
}
let row = ProfileRechargeRefundBillCheckpoint {
checkpoint_id: validated.checkpoint_id,
bill_date: validated.bill_date,
bill_hash: validated.bill_hash,
processed_refund_count: validated.processed_refund_count,
completed_at,
updated_at: ctx.timestamp,
};
ctx.db
.profile_recharge_refund_bill_checkpoint()
.insert(row.clone());
Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row))
}
fn claim_profile_recharge_order_expiration_schedules(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderExpirationClaimInput,
) -> Result<Vec<RuntimeProfileRechargeOrderExpirationScheduleSnapshot>, String> {
let validated_input = build_runtime_profile_recharge_order_expiration_claim_input(
input.worker_id,
input.now_micros,
input.lease_expires_at_micros,
input.limit,
)
.map_err(|error| error.to_string())?;
let now = Timestamp::from_micros_since_unix_epoch(validated_input.now_micros);
let lease_expires_at =
Timestamp::from_micros_since_unix_epoch(validated_input.lease_expires_at_micros);
let limit = if validated_input.limit == 0 {
PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_DEFAULT
} else {
validated_input
.limit
.min(PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_MAX)
} as usize;
let mut candidates = ctx
.db
.profile_recharge_order_expiration_schedule()
.iter()
.filter(|row| {
row.scheduled_at <= now
&& row
.lease_expires_at
.map(|value| value <= now)
.unwrap_or(true)
})
.collect::<Vec<_>>();
candidates.sort_by_key(|row| row.scheduled_at.to_micros_since_unix_epoch());
let mut claimed = Vec::new();
for mut schedule in candidates.into_iter().take(limit) {
let should_claim = ctx
.db
.profile_recharge_order()
.order_id()
.find(&schedule.order_id)
.map(|order| order.status == RuntimeProfileRechargeOrderStatus::Pending)
.unwrap_or(false);
if !should_claim {
delete_profile_recharge_order_expiration_schedule(ctx, &schedule.order_id);
continue;
}
ctx.db
.profile_recharge_order_expiration_schedule()
.order_id()
.delete(&schedule.order_id);
schedule.lease_owner = Some(validated_input.worker_id.clone());
schedule.lease_expires_at = Some(lease_expires_at);
schedule.updated_at = now;
ctx.db
.profile_recharge_order_expiration_schedule()
.insert(schedule.clone());
claimed.push(build_profile_recharge_order_expiration_schedule_snapshot_from_row(&schedule));
}
Ok(claimed)
}
fn list_unchecked_expired_profile_recharge_order_rows(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderExpirationCheckListInput,
) -> Result<Vec<RuntimeProfileRechargeOrderSnapshot>, String> {
let validated_input =
build_runtime_profile_recharge_order_expiration_check_list_input(input.limit)
.map_err(|error| error.to_string())?;
let limit = if validated_input.limit == 0 {
PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT
} else {
validated_input
.limit
.min(PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX)
} as usize;
let mut orders = ctx
.db
.profile_recharge_order()
.iter()
.filter(|row| {
row.status == RuntimeProfileRechargeOrderStatus::Expired
&& row.expiration_checked_at.is_none()
})
.collect::<Vec<_>>();
orders.sort_by(|left, right| {
left.expired_at
.map(|value| value.to_micros_since_unix_epoch())
.unwrap_or_else(|| left.created_at.to_micros_since_unix_epoch())
.cmp(
&right
.expired_at
.map(|value| value.to_micros_since_unix_epoch())
.unwrap_or_else(|| right.created_at.to_micros_since_unix_epoch()),
)
.then_with(|| left.order_id.cmp(&right.order_id))
});
Ok(orders
.into_iter()
.take(limit)
.map(|row| build_profile_recharge_order_snapshot_from_row(&row))
.collect())
}
fn mark_profile_recharge_order_expiration_checked_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderExpirationCheckInput,
) -> Result<RuntimeProfileRechargeOrderSnapshot, String> {
let validated_input = build_runtime_profile_recharge_order_expiration_check_input(
input.order_id,
input.checked_at_micros,
input.provider_state,
input.last_error,
)
.map_err(|error| error.to_string())?;
let mut order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&validated_input.order_id)
.ok_or_else(|| "profile_recharge_order missing".to_string())?;
ctx.db
.profile_recharge_order()
.order_id()
.delete(&order.order_id);
order.expiration_checked_at = validated_input
.checked_at_micros
.map(Timestamp::from_micros_since_unix_epoch);
order.expiration_provider_state = validated_input.provider_state;
order.expiration_last_error = validated_input.last_error;
ctx.db.profile_recharge_order().insert(order.clone());
Ok(build_profile_recharge_order_snapshot_from_row(&order))
}
fn complete_profile_recharge_order_expiration_schedule(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderExpirationCompleteInput,
) -> Result<(), String> {
let validated_input =
build_runtime_profile_recharge_order_expiration_complete_input(input.order_id)
.map_err(|error| error.to_string())?;
delete_profile_recharge_order_expiration_schedule(ctx, &validated_input.order_id);
Ok(())
}
fn close_profile_recharge_order_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderCloseInput,
) -> Result<
(
RuntimeProfileRechargeCenterSnapshot,
RuntimeProfileRechargeOrderSnapshot,
),
String,
> {
let validated_input =
build_runtime_profile_recharge_order_close_input(input.order_id, input.closed_at_micros)
.map_err(|error| error.to_string())?;
let mut order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&validated_input.order_id)
.ok_or_else(|| "profile_recharge_order 不存在".to_string())?;
if order.status == RuntimeProfileRechargeOrderStatus::Pending {
ctx.db
.profile_recharge_order()
.order_id()
.delete(&order.order_id);
order.status = RuntimeProfileRechargeOrderStatus::Closed;
ctx.db.profile_recharge_order().insert(order.clone());
}
delete_profile_recharge_order_expiration_task(ctx, &order.order_id);
Ok((
build_profile_recharge_center_snapshot(ctx, &order.user_id),
build_profile_recharge_order_snapshot_from_row(&order),
))
}
fn should_schedule_profile_recharge_order_expiration(payment_channel: &str) -> bool {
matches!(
payment_channel,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
)
}
fn delete_profile_recharge_order_expiration_task(ctx: &ReducerContext, order_id: &str) {
delete_profile_recharge_order_expiration_timer(ctx, order_id);
delete_profile_recharge_order_expiration_schedule(ctx, order_id);
}
fn delete_profile_recharge_order_expiration_timer(ctx: &ReducerContext, order_id: &str) {
let order_id = order_id.to_string();
if let Some(timer) = ctx
.db
.profile_recharge_order_expiration_timer()
.order_id()
.find(&order_id)
{
ctx.db
.profile_recharge_order_expiration_timer()
.scheduled_id()
.delete(&timer.scheduled_id);
}
}
fn delete_profile_recharge_order_expiration_schedule(ctx: &ReducerContext, order_id: &str) {
ctx.db
.profile_recharge_order_expiration_schedule()
.order_id()
.delete(&order_id.to_string());
}
fn apply_profile_recharge_purchase(
ctx: &ReducerContext,
user_id: &str,
product: &RuntimeProfileRechargeProductSnapshot,
order_created_at_micros: i64,
paid_at: Timestamp,
) -> Result<(i64, Option<Timestamp>), String> {
match product.kind {
RuntimeProfileRechargeProductKind::Points => {
let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id);
let points_delta =
resolve_runtime_profile_points_recharge_delta(product, has_recharged);
apply_profile_wallet_delta(
ctx,
user_id,
points_delta,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
&build_runtime_profile_recharge_wallet_ledger_id(
user_id,
order_created_at_micros,
&product.product_id,
),
paid_at,
)?;
Ok((points_delta as i64, None))
}
RuntimeProfileRechargeProductKind::Membership => {
let purchase_result =
apply_profile_membership_purchase(ctx, user_id, product, paid_at)?;
Ok((
purchase_result.period_points_delta as i64,
Some(purchase_result.expires_at),
))
}
}
}
fn submit_profile_feedback_record(
ctx: &ReducerContext,
input: RuntimeProfileFeedbackSubmissionInput,
) -> Result<RuntimeProfileFeedbackSubmissionSnapshot, String> {
let validated_input = build_runtime_profile_feedback_submission_input(
input.user_id,
input.description,
input.contact_phone,
input.evidence_items,
input.created_at_micros,
)
.map_err(|error| error.to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let feedback_id = build_runtime_profile_feedback_submission_id(
&validated_input.user_id,
validated_input.created_at_micros,
);
let evidence_json = serde_json::to_string(&validated_input.evidence_items)
.map_err(|error| format!("反馈凭证序列化失败: {error}"))?;
let row = ProfileFeedbackSubmission {
feedback_id: feedback_id.clone(),
user_id: validated_input.user_id,
description: validated_input.description,
contact_phone: validated_input.contact_phone,
evidence_json,
status: RuntimeProfileFeedbackStatus::Open,
created_at,
updated_at: created_at,
};
ctx.db.profile_feedback_submission().insert(row);
let latest = ctx
.db
.profile_feedback_submission()
.feedback_id()
.find(&feedback_id)
.ok_or_else(|| "profile_feedback_submission 写入后未能读取".to_string())?;
Ok(build_profile_feedback_submission_snapshot_from_row(&latest))
}
fn get_profile_referral_invite_center_snapshot(
ctx: &ReducerContext,
input: RuntimeReferralInviteCenterGetInput,
) -> Result<RuntimeReferralInviteCenterSnapshot, String> {
let validated_input = build_runtime_referral_invite_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
Ok(build_profile_referral_invite_center_snapshot(
ctx,
&validated_input.user_id,
))
}
fn redeem_profile_referral_invite_code_record(
ctx: &ReducerContext,
input: RuntimeReferralRedeemInput,
) -> Result<RuntimeReferralRedeemSnapshot, String> {
let validated_input = build_runtime_referral_redeem_input(
input.user_id,
input.invite_code,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let bound_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let invitee_user_id = validated_input.user_id;
let invite_code = validated_input.invite_code;
if ctx
.db
.user_account()
.user_id()
.find(&invitee_user_id)
.is_none()
{
return Err("用户不存在".to_string());
}
if ctx
.db
.profile_referral_relation()
.invitee_user_id()
.find(&invitee_user_id)
.is_some()
{
return Err("每个用户最多只能填写一个邀请码".to_string());
}
let inviter_code = ctx
.db
.profile_invite_code()
.invite_code()
.find(&invite_code)
.ok_or_else(|| "邀请码不存在".to_string())?;
validate_profile_invite_code_redeem_time(&inviter_code, validated_input.updated_at_micros)?;
if inviter_code.user_id == invitee_user_id {
return Err("不能填写自己的邀请码".to_string());
}
let invite_metadata_user_tags =
profile_invite_code_metadata_user_tags(&inviter_code.metadata_json)?;
let invitee_balance_after = apply_profile_wallet_delta(
ctx,
&invitee_user_id,
PROFILE_REFERRAL_REWARD_POINTS,
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward,
&build_runtime_profile_referral_invitee_ledger_id(
&invitee_user_id,
validated_input.updated_at_micros,
),
bound_at,
)?;
let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id);
let today_inviter_reward_count = if is_admin_invite_code {
0
} else {
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at)
};
let inviter_reward_granted = !is_admin_invite_code
&& module_runtime::should_grant_runtime_profile_inviter_reward(today_inviter_reward_count);
let inviter_balance_after = if inviter_reward_granted {
apply_profile_wallet_delta(
ctx,
&inviter_code.user_id,
PROFILE_REFERRAL_REWARD_POINTS,
RuntimeProfileWalletLedgerSourceType::InviteInviterReward,
&build_runtime_profile_referral_inviter_ledger_id(
&inviter_code.user_id,
validated_input.updated_at_micros,
),
bound_at,
)?
} else {
profile_wallet_balance(ctx, &inviter_code.user_id)
};
ctx.db
.profile_referral_relation()
.insert(ProfileReferralRelation {
invitee_user_id: invitee_user_id.clone(),
inviter_user_id: inviter_code.user_id,
invite_code,
inviter_reward_granted,
invitee_reward_granted: true,
bound_at,
});
merge_user_account_tags(ctx, &invitee_user_id, invite_metadata_user_tags)?;
Ok(RuntimeReferralRedeemSnapshot {
center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id),
invitee_reward_granted: true,
inviter_reward_granted,
invitee_balance_after,
inviter_balance_after,
})
}
fn redeem_profile_reward_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRewardCodeRedeemInput,
) -> Result<RuntimeProfileRewardCodeRedeemSnapshot, String> {
let validated_input = build_runtime_profile_reward_code_redeem_input(
input.user_id,
input.code,
input.redeemed_at_micros,
)
.map_err(|error| error.to_string())?;
let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros);
let user_id = validated_input.user_id;
let code = validated_input.code;
let redeem_code = ctx
.db
.profile_redeem_code()
.code()
.find(&code)
.ok_or_else(|| "兑换码不存在".to_string())?;
let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id);
validate_runtime_profile_redeem_code_usage(
&build_profile_redeem_code_snapshot_from_row(&redeem_code),
&user_id,
user_used_count,
validated_input.redeemed_at_micros,
)
.map_err(|error| error.to_string())?;
let usage_id = build_runtime_profile_redeem_code_usage_id(
&code,
&user_id,
validated_input.redeemed_at_micros,
user_used_count,
);
let wallet_ledger_id = build_runtime_profile_redeem_code_ledger_id(&usage_id);
let wallet_balance = apply_profile_wallet_delta(
ctx,
&user_id,
redeem_code.reward_points,
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward,
&wallet_ledger_id,
redeemed_at,
)?;
ctx.db
.profile_redeem_code_usage()
.insert(ProfileRedeemCodeUsage {
usage_id,
code: code.clone(),
user_id,
amount_granted: redeem_code.reward_points,
created_at: redeemed_at,
});
let next_code = ProfileRedeemCode {
global_used_count: redeem_code.global_used_count.saturating_add(1),
updated_at: redeemed_at,
..redeem_code
};
ctx.db.profile_redeem_code().code().delete(&code);
ctx.db.profile_redeem_code().insert(next_code);
let ledger_entry = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&wallet_ledger_id)
.ok_or_else(|| "兑换码钱包流水写入失败".to_string())?;
Ok(RuntimeProfileRewardCodeRedeemSnapshot {
wallet_balance,
amount_granted: ledger_entry.amount_delta.max(0) as u64,
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
})
}
fn admin_upsert_profile_redeem_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
let validated_input = build_runtime_profile_redeem_code_admin_upsert_input(
input.admin_user_id,
input.code,
input.mode,
input.reward_points,
input.max_uses,
input.enabled,
input.allowed_user_ids,
input.allowed_public_user_codes,
input.starts_at_micros,
input.expires_at_micros,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?;
let existing = ctx
.db
.profile_redeem_code()
.code()
.find(&validated_input.code);
let action = if existing.is_some() {
"update"
} else {
"create"
};
let operation_code = validated_input.code.clone();
let operator_user_id = validated_input.admin_user_id.clone();
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at);
let global_used_count = existing
.as_ref()
.map(|row| row.global_used_count)
.unwrap_or(0);
if let Some(existing) = existing {
ctx.db.profile_redeem_code().code().delete(&existing.code);
}
let row = ProfileRedeemCode {
code: validated_input.code,
mode: validated_input.mode,
reward_points: validated_input.reward_points,
max_uses: validated_input.max_uses,
global_used_count,
enabled: validated_input.enabled,
allowed_user_ids,
created_by: validated_input.admin_user_id,
created_at,
updated_at,
starts_at: validated_input
.starts_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
};
let inserted = ctx.db.profile_redeem_code().insert(row);
insert_profile_code_operation(
ctx,
"redeem",
&operation_code,
action,
&operator_user_id,
updated_at,
);
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
}
fn admin_disable_profile_redeem_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminDisableInput,
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
let validated_input = build_runtime_profile_redeem_code_admin_disable_input(
input.admin_user_id,
input.code,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_redeem_code()
.code()
.find(&validated_input.code)
.ok_or_else(|| "兑换码不存在".to_string())?;
ctx.db.profile_redeem_code().code().delete(&existing.code);
let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode {
enabled: false,
updated_at,
..existing
});
insert_profile_code_operation(
ctx,
"redeem",
&validated_input.code,
"disable",
&validated_input.admin_user_id,
updated_at,
);
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
}
fn admin_upsert_profile_invite_code_record(
ctx: &ReducerContext,
input: RuntimeProfileInviteCodeAdminUpsertInput,
) -> Result<RuntimeProfileInviteCodeSnapshot, String> {
let validated_input = build_runtime_profile_invite_code_admin_upsert_input(
input.admin_user_id,
input.invite_code,
input.metadata_json,
input.starts_at_micros,
input.expires_at_micros,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let user_id = build_admin_profile_invite_code_user_id(
&validated_input.admin_user_id,
&validated_input.invite_code,
);
let operation_code = validated_input.invite_code.clone();
let operator_user_id = validated_input.admin_user_id.clone();
if let Some(existing) = ctx
.db
.profile_invite_code()
.invite_code()
.find(&validated_input.invite_code)
{
if existing.user_id != user_id {
return Err("邀请码已被其他用户占用".to_string());
}
ctx.db
.profile_invite_code()
.user_id()
.delete(&existing.user_id);
let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id,
invite_code: validated_input.invite_code,
metadata_json: validated_input.metadata_json,
created_at: existing.created_at,
updated_at,
starts_at: validated_input
.starts_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
});
insert_profile_code_operation(
ctx,
"invite",
&operation_code,
"update",
&operator_user_id,
updated_at,
);
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
}
let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id,
invite_code: validated_input.invite_code,
metadata_json: validated_input.metadata_json,
created_at: updated_at,
updated_at,
starts_at: validated_input
.starts_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
});
insert_profile_code_operation(
ctx,
"invite",
&operation_code,
"create",
&operator_user_id,
updated_at,
);
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
}
fn build_profile_referral_invite_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeReferralInviteCenterSnapshot {
let code = ensure_profile_invite_code(ctx, user_id);
let today_inviter_reward_count =
count_today_profile_referral_inviter_rewards(ctx, user_id, ctx.timestamp);
let invited_relations = ctx
.db
.profile_referral_relation()
.by_profile_referral_inviter_user_id()
.filter(user_id)
.collect::<Vec<_>>();
let invited_count = invited_relations.len() as u32;
let rewarded_invite_count = invited_relations
.iter()
.filter(|row| row.inviter_reward_granted)
.count() as u32;
let bound_relation = ctx
.db
.profile_referral_relation()
.invitee_user_id()
.find(&user_id.to_string());
RuntimeReferralInviteCenterSnapshot {
user_id: user_id.to_string(),
invite_code: code.invite_code.clone(),
invite_link_path: build_runtime_profile_invite_link_path(&code.invite_code),
invited_count,
rewarded_invite_count,
today_inviter_reward_count,
today_inviter_reward_remaining: PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
.saturating_sub(today_inviter_reward_count),
reward_points: PROFILE_REFERRAL_REWARD_POINTS,
invited_users: list_profile_referral_invited_users(ctx, user_id),
has_redeemed_code: bound_relation.is_some(),
bound_inviter_user_id: bound_relation
.as_ref()
.map(|relation| relation.inviter_user_id.clone()),
bound_at_micros: bound_relation
.as_ref()
.map(|relation| relation.bound_at.to_micros_since_unix_epoch()),
updated_at_micros: code.updated_at.to_micros_since_unix_epoch(),
}
}
fn list_profile_referral_invited_users(
ctx: &ReducerContext,
inviter_user_id: &str,
) -> Vec<RuntimeReferralInvitedUserSnapshot> {
// 中文注释:邀请面板只展示最近成功邀请用户,完整统计仍由计数字段承担。
let inviter_user_id = inviter_user_id.to_string();
let mut relations = ctx
.db
.profile_referral_relation()
.by_profile_referral_inviter_user_id()
.filter(&inviter_user_id)
.collect::<Vec<_>>();
relations.sort_by(|left, right| {
right
.bound_at
.to_micros_since_unix_epoch()
.cmp(&left.bound_at.to_micros_since_unix_epoch())
});
relations
.into_iter()
.take(PROFILE_REFERRAL_INVITED_USERS_LIMIT)
.map(|relation| {
let account = ctx
.db
.user_account()
.user_id()
.find(&relation.invitee_user_id);
RuntimeReferralInvitedUserSnapshot {
user_id: relation.invitee_user_id,
display_name: account
.as_ref()
.map(|user| user.display_name.trim())
.filter(|name| !name.is_empty())
.unwrap_or("玩家")
.to_string(),
avatar_url: account.and_then(|user| user.avatar_url),
bound_at_micros: relation.bound_at.to_micros_since_unix_epoch(),
}
})
.collect()
}
fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInviteCode {
if let Some(row) = ctx
.db
.profile_invite_code()
.user_id()
.find(&user_id.to_string())
{
return row;
}
let mut invite_code = build_runtime_profile_invite_code(user_id, 0);
let mut salt = 1;
while ctx
.db
.profile_invite_code()
.invite_code()
.find(&invite_code)
.is_some()
{
invite_code = build_runtime_profile_invite_code(user_id, salt);
salt += 1;
}
ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id: user_id.to_string(),
invite_code,
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
starts_at: None,
expires_at: None,
})
}
fn merge_user_account_tags(
ctx: &ReducerContext,
user_id: &str,
granted_tags: Vec<String>,
) -> Result<(), String> {
let granted_tags =
normalize_profile_user_tags(granted_tags).map_err(|error| error.to_string())?;
if granted_tags.is_empty() {
return Ok(());
}
let Some(mut account) = ctx.db.user_account().user_id().find(&user_id.to_string()) else {
return Err("用户不存在".to_string());
};
let mut next_tags = account.user_tags.take().unwrap_or_default();
next_tags.extend(granted_tags);
account.user_tags =
Some(normalize_profile_user_tags(next_tags).map_err(|error| error.to_string())?);
ctx.db.user_account().user_id().delete(&account.user_id);
ctx.db.user_account().insert(account);
Ok(())
}
fn profile_invite_code_metadata_user_tags(metadata_json: &str) -> Result<Vec<String>, String> {
let metadata = serde_json::from_str::<JsonValue>(metadata_json)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata.to_string())?;
let tags = metadata
.get("userTags")
.or_else(|| metadata.get("user_tags"))
.or_else(|| metadata.get("tags"))
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
normalize_profile_user_tags(tags).map_err(|error| error.to_string())
}
fn validate_profile_invite_code_redeem_time(
invite_code: &ProfileInviteCode,
now_micros: i64,
) -> Result<(), String> {
if invite_code
.starts_at
.map(|starts_at| now_micros < starts_at.to_micros_since_unix_epoch())
.unwrap_or(false)
{
return Err("邀请码未生效".to_string());
}
if invite_code
.expires_at
.map(|expires_at| now_micros >= expires_at.to_micros_since_unix_epoch())
.unwrap_or(false)
{
return Err("邀请码已过期".to_string());
}
Ok(())
}
fn count_today_profile_referral_inviter_rewards(
ctx: &ReducerContext,
user_id: &str,
now: Timestamp,
) -> u32 {
let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch());
ctx.db
.profile_wallet_ledger()
.by_profile_wallet_ledger_user_id()
.filter(user_id)
.filter(|row| {
row.user_id == user_id
&& row.source_type == RuntimeProfileWalletLedgerSourceType::InviteInviterReward
&& row.created_at.to_micros_since_unix_epoch() >= day_start_micros
})
.count() as u32
}
fn is_admin_profile_invite_code_user_id(user_id: &str) -> bool {
user_id.starts_with("admin:")
}
fn build_admin_profile_invite_code_user_id(admin_user_id: &str, invite_code: &str) -> String {
format!("admin:{}:{}", admin_user_id, invite_code)
}
fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 {
ctx.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string())
.map(|row| row.wallet_balance)
.unwrap_or(0)
}
fn build_profile_wallet_config_snapshot(
ctx: &ReducerContext,
) -> RuntimeProfileWalletConfigSnapshot {
ctx.db
.profile_wallet_config()
.config_id()
.find(&PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string())
.map(|row| build_profile_wallet_config_snapshot_from_row(&row))
.unwrap_or_else(|| RuntimeProfileWalletConfigSnapshot {
config_id: PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string(),
initial_mud_points: PROFILE_NEW_USER_INITIAL_WALLET_POINTS,
created_by: String::new(),
created_at_micros: 0,
updated_by: String::new(),
updated_at_micros: 0,
})
}
fn profile_new_user_initial_wallet_points(ctx: &ReducerContext) -> u64 {
build_profile_wallet_config_snapshot(ctx).initial_mud_points
}
fn build_new_user_registration_wallet_ledger_id(user_id: &str) -> String {
format!("{PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX}:{user_id}")
}
fn grant_new_user_registration_wallet_reward_tx(
ctx: &ReducerContext,
input: RuntimeProfileDashboardGetInput,
) -> Result<RuntimeProfileDashboardSnapshot, String> {
let validated_input = build_runtime_profile_dashboard_get_input(input.user_id)
.map_err(|error| error.to_string())?;
let ledger_id = build_new_user_registration_wallet_ledger_id(&validated_input.user_id);
if ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id)
.is_none()
{
apply_profile_wallet_delta(
ctx,
&validated_input.user_id,
profile_new_user_initial_wallet_points(ctx),
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward,
&ledger_id,
ctx.timestamp,
)?;
}
get_profile_dashboard_snapshot(
ctx,
RuntimeProfileDashboardGetInput {
user_id: validated_input.user_id,
},
)
}
fn build_profile_recharge_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeProfileRechargeCenterSnapshot {
ensure_default_profile_recharge_product_config(ctx);
refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp);
let wallet_balance = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string())
.map(|row| row.wallet_balance)
.unwrap_or(0);
let has_points_recharged = has_profile_points_recharged(ctx, user_id);
let mut point_products = Vec::new();
let mut membership_products = Vec::new();
for row in profile_recharge_product_config_rows(ctx, false) {
let product = build_profile_recharge_product_snapshot_from_config_row(&row);
match product.kind {
RuntimeProfileRechargeProductKind::Points => {
let has_product_recharged =
has_profile_product_recharged(ctx, user_id, &product.product_id);
point_products.push(resolve_profile_recharge_product_display(
product,
has_product_recharged,
));
}
RuntimeProfileRechargeProductKind::Membership => {
membership_products.push(product);
}
}
}
RuntimeProfileRechargeCenterSnapshot {
user_id: user_id.to_string(),
wallet_balance,
membership: build_profile_membership_snapshot(ctx, user_id),
point_products,
membership_products,
benefits: runtime_profile_membership_benefits(),
latest_order: latest_profile_recharge_order(ctx, user_id)
.map(|row| build_profile_recharge_order_snapshot_from_row(&row)),
has_points_recharged,
daily_free_points: build_profile_daily_free_points_snapshot(ctx, user_id, ctx.timestamp),
}
}
fn get_profile_task_center_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileTaskCenterGetInput,
record_login_event: bool,
) -> Result<RuntimeProfileTaskCenterSnapshot, String> {
let validated_input = build_runtime_profile_task_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
if record_login_event {
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)
}
fn claim_profile_task_reward_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskClaimInput,
) -> Result<RuntimeProfileTaskClaimSnapshot, String> {
let validated_input = build_runtime_profile_task_claim_input(input.user_id, input.task_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
let config = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id)
.ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?;
if !config.enabled {
return Err(RuntimeProfileFieldError::TaskDisabled.to_string());
}
if is_daily_login_task_config(&config) {
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let claim_id =
build_runtime_profile_task_claim_id(&validated_input.user_id, &config.task_id, day_key);
if ctx
.db
.profile_task_reward_claim()
.claim_id()
.find(&claim_id)
.is_some()
{
return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string());
}
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config)?;
if progress_count < config.threshold {
return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string());
}
let ledger_id = build_runtime_profile_task_reward_ledger_id(
&validated_input.user_id,
&config.task_id,
day_key,
);
let wallet_balance = grant_profile_wallet_points(
ctx,
&validated_input.user_id,
config.reward_points,
RuntimeProfileWalletLedgerSourceType::DailyTaskReward,
&ledger_id,
ctx.timestamp,
)?;
let claim = ctx
.db
.profile_task_reward_claim()
.insert(ProfileTaskRewardClaim {
claim_id: claim_id.clone(),
user_id: validated_input.user_id.clone(),
task_id: config.task_id.clone(),
day_key,
reward_points: config.reward_points,
wallet_ledger_id: ledger_id.clone(),
claimed_at: ctx.timestamp,
});
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key)?;
let ledger_entry = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id)
.ok_or_else(|| "任务奖励钱包流水写入失败".to_string())?;
Ok(RuntimeProfileTaskClaimSnapshot {
user_id: validated_input.user_id.clone(),
task_id: config.task_id.clone(),
day_key,
reward_points: claim.reward_points,
wallet_balance,
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)?,
})
}
fn list_profile_task_config_snapshots(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminListInput,
) -> Result<Vec<RuntimeProfileTaskConfigSnapshot>, String> {
let _validated_input = build_runtime_profile_task_config_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
let mut entries = ctx
.db
.profile_task_config()
.iter()
.map(|row| build_profile_task_config_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
Ok(entries)
}
fn get_profile_wallet_config_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileWalletConfigAdminGetInput,
) -> Result<RuntimeProfileWalletConfigSnapshot, String> {
let _validated_input = build_runtime_profile_wallet_config_admin_get_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
Ok(build_profile_wallet_config_snapshot(ctx))
}
fn upsert_profile_wallet_config_record(
ctx: &ReducerContext,
input: RuntimeProfileWalletConfigAdminUpsertInput,
) -> Result<RuntimeProfileWalletConfigSnapshot, String> {
let validated_input = build_runtime_profile_wallet_config_admin_upsert_input(
input.admin_user_id,
input.initial_mud_points,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let config_id = PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string();
let existing = ctx.db.profile_wallet_config().config_id().find(&config_id);
if let Some(row) = existing.as_ref() {
ctx.db
.profile_wallet_config()
.config_id()
.delete(&row.config_id);
}
let inserted = ctx.db.profile_wallet_config().insert(ProfileWalletConfig {
config_id,
initial_mud_points: validated_input.initial_mud_points,
created_by: existing
.as_ref()
.map(|row| row.created_by.clone())
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at),
updated_by: validated_input.admin_user_id,
updated_at,
});
Ok(build_profile_wallet_config_snapshot_from_row(&inserted))
}
fn list_profile_recharge_product_config_snapshots(
ctx: &ReducerContext,
input: RuntimeProfileRechargeProductAdminListInput,
) -> Result<Vec<RuntimeProfileRechargeProductConfigSnapshot>, String> {
let _validated_input =
build_runtime_profile_recharge_product_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_recharge_product_config(ctx);
Ok(profile_recharge_product_config_rows(ctx, true)
.iter()
.map(build_profile_recharge_product_config_snapshot_from_row)
.collect())
}
fn admin_list_profile_redeem_code_records(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminListInput,
) -> Result<
(
Vec<RuntimeProfileRedeemCodeSnapshot>,
Vec<RuntimeProfileCodeOperationSnapshot>,
),
String,
> {
let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_redeem_code()
.iter()
.map(|row| build_profile_redeem_code_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.code.cmp(&right.code))
});
Ok((entries, profile_code_operation_snapshots(ctx, "redeem")))
}
fn admin_list_profile_invite_code_records(
ctx: &ReducerContext,
input: RuntimeProfileInviteCodeAdminListInput,
) -> Result<
(
Vec<RuntimeProfileInviteCodeSnapshot>,
Vec<RuntimeProfileCodeOperationSnapshot>,
),
String,
> {
let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_invite_code()
.iter()
.filter(|row| is_admin_profile_invite_code_user_id(&row.user_id))
.map(|row| build_profile_invite_code_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.invite_code.cmp(&right.invite_code))
});
Ok((entries, profile_code_operation_snapshots(ctx, "invite")))
}
fn upsert_profile_recharge_product_config_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeProductAdminUpsertInput,
) -> Result<RuntimeProfileRechargeProductConfigSnapshot, String> {
let validated_input = build_runtime_profile_recharge_product_admin_upsert_input(
input.admin_user_id,
input.product_id,
input.title,
input.price_cents,
input.kind,
input.points_amount,
input.bonus_points,
input.duration_days,
input.badge_label,
input.description,
input.tier,
input.membership_period_points,
input.membership_period_days,
input.membership_queue_limit,
input.membership_discount_bps,
input.enabled,
input.sort_order,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
ensure_default_profile_recharge_product_config(ctx);
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_recharge_product_config()
.product_id()
.find(&validated_input.product_id);
if let Some(row) = existing.as_ref() {
ctx.db
.profile_recharge_product_config()
.product_id()
.delete(&row.product_id);
}
let inserted = ctx
.db
.profile_recharge_product_config()
.insert(ProfileRechargeProductConfig {
product_id: validated_input.product_id,
title: validated_input.title,
price_cents: validated_input.price_cents,
kind: validated_input.kind,
points_amount: validated_input.points_amount,
bonus_points: validated_input.bonus_points,
duration_days: validated_input.duration_days,
badge_label: validated_input.badge_label,
description: validated_input.description,
tier: validated_input.tier,
enabled: validated_input.enabled,
sort_order: validated_input.sort_order,
created_by: existing
.as_ref()
.map(|row| row.created_by.clone())
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at),
updated_by: validated_input.admin_user_id,
updated_at,
membership_period_points: validated_input.membership_period_points,
membership_period_days: validated_input.membership_period_days,
membership_queue_limit: validated_input.membership_queue_limit,
membership_discount_bps: validated_input.membership_discount_bps,
});
Ok(build_profile_recharge_product_config_snapshot_from_row(
&inserted,
))
}
fn upsert_profile_task_config_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminUpsertInput,
) -> Result<RuntimeProfileTaskConfigSnapshot, String> {
let validated_input = build_runtime_profile_task_config_admin_upsert_input(
input.admin_user_id,
input.task_id,
input.title,
input.description,
input.event_key,
input.cycle,
input.scope_kind,
input.threshold,
input.reward_points,
input.enabled,
input.sort_order,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id);
if let Some(row) = existing.as_ref() {
ctx.db.profile_task_config().task_id().delete(&row.task_id);
}
let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig {
task_id: validated_input.task_id,
title: validated_input.title,
description: validated_input.description,
event_key: validated_input.event_key,
cycle: validated_input.cycle,
scope_kind: validated_input.scope_kind,
threshold: validated_input.threshold,
reward_points: validated_input.reward_points,
enabled: validated_input.enabled,
sort_order: validated_input.sort_order,
created_by: existing
.as_ref()
.map(|row| row.created_by.clone())
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at),
updated_by: validated_input.admin_user_id,
updated_at,
});
Ok(build_profile_task_config_snapshot_from_row(&inserted))
}
fn disable_profile_task_config_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminDisableInput,
) -> Result<RuntimeProfileTaskConfigSnapshot, String> {
let validated_input = build_runtime_profile_task_config_admin_disable_input(
input.admin_user_id,
input.task_id,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let row = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id)
.ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
ctx.db.profile_task_config().task_id().delete(&row.task_id);
let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig {
enabled: false,
updated_by: validated_input.admin_user_id,
updated_at,
..row
});
Ok(build_profile_task_config_snapshot_from_row(&inserted))
}
fn build_profile_task_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
updated_at: Timestamp,
) -> Result<RuntimeProfileTaskCenterSnapshot, String> {
ensure_default_profile_task_config(ctx);
refresh_profile_wallet_expiring_points(ctx, user_id, updated_at);
let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch());
let mut configs = ctx.db.profile_task_config().iter().collect::<Vec<_>>();
configs.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
let mut tasks = Vec::with_capacity(configs.len());
for config in configs {
validate_profile_task_user_scope(&config)?;
let progress_count = profile_task_progress_count(ctx, user_id, &config)?;
refresh_profile_task_progress(ctx, user_id, &config, day_key)?;
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
);
tasks.push(RuntimeProfileTaskItemSnapshot {
task_id: config.task_id,
title: config.title,
description: config.description,
event_key: config.event_key,
cycle: config.cycle,
threshold: config.threshold,
progress_count,
reward_points: config.reward_points,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claim.is_some(),
),
day_key,
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
});
}
Ok(RuntimeProfileTaskCenterSnapshot {
user_id: user_id.to_string(),
day_key,
wallet_balance: profile_wallet_balance(ctx, user_id),
tasks,
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
})
}
fn query_analytics_metric_buckets(
ctx: &ReducerContext,
input: AnalyticsMetricQueryInput,
) -> Result<Vec<AnalyticsBucketMetric>, String> {
let validated_input = build_analytics_metric_query_input(
input.event_key,
input.scope_kind,
input.scope_id,
input.granularity,
)
.map_err(|error| error.to_string())?;
let stats = ctx
.db
.tracking_daily_stat()
.by_tracking_daily_stat_scope_day()
.filter((
validated_input.scope_kind,
validated_input.scope_id.as_str(),
))
.filter(|row| {
row.event_key.trim() == validated_input.event_key
&& row.scope_kind == validated_input.scope_kind
&& row.scope_id.trim() == validated_input.scope_id
})
.map(|row| RuntimeAnalyticsDailyStatSnapshot {
event_key: row.event_key,
scope_kind: row.scope_kind,
scope_id: row.scope_id,
day_key: row.day_key,
count: row.count,
})
.collect::<Vec<_>>();
Ok(aggregate_runtime_tracking_daily_stats(
stats,
&validated_input.event_key,
validated_input.scope_kind,
&validated_input.scope_id,
validated_input.granularity,
))
}
fn refresh_profile_task_progress(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
day_key: i64,
) -> Result<ProfileTaskProgress, String> {
let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key);
if let Some(existing) = ctx
.db
.profile_task_progress()
.progress_id()
.find(&progress_id)
{
ctx.db
.profile_task_progress()
.progress_id()
.delete(&existing.progress_id);
}
let progress_count = profile_task_progress_count(ctx, user_id, config)?;
let claimed = ctx
.db
.profile_task_reward_claim()
.claim_id()
.find(&build_runtime_profile_task_claim_id(
user_id,
&config.task_id,
day_key,
))
.is_some();
Ok(ctx.db.profile_task_progress().insert(ProfileTaskProgress {
progress_id,
user_id: user_id.to_string(),
task_id: config.task_id.clone(),
day_key,
progress_count,
threshold: config.threshold,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claimed,
),
updated_at: ctx.timestamp,
}))
}
fn profile_task_progress_count(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
) -> Result<u32, String> {
validate_profile_task_user_scope(config)?;
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let scope_id = profile_task_tracking_scope_id(user_id, config)?;
Ok(ctx
.db
.tracking_daily_stat()
.stat_id()
.find(&build_runtime_tracking_daily_stat_id(
&config.event_key,
config.scope_kind,
&scope_id,
day_key,
))
.map(|row| row.count)
.unwrap_or(0))
}
fn profile_task_tracking_scope_id(
user_id: &str,
config: &ProfileTaskConfig,
) -> Result<String, String> {
validate_profile_task_user_scope(config)?;
Ok(user_id.to_string())
}
fn insert_profile_code_operation(
ctx: &ReducerContext,
code_kind: &str,
code: &str,
action: &str,
operator_user_id: &str,
created_at: Timestamp,
) {
let created_at_micros = created_at.to_micros_since_unix_epoch();
let operation_index = ctx
.db
.profile_code_operation()
.by_profile_code_operation_kind_code()
.filter((code_kind, code))
.count();
ctx.db
.profile_code_operation()
.insert(ProfileCodeOperation {
operation_id: format!(
"{}:{}:{}:{}:{}",
code_kind.trim(),
code.trim(),
action.trim(),
created_at_micros,
operation_index
),
code_kind: code_kind.to_string(),
code: code.to_string(),
action: action.to_string(),
operator_user_id: operator_user_id.to_string(),
created_at,
});
}
fn profile_code_operation_snapshots(
ctx: &ReducerContext,
code_kind: &str,
) -> Vec<RuntimeProfileCodeOperationSnapshot> {
let mut entries = ctx
.db
.profile_code_operation()
.by_profile_code_operation_code_kind()
.filter(code_kind)
.map(|row| build_profile_code_operation_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| left.code.cmp(&right.code))
.then_with(|| left.operation_id.cmp(&right.operation_id))
});
entries
}
fn validate_profile_task_user_scope(config: &ProfileTaskConfig) -> Result<(), String> {
if config.scope_kind == RuntimeTrackingScopeKind::User {
Ok(())
} else {
Err(format!(
"个人任务 scope_kind 首版仅支持 user,当前 task_id={} scope_kind={}",
config.task_id,
config.scope_kind.as_str()
))
}
}
fn is_daily_login_task_config(config: &ProfileTaskConfig) -> bool {
config.task_id == PROFILE_TASK_ID_DAILY_LOGIN
&& config.event_key == PROFILE_TASK_EVENT_KEY_DAILY_LOGIN
&& config.scope_kind == RuntimeTrackingScopeKind::User
}
fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Result<(), String> {
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let event_id = format!(
"{}:{}:{}",
PROFILE_TASK_LOGIN_EVENT_ID_PREFIX,
user_id.trim(),
day_key
);
if ctx.db.tracking_event().event_id().find(&event_id).is_some() {
return Ok(());
}
record_tracking_event(
ctx,
RuntimeTrackingEventInput {
event_id,
event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
scope_kind: RuntimeTrackingScopeKind::User,
scope_id: user_id.to_string(),
user_id: Some(user_id.to_string()),
owner_user_id: None,
profile_id: None,
module_key: Some(PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string()),
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
occurred_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)
}
fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool {
event_exists
}
fn record_tracking_event(
ctx: &ReducerContext,
input: RuntimeTrackingEventInput,
) -> Result<(), String> {
let validated_input = build_runtime_tracking_event_input(
input.event_id,
input.event_key,
input.scope_kind,
input.scope_id,
input.user_id,
input.owner_user_id,
input.profile_id,
input.module_key,
input.metadata_json,
input.occurred_at_micros,
)
.map_err(|error| error.to_string())?;
let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros);
let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros);
if should_skip_existing_tracking_event_id(
ctx.db
.tracking_event()
.event_id()
.find(&validated_input.event_id)
.is_some(),
) {
return Ok(());
}
// 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。
ensure_analytics_date_dimension_row(ctx, day_key)?;
ctx.db.tracking_event().insert(TrackingEvent {
event_id: validated_input.event_id,
event_key: validated_input.event_key.clone(),
scope_kind: validated_input.scope_kind,
scope_id: validated_input.scope_id.clone(),
day_key,
user_id: validated_input.user_id,
owner_user_id: validated_input.owner_user_id,
profile_id: validated_input.profile_id,
module_key: validated_input.module_key,
metadata_json: validated_input.metadata_json,
occurred_at,
});
upsert_tracking_daily_stat(
ctx,
&validated_input.event_key,
validated_input.scope_kind,
&validated_input.scope_id,
day_key,
occurred_at,
);
Ok(())
}
fn upsert_tracking_daily_stat(
ctx: &ReducerContext,
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
day_key: i64,
occurred_at: Timestamp,
) {
let stat_id = build_runtime_tracking_daily_stat_id(event_key, scope_kind, scope_id, day_key);
let existing = ctx.db.tracking_daily_stat().stat_id().find(&stat_id);
if let Some(row) = existing {
ctx.db.tracking_daily_stat().stat_id().delete(&row.stat_id);
ctx.db.tracking_daily_stat().insert(TrackingDailyStat {
stat_id,
event_key: row.event_key,
scope_kind: row.scope_kind,
scope_id: row.scope_id,
day_key: row.day_key,
count: row.count.saturating_add(1),
first_occurred_at: row.first_occurred_at,
last_occurred_at: occurred_at,
updated_at: occurred_at,
});
} else {
ctx.db.tracking_daily_stat().insert(TrackingDailyStat {
stat_id,
event_key: event_key.to_string(),
scope_kind,
scope_id: scope_id.to_string(),
day_key,
count: 1,
first_occurred_at: occurred_at,
last_occurred_at: occurred_at,
updated_at: occurred_at,
});
}
}
fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig {
if let Some(row) = ctx
.db
.profile_task_config()
.task_id()
.find(&PROFILE_TASK_ID_DAILY_LOGIN.to_string())
{
return row;
}
let default_config = build_default_runtime_profile_task_config(
ctx.timestamp.to_micros_since_unix_epoch(),
PROFILE_TASK_SYSTEM_USER_ID.to_string(),
);
ctx.db.profile_task_config().insert(ProfileTaskConfig {
task_id: default_config.task_id,
title: default_config.title,
description: default_config.description,
event_key: default_config.event_key,
cycle: default_config.cycle,
scope_kind: default_config.scope_kind,
threshold: default_config.threshold,
reward_points: default_config.reward_points,
enabled: default_config.enabled,
sort_order: default_config.sort_order,
created_by: default_config.created_by,
created_at: ctx.timestamp,
updated_by: default_config.updated_by,
updated_at: ctx.timestamp,
})
}
fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) {
if ctx.db.profile_recharge_product_config().count() > 0 {
migrate_legacy_default_profile_recharge_product_config(ctx);
return;
}
let now = ctx.timestamp;
for (sort_order, product) in runtime_profile_recharge_point_products()
.into_iter()
.chain(runtime_profile_recharge_membership_products())
.enumerate()
{
ctx.db
.profile_recharge_product_config()
.insert(ProfileRechargeProductConfig {
product_id: product.product_id,
title: product.title,
price_cents: product.price_cents,
kind: product.kind,
points_amount: product.points_amount,
bonus_points: product.bonus_points,
duration_days: product.duration_days,
badge_label: product.badge_label,
description: product.description,
tier: product.tier,
enabled: true,
sort_order: sort_order as i32,
created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
created_at: now,
updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
updated_at: now,
membership_period_points: product.membership_period_points,
membership_period_days: product.membership_period_days,
membership_queue_limit: product.membership_queue_limit,
membership_discount_bps: product.membership_discount_bps,
});
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DefaultPointProductMigration {
bonus_points: u64,
badge_label: &'static str,
description: &'static str,
enabled: bool,
}
fn resolve_default_point_product_migration(
row: &ProfileRechargeProductConfig,
) -> Option<DefaultPointProductMigration> {
if row.kind != RuntimeProfileRechargeProductKind::Points
|| row.tier != RuntimeProfileMembershipTier::Normal
|| row.updated_by != PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID
|| !row.enabled
|| row.duration_days != 0
|| row.bonus_points != row.points_amount
|| row.badge_label != "首充双倍"
|| row.description != format!("首充送{}泥点", row.points_amount)
{
return None;
}
let expected_product_id = format!("points_{}", row.points_amount);
let expected_title = format!("{}泥点", row.points_amount);
if row.product_id != expected_product_id
|| row.title != expected_title
|| row.price_cents != row.points_amount.saturating_mul(10)
{
return None;
}
match row.points_amount {
60 => Some(DefaultPointProductMigration {
bonus_points: 0,
badge_label: "",
description: "60泥点",
enabled: true,
}),
180 => Some(DefaultPointProductMigration {
bonus_points: 90,
badge_label: "首充加赠",
description: "首充加赠90泥点",
enabled: true,
}),
300 => Some(DefaultPointProductMigration {
bonus_points: 150,
badge_label: "首充加赠",
description: "首充加赠150泥点",
enabled: true,
}),
680 => Some(DefaultPointProductMigration {
bonus_points: 340,
badge_label: "首充加赠",
description: "首充加赠340泥点",
enabled: true,
}),
1_280 | 3_280 => Some(DefaultPointProductMigration {
bonus_points: row.bonus_points,
badge_label: "首充双倍",
description: "",
enabled: false,
}),
_ => None,
}
}
fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) {
let rows = ctx
.db
.profile_recharge_product_config()
.iter()
.collect::<Vec<_>>();
for mut row in rows {
let Some(migration) = resolve_default_point_product_migration(&row) else {
continue;
};
row.bonus_points = migration.bonus_points;
row.badge_label = migration.badge_label.to_string();
if !migration.description.is_empty() {
row.description = migration.description.to_string();
}
row.enabled = migration.enabled;
row.updated_at = ctx.timestamp;
ctx.db
.profile_recharge_product_config()
.product_id()
.update(row);
}
}
fn profile_recharge_product_config_rows(
ctx: &ReducerContext,
include_disabled: bool,
) -> Vec<ProfileRechargeProductConfig> {
ensure_default_profile_recharge_product_config(ctx);
let mut rows = ctx
.db
.profile_recharge_product_config()
.iter()
.filter(|row| include_disabled || row.enabled)
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.product_id.cmp(&right.product_id))
});
rows
}
fn profile_recharge_product_by_id(
ctx: &ReducerContext,
product_id: &str,
) -> Option<RuntimeProfileRechargeProductSnapshot> {
ensure_default_profile_recharge_product_config(ctx);
ctx.db
.profile_recharge_product_config()
.product_id()
.find(&product_id.to_string())
.map(|row| build_profile_recharge_product_snapshot_from_config_row(&row))
}
fn enabled_profile_recharge_product_by_id(
ctx: &ReducerContext,
product_id: &str,
) -> Option<RuntimeProfileRechargeProductSnapshot> {
ensure_default_profile_recharge_product_config(ctx);
ctx.db
.profile_recharge_product_config()
.product_id()
.find(&product_id.to_string())
.filter(|row| row.enabled)
.map(|row| build_profile_recharge_product_snapshot_from_config_row(&row))
}
fn resolve_profile_recharge_product_display(
mut product: RuntimeProfileRechargeProductSnapshot,
has_product_recharged: bool,
) -> RuntimeProfileRechargeProductSnapshot {
if product.kind == RuntimeProfileRechargeProductKind::Points && has_product_recharged {
product.bonus_points = 0;
product.badge_label.clear();
product.description = product.title.clone();
}
product
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProfileMembershipPurchaseResult {
expires_at: Timestamp,
period_points_delta: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct MembershipCyclePointMutation {
points: u64,
cycle_resets_at_micros: Option<i64>,
}
impl MembershipCyclePointMutation {
fn none() -> Self {
Self {
points: 0,
cycle_resets_at_micros: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DailyFreePointMutation {
points: u64,
day_key: Option<i64>,
}
impl DailyFreePointMutation {
fn none() -> Self {
Self {
points: 0,
day_key: None,
}
}
}
struct MembershipCycleInitialization {
row: ProfileMembership,
granted_points_delta: u64,
cycle_resets_at: Timestamp,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActiveMembershipPurchaseMode {
Renew,
Upgrade,
}
fn normalized_membership_period_days(period_days: u32) -> u32 {
if period_days == 0 {
PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS
} else {
period_days
}
}
fn membership_period_micros(period_days: u32) -> i64 {
i64::from(normalized_membership_period_days(period_days))
.saturating_mul(PROFILE_RUNTIME_DAY_MICROS)
}
fn membership_cycle_reset_at(started_at: Timestamp, period_days: u32) -> Timestamp {
Timestamp::from_micros_since_unix_epoch(
started_at
.to_micros_since_unix_epoch()
.saturating_add(membership_period_micros(period_days)),
)
}
fn membership_product_period_points(product: &RuntimeProfileRechargeProductSnapshot) -> u64 {
if product.membership_period_points == 0 {
runtime_profile_membership_period_points(product.tier)
} else {
product.membership_period_points
}
}
fn membership_product_period_days(product: &RuntimeProfileRechargeProductSnapshot) -> u32 {
normalized_membership_period_days(product.membership_period_days)
}
fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool {
row.expires_at.to_micros_since_unix_epoch() > at.to_micros_since_unix_epoch()
}
fn canonical_membership_pricing_tier(
tier: RuntimeProfileMembershipTier,
) -> RuntimeProfileMembershipTier {
match tier {
RuntimeProfileMembershipTier::Month => RuntimeProfileMembershipTier::Starter,
RuntimeProfileMembershipTier::Season => RuntimeProfileMembershipTier::Basic,
RuntimeProfileMembershipTier::Year => RuntimeProfileMembershipTier::Pro,
_ => tier,
}
}
fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool {
matches!(
tier,
RuntimeProfileMembershipTier::Month
| RuntimeProfileMembershipTier::Season
| RuntimeProfileMembershipTier::Year
)
}
fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 {
runtime_profile_membership_tier_rank(tier).unwrap_or(0)
}
fn resolve_active_membership_purchase_mode(
current_tier: RuntimeProfileMembershipTier,
next_tier: RuntimeProfileMembershipTier,
) -> Result<ActiveMembershipPurchaseMode, String> {
if current_tier == next_tier {
return Ok(ActiveMembershipPurchaseMode::Renew);
}
let current_rank = active_membership_tier_rank(current_tier);
let next_rank = active_membership_tier_rank(next_tier);
let is_legacy_equivalent_migration = current_rank == next_rank
&& is_legacy_membership_tier(current_tier)
&& canonical_membership_pricing_tier(current_tier) == next_tier;
if is_legacy_equivalent_migration {
return Ok(ActiveMembershipPurchaseMode::Renew);
}
if next_rank > current_rank {
return Ok(ActiveMembershipPurchaseMode::Upgrade);
}
Err("当前会员有效期内不能购买更低档会员".to_string())
}
fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 {
target_price_cents.saturating_sub(current_price_cents)
}
fn membership_product_price_cents_by_tier(
ctx: &ReducerContext,
tier: RuntimeProfileMembershipTier,
) -> Option<u64> {
let canonical_tier = canonical_membership_pricing_tier(tier);
profile_recharge_product_config_rows(ctx, false)
.into_iter()
.find(|row| {
row.kind == RuntimeProfileRechargeProductKind::Membership
&& canonical_membership_pricing_tier(row.tier) == canonical_tier
})
.map(|row| row.price_cents)
.or_else(|| {
runtime_profile_recharge_membership_products()
.into_iter()
.find(|product| canonical_membership_pricing_tier(product.tier) == canonical_tier)
.map(|product| product.price_cents)
})
}
fn resolve_profile_membership_order_amount_cents(
ctx: &ReducerContext,
user_id: &str,
product: &RuntimeProfileRechargeProductSnapshot,
created_at: Timestamp,
) -> Result<u64, String> {
let Some(row) = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
.filter(|row| active_membership_row_at(row, created_at))
else {
return Ok(product.price_cents);
};
let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?;
if purchase_mode == ActiveMembershipPurchaseMode::Renew {
return Ok(product.price_cents);
}
let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0);
Ok(membership_upgrade_amount_cents(
product.price_cents,
current_price_cents,
))
}
fn resolve_profile_recharge_order_amount_cents(
ctx: &ReducerContext,
user_id: &str,
product: &RuntimeProfileRechargeProductSnapshot,
created_at: Timestamp,
) -> Result<u64, String> {
match product.kind {
RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents),
RuntimeProfileRechargeProductKind::Membership => {
resolve_profile_membership_order_amount_cents(ctx, user_id, product, created_at)
}
}
}
fn apply_active_membership_upgrade_row(
mut row: ProfileMembership,
tier: RuntimeProfileMembershipTier,
period_days: u32,
period_points: u64,
updated_at: Timestamp,
) -> (ProfileMembership, u64) {
let period_points_delta = period_points.saturating_sub(row.cycle_granted_points);
row.tier = tier;
row.cycle_granted_points = row.cycle_granted_points.max(period_points);
row.cycle_remaining_points = row
.cycle_remaining_points
.saturating_add(period_points_delta);
row.cycle_period_days = period_days;
row.updated_at = updated_at;
(row, period_points_delta)
}
fn apply_active_membership_renew_row(
mut row: ProfileMembership,
tier: RuntimeProfileMembershipTier,
duration_days: u32,
purchased_at: Timestamp,
) -> ProfileMembership {
row.tier = tier;
row.expires_at = Timestamp::from_micros_since_unix_epoch(
row.expires_at
.to_micros_since_unix_epoch()
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)),
);
row.updated_at = purchased_at;
row
}
fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) {
ctx.db.profile_membership().user_id().delete(&row.user_id);
ctx.db.profile_membership().insert(row);
}
fn initialize_missing_profile_membership_cycle(
mut row: ProfileMembership,
now: Timestamp,
) -> Option<MembershipCycleInitialization> {
if row.cycle_resets_at.is_some() {
return None;
}
let period_days = normalized_membership_period_days(row.cycle_period_days);
let period_micros = membership_period_micros(period_days);
let mut cycle_started_at = row.cycle_started_at.unwrap_or(row.started_at);
let mut next_reset_at = membership_cycle_reset_at(cycle_started_at, period_days);
let now_micros = now.to_micros_since_unix_epoch();
let mut guard = 0u32;
while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 {
cycle_started_at = next_reset_at;
next_reset_at = Timestamp::from_micros_since_unix_epoch(
next_reset_at
.to_micros_since_unix_epoch()
.saturating_add(period_micros),
);
guard = guard.saturating_add(1);
}
let granted_points =
runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points);
let granted_points_delta = granted_points.saturating_sub(row.cycle_remaining_points);
row.cycle_started_at = Some(cycle_started_at);
row.cycle_resets_at = Some(next_reset_at);
row.cycle_granted_points = granted_points;
row.cycle_remaining_points = granted_points;
row.cycle_period_days = period_days;
row.updated_at = now;
Some(MembershipCycleInitialization {
row,
granted_points_delta,
cycle_resets_at: next_reset_at,
})
}
fn membership_cycle_metadata(
action: &str,
expired_points: u64,
granted_points: u64,
cycle_resets_at: Option<Timestamp>,
) -> String {
serde_json::to_string(&json!({
"action": action,
"expiredMembershipPeriodPoints": expired_points,
"grantedMembershipPeriodPoints": granted_points,
"cycleResetsAtMicros": cycle_resets_at
.map(|value| value.to_micros_since_unix_epoch()),
}))
.unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string())
}
fn update_profile_wallet_balance_for_expiring_points(
ctx: &ReducerContext,
user_id: &str,
expired_points: u64,
granted_points: u64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
metadata_json: String,
) {
if expired_points == 0 && granted_points == 0 {
return;
}
if ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id.to_string())
.is_some()
{
return;
}
let current = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string());
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
let after_expiry = previous_balance.saturating_sub(expired_points);
let next_balance = after_expiry.saturating_add(granted_points);
let created_state_at = current
.as_ref()
.map(|row| row.created_at)
.unwrap_or(created_at);
if let Some(existing) = current {
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: next_balance,
total_play_time_ms: existing.total_play_time_ms,
created_at: existing.created_at,
updated_at: created_at,
});
} else {
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: next_balance,
total_play_time_ms: 0,
created_at: created_state_at,
updated_at: created_at,
});
}
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: ledger_id.to_string(),
user_id: user_id.to_string(),
amount_delta: next_balance as i64 - previous_balance as i64,
balance_after: next_balance,
source_type,
created_at,
metadata_json: Some(metadata_json),
});
}
fn daily_free_points_metadata(
action: &str,
expired_points: u64,
granted_points: u64,
day_key: i64,
) -> String {
serde_json::to_string(&json!({
"action": action,
"dailyFreeDayKey": day_key,
"expiredDailyFreePoints": expired_points,
"grantedDailyFreePoints": granted_points,
}))
.unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DailyFreeRefreshPlan {
expired_points: u64,
granted_points: u64,
reset: bool,
}
fn resolve_daily_free_refresh_plan(
current_day_key: Option<i64>,
current_remaining_points: u64,
day_key: i64,
) -> Option<DailyFreeRefreshPlan> {
match current_day_key {
Some(current_day_key) if current_day_key >= day_key => None,
Some(_) => Some(DailyFreeRefreshPlan {
expired_points: current_remaining_points,
granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY,
reset: true,
}),
None => Some(DailyFreeRefreshPlan {
expired_points: 0,
granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY,
reset: false,
}),
}
}
fn refresh_profile_daily_free_points(ctx: &ReducerContext, user_id: &str, now: Timestamp) {
let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch());
let current = ctx
.db
.profile_daily_free_points()
.user_id()
.find(&user_id.to_string());
let Some(plan) = resolve_daily_free_refresh_plan(
current.as_ref().map(|row| row.day_key),
current
.as_ref()
.map(|row| row.remaining_points)
.unwrap_or(0),
day_key,
) else {
return;
};
match current {
Some(row) => {
debug_assert!(plan.reset);
ctx.db
.profile_daily_free_points()
.user_id()
.update(ProfileDailyFreePoints {
day_key,
granted_points: plan.granted_points,
remaining_points: plan.granted_points,
updated_at: now,
..row
});
update_profile_wallet_balance_for_expiring_points(
ctx,
user_id,
plan.expired_points,
plan.granted_points,
RuntimeProfileWalletLedgerSourceType::DailyFreeReset,
&format!("daily-free-reset:{user_id}:{day_key}"),
now,
daily_free_points_metadata(
"reset",
plan.expired_points,
plan.granted_points,
day_key,
),
);
}
None => {
debug_assert!(!plan.reset);
ctx.db
.profile_daily_free_points()
.insert(ProfileDailyFreePoints {
user_id: user_id.to_string(),
day_key,
granted_points: plan.granted_points,
remaining_points: plan.granted_points,
updated_at: now,
});
update_profile_wallet_balance_for_expiring_points(
ctx,
user_id,
0,
plan.granted_points,
RuntimeProfileWalletLedgerSourceType::DailyFreeGrant,
&format!("daily-free-grant:{user_id}:{day_key}"),
now,
daily_free_points_metadata("grant", 0, plan.granted_points, day_key),
);
}
}
}
fn profile_daily_free_points_resets_at_micros(day_key: i64) -> i64 {
day_key
.saturating_add(1)
.saturating_mul(PROFILE_RUNTIME_DAY_MICROS)
.saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS)
}
fn build_profile_daily_free_points_snapshot(
ctx: &ReducerContext,
user_id: &str,
now: Timestamp,
) -> RuntimeProfileDailyFreePointsSnapshot {
let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch());
ctx.db
.profile_daily_free_points()
.user_id()
.find(&user_id.to_string())
.map(|row| RuntimeProfileDailyFreePointsSnapshot {
day_key: row.day_key,
granted_points: row.granted_points,
remaining_points: row.remaining_points,
resets_at_micros: profile_daily_free_points_resets_at_micros(row.day_key),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
.unwrap_or(RuntimeProfileDailyFreePointsSnapshot {
day_key,
granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY,
remaining_points: PROFILE_DAILY_FREE_POINTS_PER_DAY,
resets_at_micros: profile_daily_free_points_resets_at_micros(day_key),
updated_at_micros: now.to_micros_since_unix_epoch(),
})
}
fn refresh_profile_wallet_expiring_points(
ctx: &ReducerContext,
user_id: &str,
membership_now: Timestamp,
) {
refresh_profile_daily_free_points(ctx, user_id, ctx.timestamp);
refresh_profile_membership_cycle(ctx, user_id, membership_now);
}
fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Timestamp) {
let Some(mut row) = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
else {
return;
};
let now_micros = now.to_micros_since_unix_epoch();
let expires_at_micros = row.expires_at.to_micros_since_unix_epoch();
if expires_at_micros <= now_micros {
let expired_points = row.cycle_remaining_points;
if row.status != RuntimeProfileMembershipStatus::Normal || expired_points > 0 {
row.status = RuntimeProfileMembershipStatus::Normal;
row.cycle_remaining_points = 0;
row.updated_at = now;
let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}");
upsert_profile_membership_row(ctx, row);
update_profile_wallet_balance_for_expiring_points(
ctx,
user_id,
expired_points,
0,
RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset,
&ledger_id,
now,
membership_cycle_metadata("expire", expired_points, 0, None),
);
}
return;
}
if row.cycle_resets_at.is_none() {
let Some(initialization) = initialize_missing_profile_membership_cycle(row, now) else {
return;
};
let ledger_id = format!(
"membership-period-initialize:{user_id}:{}",
initialization.cycle_resets_at.to_micros_since_unix_epoch()
);
let granted_points_delta = initialization.granted_points_delta;
row = initialization.row;
let cycle_resets_at = row.cycle_resets_at.clone();
upsert_profile_membership_row(ctx, row);
update_profile_wallet_balance_for_expiring_points(
ctx,
user_id,
0,
granted_points_delta,
RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant,
&ledger_id,
now,
membership_cycle_metadata("initialize", 0, granted_points_delta, cycle_resets_at),
);
let Some(refreshed_row) = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
else {
return;
};
row = refreshed_row;
}
let Some(mut next_reset_at) = row.cycle_resets_at else {
return;
};
if next_reset_at.to_micros_since_unix_epoch() > now_micros {
return;
}
let period_days = normalized_membership_period_days(row.cycle_period_days);
let period_micros = membership_period_micros(period_days);
let mut next_cycle_started_at = next_reset_at;
let mut guard = 0u32;
while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 {
next_cycle_started_at = next_reset_at;
next_reset_at = Timestamp::from_micros_since_unix_epoch(
next_reset_at
.to_micros_since_unix_epoch()
.saturating_add(period_micros),
);
guard = guard.saturating_add(1);
}
let expired_points = row.cycle_remaining_points;
let granted_points =
runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points);
row.cycle_started_at = Some(next_cycle_started_at);
row.cycle_resets_at = Some(next_reset_at);
row.cycle_granted_points = granted_points;
row.cycle_remaining_points = granted_points;
row.cycle_period_days = period_days;
row.updated_at = now;
let reset_micros = next_cycle_started_at.to_micros_since_unix_epoch();
let ledger_id = format!("membership-period-reset:{user_id}:{reset_micros}");
upsert_profile_membership_row(ctx, row);
update_profile_wallet_balance_for_expiring_points(
ctx,
user_id,
expired_points,
granted_points,
RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset,
&ledger_id,
now,
membership_cycle_metadata("reset", expired_points, granted_points, Some(next_reset_at)),
);
}
fn build_profile_membership_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeProfileMembershipSnapshot {
refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp);
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let membership = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string());
match membership {
Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => {
RuntimeProfileMembershipSnapshot {
user_id: row.user_id,
status: row.status,
tier: row.tier,
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
cycle_started_at_micros: row
.cycle_started_at
.map(|value| value.to_micros_since_unix_epoch()),
cycle_resets_at_micros: row
.cycle_resets_at
.map(|value| value.to_micros_since_unix_epoch()),
cycle_granted_points: row.cycle_granted_points,
cycle_remaining_points: row.cycle_remaining_points,
cycle_period_days: normalized_membership_period_days(row.cycle_period_days),
}
}
Some(row) => RuntimeProfileMembershipSnapshot {
user_id: row.user_id,
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
cycle_started_at_micros: row
.cycle_started_at
.map(|value| value.to_micros_since_unix_epoch()),
cycle_resets_at_micros: row
.cycle_resets_at
.map(|value| value.to_micros_since_unix_epoch()),
cycle_granted_points: row.cycle_granted_points,
cycle_remaining_points: 0,
cycle_period_days: normalized_membership_period_days(row.cycle_period_days),
},
None => RuntimeProfileMembershipSnapshot {
user_id: user_id.to_string(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at_micros: None,
expires_at_micros: None,
updated_at_micros: None,
cycle_started_at_micros: None,
cycle_resets_at_micros: None,
cycle_granted_points: 0,
cycle_remaining_points: 0,
cycle_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS,
},
}
}
fn apply_profile_membership_purchase(
ctx: &ReducerContext,
user_id: &str,
product: &RuntimeProfileRechargeProductSnapshot,
purchased_at: Timestamp,
) -> Result<ProfileMembershipPurchaseResult, String> {
refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at);
let tier = product.tier;
let duration_days = product.duration_days;
let period_days = membership_product_period_days(product);
let period_points = membership_product_period_points(product);
let current = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string());
let purchased_at_micros = purchased_at.to_micros_since_unix_epoch();
let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS);
let (next_row, period_points_delta) = match current {
Some(row) if active_membership_row_at(&row, purchased_at) => {
let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?;
if purchase_mode == ActiveMembershipPurchaseMode::Renew {
(
apply_active_membership_renew_row(row, tier, duration_days, purchased_at),
0,
)
} else {
apply_active_membership_upgrade_row(
row,
tier,
period_days,
period_points,
purchased_at,
)
}
}
Some(row) => {
let expires_at = Timestamp::from_micros_since_unix_epoch(
purchased_at_micros.saturating_add(duration_micros),
);
(
ProfileMembership {
user_id: row.user_id,
status: RuntimeProfileMembershipStatus::Active,
tier,
started_at: purchased_at,
expires_at,
updated_at: purchased_at,
cycle_started_at: Some(purchased_at),
cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)),
cycle_granted_points: period_points,
cycle_remaining_points: period_points,
cycle_period_days: period_days,
},
period_points,
)
}
None => {
let expires_at = Timestamp::from_micros_since_unix_epoch(
purchased_at_micros.saturating_add(duration_micros),
);
(
ProfileMembership {
user_id: user_id.to_string(),
status: RuntimeProfileMembershipStatus::Active,
tier,
started_at: purchased_at,
expires_at,
updated_at: purchased_at,
cycle_started_at: Some(purchased_at),
cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)),
cycle_granted_points: period_points,
cycle_remaining_points: period_points,
cycle_period_days: period_days,
},
period_points,
)
}
};
let expires_at = next_row.expires_at;
let cycle_resets_at = next_row.cycle_resets_at.clone();
upsert_profile_membership_row(ctx, next_row);
if period_points_delta > 0 {
let ledger_id = format!(
"membership-period-grant:{user_id}:{}:{}",
purchased_at_micros, product.product_id
);
update_profile_wallet_balance_for_expiring_points(
ctx,
user_id,
0,
period_points_delta,
RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant,
&ledger_id,
purchased_at,
membership_cycle_metadata("purchase", 0, period_points_delta, cycle_resets_at),
);
}
Ok(ProfileMembershipPurchaseResult {
expires_at,
period_points_delta,
})
}
fn apply_profile_wallet_delta(
ctx: &ReducerContext,
user_id: &str,
amount_delta: u64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
) -> Result<u64, String> {
let amount_delta = convert_runtime_profile_wallet_unsigned_delta(amount_delta)
.map_err(|error| error.to_string())?;
apply_profile_wallet_signed_delta(
ctx,
user_id,
amount_delta,
source_type,
ledger_id,
created_at,
false,
PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON,
)
}
pub(crate) fn grant_profile_wallet_points(
ctx: &ReducerContext,
user_id: &str,
amount_delta: u64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
) -> Result<u64, String> {
apply_profile_wallet_delta(
ctx,
user_id,
amount_delta,
source_type,
ledger_id,
created_at,
)
}
pub(crate) fn grant_profile_wallet_points_with_metadata(
ctx: &ReducerContext,
user_id: &str,
amount_delta: u64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
metadata_json: &str,
) -> Result<u64, String> {
apply_profile_wallet_signed_delta(
ctx,
user_id,
convert_runtime_profile_wallet_unsigned_delta(amount_delta)
.map_err(|error| error.to_string())?,
source_type,
ledger_id,
created_at,
true,
metadata_json,
)
}
fn apply_profile_wallet_adjustment(
ctx: &ReducerContext,
input: RuntimeProfileWalletAdjustmentInput,
source_type: RuntimeProfileWalletLedgerSourceType,
consume: bool,
) -> Result<RuntimeProfileDashboardSnapshot, String> {
let validated_input = build_runtime_profile_wallet_adjustment_input_with_metadata(
input.user_id,
input.amount,
input.ledger_id,
input.created_at_micros,
input.metadata_json,
)
.map_err(|error| error.to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount)
.map_err(|error| error.to_string())?;
if consume {
if !validated_input
.ledger_id
.starts_with(ASSET_OPERATION_CONSUME_LEDGER_PREFIX)
{
return Err("资产操作扣费流水缺少合法前缀".to_string());
}
require_asset_operation_consume_unsettled(
ctx,
&validated_input.user_id,
unsigned_delta,
&validated_input.ledger_id,
)?;
} else {
if !validated_input
.ledger_id
.starts_with(ASSET_OPERATION_REFUND_LEDGER_PREFIX)
{
return Err("资产操作退款流水缺少合法前缀".to_string());
}
match resolve_asset_operation_refund_disposition_from_ledger(
ctx,
&validated_input.user_id,
unsigned_delta,
&validated_input.ledger_id,
)? {
AssetOperationRefundDisposition::Apply => {}
AssetOperationRefundDisposition::RecordIntent => {
record_asset_operation_wallet_settlement(
ctx,
&validated_input.user_id,
unsigned_delta,
&validated_input.ledger_id,
created_at,
)?;
return get_profile_dashboard_snapshot(
ctx,
RuntimeProfileDashboardGetInput {
user_id: validated_input.user_id,
},
);
}
AssetOperationRefundDisposition::Noop => {
record_asset_operation_wallet_settlement(
ctx,
&validated_input.user_id,
unsigned_delta,
&validated_input.ledger_id,
created_at,
)?;
return get_profile_dashboard_snapshot(
ctx,
RuntimeProfileDashboardGetInput {
user_id: validated_input.user_id,
},
);
}
}
}
let amount_delta = if consume {
-unsigned_delta
} else {
unsigned_delta
};
apply_profile_wallet_signed_delta(
ctx,
&validated_input.user_id,
amount_delta,
source_type,
&validated_input.ledger_id,
created_at,
true,
&validated_input.metadata_json,
)?;
if !consume {
record_asset_operation_wallet_settlement(
ctx,
&validated_input.user_id,
unsigned_delta,
&validated_input.ledger_id,
created_at,
)?;
}
get_profile_dashboard_snapshot(
ctx,
RuntimeProfileDashboardGetInput {
user_id: validated_input.user_id,
},
)
}
pub(crate) fn settle_external_generation_attempt_refund(
ctx: &ReducerContext,
job_id: &str,
attempt: u32,
user_id: &str,
amount: u64,
settled_at: Timestamp,
) -> Result<Option<String>, String> {
if amount == 0 {
return Ok(None);
}
let refund_ledger_id = format!(
"{ASSET_OPERATION_REFUND_LEDGER_PREFIX}external_generation_job:{}:attempt:{attempt}",
job_id.trim()
);
apply_profile_wallet_adjustment(
ctx,
RuntimeProfileWalletAdjustmentInput {
user_id: user_id.to_string(),
amount,
ledger_id: refund_ledger_id.clone(),
created_at_micros: settled_at.to_micros_since_unix_epoch(),
metadata_json: serde_json::json!({
"externalGenerationJobId": job_id.trim(),
"claimAttempt": attempt,
"settlementReason": "final_attempt_lease_expired",
})
.to_string(),
},
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
false,
)?;
Ok(Some(refund_ledger_id))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AssetOperationRefundDisposition {
Apply,
RecordIntent,
Noop,
}
fn resolve_asset_operation_refund_disposition_from_ledger(
ctx: &ReducerContext,
user_id: &str,
amount: i64,
refund_ledger_id: &str,
) -> Result<AssetOperationRefundDisposition, String> {
let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?;
let existing_refund = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&refund_ledger_id.to_string());
let consume_ledger = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&consume_ledger_id);
let settlement = ctx
.db
.asset_operation_wallet_settlement()
.consume_ledger_id()
.find(&consume_ledger_id);
resolve_asset_operation_refund_disposition(
user_id,
amount,
refund_ledger_id,
settlement.as_ref(),
existing_refund.as_ref(),
consume_ledger.as_ref(),
)
}
fn resolve_asset_operation_refund_disposition(
user_id: &str,
amount: i64,
refund_ledger_id: &str,
settlement: Option<&AssetOperationWalletSettlement>,
existing_refund: Option<&ProfileWalletLedger>,
consume_ledger: Option<&ProfileWalletLedger>,
) -> Result<AssetOperationRefundDisposition, String> {
if amount <= 0 {
return Err("资产操作退款金额必须大于 0".to_string());
}
let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?;
if let Some(settlement) = settlement {
validate_asset_operation_wallet_settlement(
settlement,
&consume_ledger_id,
refund_ledger_id,
user_id,
amount,
)?;
if let Some(existing_refund) = existing_refund {
validate_asset_operation_wallet_ledger(
existing_refund,
refund_ledger_id,
user_id,
amount,
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
"退款",
)?;
}
return Ok(AssetOperationRefundDisposition::Noop);
}
if let Some(existing_refund) = existing_refund {
validate_asset_operation_wallet_ledger(
existing_refund,
refund_ledger_id,
user_id,
amount,
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
"退款",
)?;
return Ok(AssetOperationRefundDisposition::Noop);
}
let Some(consume_ledger) = consume_ledger else {
return Ok(AssetOperationRefundDisposition::RecordIntent);
};
validate_asset_operation_wallet_ledger(
consume_ledger,
&consume_ledger_id,
user_id,
-amount,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
"扣费",
)?;
Ok(AssetOperationRefundDisposition::Apply)
}
fn require_asset_operation_consume_unsettled(
ctx: &ReducerContext,
user_id: &str,
amount: i64,
consume_ledger_id: &str,
) -> Result<(), String> {
if amount <= 0 {
return Err("资产操作扣费金额必须大于 0".to_string());
}
let refund_ledger_id = asset_operation_refund_ledger_id(consume_ledger_id)?;
let Some(settlement) = ctx
.db
.asset_operation_wallet_settlement()
.consume_ledger_id()
.find(&consume_ledger_id.to_string())
else {
return Ok(());
};
validate_asset_operation_wallet_settlement(
&settlement,
consume_ledger_id,
&refund_ledger_id,
user_id,
amount,
)?;
Err("资产操作扣费已结算,拒绝迟到或重复执行".to_string())
}
fn record_asset_operation_wallet_settlement(
ctx: &ReducerContext,
user_id: &str,
amount: i64,
refund_ledger_id: &str,
settled_at: Timestamp,
) -> Result<(), String> {
if amount <= 0 {
return Err("资产操作退款金额必须大于 0".to_string());
}
let amount = u64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?;
let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?;
if let Some(existing) = ctx
.db
.asset_operation_wallet_settlement()
.consume_ledger_id()
.find(&consume_ledger_id)
{
return validate_asset_operation_wallet_settlement(
&existing,
&consume_ledger_id,
refund_ledger_id,
user_id,
i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?,
);
}
ctx.db
.asset_operation_wallet_settlement()
.insert(AssetOperationWalletSettlement {
consume_ledger_id,
refund_ledger_id: refund_ledger_id.to_string(),
user_id: user_id.to_string(),
amount,
settled_at,
});
Ok(())
}
fn validate_asset_operation_wallet_settlement(
settlement: &AssetOperationWalletSettlement,
expected_consume_ledger_id: &str,
expected_refund_ledger_id: &str,
expected_user_id: &str,
expected_amount: i64,
) -> Result<(), String> {
let expected_amount =
u64::try_from(expected_amount).map_err(|_| "资产操作结算金额必须大于 0".to_string())?;
if expected_amount == 0 {
return Err("资产操作结算金额必须大于 0".to_string());
}
if settlement.consume_ledger_id != expected_consume_ledger_id {
return Err("资产操作结算扣费流水 ID 不匹配".to_string());
}
if settlement.refund_ledger_id != expected_refund_ledger_id {
return Err("资产操作结算退款流水 ID 不匹配".to_string());
}
if settlement.user_id != expected_user_id {
return Err("资产操作结算用户不匹配".to_string());
}
if settlement.amount != expected_amount {
return Err("资产操作结算金额不匹配".to_string());
}
Ok(())
}
fn asset_operation_consume_ledger_id(refund_ledger_id: &str) -> Result<String, String> {
refund_ledger_id
.strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX)
.map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}"))
.ok_or_else(|| format!("资产操作退款流水 {refund_ledger_id} 缺少合法前缀"))
}
fn asset_operation_refund_ledger_id(consume_ledger_id: &str) -> Result<String, String> {
consume_ledger_id
.strip_prefix(ASSET_OPERATION_CONSUME_LEDGER_PREFIX)
.map(|suffix| format!("{ASSET_OPERATION_REFUND_LEDGER_PREFIX}{suffix}"))
.ok_or_else(|| format!("资产操作扣费流水 {consume_ledger_id} 缺少合法前缀"))
}
fn validate_asset_operation_wallet_ledger(
ledger: &ProfileWalletLedger,
expected_ledger_id: &str,
expected_user_id: &str,
expected_amount_delta: i64,
expected_source_type: RuntimeProfileWalletLedgerSourceType,
label: &str,
) -> Result<(), String> {
if ledger.wallet_ledger_id != expected_ledger_id {
return Err(format!("资产操作{label}流水 ID 不匹配"));
}
if ledger.user_id != expected_user_id {
return Err(format!("资产操作{label}流水用户不匹配"));
}
if ledger.amount_delta != expected_amount_delta {
return Err(format!("资产操作{label}流水金额不匹配"));
}
if ledger.source_type != expected_source_type {
return Err(format!("资产操作{label}流水来源不匹配"));
}
Ok(())
}
fn consume_profile_membership_cycle_points(
ctx: &ReducerContext,
user_id: &str,
amount: u64,
consumed_at: Timestamp,
) -> MembershipCyclePointMutation {
if amount == 0 {
return MembershipCyclePointMutation::none();
}
let Some(mut row) = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
else {
return MembershipCyclePointMutation::none();
};
if !active_membership_row_at(&row, consumed_at) || row.cycle_remaining_points == 0 {
return MembershipCyclePointMutation::none();
}
let consumed = row.cycle_remaining_points.min(amount);
let cycle_resets_at_micros = row
.cycle_resets_at
.map(|value| value.to_micros_since_unix_epoch());
row.cycle_remaining_points -= consumed;
row.updated_at = consumed_at;
upsert_profile_membership_row(ctx, row);
MembershipCyclePointMutation {
points: consumed,
cycle_resets_at_micros,
}
}
fn consume_profile_daily_free_points(
ctx: &ReducerContext,
user_id: &str,
amount: u64,
consumed_at: Timestamp,
) -> DailyFreePointMutation {
if amount == 0 {
return DailyFreePointMutation::none();
}
let day_key = runtime_profile_beijing_day_key(consumed_at.to_micros_since_unix_epoch());
let Some(mut row) = ctx
.db
.profile_daily_free_points()
.user_id()
.find(&user_id.to_string())
else {
return DailyFreePointMutation::none();
};
if row.day_key != day_key || row.remaining_points == 0 {
return DailyFreePointMutation::none();
}
let consumed = row.remaining_points.min(amount);
row.remaining_points -= consumed;
row.updated_at = consumed_at;
ctx.db.profile_daily_free_points().user_id().update(row);
DailyFreePointMutation {
points: consumed,
day_key: Some(day_key),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DailyFreeRefundRestorePlan {
restored_points: u64,
target_day_key: i64,
granted_points_delta: u64,
}
fn resolve_daily_free_refund_restore_plan(
consumed_day_key: i64,
current_day_key: i64,
row_day_key: i64,
granted_points: u64,
remaining_points: u64,
candidate_points: u64,
refund_amount: u64,
) -> Option<DailyFreeRefundRestorePlan> {
if candidate_points == 0 || refund_amount == 0 || row_day_key != current_day_key {
return None;
}
let candidate_points = candidate_points.min(refund_amount);
if consumed_day_key == current_day_key {
let restored_points = candidate_points.min(granted_points.saturating_sub(remaining_points));
return (restored_points > 0).then_some(DailyFreeRefundRestorePlan {
restored_points,
target_day_key: current_day_key,
granted_points_delta: 0,
});
}
if consumed_day_key > current_day_key {
return None;
}
Some(DailyFreeRefundRestorePlan {
restored_points: candidate_points,
target_day_key: current_day_key,
granted_points_delta: candidate_points,
})
}
fn restore_profile_daily_free_points_for_refund(
ctx: &ReducerContext,
user_id: &str,
amount: u64,
refund_ledger_id: &str,
refunded_at: Timestamp,
) -> DailyFreePointMutation {
if amount == 0 {
return DailyFreePointMutation::none();
}
let Some(consume_ledger_id) = refund_ledger_id
.strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX)
.map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}"))
else {
return DailyFreePointMutation::none();
};
let Some(consume_ledger) = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&consume_ledger_id)
else {
return DailyFreePointMutation::none();
};
let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else {
return DailyFreePointMutation::none();
};
let restore_candidate =
daily_free_refund_restore_candidate_from_consume_metadata(metadata_json);
let Some(expected_day_key) = restore_candidate.day_key else {
return DailyFreePointMutation::none();
};
let current_day_key = runtime_profile_beijing_day_key(refunded_at.to_micros_since_unix_epoch());
let Some(mut row) = ctx
.db
.profile_daily_free_points()
.user_id()
.find(&user_id.to_string())
else {
return DailyFreePointMutation::none();
};
let Some(plan) = resolve_daily_free_refund_restore_plan(
expected_day_key,
current_day_key,
row.day_key,
row.granted_points,
row.remaining_points,
restore_candidate.points,
amount,
) else {
return DailyFreePointMutation::none();
};
row.granted_points = row.granted_points.saturating_add(plan.granted_points_delta);
row.remaining_points = row.remaining_points.saturating_add(plan.restored_points);
row.updated_at = refunded_at;
ctx.db.profile_daily_free_points().user_id().update(row);
DailyFreePointMutation {
points: plan.restored_points,
day_key: Some(plan.target_day_key),
}
}
fn restore_profile_membership_cycle_points_for_refund(
ctx: &ReducerContext,
user_id: &str,
amount: u64,
refund_ledger_id: &str,
refunded_at: Timestamp,
) -> MembershipCyclePointMutation {
if amount == 0 {
return MembershipCyclePointMutation::none();
}
let Some(consume_ledger_id) = refund_ledger_id
.strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX)
.map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}"))
else {
return MembershipCyclePointMutation::none();
};
let Some(consume_ledger) = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&consume_ledger_id)
else {
return MembershipCyclePointMutation::none();
};
let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else {
return MembershipCyclePointMutation::none();
};
let restore_candidate =
membership_refund_restore_candidate_from_consume_metadata(metadata_json);
if restore_candidate.points == 0 {
return MembershipCyclePointMutation::none();
}
let Some(expected_cycle_resets_at_micros) = restore_candidate.cycle_resets_at_micros else {
return MembershipCyclePointMutation::none();
};
let Some(mut row) = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string())
else {
return MembershipCyclePointMutation::none();
};
if !active_membership_row_at(&row, refunded_at) {
return MembershipCyclePointMutation::none();
}
if row
.cycle_resets_at
.map(|value| value.to_micros_since_unix_epoch())
!= Some(expected_cycle_resets_at_micros)
{
return MembershipCyclePointMutation::none();
}
let available_restore_room = row
.cycle_granted_points
.saturating_sub(row.cycle_remaining_points);
let restored = restore_candidate
.points
.min(amount)
.min(available_restore_room);
if restored == 0 {
return MembershipCyclePointMutation::none();
}
row.cycle_remaining_points = row.cycle_remaining_points.saturating_add(restored);
row.updated_at = refunded_at;
upsert_profile_membership_row(ctx, row);
MembershipCyclePointMutation {
points: restored,
cycle_resets_at_micros: Some(expected_cycle_resets_at_micros),
}
}
fn membership_refund_restore_candidate_from_consume_metadata(
metadata_json: &str,
) -> MembershipCyclePointMutation {
let Some(metadata) = serde_json::from_str::<JsonValue>(metadata_json)
.ok()
.filter(JsonValue::is_object)
else {
return MembershipCyclePointMutation::none();
};
let membership_period_delta = metadata
.get("membershipPeriodPointsDelta")
.and_then(JsonValue::as_i64)
.unwrap_or(0);
if membership_period_delta >= 0 {
return MembershipCyclePointMutation::none();
}
MembershipCyclePointMutation {
points: membership_period_delta.unsigned_abs(),
cycle_resets_at_micros: metadata
.get("cycleResetsAtMicros")
.and_then(JsonValue::as_i64),
}
}
fn daily_free_refund_restore_candidate_from_consume_metadata(
metadata_json: &str,
) -> DailyFreePointMutation {
let Some(metadata) = serde_json::from_str::<JsonValue>(metadata_json)
.ok()
.filter(JsonValue::is_object)
else {
return DailyFreePointMutation::none();
};
let daily_free_delta = metadata
.get("dailyFreePointsDelta")
.and_then(JsonValue::as_i64)
.unwrap_or(0);
if daily_free_delta >= 0 {
return DailyFreePointMutation::none();
}
DailyFreePointMutation {
points: daily_free_delta.unsigned_abs(),
day_key: metadata.get("dailyFreeDayKey").and_then(JsonValue::as_i64),
}
}
fn metadata_with_profile_wallet_delta_split(
metadata_json: &str,
daily_free_delta: i64,
membership_period_delta: i64,
permanent_delta: i64,
daily_free_day_key: Option<i64>,
cycle_resets_at_micros: Option<i64>,
) -> String {
let mut metadata = serde_json::from_str::<JsonValue>(metadata_json)
.ok()
.filter(JsonValue::is_object)
.unwrap_or_else(|| json!({}));
if let Some(object) = metadata.as_object_mut() {
object.insert("dailyFreePointsDelta".to_string(), json!(daily_free_delta));
object.insert(
"membershipPeriodPointsDelta".to_string(),
json!(membership_period_delta),
);
object.insert("permanentPointsDelta".to_string(), json!(permanent_delta));
if let Some(daily_free_day_key) = daily_free_day_key {
object.insert("dailyFreeDayKey".to_string(), json!(daily_free_day_key));
}
if let Some(cycle_resets_at_micros) = cycle_resets_at_micros {
object.insert(
"cycleResetsAtMicros".to_string(),
json!(cycle_resets_at_micros),
);
}
}
serde_json::to_string(&metadata).unwrap_or_else(|_| metadata_json.to_string())
}
fn metadata_with_profile_wallet_consumption_split(
metadata_json: &str,
total_consumed: u64,
daily_free_consumed: DailyFreePointMutation,
membership_period_consumed: MembershipCyclePointMutation,
) -> String {
if total_consumed == 0 {
return metadata_json.to_string();
}
let permanent_consumed = total_consumed
.saturating_sub(daily_free_consumed.points)
.saturating_sub(membership_period_consumed.points);
metadata_with_profile_wallet_delta_split(
metadata_json,
-(daily_free_consumed.points as i64),
-(membership_period_consumed.points as i64),
-(permanent_consumed as i64),
daily_free_consumed.day_key,
membership_period_consumed.cycle_resets_at_micros,
)
}
fn metadata_with_profile_wallet_refund_split(
metadata_json: &str,
total_refunded: u64,
daily_free_refunded: DailyFreePointMutation,
membership_period_refunded: MembershipCyclePointMutation,
) -> String {
if total_refunded == 0 {
return metadata_json.to_string();
}
let permanent_refunded = total_refunded
.saturating_sub(daily_free_refunded.points)
.saturating_sub(membership_period_refunded.points);
metadata_with_profile_wallet_delta_split(
metadata_json,
daily_free_refunded.points as i64,
membership_period_refunded.points as i64,
permanent_refunded as i64,
daily_free_refunded.day_key,
membership_period_refunded.cycle_resets_at_micros,
)
}
fn apply_profile_wallet_signed_delta(
ctx: &ReducerContext,
user_id: &str,
amount_delta: i64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
idempotent: bool,
metadata_json: &str,
) -> Result<u64, String> {
let settled_at = ctx.timestamp;
let ledger_recorded_at = profile_wallet_ledger_recorded_at(created_at, settled_at);
refresh_profile_wallet_expiring_points(ctx, user_id, settled_at);
if idempotent {
if let Some(existing) = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id.to_string())
{
validate_idempotent_profile_wallet_ledger(
&existing,
user_id,
amount_delta,
source_type,
)?;
return Ok(profile_wallet_balance(ctx, user_id));
}
}
if amount_delta < 0 {
validate_runtime_profile_wallet_debit_restrictions(
amount_delta,
source_type,
has_profile_wallet_manual_restriction(ctx, user_id),
has_profile_recharge_refund_wallet_freeze(ctx, user_id),
)?;
}
let current = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string());
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
if amount_delta < 0
&& source_type != RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery
{
validate_runtime_profile_wallet_debit_availability(
previous_balance,
active_profile_recharge_refund_hold_points(ctx, user_id),
amount_delta.unsigned_abs(),
)?;
}
let next_balance = calculate_runtime_profile_wallet_balance(previous_balance, amount_delta)
.map_err(|error| error.to_string())?;
let created_state_at = current
.as_ref()
.map(|row| row.created_at)
.unwrap_or(ledger_recorded_at);
let daily_free_consumed = if amount_delta < 0
&& !matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset
| RuntimeProfileWalletLedgerSourceType::DailyFreeReset
) {
consume_profile_daily_free_points(ctx, user_id, amount_delta.unsigned_abs(), settled_at)
} else {
DailyFreePointMutation::none()
};
let membership_period_consumed = if amount_delta < 0
&& !matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset
| RuntimeProfileWalletLedgerSourceType::DailyFreeReset
) {
consume_profile_membership_cycle_points(
ctx,
user_id,
amount_delta
.unsigned_abs()
.saturating_sub(daily_free_consumed.points),
settled_at,
)
} else {
MembershipCyclePointMutation::none()
};
let daily_free_refunded = if amount_delta > 0
&& source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
{
restore_profile_daily_free_points_for_refund(
ctx,
user_id,
amount_delta as u64,
ledger_id,
settled_at,
)
} else {
DailyFreePointMutation::none()
};
let membership_period_refunded = if amount_delta > 0
&& source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
{
restore_profile_membership_cycle_points_for_refund(
ctx,
user_id,
(amount_delta as u64).saturating_sub(daily_free_refunded.points),
ledger_id,
settled_at,
)
} else {
MembershipCyclePointMutation::none()
};
let ledger_metadata_json = if amount_delta < 0 {
metadata_with_profile_wallet_consumption_split(
metadata_json,
amount_delta.unsigned_abs(),
daily_free_consumed,
membership_period_consumed,
)
} else if amount_delta > 0 {
metadata_with_profile_wallet_refund_split(
metadata_json,
amount_delta as u64,
daily_free_refunded,
membership_period_refunded,
)
} else {
metadata_json.to_string()
};
if let Some(existing) = current {
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: next_balance,
total_play_time_ms: existing.total_play_time_ms,
created_at: existing.created_at,
updated_at: ledger_recorded_at,
});
} else {
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: next_balance,
total_play_time_ms: 0,
created_at: created_state_at,
updated_at: ledger_recorded_at,
});
}
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: ledger_id.to_string(),
user_id: user_id.to_string(),
amount_delta,
balance_after: next_balance,
source_type,
created_at: ledger_recorded_at,
metadata_json: Some(ledger_metadata_json),
});
if amount_delta > 0 {
repay_profile_recharge_refund_debt_from_permanent_points(ctx, user_id);
}
Ok(profile_wallet_balance(ctx, user_id))
}
fn profile_wallet_ledger_recorded_at(
_business_event_at: Timestamp,
settled_at: Timestamp,
) -> Timestamp {
settled_at
}
fn validate_idempotent_profile_wallet_ledger(
existing: &ProfileWalletLedger,
expected_user_id: &str,
expected_amount_delta: i64,
expected_source_type: RuntimeProfileWalletLedgerSourceType,
) -> Result<(), String> {
if existing.user_id != expected_user_id {
return Err("钱包幂等流水用户不匹配".to_string());
}
if existing.amount_delta != expected_amount_delta {
return Err("钱包幂等流水金额不匹配".to_string());
}
if existing.source_type != expected_source_type {
return Err("钱包幂等流水来源不匹配".to_string());
}
Ok(())
}
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db
.profile_recharge_order()
.by_profile_recharge_order_user_id()
.filter(user_id)
.any(|row| {
row.user_id == user_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& profile_recharge_order_counts_as_paid_purchase(&row)
})
}
fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool {
ctx.db
.profile_recharge_order()
.by_profile_recharge_order_user_id()
.filter(user_id)
.any(|row| {
row.user_id == user_id
&& row.product_id == product_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& profile_recharge_order_counts_as_paid_purchase(&row)
})
}
fn profile_recharge_order_counts_as_paid_purchase(order: &ProfileRechargeOrder) -> bool {
order.paid_at.is_some()
}
fn has_profile_recharge_refund_wallet_freeze(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db
.profile_recharge_order_refund_settlement()
.by_profile_recharge_order_refund_settlement_user_id()
.filter(user_id)
.any(|row| profile_recharge_refund_settlement_freezes_wallet(&row))
}
fn profile_recharge_refund_settlement_freezes_wallet(
settlement: &ProfileRechargeOrderRefundSettlement,
) -> bool {
settlement.wallet_frozen || settlement.unrecovered_points > 0
}
fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db
.profile_wallet_ledger()
.by_profile_wallet_ledger_user_id()
.filter(user_id)
.any(|row| {
row.user_id == user_id
&& profile_wallet_ledger_source_blocks_legacy_snapshot_sync(row.source_type)
})
}
fn profile_wallet_ledger_source_blocks_legacy_snapshot_sync(
source_type: RuntimeProfileWalletLedgerSourceType,
) -> bool {
!matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::SnapshotSync
| RuntimeProfileWalletLedgerSourceType::DailyFreeGrant
| RuntimeProfileWalletLedgerSourceType::DailyFreeReset
)
}
fn merge_legacy_wallet_balance_with_daily_free_points(
legacy_balance: u64,
daily_free_remaining_points: u64,
) -> u64 {
legacy_balance.saturating_add(daily_free_remaining_points)
}
fn latest_profile_recharge_order(
ctx: &ReducerContext,
user_id: &str,
) -> Option<ProfileRechargeOrder> {
let mut orders = ctx
.db
.profile_recharge_order()
.by_profile_recharge_order_user_id()
.filter(user_id)
.collect::<Vec<_>>();
orders.sort_by(|left, right| {
right
.created_at
.to_micros_since_unix_epoch()
.cmp(&left.created_at.to_micros_since_unix_epoch())
.then_with(|| left.order_id.cmp(&right.order_id))
});
orders.into_iter().next()
}
fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 {
ctx.db
.profile_redeem_code_usage()
.by_profile_redeem_code_usage_code_user_id()
.filter((code, user_id))
.count() as u32
}
fn resolve_profile_redeem_code_allowed_user_ids(
ctx: &ReducerContext,
input: &RuntimeProfileRedeemCodeAdminUpsertInput,
) -> Result<Vec<String>, String> {
if input.mode != RuntimeProfileRedeemCodeMode::Private {
return Ok(Vec::new());
}
let mut allowed_user_ids = input.allowed_user_ids.clone();
for public_user_code in &input.allowed_public_user_codes {
if let Some(account) = ctx
.db
.user_account()
.by_user_account_public_code()
.filter(public_user_code)
.next()
{
allowed_user_ids.push(account.user_id);
}
}
allowed_user_ids.sort();
allowed_user_ids.dedup();
if allowed_user_ids.is_empty() {
return Err("私有兑换码必须指定可兑换用户".to_string());
}
Ok(allowed_user_ids)
}
fn build_profile_redeem_code_snapshot_from_row(
row: &ProfileRedeemCode,
) -> RuntimeProfileRedeemCodeSnapshot {
RuntimeProfileRedeemCodeSnapshot {
code: row.code.clone(),
mode: row.mode,
reward_points: row.reward_points,
max_uses: row.max_uses,
global_used_count: row.global_used_count,
enabled: row.enabled,
allowed_user_ids: row.allowed_user_ids.clone(),
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
starts_at_micros: row
.starts_at
.map(|value| value.to_micros_since_unix_epoch()),
expires_at_micros: row
.expires_at
.map(|value| value.to_micros_since_unix_epoch()),
}
}
fn build_profile_invite_code_snapshot_from_row(
row: &ProfileInviteCode,
) -> RuntimeProfileInviteCodeSnapshot {
RuntimeProfileInviteCodeSnapshot {
user_id: row.user_id.clone(),
invite_code: row.invite_code.clone(),
metadata_json: row.metadata_json.clone(),
starts_at_micros: row
.starts_at
.map(|value| value.to_micros_since_unix_epoch()),
expires_at_micros: row
.expires_at
.map(|value| value.to_micros_since_unix_epoch()),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_code_operation_snapshot_from_row(
row: &ProfileCodeOperation,
) -> RuntimeProfileCodeOperationSnapshot {
RuntimeProfileCodeOperationSnapshot {
operation_id: row.operation_id.clone(),
code_kind: row.code_kind.clone(),
code: row.code.clone(),
action: row.action.clone(),
operator_user_id: row.operator_user_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot {
RuntimeProfileWalletLedgerEntrySnapshot {
wallet_ledger_id: row.wallet_ledger_id.clone(),
user_id: row.user_id.clone(),
amount_delta: row.amount_delta,
balance_after: row.balance_after,
source_type: row.source_type,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
metadata_json: row
.metadata_json
.clone()
.unwrap_or_else(|| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()),
}
}
fn build_profile_wallet_config_snapshot_from_row(
row: &ProfileWalletConfig,
) -> RuntimeProfileWalletConfigSnapshot {
RuntimeProfileWalletConfigSnapshot {
config_id: row.config_id.clone(),
initial_mud_points: row.initial_mud_points,
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by: row.updated_by.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_task_config_snapshot_from_row(
row: &ProfileTaskConfig,
) -> RuntimeProfileTaskConfigSnapshot {
RuntimeProfileTaskConfigSnapshot {
task_id: row.task_id.clone(),
title: row.title.clone(),
description: row.description.clone(),
event_key: row.event_key.clone(),
cycle: row.cycle,
scope_kind: row.scope_kind,
threshold: row.threshold,
reward_points: row.reward_points,
enabled: row.enabled,
sort_order: row.sort_order,
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by: row.updated_by.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_product_config_snapshot_from_row(
row: &ProfileRechargeProductConfig,
) -> RuntimeProfileRechargeProductConfigSnapshot {
RuntimeProfileRechargeProductConfigSnapshot {
product_id: row.product_id.clone(),
title: row.title.clone(),
price_cents: row.price_cents,
kind: row.kind,
points_amount: row.points_amount,
bonus_points: row.bonus_points,
duration_days: row.duration_days,
badge_label: row.badge_label.clone(),
description: row.description.clone(),
tier: row.tier,
membership_period_points: row.membership_period_points,
membership_period_days: row.membership_period_days,
membership_queue_limit: row.membership_queue_limit,
membership_discount_bps: row.membership_discount_bps,
enabled: row.enabled,
sort_order: row.sort_order,
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by: row.updated_by.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_product_snapshot_from_config_row(
row: &ProfileRechargeProductConfig,
) -> RuntimeProfileRechargeProductSnapshot {
RuntimeProfileRechargeProductSnapshot {
product_id: row.product_id.clone(),
title: row.title.clone(),
price_cents: row.price_cents,
kind: row.kind,
points_amount: row.points_amount,
bonus_points: row.bonus_points,
duration_days: row.duration_days,
badge_label: row.badge_label.clone(),
description: row.description.clone(),
tier: row.tier,
membership_period_points: row.membership_period_points,
membership_period_days: row.membership_period_days,
membership_queue_limit: row.membership_queue_limit,
membership_discount_bps: row.membership_discount_bps,
}
}
fn build_profile_recharge_order_snapshot_from_row(
row: &ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderSnapshot {
RuntimeProfileRechargeOrderSnapshot {
order_id: row.order_id.clone(),
user_id: row.user_id.clone(),
product_id: row.product_id.clone(),
product_title: row.product_title.clone(),
kind: row.kind,
amount_cents: row.amount_cents,
status: row.status,
payment_channel: row.payment_channel.clone(),
paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()),
provider_transaction_id: row.provider_transaction_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
points_delta: row.points_delta,
membership_expires_at_micros: row
.membership_expires_at
.map(|value| value.to_micros_since_unix_epoch()),
expired_at_micros: row
.expired_at
.map(|value| value.to_micros_since_unix_epoch()),
expiration_checked_at_micros: row
.expiration_checked_at
.map(|value| value.to_micros_since_unix_epoch()),
expiration_provider_state: row.expiration_provider_state.clone(),
expiration_last_error: row.expiration_last_error.clone(),
}
}
fn build_profile_recharge_refund_snapshot_from_row(
row: &ProfileRechargeRefund,
) -> RuntimeProfileRechargeRefundSnapshot {
RuntimeProfileRechargeRefundSnapshot {
out_refund_no: row.out_refund_no.clone(),
provider_refund_id: row.provider_refund_id.clone(),
order_id: row.order_id.clone(),
provider_transaction_id: row.provider_transaction_id.clone(),
user_id: row.user_id.clone(),
provider_status: row.provider_status,
total_cents: row.total_cents,
refund_cents: row.refund_cents,
payer_total_cents: row.payer_total_cents,
payer_refund_cents: row.payer_refund_cents,
success_at_micros: row
.success_at
.map(|value| value.to_micros_since_unix_epoch()),
first_observed_at_micros: row.first_observed_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
last_observation_source: row.last_observation_source,
last_observation_id: row.last_observation_id.clone(),
order_settled_at_micros: row
.order_settled_at
.map(|value| value.to_micros_since_unix_epoch()),
target_recovery_points: row.target_recovery_points,
recovered_points: row.recovered_points,
unrecovered_points: row.unrecovered_points,
recovery_status: row.recovery_status,
last_recovery_ledger_id: row.last_recovery_ledger_id.clone(),
last_error_code: row.last_error_code.clone(),
manual_review_resolved_by_admin_user_id: row
.manual_review_resolved_by_admin_user_id
.clone(),
manual_review_resolution_reason: row.manual_review_resolution_reason.clone(),
manual_review_resolved_at_micros: row
.manual_review_resolved_at
.map(|value| value.to_micros_since_unix_epoch()),
manual_review_resolved_error_code: row.manual_review_resolved_error_code.clone(),
}
}
fn build_profile_recharge_refund_hold_snapshot_from_row(
row: &ProfileRechargeRefundHold,
) -> RuntimeProfileRechargeRefundHoldSnapshot {
RuntimeProfileRechargeRefundHoldSnapshot {
out_refund_no: row.out_refund_no.clone(),
order_id: row.order_id.clone(),
user_id: row.user_id.clone(),
refund_cents: row.refund_cents,
held_points: row.held_points,
status: row.status,
admin_user_id: row.admin_user_id.clone(),
reason: row.reason.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
settled_at_micros: row
.settled_at
.map(|value| value.to_micros_since_unix_epoch()),
released_at_micros: row
.released_at
.map(|value| value.to_micros_since_unix_epoch()),
released_by_admin_user_id: row.released_by_admin_user_id.clone(),
release_reason: row.release_reason.clone(),
}
}
fn build_profile_wallet_manual_restriction_snapshot_from_row(
row: &ProfileWalletManualRestriction,
) -> RuntimeProfileWalletManualRestrictionSnapshot {
RuntimeProfileWalletManualRestrictionSnapshot {
user_id: row.user_id.clone(),
frozen: row.frozen,
reason: row.reason.clone(),
created_by_admin_user_id: row.created_by_admin_user_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by_admin_user_id: row.updated_by_admin_user_id.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
#[cfg(test)]
fn build_profile_recharge_refund_observation_snapshot_from_row(
row: &ProfileRechargeRefundObservation,
) -> RuntimeProfileRechargeRefundObservationSnapshot {
RuntimeProfileRechargeRefundObservationSnapshot {
observation_id: row.observation_id.clone(),
out_refund_no: row.out_refund_no.clone(),
provider_refund_id: row.provider_refund_id.clone(),
order_id: row.order_id.clone(),
provider_transaction_id: row.provider_transaction_id.clone(),
source: row.source,
provider_status: row.provider_status,
total_cents: row.total_cents,
refund_cents: row.refund_cents,
payer_total_cents: row.payer_total_cents,
payer_refund_cents: row.payer_refund_cents,
success_at_micros: row
.success_at
.map(|value| value.to_micros_since_unix_epoch()),
notification_ref: row.notification_ref.clone(),
payload_fingerprint: row.payload_fingerprint.clone(),
resolution_code: row.resolution_code.clone(),
observed_at_micros: row.observed_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_order_refund_settlement_snapshot_from_row(
row: &ProfileRechargeOrderRefundSettlement,
) -> RuntimeProfileRechargeOrderRefundSettlementSnapshot {
RuntimeProfileRechargeOrderRefundSettlementSnapshot {
order_id: row.order_id.clone(),
user_id: row.user_id.clone(),
successful_refund_count: row.successful_refund_count,
cumulative_success_refund_cents: row.cumulative_success_refund_cents,
target_recovery_points: row.target_recovery_points,
recovered_points: row.recovered_points,
unrecovered_points: row.unrecovered_points,
recovery_status: row.recovery_status,
wallet_frozen: row.wallet_frozen,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(
row: &ProfileRechargeRefundBillCheckpoint,
) -> RuntimeProfileRechargeRefundBillCheckpointSnapshot {
RuntimeProfileRechargeRefundBillCheckpointSnapshot {
checkpoint_id: row.checkpoint_id.clone(),
bill_date: row.bill_date.clone(),
bill_hash: row.bill_hash.clone(),
processed_refund_count: row.processed_refund_count,
completed_at_micros: row.completed_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_order_expiration_schedule_snapshot_from_row(
row: &ProfileRechargeOrderExpirationSchedule,
) -> RuntimeProfileRechargeOrderExpirationScheduleSnapshot {
RuntimeProfileRechargeOrderExpirationScheduleSnapshot {
order_id: row.order_id.clone(),
user_id: row.user_id.clone(),
scheduled_at_micros: row.scheduled_at.to_micros_since_unix_epoch(),
lease_owner: row.lease_owner.clone(),
lease_expires_at_micros: row
.lease_expires_at
.map(|value| value.to_micros_since_unix_epoch()),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_feedback_submission_snapshot_from_row(
row: &ProfileFeedbackSubmission,
) -> RuntimeProfileFeedbackSubmissionSnapshot {
RuntimeProfileFeedbackSubmissionSnapshot {
feedback_id: row.feedback_id.clone(),
user_id: row.user_id.clone(),
description: row.description.clone(),
contact_phone: row.contact_phone.clone(),
evidence_json: row.evidence_json.clone(),
status: row.status,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_played_world_snapshot_from_row(
row: &ProfilePlayedWorld,
) -> RuntimeProfilePlayedWorldSnapshot {
RuntimeProfilePlayedWorldSnapshot {
played_world_id: row.played_world_id.clone(),
user_id: row.user_id.clone(),
world_key: row.world_key.clone(),
owner_user_id: row.owner_user_id.clone(),
profile_id: row.profile_id.clone(),
world_type: row.world_type.clone(),
world_title: row.world_title.clone(),
world_subtitle: row.world_subtitle.clone(),
first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(),
last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(),
last_observed_play_time_ms: row.last_observed_play_time_ms,
}
}