diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index db3474798..317b410d1 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -150,6 +150,8 @@ pub fn quote_runtime_profile_membership_upgrade( let current = input.current; let target = input.target; + // 年付补差按「本期还剩几个完整月」折算;期号来自只前进的账期水位, + // 因此这里直接相减得到的剩余整月不会因为重复刷新而漂移。 let cycle_count = target.cycle_kind.cycle_count(); let remaining_full_months = cycle_count.saturating_sub(input.cycle_index); let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( 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 98e74a997..f36cad305 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8167,6 +8167,10 @@ fn list_profile_membership_plan_records( } /// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。 +/// +/// 注意:本函数会先刷新该用户的会员 / 钱包账期(写库),因此「预览报价」并非纯只读操作。 +/// 这是有意保留的现状——保证预览、建单、结算三处读到同一口径的账期;副作用仅限与刷新一致, +/// 不额外引入其它写入。 fn build_profile_membership_upgrade_quote_record( ctx: &ReducerContext, input: RuntimeProfileMembershipUpgradeQuoteGetInput, @@ -8174,6 +8178,7 @@ fn build_profile_membership_upgrade_quote_record( let validated = build_runtime_profile_membership_upgrade_quote_input(input.user_id, input.target_plan) .map_err(|error| error.to_string())?; + // 有意保留的写操作:先把账期推进到当前时刻再报价;改成只读投影需先确认契约与前端预期。 refresh_profile_wallet_expiring_points(ctx, &validated.user_id, ctx.timestamp); let Some(row) = ctx @@ -9207,25 +9212,35 @@ fn membership_plan_records(ctx: &ReducerContext) -> Vec MembershipCycleAdvance { let mut row = row; + // 锚点:有效期内唯一的固定参考,所有账期窗口都由它重算(见 `membership_cycle_window`)。 let anchor_micros = row.started_at.to_micros_since_unix_epoch(); let at_micros = at.to_micros_since_unix_epoch(); let previous_remaining_points = row.cycle_remaining_points; + // 期数上限:年付最多 12 期,再留 2 期余量容错,避免坏数据把循环拖长。 let max_index = row.cycle_count.max(1).saturating_add(2); + // 起点取已落库的期号(水位下限),保证只前进、不后退。 let mut cycle_index = row.cycle_index.max(1); let mut window = membership_cycle_window(anchor_micros, cycle_index); let mut advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0; + // 仅当 `at` 已越过当前窗口结束时刻才推进;时钟回拨时条件不成立,整行保持原状。 while window.1 <= at_micros && cycle_index < max_index { cycle_index = cycle_index.saturating_add(1); window = membership_cycle_window(anchor_micros, cycle_index); advanced = true; } if advanced { + // 换期即重置本期额度;上期剩余额度由调用方按 `previous_remaining_points` 回收。 row.cycle_index = cycle_index; row.cycle_started_at = Some(Timestamp::from_micros_since_unix_epoch(window.0)); row.cycle_resets_at = Some(Timestamp::from_micros_since_unix_epoch(window.1)); @@ -9272,6 +9287,7 @@ fn membership_upgrade_quote_input( RuntimeProfileMembershipUpgradeQuoteInput { current: current.clone(), target: target.clone(), + // 期号即账期水位(1-based);年付补差按 `cycle_count - cycle_index` 折算剩余整月。 cycle_index: row.cycle_index.max(1), cycle_started_at_micros: row .cycle_started_at @@ -9354,6 +9370,7 @@ fn build_profile_membership_order_snapshot( return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); } + // 只读地把账期投射到建单时刻(不写库),避免用到过期账期导致金额与预览 / 结算不一致。 let active_row = ctx .db .profile_membership() @@ -9715,6 +9732,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti let period_points = membership_plan_period_points(ctx, row.plan); let advance = advance_profile_membership_cycle_to(row, now, period_points); + // 未换期(含时钟回拨)直接返回:不写库、不补发,靠已落库的窗口水位挡住账期回退。 if !advance.advanced { return; }