fix(会员结算): 升级单冻结账期身份并在支付时校验
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

- ProfileMembershipOrderSnapshot / ProfileMembershipSettlement 增加 cycle_index_before 与 cycle_resets_at_before_micros,建单时写入订单已有的会员快照列,不新增 schema 字段。
- 支付结算前比对冻结期号与刷新时刻,账期已推进则返回「会员状态已变化,请重新下单」,避免按旧周期剩余比例算出的补点叠加到已重置的新周期。
- 抽出 frozen_membership_cycle_matches 纯函数并补单测;历史无快照订单冻结值为 None,跳过校验。
This commit is contained in:
2026-10-03 20:18:27 +08:00
parent 0fc564c4e5
commit 5826b2e56c
@@ -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<RuntimeProfileMembershipPlan>,
cycle_kind_before: Option<RuntimeProfileMembershipCycleKind>,
period_points_before: Option<u64>,
// 末位追加:下单当期的期号与刷新时刻,支付时若账期已推进则拒绝重复补点。
cycle_index_before: Option<u32>,
cycle_resets_at_before_micros: Option<i64>,
amount_cents: u64,
price_breakdown_json: String,
}
@@ -9117,6 +9183,9 @@ struct ProfileMembershipSettlement {
plan_before: Option<RuntimeProfileMembershipPlan>,
cycle_kind_before: Option<RuntimeProfileMembershipCycleKind>,
period_points_before: Option<u64>,
// 建单冻结的当期身份;历史重算订单为 `None`,此时不做账期推进校验。
cycle_index_before: Option<u32>,
cycle_resets_at_before_micros: Option<i64>,
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(&current),