diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 267ff368e..973adad40 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; -use crate::RuntimeProfileMembershipPlanSnapshot; +use crate::RuntimeProfileMembershipPlanRecord; #[cfg(any())] use serde_json::Value; @@ -1718,7 +1718,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_plans: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, @@ -2665,7 +2665,7 @@ pub struct RuntimeProfileRechargeCenterRecord { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, - pub membership_plans: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsRecord, diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 03e973fe9..d2f9939e4 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -440,7 +440,6 @@ 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, @@ -452,8 +451,15 @@ pub struct ProfileMembership { pub(crate) cycle_granted_points: u64, #[default(0u64)] pub(crate) cycle_remaining_points: u64, - #[default(30u32)] - pub(crate) cycle_period_days: u32, + // 末位追加:档位身份与自然月期数。旧 `tier` 与固定 30 天的 `cycle_period_days` 已退役。 + #[default(RuntimeProfileMembershipPlan::Normal)] + pub(crate) plan: RuntimeProfileMembershipPlan, + #[default(0u32)] + pub(crate) cycle_index: u32, + #[default(1u32)] + pub(crate) cycle_count: u32, + #[default(RuntimeProfileMembershipCycleKind::Monthly)] + pub(crate) cycle_kind: RuntimeProfileMembershipCycleKind, } #[spacetimedb::table(accessor = profile_recharge_product_config)] @@ -469,21 +475,12 @@ pub struct ProfileRechargeProductConfig { 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_membership_plan)] @@ -540,6 +537,35 @@ pub struct ProfileRechargeOrder { pub(crate) expiration_provider_state: Option, #[default(None::)] pub(crate) expiration_last_error: Option, + // 末位追加:会员购买 / 升级的变更快照,人工复核退款时据此还原变更前后的权益。 + #[default(None::)] + pub(crate) membership_change_kind: Option, + #[default(None::)] + pub(crate) membership_plan_before: Option, + #[default(None::)] + pub(crate) membership_plan_after: Option, + #[default(None::)] + pub(crate) membership_cycle_kind_before: Option, + #[default(None::)] + pub(crate) membership_cycle_kind_after: Option, + #[default(None::)] + pub(crate) membership_period_points_before: Option, + #[default(None::)] + pub(crate) membership_period_points_after: Option, + #[default(0i64)] + pub(crate) membership_granted_points_delta: i64, + #[default(None::)] + pub(crate) membership_cycle_index: Option, + #[default(None::)] + pub(crate) membership_expires_at_before: Option, + #[default(None::)] + pub(crate) membership_expires_at_after: Option, + #[default(None::)] + pub(crate) membership_cycle_resets_at_before: Option, + #[default(None::)] + pub(crate) membership_cycle_resets_at_after: Option, + #[default(None::)] + pub(crate) membership_price_breakdown_json: Option, } #[spacetimedb::table( @@ -4651,7 +4677,7 @@ fn create_profile_recharge_order_record( input.created_at_micros, ) .map_err(|error| error.to_string())?; - let product = enabled_profile_recharge_product_by_id(ctx, &validated_input.product_id) + let product = resolve_enabled_profile_recharge_order_product(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 = @@ -4662,30 +4688,40 @@ fn create_profile_recharge_order_record( &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( + let order_id = build_runtime_profile_recharge_order_id( + &validated_input.user_id, + validated_input.created_at_micros, + &product.product_id, + ); + let (status, paid_at, purchase) = if should_settle_immediately { + let purchase = apply_profile_recharge_purchase( ctx, &validated_input.user_id, &product, + &order_id, + amount_cents, validated_input.created_at_micros, created_at, )?; ( RuntimeProfileRechargeOrderStatus::Paid, Some(created_at), - points_delta, - membership_expires_at, + Some(purchase), ) } else { - (RuntimeProfileRechargeOrderStatus::Pending, None, 0, None) + (RuntimeProfileRechargeOrderStatus::Pending, None, None) + }; + let (points_delta, membership_expires_at, membership_change) = match purchase { + Some(purchase) => ( + purchase.points_delta, + purchase.membership_expires_at, + purchase.membership_change, + ), + None => (0, None, None), }; - let order = ProfileRechargeOrder { - order_id: build_runtime_profile_recharge_order_id( - &validated_input.user_id, - validated_input.created_at_micros, - &product.product_id, - ), + let mut order = ProfileRechargeOrder { + order_id, user_id: validated_input.user_id.clone(), product_id: product.product_id.clone(), product_title: product.title.clone(), @@ -4702,7 +4738,22 @@ fn create_profile_recharge_order_record( expiration_checked_at: None, expiration_provider_state: None, expiration_last_error: None, + membership_change_kind: None, + membership_plan_before: None, + membership_plan_after: None, + membership_cycle_kind_before: None, + membership_cycle_kind_after: None, + membership_period_points_before: None, + membership_period_points_after: None, + membership_granted_points_delta: 0, + membership_cycle_index: None, + membership_expires_at_before: None, + membership_expires_at_after: None, + membership_cycle_resets_at_before: None, + membership_cycle_resets_at_after: None, + membership_price_breakdown_json: None, }; + apply_profile_membership_change_to_order(&mut order, membership_change); ctx.db.profile_recharge_order().insert(order.clone()); if order.status == RuntimeProfileRechargeOrderStatus::Pending && should_schedule_profile_recharge_order_expiration(&order.payment_channel) @@ -4797,13 +4848,15 @@ fn mark_profile_recharge_order_paid_record( return Err("profile_recharge_order 当前状态不能确认支付".to_string()); } - let product = profile_recharge_product_by_id(ctx, &order.product_id) + let product = resolve_profile_recharge_order_product(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( + let purchase = apply_profile_recharge_purchase( ctx, &order.user_id, &product, + &order.order_id, + order.amount_cents, order.created_at.to_micros_since_unix_epoch(), paid_at, )?; @@ -4815,8 +4868,9 @@ fn mark_profile_recharge_order_paid_record( 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; + order.points_delta = purchase.points_delta; + order.membership_expires_at = purchase.membership_expires_at; + apply_profile_membership_change_to_order(&mut order, purchase.membership_change); ctx.db.profile_recharge_order().insert(order.clone()); delete_profile_recharge_order_expiration_task(ctx, &order.order_id); @@ -6844,13 +6898,22 @@ fn delete_profile_recharge_order_expiration_schedule(ctx: &ReducerContext, order .delete(&order_id.to_string()); } +/// 充值 / 会员购买落账结果;会员订单额外带权益变更快照供人工复核。 +struct ProfileRechargePurchaseOutcome { + points_delta: i64, + membership_expires_at: Option, + membership_change: Option, +} + fn apply_profile_recharge_purchase( ctx: &ReducerContext, user_id: &str, product: &RuntimeProfileRechargeProductSnapshot, + order_id: &str, + order_amount_cents: u64, order_created_at_micros: i64, paid_at: Timestamp, -) -> Result<(i64, Option), String> { +) -> Result { match product.kind { RuntimeProfileRechargeProductKind::Points => { let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id); @@ -6868,19 +6931,62 @@ fn apply_profile_recharge_purchase( ), paid_at, )?; - Ok((points_delta as i64, None)) + Ok(ProfileRechargePurchaseOutcome { + points_delta: points_delta as i64, + membership_expires_at: None, + membership_change: 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), - )) + let outcome = apply_profile_membership_purchase( + ctx, + user_id, + product, + order_id, + order_amount_cents, + paid_at, + )?; + Ok(ProfileRechargePurchaseOutcome { + points_delta: outcome.period_points_delta as i64, + membership_expires_at: Some(outcome.expires_at), + membership_change: Some(outcome.change), + }) } } } +/// 把会员权益变更快照落到订单行的末位快照列上。 +fn apply_profile_membership_change_to_order( + order: &mut ProfileRechargeOrder, + change: Option, +) { + let Some(change) = change else { + return; + }; + order.membership_change_kind = Some(change.change_kind); + order.membership_plan_before = change.plan_before; + order.membership_plan_after = change.plan_after; + order.membership_cycle_kind_before = change.cycle_kind_before; + order.membership_cycle_kind_after = change.cycle_kind_after; + order.membership_period_points_before = change.period_points_before; + order.membership_period_points_after = change.period_points_after; + order.membership_granted_points_delta = change.granted_points_delta; + order.membership_cycle_index = change.cycle_index; + order.membership_expires_at_before = change + .expires_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_expires_at_after = change + .expires_at_after_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_cycle_resets_at_before = change + .cycle_resets_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_cycle_resets_at_after = change + .cycle_resets_at_after_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_price_breakdown_json = change.price_breakdown_json; +} + fn submit_profile_feedback_record( ctx: &ReducerContext, input: RuntimeProfileFeedbackSubmissionInput, @@ -7606,22 +7712,18 @@ fn build_profile_recharge_center_snapshot( 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); - } + if product.kind != RuntimeProfileRechargeProductKind::Points { + // 会员商品不在商品配置表里:会员购买 / 升级由 profile_membership_plan 目录表驱动。 + continue; } + 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, + )); } RuntimeProfileRechargeCenterSnapshot { @@ -7629,8 +7731,7 @@ fn build_profile_recharge_center_snapshot( wallet_balance, membership: build_profile_membership_snapshot(ctx, user_id), point_products, - membership_products, - benefits: runtime_profile_membership_benefits(), + membership_plans: membership_plan_records(ctx), latest_order: latest_profile_recharge_order(ctx, user_id) .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), has_points_recharged, @@ -8475,7 +8576,6 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { 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 @@ -8490,40 +8590,32 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { 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)] +/// 旧充点商品行的规则迁移结果。新方案只保留六档「¥1 = 10 泥点、无任何首充赠送」。 struct DefaultPointProductMigration { bonus_points: u64, badge_label: &'static str, - description: &'static str, + description: String, enabled: bool, } fn resolve_default_point_product_migration( row: &ProfileRechargeProductConfig, ) -> Option { + // 只迁移系统播种的充点商品;后台改过的行(`updated_by` 已变)一律不动。 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) + || row.points_amount == 0 { return None; } @@ -8537,39 +8629,26 @@ fn resolve_default_point_product_migration( 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, + let canonical = DefaultPointProductMigration { + bonus_points: 0, + badge_label: "", + description: expected_title, + enabled: true, + }; + + // 旧播种行:首充赠送(赠送 = 面额)+「首充双倍」标签 +「首充送N泥点」说明。 + let is_legacy_bonus_seed = row.enabled + && row.bonus_points == row.points_amount + && row.badge_label == "首充双倍" + && row.description == format!("首充送{}泥点", row.points_amount); + // ¥128 / ¥328 两档曾是「停用的首充双倍行」,现在改为在售且无赠送。 + let is_legacy_disabled_seed = + !row.enabled && row.bonus_points == 0 && row.badge_label == "首充双倍"; + + if is_legacy_bonus_seed || is_legacy_disabled_seed { + return Some(canonical); } + None } fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) { @@ -8584,9 +8663,7 @@ fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) }; 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.description = migration.description; row.enabled = migration.enabled; row.updated_at = ctx.timestamp; ctx.db @@ -8652,10 +8729,55 @@ fn resolve_profile_recharge_product_display( product } +/// 会员订单产生的权益变更快照,落到订单行上供人工复核退款时还原变更前后状态。 #[derive(Clone, Debug, PartialEq, Eq)] -struct ProfileMembershipPurchaseResult { +struct ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind, + plan_before: Option, + plan_after: Option, + cycle_kind_before: Option, + cycle_kind_after: Option, + period_points_before: Option, + period_points_after: Option, + granted_points_delta: i64, + cycle_index: Option, + expires_at_before_micros: Option, + expires_at_after_micros: Option, + cycle_resets_at_before_micros: Option, + cycle_resets_at_after_micros: Option, + price_breakdown_json: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipPurchaseOutcome { expires_at: Timestamp, period_points_delta: u64, + change: ProfileMembershipChangeSnapshot, +} + +/// 订单里的价格变更快照(JSON)。人工复核退款时据此确认「按哪个价、补了多少点」。 +fn membership_price_breakdown_json( + target: &RuntimeProfileMembershipPlanSnapshot, + current: Option<&RuntimeProfileMembershipPlanSnapshot>, + order_amount_cents: u64, +) -> String { + serde_json::to_string(&json!({ + "changeKind": match current { + Some(_) => RuntimeProfileMembershipChangeKind::Upgrade, + None => RuntimeProfileMembershipChangeKind::Purchase, + } + .as_str(), + "targetPlan": target.plan.as_str(), + "targetCycleKind": target.cycle_kind.as_str(), + "targetListPriceCents": target.price_cents, + "targetPeriodPoints": target.period_points, + "currentPlan": current.map(|value| value.plan.as_str()), + "currentCycleKind": current.map(|value| value.cycle_kind.as_str()), + "currentListPriceCents": current.map(|value| value.price_cents), + "currentPeriodPoints": current.map(|value| value.period_points), + "orderAmountCents": order_amount_cents, + })) + .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -8688,134 +8810,206 @@ impl DailyFreePointMutation { } } -struct MembershipCycleInitialization { +/// 会员周期推进结果;`advanced = false` 表示行仍处在本期,无需写账本。 +struct MembershipCycleAdvance { 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) + advanced: bool, + previous_remaining_points: u64, + cycle_started_at_micros: i64, + cycle_resets_at_micros: i64, } fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool { row.expires_at.to_micros_since_unix_epoch() > at.to_micros_since_unix_epoch() } -fn canonical_membership_pricing_tier( - tier: RuntimeProfileMembershipTier, -) -> RuntimeProfileMembershipTier { - match tier { - RuntimeProfileMembershipTier::Month => RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Season => RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Year => RuntimeProfileMembershipTier::Pro, - _ => tier, - } -} - -fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool { - matches!( - tier, - RuntimeProfileMembershipTier::Month - | RuntimeProfileMembershipTier::Season - | RuntimeProfileMembershipTier::Year - ) -} - -fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 { - runtime_profile_membership_tier_rank(tier).unwrap_or(0) -} - -fn resolve_active_membership_purchase_mode( - current_tier: RuntimeProfileMembershipTier, - next_tier: RuntimeProfileMembershipTier, -) -> Result { - if current_tier == next_tier { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - let current_rank = active_membership_tier_rank(current_tier); - let next_rank = active_membership_tier_rank(next_tier); - let is_legacy_equivalent_migration = current_rank == next_rank - && is_legacy_membership_tier(current_tier) - && canonical_membership_pricing_tier(current_tier) == next_tier; - - if is_legacy_equivalent_migration { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - if next_rank > current_rank { - return Ok(ActiveMembershipPurchaseMode::Upgrade); - } - - Err("当前会员有效期内不能购买更低档会员".to_string()) -} - -fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 { - target_price_cents.saturating_sub(current_price_cents) -} - -fn membership_product_price_cents_by_tier( +/// 会员目录行读取。`profile_membership_plan` 表是价格与权益的唯一真相源: +/// 代码内种子只在表为空时落一次,之后后台改价不会被覆盖。 +fn membership_plan_row( ctx: &ReducerContext, - tier: RuntimeProfileMembershipTier, -) -> Option { - let canonical_tier = canonical_membership_pricing_tier(tier); - profile_recharge_product_config_rows(ctx, false) - .into_iter() - .find(|row| { - row.kind == RuntimeProfileRechargeProductKind::Membership - && canonical_membership_pricing_tier(row.tier) == canonical_tier - }) - .map(|row| row.price_cents) - .or_else(|| { - runtime_profile_recharge_membership_products() - .into_iter() - .find(|product| canonical_membership_pricing_tier(product.tier) == canonical_tier) - .map(|product| product.price_cents) - }) + plan: RuntimeProfileMembershipPlan, +) -> Option { + ensure_default_profile_membership_plan(ctx); + ctx.db.profile_membership_plan().plan().find(&plan) } +fn membership_plan_period_points(ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan) -> u64 { + membership_plan_row(ctx, plan) + .map(|row| row.period_points) + .unwrap_or(0) +} + +fn membership_plan_snapshot( + ctx: &ReducerContext, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> Option { + membership_plan_row(ctx, plan) + .map(|row| build_runtime_profile_membership_plan_snapshot(&row, cycle_kind)) +} + +/// 充值中心与后台共用的目录行读取,按 `sort_order` 升序。 +fn membership_plan_records(ctx: &ReducerContext) -> Vec { + ensure_default_profile_membership_plan(ctx); + let mut rows = ctx.db.profile_membership_plan().iter().collect::>(); + rows.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.plan.as_str().cmp(right.plan.as_str())) + }); + rows.into_iter() + .map(|row| RuntimeProfileMembershipPlanRecord { + plan: row.plan, + title: row.title, + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + sort_order: row.sort_order, + enabled: row.enabled, + }) + .collect() +} + +/// 把会员行推进到 `at` 时刻所在的那一期。 +/// +/// 每一期都从**原始锚点**(`started_at`)按自然月重算,因此 1/31 开通得到 +/// 1/31 → 2/28 → 3/31,不会像按固定天数递推那样漂移成 3/28。 +fn advance_profile_membership_cycle_to( + row: ProfileMembership, + at: Timestamp, + period_points: u64, +) -> MembershipCycleAdvance { + let mut row = row; + let anchor_micros = row.started_at.to_micros_since_unix_epoch(); + let at_micros = at.to_micros_since_unix_epoch(); + let previous_remaining_points = row.cycle_remaining_points; + let max_index = row.cycle_count.max(1).saturating_add(2); + let mut cycle_index = row.cycle_index.max(1); + let mut window = membership_cycle_window(anchor_micros, cycle_index); + let mut advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0; + while window.1 <= at_micros && cycle_index < max_index { + cycle_index = cycle_index.saturating_add(1); + window = membership_cycle_window(anchor_micros, cycle_index); + advanced = true; + } + if advanced { + row.cycle_index = cycle_index; + row.cycle_started_at = Some(Timestamp::from_micros_since_unix_epoch(window.0)); + row.cycle_resets_at = Some(Timestamp::from_micros_since_unix_epoch(window.1)); + row.cycle_granted_points = period_points; + row.cycle_remaining_points = period_points; + row.updated_at = at; + } + MembershipCycleAdvance { + row, + advanced, + previous_remaining_points, + cycle_started_at_micros: window.0, + cycle_resets_at_micros: window.1, + } +} + +/// 升级 / 报价共用的纯函数入参:两个套餐快照都来自目录表。 +fn membership_upgrade_quote_input( + row: &ProfileMembership, + current: &RuntimeProfileMembershipPlanSnapshot, + target: &RuntimeProfileMembershipPlanSnapshot, + now: Timestamp, +) -> RuntimeProfileMembershipUpgradeQuoteInput { + RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_started_at_micros: row + .cycle_started_at + .map(|value| value.to_micros_since_unix_epoch()) + .unwrap_or_else(|| row.started_at.to_micros_since_unix_epoch()), + cycle_resets_at_micros: row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()) + .unwrap_or_else(|| row.expires_at.to_micros_since_unix_epoch()), + expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + cycle_remaining_points: row.cycle_remaining_points, + now_micros: now.to_micros_since_unix_epoch(), + } +} + +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); +} + +/// 会员商品在订单里的商品快照。会员商品没有 `profile_recharge_product_config` 行: +/// `product_id` 就是「档位 + 周期」,标题与价格来自 `profile_membership_plan` 目录表。 +fn build_membership_order_product( + row: &ProfileMembershipPlan, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> RuntimeProfileRechargeProductSnapshot { + let snapshot = build_runtime_profile_membership_plan_snapshot(row, cycle_kind); + let cycle_label = match cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => "月付", + RuntimeProfileMembershipCycleKind::Yearly => "年付", + }; + RuntimeProfileRechargeProductSnapshot { + product_id: runtime_profile_membership_product_id(plan, cycle_kind), + title: format!("{}·{}", snapshot.title, cycle_label), + price_cents: snapshot.price_cents, + kind: RuntimeProfileRechargeProductKind::Membership, + points_amount: 0, + bonus_points: 0, + duration_days: 0, + badge_label: String::new(), + description: snapshot.title, + } +} + +/// 下单时可购买的商品:会员商品要求档位在售,充点商品要求行已启用。 +fn resolve_enabled_profile_recharge_order_product( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + if let Some((plan, cycle_kind)) = parse_runtime_profile_membership_product_id(product_id) { + let row = membership_plan_row(ctx, plan)?; + if !(row.enabled && plan.is_purchasable()) { + return None; + } + return Some(build_membership_order_product(&row, plan, cycle_kind)); + } + enabled_profile_recharge_product_by_id(ctx, product_id) +} + +/// 支付确认时解析订单商品:会员档位即使下单后被下架,已支付订单仍按原档位结算。 +fn resolve_profile_recharge_order_product( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + if let Some((plan, cycle_kind)) = parse_runtime_profile_membership_product_id(product_id) { + let row = membership_plan_row(ctx, plan)?; + return Some(build_membership_order_product(&row, plan, cycle_kind)); + } + profile_recharge_product_by_id(ctx, product_id) +} + +/// 会员订单应付金额:首次购买按目录价;有效期内升级按补差报价重算。 fn resolve_profile_membership_order_amount_cents( ctx: &ReducerContext, user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, + product_id: &str, created_at: Timestamp, ) -> Result { + let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) + .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; + if !target.enabled { + return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); + } + let Some(row) = ctx .db .profile_membership() @@ -8823,19 +9017,21 @@ fn resolve_profile_membership_order_amount_cents( .find(&user_id.to_string()) .filter(|row| active_membership_row_at(row, created_at)) else { - return Ok(product.price_cents); + return Ok(target.price_cents); }; - - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - return Ok(product.price_cents); + if row.plan == plan { + return Err("当前套餐已生效,无需重复购买".to_string()); } - - 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, + if row.cycle_kind != cycle_kind { + return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, ¤t, &target, created_at, )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + Ok(quote.amount_cents) } fn resolve_profile_recharge_order_amount_cents( @@ -8847,91 +9043,16 @@ fn resolve_profile_recharge_order_amount_cents( match product.kind { RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents(ctx, user_id, product, created_at) + resolve_profile_membership_order_amount_cents( + ctx, + user_id, + &product.product_id, + created_at, + ) } } } -fn apply_active_membership_upgrade_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - period_days: u32, - period_points: u64, - updated_at: Timestamp, -) -> (ProfileMembership, u64) { - let period_points_delta = period_points.saturating_sub(row.cycle_granted_points); - row.tier = tier; - row.cycle_granted_points = row.cycle_granted_points.max(period_points); - row.cycle_remaining_points = row - .cycle_remaining_points - .saturating_add(period_points_delta); - row.cycle_period_days = period_days; - row.updated_at = updated_at; - (row, period_points_delta) -} - -fn apply_active_membership_renew_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - duration_days: u32, - purchased_at: Timestamp, -) -> ProfileMembership { - row.tier = tier; - row.expires_at = Timestamp::from_micros_since_unix_epoch( - row.expires_at - .to_micros_since_unix_epoch() - .saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)), - ); - row.updated_at = purchased_at; - row -} - -fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { - ctx.db.profile_membership().user_id().delete(&row.user_id); - ctx.db.profile_membership().insert(row); -} - -fn initialize_missing_profile_membership_cycle( - mut row: ProfileMembership, - now: Timestamp, -) -> Option { - if row.cycle_resets_at.is_some() { - return None; - } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut cycle_started_at = row.cycle_started_at.unwrap_or(row.started_at); - let mut next_reset_at = membership_cycle_reset_at(cycle_started_at, period_days); - let now_micros = now.to_micros_since_unix_epoch(); - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - let granted_points_delta = granted_points.saturating_sub(row.cycle_remaining_points); - row.cycle_started_at = Some(cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - - Some(MembershipCycleInitialization { - row, - granted_points_delta, - cycle_resets_at: next_reset_at, - }) -} - fn membership_cycle_metadata( action: &str, expired_points: u64, @@ -9196,8 +9317,12 @@ fn refresh_profile_wallet_expiring_points( 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 + let Some(row) = ctx .db .profile_membership() .user_id() @@ -9211,11 +9336,12 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti 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 mut expired_row = row; + expired_row.status = RuntimeProfileMembershipStatus::Normal; + expired_row.cycle_remaining_points = 0; + expired_row.updated_at = now; let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}"); - upsert_profile_membership_row(ctx, row); + upsert_profile_membership_row(ctx, expired_row); update_profile_wallet_balance_for_expiring_points( ctx, user_id, @@ -9230,72 +9356,19 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti 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 { + let period_points = membership_plan_period_points(ctx, row.plan); + let advance = advance_profile_membership_cycle_to(row, now, period_points); + if !advance.advanced { 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); + let expired_points = advance.previous_remaining_points; + let granted_points = advance.row.cycle_granted_points; + let next_reset_at = advance.row.cycle_resets_at; + let ledger_id = format!( + "membership-period-reset:{user_id}:{}", + advance.cycle_started_at_micros + ); + upsert_profile_membership_row(ctx, advance.row); update_profile_wallet_balance_for_expiring_points( ctx, user_id, @@ -9304,7 +9377,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, &ledger_id, now, - membership_cycle_metadata("reset", expired_points, granted_points, Some(next_reset_at)), + membership_cycle_metadata("reset", expired_points, granted_points, next_reset_at), ); } @@ -9324,7 +9397,7 @@ fn build_profile_membership_snapshot( RuntimeProfileMembershipSnapshot { user_id: row.user_id, status: row.status, - tier: row.tier, + plan: row.plan, 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()), @@ -9336,13 +9409,16 @@ fn build_profile_membership_snapshot( .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), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_kind: row.cycle_kind, } } + // 中文注释:到期后对外只暴露“非会员”,但保留原周期信息供展示“已失效的套餐”。 Some(row) => RuntimeProfileMembershipSnapshot { user_id: row.user_id, status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::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()), @@ -9354,12 +9430,14 @@ fn build_profile_membership_snapshot( .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), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_kind: row.cycle_kind, }, None => RuntimeProfileMembershipSnapshot { user_id: user_id.to_string(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at_micros: None, expires_at_micros: None, updated_at_micros: None, @@ -9367,116 +9445,192 @@ fn build_profile_membership_snapshot( cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, + cycle_index: 1, + cycle_count: 1, + cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, }, } } +/// 会员购买 / 补差升级写路径。 +/// +/// - 有效期内:只允许「同周期升更高档位」,按目录表实时价格算补差、补点,刷新日与到期日不变。 +/// - 无有效会员(从未开通或已到期):按完整周期重新起算,锚点就是本次支付时刻。 +/// +/// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( ctx: &ReducerContext, user_id: &str, product: &RuntimeProfileRechargeProductSnapshot, + order_id: &str, + order_amount_cents: u64, purchased_at: Timestamp, -) -> Result { +) -> Result { + let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(&product.product_id) + .ok_or_else(|| format!("会员商品 product_id 无法解析: {}", product.product_id))?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; + if !target.enabled { + return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); + } + 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 + + let active_row = 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); + .find(&user_id.to_string()) + .filter(|row| active_membership_row_at(row, purchased_at)); - 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, - ) - } + if let Some(row) = active_row { + if row.plan == plan { + return Err("当前套餐已生效,无需重复购买".to_string()); } - 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, - ) + if row.cycle_kind != cycle_kind { + return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); } - 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 current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, + ¤t, + &target, + purchased_at, + )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; - let expires_at = next_row.expires_at; - let cycle_resets_at = next_row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, next_row); + let expires_at_before_micros = row.expires_at.to_micros_since_unix_epoch(); + let cycle_resets_at_before_micros = row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()); + let mut next_row = row; + // 已用点不返还、永久泥点不动:只把档位换成新档,并追加本期补点。 + next_row.plan = plan; + next_row.cycle_kind = cycle_kind; + next_row.cycle_count = cycle_kind.cycle_count(); + next_row.cycle_granted_points = next_row.cycle_granted_points.max(target.period_points); + next_row.cycle_remaining_points = next_row + .cycle_remaining_points + .saturating_add(quote.granted_points_delta); + next_row.updated_at = purchased_at; + let expires_at = next_row.expires_at; + let cycle_resets_at = next_row.cycle_resets_at; + let cycle_index = next_row.cycle_index.max(1); + 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 - ); + let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); update_profile_wallet_balance_for_expiring_points( ctx, user_id, 0, - period_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, + quote.granted_points_delta, + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, &ledger_id, purchased_at, - membership_cycle_metadata("purchase", 0, period_points_delta, cycle_resets_at), + membership_cycle_metadata("upgrade", 0, quote.granted_points_delta, cycle_resets_at), ); + + return Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: quote.granted_points_delta, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan_before: Some(current.plan), + plan_after: Some(plan), + cycle_kind_before: Some(current.cycle_kind), + cycle_kind_after: Some(cycle_kind), + period_points_before: Some(current.period_points), + period_points_after: Some(target.period_points), + granted_points_delta: quote.granted_points_delta as i64, + cycle_index: Some(cycle_index), + expires_at_before_micros: Some(expires_at_before_micros), + expires_at_after_micros: Some(expires_at.to_micros_since_unix_epoch()), + cycle_resets_at_before_micros, + cycle_resets_at_after_micros: cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: Some(membership_price_breakdown_json( + &target, + Some(¤t), + order_amount_cents, + )), + }, + }); } - Ok(ProfileMembershipPurchaseResult { + // 无有效会员:全新起算。到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 + let expired_existing = ctx + .db + .profile_membership() + .user_id() + .find(&user_id.to_string()); + let anchor_micros = purchased_at.to_micros_since_unix_epoch(); + let (cycle_started_at_micros, cycle_resets_at_micros) = + membership_cycle_window(anchor_micros, 1); + let expires_at_micros = membership_expires_at(anchor_micros, cycle_kind); + let expires_at = Timestamp::from_micros_since_unix_epoch(expires_at_micros); + let cycle_resets_at = Timestamp::from_micros_since_unix_epoch(cycle_resets_at_micros); + let next_row = ProfileMembership { + user_id: user_id.to_string(), + status: RuntimeProfileMembershipStatus::Active, + started_at: purchased_at, expires_at, - period_points_delta, + updated_at: purchased_at, + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch( + cycle_started_at_micros, + )), + cycle_resets_at: Some(cycle_resets_at), + cycle_granted_points: target.period_points, + cycle_remaining_points: target.period_points, + plan, + cycle_index: 1, + cycle_count: cycle_kind.cycle_count(), + cycle_kind, + }; + upsert_profile_membership_row(ctx, next_row); + + let ledger_id = format!("membership-period-grant:{user_id}:{order_id}"); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + target.period_points, + RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, + &ledger_id, + purchased_at, + membership_cycle_metadata("purchase", 0, target.period_points, Some(cycle_resets_at)), + ); + + Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: target.period_points, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan_before: expired_existing.as_ref().map(|row| row.plan), + plan_after: Some(plan), + cycle_kind_before: expired_existing.as_ref().map(|row| row.cycle_kind), + cycle_kind_after: Some(cycle_kind), + period_points_before: expired_existing + .as_ref() + .map(|row| row.cycle_granted_points), + period_points_after: Some(target.period_points), + granted_points_delta: target.period_points as i64, + cycle_index: Some(1), + expires_at_before_micros: expired_existing + .as_ref() + .map(|row| row.expires_at.to_micros_since_unix_epoch()), + expires_at_after_micros: Some(expires_at_micros), + cycle_resets_at_before_micros: expired_existing + .as_ref() + .and_then(|row| row.cycle_resets_at) + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_after_micros: Some(cycle_resets_at_micros), + price_breakdown_json: Some(membership_price_breakdown_json( + &target, + None, + order_amount_cents, + )), + }, }) } @@ -10993,11 +11147,6 @@ fn build_profile_recharge_product_config_snapshot_from_row( 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(), @@ -11020,11 +11169,6 @@ fn build_profile_recharge_product_snapshot_from_config_row( 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, } }