diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 99e43f685..570ebfe05 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -92,6 +92,7 @@ pub enum RuntimeProfileFieldError { InvalidRechargeProductKind, InvalidRechargeProductFields, InvalidMembershipPlan, + InvalidMembershipPlanOrder, MissingPaymentChannel, #[cfg(any())] MissingWorldKey, @@ -194,6 +195,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidMembershipPlan => { f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效") } + Self::InvalidMembershipPlanOrder => f.write_str( + "会员档位必须按 rank 严格递增:更高档位的月价、年价与每期泥点都必须更高", + ), Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"), #[cfg(any())] Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 614e9695a..4a1381e8b 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -28,6 +28,7 @@ pub use membership::{ quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, + validate_runtime_profile_membership_plan_row_against_catalog, }; use shared_kernel::format_rfc3339 as format_shared_rfc3339; diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index d531292c5..b2229d840 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -11,7 +11,8 @@ //! //! 目录不变量:按 `rank` 升序,月价、年价、每期泥点必须**严格递增**(`Normal` 为 0)。 //! 升级补差按 `rank` 判定、按价差 / 点差计价,若高 `rank` 的行反而更便宜或更少点, -//! `saturating_sub` 会把补差静默算成 0;后台改价时不得破坏该不变量。 +//! 报价会直接报错;后台改价时由 [`validate_runtime_profile_membership_plan_row_against_catalog`] +//! 在写入侧拦住,禁止把倒挂目录落库。 use serde::{Deserialize, Serialize}; @@ -19,8 +20,8 @@ use serde::{Deserialize, Serialize}; use spacetimedb::SpacetimeType; use crate::{ - RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipModelAccess, - RuntimeProfileMembershipPlan, + RuntimeProfileFieldError, RuntimeProfileMembershipCycleKind, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, }; /// 目录一行:一个档位的可配置价格与权益。 @@ -140,6 +141,35 @@ pub fn resolve_runtime_profile_membership_plan_row( .find(|row| row.plan == plan) } +/// 校验后台写入的档位不会破坏目录不变量。 +/// +/// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。 +/// 只比较 `candidate` 与其余每一行:这样任何单次改写都不能制造新的倒挂,而目录原本单调时 +/// 写完后仍严格单调;同时历史脏目录不会把后台彻底锁死,后台仍能逐档修正。 +pub fn validate_runtime_profile_membership_plan_row_against_catalog( + candidate: &RuntimeProfileMembershipPlanRecord, + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Result<(), RuntimeProfileFieldError> { + for row in existing.iter().filter(|row| row.plan != candidate.plan) { + if row.rank == candidate.rank { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + let (lower, higher) = if row.rank < candidate.rank { + (row, candidate) + } else { + (candidate, row) + }; + // 中文注释:月价、年价、每期泥点都必须随 rank 严格递增,否则升级补差会算出 0 元 / 0 泥点。 + if higher.month_price_cents <= lower.month_price_cents + || higher.year_price_cents <= lower.year_price_cents + || higher.period_points <= lower.period_points + { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + Ok(()) +} + /// 目录行 → 可下单套餐快照,价格与权益来自目录表(后台可改)。 pub fn build_runtime_profile_membership_plan_snapshot( row: &RuntimeProfileMembershipPlanRecord, @@ -334,6 +364,70 @@ mod tests { } } + #[test] + fn catalog_seed_passes_the_admin_upsert_invariant() { + let catalog = runtime_profile_membership_plan_catalog(); + for candidate in &catalog { + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(candidate, &catalog), + Ok(()) + ); + } + } + + #[test] + fn admin_upsert_allows_a_monotonic_price_change() { + let catalog = runtime_profile_membership_plan_catalog(); + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.month_price_cents += 1; + candidate.year_price_cents += 1; + candidate.period_points += 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Ok(()) + ); + } + + #[test] + fn admin_upsert_rejects_inverted_price_or_points() { + let catalog = runtime_profile_membership_plan_catalog(); + + // 中文注释:把 Plus 定得比 Starter 便宜,破坏「越高档越贵」。 + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.month_price_cents = 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + + // 中文注释:每期泥点与下一档持平也算倒挂(必须严格递增)。 + let mut candidate = row(RuntimeProfileMembershipPlan::Pro); + candidate.period_points = row(RuntimeProfileMembershipPlan::Plus).period_points; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + + // 中文注释:把 Plus 抬到比 Pro 还贵,同样是倒挂。 + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.year_price_cents = row(RuntimeProfileMembershipPlan::Pro).year_price_cents + 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn admin_upsert_rejects_duplicate_rank() { + let catalog = runtime_profile_membership_plan_catalog(); + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.rank = 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + #[test] fn year_price_is_stored_independently_of_the_month_price() { // 中文注释:年价是独立可配置字段,这里断言的是种子数值本身,而不是月价 ×10 的推导关系。 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 52a153ab1..4c7175af6 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1025,7 +1025,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_task_config_snapshots(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult { ok: true, entries, @@ -1044,7 +1050,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_task_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1063,7 +1075,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + disable_profile_task_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1082,7 +1100,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + get_profile_wallet_config_snapshot(tx, input.clone()) + }) { Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1101,7 +1125,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_wallet_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1120,7 +1150,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_recharge_product_config_snapshots(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult { ok: true, entries, @@ -1139,7 +1175,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_recharge_product_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult { ok: true, record: Some(record), @@ -1159,7 +1201,13 @@ pub fn admin_list_profile_membership_plans( ctx: &mut ProcedureContext, input: RuntimeProfileMembershipPlanAdminListInput, ) -> RuntimeProfileMembershipPlanAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_membership_plan_records(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_membership_plan_records(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileMembershipPlanAdminListProcedureResult { ok: true, entries, @@ -1178,7 +1226,13 @@ pub fn admin_upsert_profile_membership_plan( ctx: &mut ProcedureContext, input: RuntimeProfileMembershipPlanAdminUpsertInput, ) -> RuntimeProfileMembershipPlanAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_membership_plan_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_membership_plan_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileMembershipPlanAdminProcedureResult { ok: true, record: Some(record), @@ -1198,7 +1252,13 @@ pub fn get_profile_membership_upgrade_quote( ctx: &mut ProcedureContext, input: RuntimeProfileMembershipUpgradeQuoteGetInput, ) -> RuntimeProfileMembershipUpgradeQuoteProcedureResult { - match ctx.try_with_tx(|tx| build_profile_membership_upgrade_quote_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + build_profile_membership_upgrade_quote_record(tx, input.clone()) + }) { Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { ok: true, quote: Some(quote), @@ -4087,6 +4147,55 @@ mod tests { assert_eq!(next_period.row.cycle_remaining_points, 400); } + #[test] + fn membership_order_snapshot_is_frozen_into_the_order_and_read_back() { + let mut order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + order.kind = RuntimeProfileRechargeProductKind::Membership; + let snapshot = ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan: RuntimeProfileMembershipPlan::Max, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + period_points: 400, + grant_points: 150, + plan_before: Some(RuntimeProfileMembershipPlan::Plus), + cycle_kind_before: Some(RuntimeProfileMembershipCycleKind::Yearly), + period_points_before: Some(250), + amount_cents: 9_900, + price_breakdown_json: "{\"orderAmountCents\":9900}".to_string(), + }; + apply_profile_membership_order_snapshot_to_order(&mut order, &snapshot); + + let settlement = frozen_profile_membership_settlement(&order).expect("应读回冻结快照"); + assert_eq!( + settlement.change_kind, + RuntimeProfileMembershipChangeKind::Upgrade + ); + assert_eq!(settlement.plan, RuntimeProfileMembershipPlan::Max); + assert_eq!( + settlement.cycle_kind, + RuntimeProfileMembershipCycleKind::Yearly + ); + assert_eq!(settlement.period_points, 400); + assert_eq!(settlement.grant_points, 150); + assert_eq!( + settlement.plan_before, + Some(RuntimeProfileMembershipPlan::Plus) + ); + assert_eq!(settlement.period_points_before, Some(250)); + assert_eq!( + settlement.price_breakdown_json, + "{\"orderAmountCents\":9900}" + ); + } + + #[test] + fn membership_order_without_frozen_snapshot_falls_back_to_recompute() { + let mut order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + order.kind = RuntimeProfileRechargeProductKind::Membership; + // 中文注释:建单时没有冻结快照的历史订单返回 None,调用方会回退到按目录重算。 + assert!(frozen_profile_membership_settlement(&order).is_none()); + } + #[test] fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { assert!(should_skip_existing_tracking_event_id(true)); @@ -4666,43 +4775,27 @@ fn create_profile_recharge_order_record( 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 membership_snapshot = match product.kind { + RuntimeProfileRechargeProductKind::Membership => { + Some(build_profile_membership_order_snapshot( + ctx, + &validated_input.user_id, + &product.product_id, + created_at, + )?) + } + RuntimeProfileRechargeProductKind::Points => None, + }; + let amount_cents = membership_snapshot + .as_ref() + .map(|snapshot| snapshot.amount_cents) + .unwrap_or(product.price_cents); 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), - Some(purchase), - ) - } else { - (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 mut order = ProfileRechargeOrder { order_id, @@ -4711,13 +4804,13 @@ fn create_profile_recharge_order_record( product_title: product.title.clone(), kind: product.kind, amount_cents, - status, + status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: validated_input.payment_channel, - paid_at, + paid_at: None, provider_transaction_id: None, created_at, - points_delta, - membership_expires_at, + points_delta: 0, + membership_expires_at: None, expired_at: None, expiration_checked_at: None, expiration_provider_state: None, @@ -4737,7 +4830,19 @@ fn create_profile_recharge_order_record( membership_cycle_resets_at_after: None, membership_price_breakdown_json: None, }; - apply_profile_membership_change_to_order(&mut order, membership_change); + if let Some(snapshot) = membership_snapshot.as_ref() { + apply_profile_membership_order_snapshot_to_order(&mut order, snapshot); + } + + if should_settle_immediately { + let purchase = apply_profile_recharge_purchase(ctx, &order, created_at)?; + order.status = RuntimeProfileRechargeOrderStatus::Paid; + order.paid_at = Some(created_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()); if order.status == RuntimeProfileRechargeOrderStatus::Pending && should_schedule_profile_recharge_order_expiration(&order.payment_channel) @@ -4832,18 +4937,9 @@ fn mark_profile_recharge_order_paid_record( return Err("profile_recharge_order 当前状态不能确认支付".to_string()); } - 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 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, - )?; + // 支付结算只认订单建单时冻结的会员快照;充点订单按商品配置发放。 + let purchase = apply_profile_recharge_purchase(ctx, &order, paid_at)?; ctx.db .profile_recharge_order() @@ -6931,18 +7027,17 @@ struct ProfileRechargePurchaseOutcome { fn apply_profile_recharge_purchase( ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_id: &str, - order_amount_cents: u64, - order_created_at_micros: i64, + order: &ProfileRechargeOrder, paid_at: Timestamp, ) -> Result { - match product.kind { + let user_id = order.user_id.as_str(); + match order.kind { RuntimeProfileRechargeProductKind::Points => { + let product = profile_recharge_product_by_id(ctx, &order.product_id) + .ok_or_else(|| "recharge.product_id 不存在".to_string())?; 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); + resolve_runtime_profile_points_recharge_delta(&product, has_recharged); apply_profile_wallet_delta( ctx, user_id, @@ -6950,7 +7045,7 @@ fn apply_profile_recharge_purchase( RuntimeProfileWalletLedgerSourceType::PointsRecharge, &build_runtime_profile_recharge_wallet_ledger_id( user_id, - order_created_at_micros, + order.created_at.to_micros_since_unix_epoch(), &product.product_id, ), paid_at, @@ -6962,14 +7057,7 @@ fn apply_profile_recharge_purchase( }) } RuntimeProfileRechargeProductKind::Membership => { - let outcome = apply_profile_membership_purchase( - ctx, - user_id, - product, - order_id, - order_amount_cents, - paid_at, - )?; + let outcome = apply_profile_membership_purchase(ctx, user_id, order, paid_at)?; Ok(ProfileRechargePurchaseOutcome { points_delta: outcome.period_points_delta as i64, membership_expires_at: Some(outcome.expires_at), @@ -8188,6 +8276,22 @@ fn upsert_profile_membership_plan_record( updated_by: validated.admin_user_id, updated_at, }; + + // 中文注释:目录必须保持「rank 越高,月价 / 年价 / 每期泥点越高」。 + // 后台单次改价不能制造倒挂,否则升级补差会静默算出 0 元 / 0 泥点。 + let candidate = membership_plan_record_from_row(&inserted); + let catalog_rows = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + module_runtime::validate_runtime_profile_membership_plan_row_against_catalog( + &candidate, + &catalog_rows, + ) + .map_err(|error| error.to_string())?; + if let Some(row) = existing { ctx.db.profile_membership_plan().plan().delete(&row.plan); } @@ -8897,6 +9001,75 @@ struct ProfileMembershipPurchaseOutcome { change: ProfileMembershipChangeSnapshot, } +/// 建单时冻结的会员订单快照:档位、价格与补点在建单那一刻确定。 +/// +/// 支付结算只消费这份快照,不再回查 `profile_membership_plan` 目录,因此「下单后改价 / +/// 改每期泥点 / 下架档位」都不会改变已受理订单的结算口径,也便于后台按订单审计。 +/// 快照写入 `ProfileRechargeOrder` 已有的会员变更快照列,不新增 schema 字段。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + period_points: u64, + grant_points: u64, + plan_before: Option, + cycle_kind_before: Option, + period_points_before: Option, + amount_cents: u64, + price_breakdown_json: String, +} + +/// 会员订单结算输入:档位、每期泥点、补点与变更前后快照。 +/// +/// 有建单冻结快照时直接来自订单行;历史 Pending 订单(建单时还没有冻结快照) +/// 回退到按结算时目录重算,保持旧口径。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + period_points: u64, + grant_points: u64, + plan_before: Option, + cycle_kind_before: Option, + period_points_before: Option, + price_breakdown_json: String, +} + +/// 把建单冻结的会员快照写到订单行的会员变更快照列上。 +fn apply_profile_membership_order_snapshot_to_order( + order: &mut ProfileRechargeOrder, + snapshot: &ProfileMembershipOrderSnapshot, +) { + order.membership_change_kind = Some(snapshot.change_kind); + order.membership_plan_before = snapshot.plan_before; + order.membership_plan_after = Some(snapshot.plan); + order.membership_cycle_kind_before = snapshot.cycle_kind_before; + order.membership_cycle_kind_after = Some(snapshot.cycle_kind); + order.membership_period_points_before = snapshot.period_points_before; + order.membership_period_points_after = Some(snapshot.period_points); + order.membership_granted_points_delta = snapshot.grant_points as i64; + order.membership_price_breakdown_json = Some(snapshot.price_breakdown_json.clone()); +} + +/// 从订单行读取建单时冻结的会员快照;非会员订单或未冻结的历史订单返回 `None`。 +fn frozen_profile_membership_settlement( + order: &ProfileRechargeOrder, +) -> Option { + Some(ProfileMembershipSettlement { + change_kind: order.membership_change_kind?, + plan: order.membership_plan_after?, + cycle_kind: order.membership_cycle_kind_after?, + period_points: order.membership_period_points_after?, + grant_points: u64::try_from(order.membership_granted_points_delta).ok()?, + plan_before: order.membership_plan_before, + cycle_kind_before: order.membership_cycle_kind_before, + period_points_before: order.membership_period_points_before, + price_breakdown_json: order.membership_price_breakdown_json.clone()?, + }) +} + /// 订单里的价格变更快照(JSON)。人工复核退款时据此确认「按哪个价、补了多少点」。 fn membership_price_breakdown_json( target: &RuntimeProfileMembershipPlanSnapshot, @@ -9074,6 +9247,27 @@ fn advance_profile_membership_cycle_to( } } +/// 只读地把会员行投射到 `at` 时刻所在的那一期:与 [`refresh_profile_membership_cycle`] 同口径, +/// 但不写库、不发账本。 +/// +/// 建单需要「假如现在刷新会得到什么周期数据」才能算对补差;直接读库会用到已过期的账期 +/// (`cycle_resets_at` 在过去、`cycle_remaining_points` 还是上一期),因此用这个只读投影 +/// 替代写库刷新,保证建单金额与预览、结算三处口径一致。 +fn project_profile_membership_cycle_at( + ctx: &ReducerContext, + row: ProfileMembership, + at: Timestamp, +) -> ProfileMembership { + if !active_membership_row_at(&row, at) { + let mut expired = row; + expired.status = RuntimeProfileMembershipStatus::Normal; + expired.cycle_remaining_points = 0; + return expired; + } + let period_points = membership_plan_period_points(ctx, row.plan); + advance_profile_membership_cycle_to(row, at, period_points).row +} + /// 升级 / 报价共用的纯函数入参:两个套餐快照都来自目录表。 fn membership_upgrade_quote_input( row: &ProfileMembership, @@ -9147,25 +9341,17 @@ fn resolve_enabled_profile_recharge_order_product( 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( +/// 计算补差前先用 [`project_profile_membership_cycle_at`] 只读地把账期投射到建单时刻, +/// 不用过期账期、也不写库,因此建单金额与「预览报价」「支付结算」三处口径一致。 +fn build_profile_membership_order_snapshot( ctx: &ReducerContext, user_id: &str, product_id: &str, created_at: Timestamp, -) -> Result { +) -> 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) @@ -9174,14 +9360,31 @@ fn resolve_profile_membership_order_amount_cents( return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); } - let Some(row) = ctx + let active_row = ctx .db .profile_membership() .user_id() .find(&user_id.to_string()) .filter(|row| active_membership_row_at(row, created_at)) - else { - return Ok(target.price_cents); + .map(|row| project_profile_membership_cycle_at(ctx, row, created_at)); + + let Some(row) = active_row else { + return Ok(ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: target.period_points, + plan_before: None, + cycle_kind_before: None, + period_points_before: None, + amount_cents: target.price_cents, + price_breakdown_json: membership_price_breakdown_json( + &target, + None, + target.price_cents, + ), + }); }; if row.plan == plan { return Err("当前套餐已生效,无需重复购买".to_string()); @@ -9195,26 +9398,22 @@ fn resolve_profile_membership_order_amount_cents( &row, ¤t, &target, created_at, )) .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; - Ok(quote.amount_cents) -} - -fn resolve_profile_recharge_order_amount_cents( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - match product.kind { - RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), - RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents( - ctx, - user_id, - &product.product_id, - created_at, - ) - } - } + Ok(ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: quote.granted_points_delta, + plan_before: Some(current.plan), + cycle_kind_before: Some(current.cycle_kind), + period_points_before: Some(current.period_points), + amount_cents: quote.amount_cents, + price_breakdown_json: membership_price_breakdown_json( + &target, + Some(¤t), + quote.amount_cents, + ), + }) } fn membership_cycle_metadata( @@ -9618,26 +9817,18 @@ fn build_profile_membership_snapshot( /// 会员购买 / 补差升级写路径。 /// -/// - 有效期内:只允许「同周期升更高档位」,按目录表实时价格算补差、补点,刷新日与到期日不变。 -/// - 无有效会员(从未开通或已到期):按完整周期重新起算,锚点就是本次支付时刻。 +/// - 结算口径以订单建单时冻结的快照为准:档位、每期泥点、补点与价格都不再回查目录, +/// 因此下单后改价 / 改每期泥点 / 下架档位都不会影响已受理订单;历史 Pending 订单回退到按目录重算。 +/// - 有效期内:只允许「同周期升更高档位」,按快照补点,刷新日与到期日不变。 +/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻。 /// /// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( ctx: &ReducerContext, user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_id: &str, - order_amount_cents: u64, + order: &ProfileRechargeOrder, purchased_at: Timestamp, ) -> 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 active_row = ctx @@ -9647,82 +9838,176 @@ fn apply_profile_membership_purchase( .find(&user_id.to_string()) .filter(|row| active_membership_row_at(row, purchased_at)); - if let Some(row) = active_row { - if row.plan == plan { - return Err("当前套餐已生效,无需重复购买".to_string()); - } - 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, - purchased_at, - )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; - - 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); - - let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); - update_profile_wallet_balance_for_expiring_points( + let settlement = match frozen_profile_membership_settlement(order) { + Some(settlement) => settlement, + None => build_recomputed_profile_membership_settlement( ctx, - user_id, - 0, - quote.granted_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, - &ledger_id, + &active_row, + &order.product_id, + order.amount_cents, purchased_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, - )), - }, - }); + if settlement.change_kind == RuntimeProfileMembershipChangeKind::Upgrade { + if let Some(row) = active_row { + return apply_profile_membership_upgrade( + ctx, + user_id, + row, + &order.order_id, + &settlement, + purchased_at, + ); + } + // 中文注释:会员在支付前已到期,退化为按冻结档位重新起算,避免已付款订单卡死。 } - // 无有效会员:全新起算。到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 + apply_profile_membership_fresh_purchase( + ctx, + user_id, + &order.order_id, + &settlement, + purchased_at, + ) +} + +/// 历史 Pending 订单(建单时还没有冻结快照)的结算口径:按结算时的目录与会员状态重算。 +fn build_recomputed_profile_membership_settlement( + ctx: &ReducerContext, + active_row: &Option, + product_id: &str, + order_amount_cents: u64, + purchased_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()))?; + let Some(row) = active_row else { + return Ok(ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: target.period_points, + plan_before: None, + cycle_kind_before: None, + period_points_before: None, + price_breakdown_json: membership_price_breakdown_json( + &target, + None, + order_amount_cents, + ), + }); + }; + if row.plan == plan { + return Err("当前套餐已生效,无需重复购买".to_string()); + } + 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, + purchased_at, + )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + Ok(ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: quote.granted_points_delta, + plan_before: Some(current.plan), + cycle_kind_before: Some(current.cycle_kind), + period_points_before: Some(current.period_points), + price_breakdown_json: membership_price_breakdown_json( + &target, + Some(¤t), + order_amount_cents, + ), + }) +} + +/// 有效期内补差升级:按冻结快照换档并追加本期补点,刷新日与到期日不变。 +fn apply_profile_membership_upgrade( + ctx: &ReducerContext, + user_id: &str, + row: ProfileMembership, + order_id: &str, + settlement: &ProfileMembershipSettlement, + purchased_at: Timestamp, +) -> Result { + let plan = settlement.plan; + let cycle_kind = settlement.cycle_kind; + 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(settlement.period_points); + next_row.cycle_remaining_points = next_row + .cycle_remaining_points + .saturating_add(settlement.grant_points); + 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); + + let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + settlement.grant_points, + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, + &ledger_id, + purchased_at, + membership_cycle_metadata("upgrade", 0, settlement.grant_points, cycle_resets_at), + ); + + Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: settlement.grant_points, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan_before: settlement.plan_before, + plan_after: Some(plan), + cycle_kind_before: settlement.cycle_kind_before, + cycle_kind_after: Some(cycle_kind), + period_points_before: settlement.period_points_before, + period_points_after: Some(settlement.period_points), + granted_points_delta: settlement.grant_points 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(settlement.price_breakdown_json.clone()), + }, + }) +} + +/// 无有效会员时按冻结快照全新起算:到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 +fn apply_profile_membership_fresh_purchase( + ctx: &ReducerContext, + user_id: &str, + order_id: &str, + settlement: &ProfileMembershipSettlement, + purchased_at: Timestamp, +) -> Result { + let plan = settlement.plan; + let cycle_kind = settlement.cycle_kind; let expired_existing = ctx .db .profile_membership() @@ -9744,8 +10029,8 @@ fn apply_profile_membership_purchase( cycle_started_at_micros, )), cycle_resets_at: Some(cycle_resets_at), - cycle_granted_points: target.period_points, - cycle_remaining_points: target.period_points, + cycle_granted_points: settlement.period_points, + cycle_remaining_points: settlement.period_points, plan, cycle_index: 1, cycle_count: cycle_kind.cycle_count(), @@ -9758,16 +10043,21 @@ fn apply_profile_membership_purchase( ctx, user_id, 0, - target.period_points, + settlement.period_points, RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, &ledger_id, purchased_at, - membership_cycle_metadata("purchase", 0, target.period_points, Some(cycle_resets_at)), + membership_cycle_metadata( + "purchase", + 0, + settlement.period_points, + Some(cycle_resets_at), + ), ); Ok(ProfileMembershipPurchaseOutcome { expires_at, - period_points_delta: target.period_points, + period_points_delta: settlement.period_points, change: ProfileMembershipChangeSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Purchase, plan_before: expired_existing.as_ref().map(|row| row.plan), @@ -9777,8 +10067,8 @@ fn apply_profile_membership_purchase( 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, + period_points_after: Some(settlement.period_points), + granted_points_delta: settlement.period_points as i64, cycle_index: Some(1), expires_at_before_micros: expired_existing .as_ref() @@ -9789,11 +10079,7 @@ fn apply_profile_membership_purchase( .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, - )), + price_breakdown_json: Some(settlement.price_breakdown_json.clone()), }, }) }