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 5510948c4..2d25e248a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -4217,6 +4217,8 @@ mod tests { plan_before: Some(RuntimeProfileMembershipPlan::Plus), cycle_kind_before: Some(RuntimeProfileMembershipCycleKind::Yearly), period_points_before: Some(250), + cycle_index_before: Some(3), + cycle_resets_at_before_micros: Some(1_700_000_000_000), amount_cents: 9_900, price_breakdown_json: "{\"orderAmountCents\":9900}".to_string(), }; @@ -4239,6 +4241,11 @@ mod tests { Some(RuntimeProfileMembershipPlan::Plus) ); assert_eq!(settlement.period_points_before, Some(250)); + assert_eq!(settlement.cycle_index_before, Some(3)); + assert_eq!( + settlement.cycle_resets_at_before_micros, + Some(1_700_000_000_000) + ); assert_eq!( settlement.price_breakdown_json, "{\"orderAmountCents\":9900}" @@ -4253,6 +4260,62 @@ mod tests { assert!(frozen_profile_membership_settlement(&order).is_none()); } + #[test] + fn frozen_membership_cycle_identity_rejects_advanced_cycle() { + let anchor = beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"); + let day = PROFILE_RUNTIME_DAY_MICROS; + let row = ProfileMembership { + user_id: "user-1".to_string(), + status: RuntimeProfileMembershipStatus::Active, + tier: RuntimeProfileMembershipTier::Normal, + started_at: Timestamp::from_micros_since_unix_epoch(anchor), + expires_at: Timestamp::from_micros_since_unix_epoch(anchor + 400 * day), + updated_at: Timestamp::from_micros_since_unix_epoch(anchor), + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch(anchor)), + cycle_resets_at: Some(Timestamp::from_micros_since_unix_epoch(anchor + 28 * day)), + cycle_granted_points: 400, + cycle_remaining_points: 120, + cycle_period_days: 30, + plan: RuntimeProfileMembershipPlan::Starter, + cycle_index: 2, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + }; + let settlement = + |cycle_index_before, cycle_resets_at_before_micros| ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan: RuntimeProfileMembershipPlan::Pro, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + period_points: 600, + grant_points: 150, + plan_before: Some(RuntimeProfileMembershipPlan::Starter), + cycle_kind_before: Some(RuntimeProfileMembershipCycleKind::Yearly), + period_points_before: Some(400), + cycle_index_before, + cycle_resets_at_before_micros, + price_breakdown_json: "{}".to_string(), + }; + + assert!(frozen_membership_cycle_matches( + &settlement(Some(2), Some(anchor + 28 * day)), + &row + )); + // 中文注释:期号或刷新时刻任一被推进都视为账期已变化,拒绝旧升级单补点。 + assert!(!frozen_membership_cycle_matches( + &settlement(Some(3), Some(anchor + 28 * day)), + &row + )); + assert!(!frozen_membership_cycle_matches( + &settlement(Some(2), Some(anchor + 60 * day)), + &row + )); + // 中文注释:历史重算订单没有冻结账期身份,跳过校验。 + assert!(frozen_membership_cycle_matches( + &settlement(None, None), + &row + )); + } + #[test] fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { assert!(should_skip_existing_tracking_event_id(true)); @@ -9099,6 +9162,9 @@ struct ProfileMembershipOrderSnapshot { plan_before: Option, cycle_kind_before: Option, period_points_before: Option, + // 末位追加:下单当期的期号与刷新时刻,支付时若账期已推进则拒绝重复补点。 + cycle_index_before: Option, + cycle_resets_at_before_micros: Option, amount_cents: u64, price_breakdown_json: String, } @@ -9117,6 +9183,9 @@ struct ProfileMembershipSettlement { plan_before: Option, cycle_kind_before: Option, period_points_before: Option, + // 建单冻结的当期身份;历史重算订单为 `None`,此时不做账期推进校验。 + cycle_index_before: Option, + cycle_resets_at_before_micros: Option, price_breakdown_json: String, } @@ -9133,6 +9202,10 @@ fn apply_profile_membership_order_snapshot_to_order( 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_cycle_index = snapshot.cycle_index_before; + order.membership_cycle_resets_at_before = snapshot + .cycle_resets_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); order.membership_price_breakdown_json = Some(snapshot.price_breakdown_json.clone()); } @@ -9149,10 +9222,39 @@ fn frozen_profile_membership_settlement( plan_before: order.membership_plan_before, cycle_kind_before: order.membership_cycle_kind_before, period_points_before: order.membership_period_points_before, + cycle_index_before: order.membership_cycle_index, + cycle_resets_at_before_micros: order + .membership_cycle_resets_at_before + .map(|value| value.to_micros_since_unix_epoch()), price_breakdown_json: order.membership_price_breakdown_json.clone()?, }) } +/// 冻结的账期身份是否仍与结算时会员行一致。 +/// +/// 只有升级单会冻结当期身份;冻结值为 `None`(历史无快照的重算订单)时视为一致, +/// 因为这类订单的补点已按结算时周期现算。期号与刷新时刻任一被推进都判为不一致。 +fn frozen_membership_cycle_matches( + settlement: &ProfileMembershipSettlement, + row: &ProfileMembership, +) -> bool { + if let Some(cycle_index_before) = settlement.cycle_index_before { + if cycle_index_before != row.cycle_index { + return false; + } + } + if let Some(cycle_resets_at_before_micros) = settlement.cycle_resets_at_before_micros { + if Some(cycle_resets_at_before_micros) + != row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()) + { + return false; + } + } + true +} + /// 订单里的价格变更快照(JSON)。人工复核退款时据此确认「按哪个价、补了多少点」。 fn membership_price_breakdown_json( target: &RuntimeProfileMembershipPlanSnapshot, @@ -9473,6 +9575,8 @@ fn build_profile_membership_order_snapshot( plan_before: None, cycle_kind_before: None, period_points_before: None, + cycle_index_before: None, + cycle_resets_at_before_micros: None, amount_cents: target.price_cents, price_breakdown_json: membership_price_breakdown_json( &target, @@ -9502,6 +9606,10 @@ fn build_profile_membership_order_snapshot( plan_before: Some(current.plan), cycle_kind_before: Some(current.cycle_kind), period_points_before: Some(current.period_points), + cycle_index_before: Some(row.cycle_index), + cycle_resets_at_before_micros: row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()), amount_cents: quote.amount_cents, price_breakdown_json: membership_price_breakdown_json( &target, @@ -9987,6 +10095,11 @@ fn apply_profile_membership_purchase( { return Err("会员状态已变化,请重新下单".to_string()); } + // 中文注释:冻结快照记录了下单当期的期号与刷新时刻。若支付前账期已推进, + // 按旧周期剩余比例算出的补点会叠加到已重置的新周期上(多发泥点),因此拒绝。 + if !frozen_membership_cycle_matches(&settlement, &row) { + return Err("会员状态已变化,请重新下单".to_string()); + } return apply_profile_membership_upgrade( ctx, user_id, @@ -10036,6 +10149,8 @@ fn build_recomputed_profile_membership_settlement( plan_before: None, cycle_kind_before: None, period_points_before: None, + cycle_index_before: None, + cycle_resets_at_before_micros: None, price_breakdown_json: membership_price_breakdown_json( &target, None, @@ -10067,6 +10182,8 @@ fn build_recomputed_profile_membership_settlement( plan_before: Some(current.plan), cycle_kind_before: Some(current.cycle_kind), period_points_before: Some(current.period_points), + cycle_index_before: None, + cycle_resets_at_before_micros: None, price_breakdown_json: membership_price_breakdown_json( &target, Some(¤t),