From 5826b2e56c87521c8d0a1c4282d7c77996044b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 20:18:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E7=BB=93=E7=AE=97):=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=8D=95=E5=86=BB=E7=BB=93=E8=B4=A6=E6=9C=9F?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=B9=B6=E5=9C=A8=E6=94=AF=E4=BB=98=E6=97=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileMembershipOrderSnapshot / ProfileMembershipSettlement 增加 cycle_index_before 与 cycle_resets_at_before_micros,建单时写入订单已有的会员快照列,不新增 schema 字段。 - 支付结算前比对冻结期号与刷新时刻,账期已推进则返回「会员状态已变化,请重新下单」,避免按旧周期剩余比例算出的补点叠加到已重置的新周期。 - 抽出 frozen_membership_cycle_matches 纯函数并补单测;历史无快照订单冻结值为 None,跳过校验。 --- .../src/runtime/active/profile.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) 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),