修复会员旧档迁移与周期权益

修正旧月季年会员迁移到新同级档位的购买判定

修正 active 升级后续周期天数切换为新商品配置

修正 mock 立即结算订单先按支付前会员状态定价

修正会员商品周期泥点文案按周期天数展示

补充会员迁移升级与前端周期文案测试
This commit is contained in:
2026-07-09 18:16:34 +08:00
parent 104c376d6e
commit 12ba0e95f3
4 changed files with 303 additions and 39 deletions
@@ -19,7 +19,7 @@
## 2026-07-09 会员有效期与周期泥点重置分离
- 背景:账户会员制度新增 Starter / Basic / Pro / Ultimate 四档后,会员有效期、周期限时泥点和普通永久泥点容易被混成同一条时间线;升级场景尤其容易误把“补差额”实现成延长会员或重算 reset time。
- 决策:`profile_membership.expires_at` 只表示会员是否生效,`cycle_resets_at/cycle_period_days` 只表示会员周期限时泥点重置时间。同级会员购买只延长 `expires_at`,不发当前周期额外泥点,不移动 reset time;升级只补齐当前周期应发泥点差额并更新档位,不延长 `expires_at`,不移动 reset time。周期刷新由后端在个人中心、充值中心、任务中心、账单读取和钱包扣费入口执行,先清上周期剩余限时泥点,再发当前档位周期额度;资产操作退款按原消费流水恢复同一周期限时泥点,避免把限时泥点退成永久泥点。
- 决策:`profile_membership.expires_at` 只表示会员是否生效,`cycle_resets_at/cycle_period_days` 只表示会员周期限时泥点重置时间。同级会员购买只延长 `expires_at`,不发当前周期额外泥点,不移动 reset time;升级只补齐当前周期应发泥点差额并更新档位,不延长 `expires_at`,不移动 reset time,但后续周期天数切换为新商品配置。旧月 / 季 / 年卡购买同等级新会员档位时按同级迁移购买处理,延长有效期并切到 Starter / Basic / Pro,避免存量会员无法迁移;购买更高等级新档位仍按升级处理。周期刷新由后端在个人中心、充值中心、任务中心、账单读取和钱包扣费入口执行,先清上周期剩余限时泥点,再发当前档位周期额度;资产操作退款按原消费流水恢复同一周期限时泥点,避免把限时泥点退成永久泥点。
- 影响范围:`profile_membership``profile_recharge_product_config``profile_wallet_ledger`、充值中心、后台充值商品配置、个人资金 ViewModel、钱包扣费入口。
- 验证方式:`npm run spacetime:generate``cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml wechat_virtual_pay_params``npm run typecheck`、充值弹窗和资金 ViewModel 定向测试。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -1827,6 +1827,154 @@ mod tests {
);
}
#[test]
fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() {
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Month,
RuntimeProfileMembershipTier::Starter,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Season,
RuntimeProfileMembershipTier::Basic,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Year,
RuntimeProfileMembershipTier::Pro,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Starter,
RuntimeProfileMembershipTier::Starter,
),
Ok(ActiveMembershipPurchaseMode::Renew),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Month,
RuntimeProfileMembershipTier::Basic,
),
Ok(ActiveMembershipPurchaseMode::Upgrade),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Season,
RuntimeProfileMembershipTier::Pro,
),
Ok(ActiveMembershipPurchaseMode::Upgrade),
);
assert_eq!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Year,
RuntimeProfileMembershipTier::Ultimate,
),
Ok(ActiveMembershipPurchaseMode::Upgrade),
);
assert!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Basic,
RuntimeProfileMembershipTier::Starter,
)
.is_err()
);
assert!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Year,
RuntimeProfileMembershipTier::Basic,
)
.is_err()
);
assert!(
resolve_active_membership_purchase_mode(
RuntimeProfileMembershipTier::Basic,
RuntimeProfileMembershipTier::Season,
)
.is_err()
);
}
#[test]
fn legacy_membership_migration_renews_and_preserves_cycle_timing() {
let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS);
let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS);
let current_reset_at =
Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS);
let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS);
let row = apply_active_membership_renew_row(
ProfileMembership {
user_id: "user-1".to_string(),
status: RuntimeProfileMembershipStatus::Active,
tier: RuntimeProfileMembershipTier::Month,
started_at,
expires_at,
updated_at: started_at,
cycle_started_at: Some(started_at),
cycle_resets_at: Some(current_reset_at),
cycle_granted_points: 0,
cycle_remaining_points: 0,
cycle_period_days: 90,
},
RuntimeProfileMembershipTier::Starter,
30,
purchased_at,
);
assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter);
assert_eq!(
row.expires_at,
Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS),
);
assert_eq!(row.cycle_resets_at, Some(current_reset_at));
assert_eq!(row.cycle_period_days, 90);
assert_eq!(row.cycle_granted_points, 0);
assert_eq!(row.cycle_remaining_points, 0);
assert_eq!(row.updated_at, purchased_at);
}
#[test]
fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() {
let started_at = Timestamp::from_micros_since_unix_epoch(1_000);
let expires_at = Timestamp::from_micros_since_unix_epoch(90_000);
let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000);
let updated_at = Timestamp::from_micros_since_unix_epoch(2_000);
let (row, period_points_delta) = apply_active_membership_upgrade_row(
ProfileMembership {
user_id: "user-1".to_string(),
status: RuntimeProfileMembershipStatus::Active,
tier: RuntimeProfileMembershipTier::Basic,
started_at,
expires_at,
updated_at: started_at,
cycle_started_at: Some(started_at),
cycle_resets_at: Some(current_reset_at),
cycle_granted_points: 800,
cycle_remaining_points: 120,
cycle_period_days: 90,
},
RuntimeProfileMembershipTier::Pro,
7,
2_500,
updated_at,
);
assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro);
assert_eq!(row.expires_at, expires_at);
assert_eq!(row.cycle_resets_at, Some(current_reset_at));
assert_eq!(row.cycle_period_days, 7);
assert_eq!(row.cycle_granted_points, 2_500);
assert_eq!(row.cycle_remaining_points, 1_820);
assert_eq!(row.updated_at, updated_at);
assert_eq!(period_points_delta, 1_700);
}
#[test]
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() {
assert!(should_skip_existing_tracking_event_id(true));
@@ -2327,6 +2475,12 @@ fn create_profile_recharge_order_record(
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let should_settle_immediately =
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
let amount_cents = resolve_profile_recharge_order_amount_cents(
ctx,
&validated_input.user_id,
&product,
created_at,
)?;
let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately {
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
ctx,
@@ -2345,13 +2499,6 @@ fn create_profile_recharge_order_record(
(RuntimeProfileRechargeOrderStatus::Pending, None, 0, None)
};
let amount_cents = resolve_profile_recharge_order_amount_cents(
ctx,
&validated_input.user_id,
&product,
created_at,
)?;
let order = ProfileRechargeOrder {
order_id: build_runtime_profile_recharge_order_id(
&validated_input.user_id,
@@ -4316,6 +4463,12 @@ struct MembershipCycleInitialization {
cycle_resets_at: Timestamp,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActiveMembershipPurchaseMode {
Renew,
Upgrade,
}
fn normalized_membership_period_days(period_days: u32) -> u32 {
if period_days == 0 {
PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS
@@ -4364,6 +4517,44 @@ fn canonical_membership_pricing_tier(
}
}
fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool {
matches!(
tier,
RuntimeProfileMembershipTier::Month
| RuntimeProfileMembershipTier::Season
| RuntimeProfileMembershipTier::Year
)
}
fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 {
runtime_profile_membership_tier_rank(tier).unwrap_or(0)
}
fn resolve_active_membership_purchase_mode(
current_tier: RuntimeProfileMembershipTier,
next_tier: RuntimeProfileMembershipTier,
) -> Result<ActiveMembershipPurchaseMode, String> {
if current_tier == next_tier {
return Ok(ActiveMembershipPurchaseMode::Renew);
}
let current_rank = active_membership_tier_rank(current_tier);
let next_rank = active_membership_tier_rank(next_tier);
let is_legacy_equivalent_migration = current_rank == next_rank
&& is_legacy_membership_tier(current_tier)
&& canonical_membership_pricing_tier(current_tier) == next_tier;
if is_legacy_equivalent_migration {
return Ok(ActiveMembershipPurchaseMode::Renew);
}
if next_rank > current_rank {
return Ok(ActiveMembershipPurchaseMode::Upgrade);
}
Err("当前会员有效期内不能购买更低档会员".to_string())
}
fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 {
target_price_cents.saturating_sub(current_price_cents)
}
@@ -4404,16 +4595,11 @@ fn resolve_profile_membership_order_amount_cents(
return Ok(product.price_cents);
};
if row.tier == product.tier {
let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?;
if purchase_mode == ActiveMembershipPurchaseMode::Renew {
return Ok(product.price_cents);
}
let current_rank = runtime_profile_membership_tier_rank(row.tier).unwrap_or(0);
let next_rank = runtime_profile_membership_tier_rank(product.tier).unwrap_or(0);
if next_rank <= current_rank {
return Err("当前会员有效期内不能购买更低档会员".to_string());
}
let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0);
Ok(membership_upgrade_amount_cents(
product.price_cents,
@@ -4435,6 +4621,40 @@ fn resolve_profile_recharge_order_amount_cents(
}
}
fn apply_active_membership_upgrade_row(
mut row: ProfileMembership,
tier: RuntimeProfileMembershipTier,
period_days: u32,
period_points: u64,
updated_at: Timestamp,
) -> (ProfileMembership, u64) {
let period_points_delta = period_points.saturating_sub(row.cycle_granted_points);
row.tier = tier;
row.cycle_granted_points = row.cycle_granted_points.max(period_points);
row.cycle_remaining_points = row
.cycle_remaining_points
.saturating_add(period_points_delta);
row.cycle_period_days = period_days;
row.updated_at = updated_at;
(row, period_points_delta)
}
fn apply_active_membership_renew_row(
mut row: ProfileMembership,
tier: RuntimeProfileMembershipTier,
duration_days: u32,
purchased_at: Timestamp,
) -> ProfileMembership {
row.tier = tier;
row.expires_at = Timestamp::from_micros_since_unix_epoch(
row.expires_at
.to_micros_since_unix_epoch()
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)),
);
row.updated_at = purchased_at;
row
}
fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) {
ctx.db.profile_membership().user_id().delete(&row.user_id);
ctx.db.profile_membership().insert(row);
@@ -4766,30 +4986,21 @@ fn apply_profile_membership_purchase(
let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS);
let (next_row, period_points_delta) = match current {
Some(mut row) if active_membership_row_at(&row, purchased_at) => {
if row.tier == tier {
row.expires_at = Timestamp::from_micros_since_unix_epoch(
row.expires_at
.to_micros_since_unix_epoch()
.saturating_add(duration_micros),
);
row.updated_at = purchased_at;
(row, 0)
Some(row) if active_membership_row_at(&row, purchased_at) => {
let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?;
if purchase_mode == ActiveMembershipPurchaseMode::Renew {
(
apply_active_membership_renew_row(row, tier, duration_days, purchased_at),
0,
)
} else {
let current_rank = runtime_profile_membership_tier_rank(row.tier).unwrap_or(0);
let next_rank = runtime_profile_membership_tier_rank(tier).unwrap_or(0);
if next_rank <= current_rank {
return Err("当前会员有效期内不能购买更低档会员".to_string());
}
let period_points_delta = period_points.saturating_sub(row.cycle_granted_points);
row.tier = tier;
row.cycle_granted_points = row.cycle_granted_points.max(period_points);
row.cycle_remaining_points = row
.cycle_remaining_points
.saturating_add(period_points_delta);
row.cycle_period_days = normalized_membership_period_days(row.cycle_period_days);
row.updated_at = purchased_at;
(row, period_points_delta)
apply_active_membership_upgrade_row(
row,
tier,
period_days,
period_points,
purchased_at,
)
}
}
Some(row) => {
@@ -152,6 +152,42 @@ test('profile funds ViewModel formats recharge product and membership labels', (
}),
),
).toBe('每月200泥点');
expect(
buildRechargeProductValueLabel(
buildRechargeProduct({
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 7,
membershipPeriodPoints: 80,
membershipPeriodDays: 7,
}),
),
).toBe('每周80泥点');
expect(
buildRechargeProductValueLabel(
buildRechargeProduct({
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 14,
membershipPeriodPoints: 300,
membershipPeriodDays: 14,
}),
),
).toBe('每14天300泥点');
expect(
buildRechargeProductValueLabel(
buildRechargeProduct({
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 90,
membershipPeriodPoints: 1200,
membershipPeriodDays: 90,
}),
),
).toBe('每90天1200泥点');
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
'普通用户',
);
@@ -86,11 +86,28 @@ export function formatRechargePrice(priceCents: number) {
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function formatMembershipPeriodPrefix(periodDays: number) {
if (periodDays === 30) {
return '每月';
}
if (periodDays === 7) {
return '每周';
}
if (periodDays > 0) {
return `${periodDays}`;
}
return '每周期';
}
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
if (product.kind === 'membership') {
const membershipPeriodPoints = product.membershipPeriodPoints;
const membershipPeriodDays =
product.membershipPeriodDays > 0
? product.membershipPeriodDays
: product.durationDays;
return membershipPeriodPoints > 0
? `每月${membershipPeriodPoints}泥点`
? `${formatMembershipPeriodPrefix(membershipPeriodDays)}${membershipPeriodPoints}泥点`
: `${product.durationDays}`;
}