diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9b8536d4a..7ed3e6db8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 01b90bed1..2fafcf1c5 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -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 { + 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) => { diff --git a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts index f6cceeacc..6f5ad9564 100644 --- a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts @@ -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( '普通用户', ); diff --git a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts index 416a4d941..e871cdc11 100644 --- a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts +++ b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts @@ -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}天`; }