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