docs(会员): 补充会员账期推进与升级报价的逻辑注释

- profile.rs:advance_profile_membership_cycle_to 补充「锚点恒定、期号可推导、落库仅作单调水位、时钟回拨不重放」说明
- profile.rs:refresh_profile_membership_cycle 补充未换期(含时钟回拨)直接返回的守卫注释
- profile.rs:membership_upgrade_quote_input 注明期号即账期水位、年付补差按剩余整月折算
- profile.rs:build_profile_membership_upgrade_quote_record 与 build_profile_membership_order_snapshot 补充账期口径说明
- upgrade.rs:注明年付补差剩余整月来自单调水位、不会因重复刷新漂移

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-03 13:31:25 +08:00
parent 16f92f2110
commit 4eb4c6ae17
2 changed files with 20 additions and 0 deletions
@@ -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(
@@ -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<RuntimeProfileMembership
///
/// 每一期都从**原始锚点**(`started_at`)按自然月重算,因此 1/31 开通得到
/// 1/31 → 2/28 → 3/31,不会像按固定天数递推那样漂移成 3/28。
///
/// `cycle_index` 不是独立真相源:一个有效期里锚点恒定(新购写 `started_at`、期内升级只改档位
/// 与补点、过期重购才重置),所以期号完全由「锚点 + 当前时刻」推导。它落库只作为**单调水位**——
/// 推进只取 `max(已存期号, 推导期号)`,只前进、不后退;时钟回拨时 `at` 早于当前窗口结束时间,
/// 循环不进入、`advanced = false`,调用方据此拒绝写库与补发,避免同一账期被重放。
fn advance_profile_membership_cycle_to(
row: ProfileMembership,
at: Timestamp,
period_points: u64,
) -> 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;
}