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
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:
@@ -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(¤t),
|
||||
|
||||
Reference in New Issue
Block a user