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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user