From f6fbd2a39248e436f19541ff720e0326eb9c67ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 14:36:16 +0800 Subject: [PATCH] =?UTF-8?q?chore(=E4=BC=9A=E5=91=98):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E8=A2=AB=E5=BC=95=E7=94=A8=E7=9A=84=E6=97=A7=E8=A1=A8?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spacetime-module:删除 src/runtime/profile.rs,它未被 src/runtime.rs 挂载(真正生效的是 runtime/active/profile.rs) - 避免设计文档与实现指向失效文件,也避免后续删列改动误落两份 Co-authored-by: Junie --- .../spacetime-module/src/runtime/profile.rs | 10139 ---------------- 1 file changed, 10139 deletions(-) delete mode 100644 server-rs/crates/spacetime-module/src/runtime/profile.rs diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs deleted file mode 100644 index 762ae0a5c..000000000 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ /dev/null @@ -1,10139 +0,0 @@ -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::)] - pub(crate) metadata_json: Option, -} - -#[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, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) module_key: Option, - 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, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) starts_at: Option, - #[default(None::)] - pub(crate) expires_at: Option, -} - -#[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::)] - pub(crate) starts_at: Option, - #[default(None::)] - pub(crate) expires_at: Option, -} - -#[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, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - 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, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - 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, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_name: String, - pub(crate) subtitle: String, - pub(crate) summary_text: String, - pub(crate) cover_image_src: Option, - pub(crate) bottom_tab: String, - pub(crate) game_state_json: String, - pub(crate) current_story_json: Option, - 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::)] - pub(crate) cycle_started_at: Option, - #[default(None::)] - pub(crate) cycle_resets_at: Option, - #[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::)] - pub(crate) paid_at: Option, - #[default(None::)] - pub(crate) provider_transaction_id: Option, - pub(crate) created_at: Timestamp, - pub(crate) points_delta: i64, - pub(crate) membership_expires_at: Option, - #[default(None::)] - pub(crate) expired_at: Option, - #[default(None::)] - pub(crate) expiration_checked_at: Option, - #[default(None::)] - pub(crate) expiration_provider_state: Option, - #[default(None::)] - pub(crate) expiration_last_error: Option, -} - -#[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, - 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, - 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, - 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, - pub(crate) last_error_code: Option, - #[default(None::)] - pub(crate) manual_review_resolved_by_admin_user_id: Option, - #[default(None::)] - pub(crate) manual_review_resolution_reason: Option, - #[default(None::)] - pub(crate) manual_review_resolved_at: Option, - #[default(None::)] - pub(crate) manual_review_resolved_error_code: Option, -} - -#[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, - pub(crate) notification_ref: Option, - 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, - pub(crate) released_at: Option, - pub(crate) released_by_admin_user_id: Option, - pub(crate) release_reason: Option, -} - -#[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::)] - pub(crate) lease_owner: Option, - #[default(None::)] - pub(crate) lease_expires_at: Option, - 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, - // 中文注释:首版凭证以 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, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_name: String, - pub(crate) subtitle: String, - pub(crate) summary_text: String, - pub(crate) cover_image_src: Option, - pub(crate) saved_at: Timestamp, - pub(crate) bottom_tab: String, - pub(crate) game_state_json: String, - pub(crate) current_story_json: Option, - 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, -) -> 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::>(); - 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, 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::>(); - - 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 { - 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 { - 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::>(); - 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, - source_type: &str, - profile_ids: &[String], - now_micros: i64, -) -> HashMap { - 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::>(); - 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![ - "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::>(); - - 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::>(); - 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::(&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::(&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::(&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>, - 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 { - 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, 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::>(); - - 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, - 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::>::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::>(); - 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 { - 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::>(); - - 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 { - 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, -) -> 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, 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::>(); - 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::>(); - 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, - 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, - 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, - 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, - 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, - 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 { - 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, - 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, 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 { - 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 { - 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, 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> { - 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::>(); - 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, - 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 { - let validated = build_runtime_profile_recharge_refund_reconciliation_list_input(input.limit); - let mut candidates = HashMap::::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::>(); - 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, - limit: usize, - rotation_slot: u64, -) -> Vec { - select_rotating_reconciliation_page(rows, limit, rotation_slot) -} - -fn select_profile_recharge_refund_hold_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - select_rotating_reconciliation_page(rows, limit, rotation_slot) -} - -fn select_rotating_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - 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 { - 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, 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::>(); - 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, 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::>(); - 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 { - 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), 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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::>(); - 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 { - // 中文注释:邀请面板只展示最近成功邀请用户,完整统计仍由计数字段承担。 - 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::>(); - - 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, -) -> 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, String> { - let metadata = serde_json::from_str::(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::>() - }) - .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 { - 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 { - 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 { - 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, 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::>(); - 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 { - 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 { - 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, 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, - Vec, - ), - 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::>(); - 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, - Vec, - ), - 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::>(); - 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 { - 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 { - 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 { - 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 { - 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::>(); - 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, 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::>(); - - 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 { - 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 { - 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 { - 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 { - 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::>(); - 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 { - 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::>(); - 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 { - ensure_default_profile_recharge_product_config(ctx); - let mut rows = ctx - .db - .profile_recharge_product_config() - .iter() - .filter(|row| include_disabled || row.enabled) - .collect::>(); - 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 { - 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 { - 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, -} - -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, -} - -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 { - 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 { - 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 { - 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 { - 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 { - 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, -) -> 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, - current_remaining_points: u64, - day_key: i64, -) -> Option { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - settlement_reason: &str, -) -> Result, 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": settlement_reason, - }) - .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 { - 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 { - 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 { - 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 { - 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 { - 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::(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::(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, - cycle_resets_at_micros: Option, -) -> String { - let mut metadata = serde_json::from_str::(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 { - 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 { - let mut orders = ctx - .db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .collect::>(); - 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, 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, - } -}