vip体验卡问题

This commit is contained in:
zhx 2026-07-23 14:12:44 +08:00
parent 4fb1a69353
commit 42e4adab56
6 changed files with 101 additions and 27 deletions

View File

@ -113,7 +113,7 @@ const (
VIPLevelNotFound Code = "VIP_LEVEL_NOT_FOUND"
// VIPLevelDisabled 表示目标 VIP 等级当前不可购买。
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于购买或体验卡的目标等级。
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
// VIPProgramInactive 表示当前 App 未启用可执行的 VIP 体系。
VIPProgramInactive Code = "VIP_PROGRAM_INACTIVE"

View File

@ -171,7 +171,8 @@ func (s *Service) GrantVipTrialCard(ctx context.Context, command ledger.GrantVip
return s.repository.GrantVipTrialCard(ctx, command)
}
// EquipVipTrialCard 只切换通用装备指针,任一卡片的 effective/expires 时间都不会变化。
// EquipVipTrialCard 只切换通用装备指针,任一卡片的 effective/expires 时间都不会变化;
// repository 会在同一事务内拒绝低于当前 paid/trial 最终等级的体验卡。
func (s *Service) EquipVipTrialCard(ctx context.Context, command ledger.EquipVipTrialCardCommand) (ledger.VipTrialCard, ledger.VipState, error) {
command.RequestID = strings.TrimSpace(command.RequestID)
command.EquipmentCommandID = strings.TrimSpace(command.EquipmentCommandID)

View File

@ -15,7 +15,7 @@ import (
)
// TestFamiVIPReplaceAndTrialCardSwitch 使用真实 MySQL repository 验证 P1 最容易回归的三条状态边界:
// 高级购买丢弃剩余时间、体验卡可跨等级自由切换且旧卡不改期、体验卡排除金币返现。
// 高级购买丢弃剩余时间、体验卡不能降低当前等级且旧卡不改期、体验卡排除金币返现。
func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
repository := mysqltest.NewRepository(t)
service := walletservice.New(repository)
@ -158,28 +158,28 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
resourceEventsBefore := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged")
vipEventsBefore := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged")
genericCard3, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
_, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
AppCode: "fami", RequestID: "generic-equip-trial-3", UserID: userID,
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
})
if err != nil {
t.Fatalf("generic backpack equip VIP3 trial card failed: %v", err)
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
t.Fatalf("generic backpack must reject VIP3 trial card below paid VIP6, got %v", err)
}
if !genericCard3.Equipped || genericCard3.EntitlementID != card3.TrialCard.EntitlementID {
t.Fatalf("generic backpack response must return the selected equipped entitlement: %+v", genericCard3)
if metadata := xerr.MetadataOf(err); metadata["current_effective_level"] != "6" || metadata["required_level"] != "6" {
t.Fatalf("lower trial rejection must expose current level metadata, got %+v", metadata)
}
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged"); got != resourceEventsBefore+1 {
t.Fatalf("generic trial equip must publish UserResourceChanged exactly once: before=%d after=%d", resourceEventsBefore, got)
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged"); got != resourceEventsBefore {
t.Fatalf("rejected lower trial must not publish UserResourceChanged: before=%d after=%d", resourceEventsBefore, got)
}
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged"); got != vipEventsBefore+1 {
t.Fatalf("generic trial equip must publish VipEffectiveChanged exactly once: before=%d after=%d", vipEventsBefore, got)
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged"); got != vipEventsBefore {
t.Fatalf("rejected lower trial must not publish VipEffectiveChanged: before=%d after=%d", vipEventsBefore, got)
}
state3, err := service.GetVipState(ctx, userID)
if err != nil {
t.Fatalf("get state after generic VIP3 trial equip failed: %v", err)
t.Fatalf("get state after rejected VIP3 trial equip failed: %v", err)
}
if state3.EffectiveSource != ledger.VipEffectiveSourceTrial || state3.EffectiveVip.Level != 3 || state3.PaidVip.Level != 6 {
t.Fatalf("lower trial card must freely overlay paid VIP without destroying it: %+v", state3)
if state3.EffectiveSource != ledger.VipEffectiveSourcePaid || state3.EffectiveVip.Level != 6 || state3.EquippedTrialCard != nil {
t.Fatalf("rejected lower trial card must preserve paid VIP6 without equipment: %+v", state3)
}
_, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
AppCode: "fami", RequestID: "generic-equip-trial-6", UserID: userID,
@ -188,6 +188,12 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
if err != nil {
t.Fatalf("generic backpack switch to VIP6 trial card failed: %v", err)
}
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged"); got != resourceEventsBefore+1 {
t.Fatalf("accepted equal-level trial must publish UserResourceChanged exactly once: before=%d after=%d", resourceEventsBefore, got)
}
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged"); got != vipEventsBefore+1 {
t.Fatalf("accepted equal-level trial must publish VipEffectiveChanged exactly once: before=%d after=%d", vipEventsBefore, got)
}
state6, err := service.GetVipState(ctx, userID)
if err != nil {
t.Fatalf("get state after generic VIP6 trial equip failed: %v", err)
@ -195,14 +201,10 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
if state6.EffectiveVip.Level != 6 || state6.EquippedTrialCard == nil || state6.EquippedTrialCard.EntitlementID != card6.TrialCard.EntitlementID {
t.Fatalf("trial card switch mismatch: %+v", state6)
}
card3Again, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-trial-3-again", UserID: userID, EntitlementID: card3.TrialCard.EntitlementID,
})
if err != nil {
t.Fatalf("re-equip VIP3 trial card failed: %v", err)
}
if card3Again.ExpiresAtMS != card3.TrialCard.ExpiresAtMS {
t.Fatalf("switching cards must preserve the old card absolute expiry: first=%d again=%d", card3.TrialCard.ExpiresAtMS, card3Again.ExpiresAtMS)
}); !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
t.Fatalf("VIP3 trial must not replace currently equipped VIP6 trial, got %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-trial-6-again", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID,

View File

@ -589,6 +589,45 @@ func (r *Repository) ensureTieredVipResourceState(ctx context.Context, userID in
if err != nil {
return err
}
if program.Status == ledger.VipStatusActive && program.TrialCardEnabled &&
state.PaidVip.Active && state.EquippedTrialCard != nil &&
state.EquippedTrialCard.Level < state.PaidVip.Level {
// 写门禁上线前可能已经留下“付费 VIP6 装备 VIP3 卡”的历史指针。这里按
// (app,user,type,entitlement) 主键范围精确解除,不销毁仍在有效期内的卡;
// 之后用户付费身份失效时仍可重新使用这张卡。
result, err := tx.ExecContext(ctx, `
DELETE FROM user_resource_equipment
WHERE app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id = ?`,
appcode.FromContext(ctx), userID, resourcedomain.TypeVIPTrialCard,
state.EquippedTrialCard.EntitlementID,
)
if err != nil {
return err
}
removed, err := result.RowsAffected()
if err != nil {
return err
}
if removed > 0 {
cleanupCommandID := "vip_trial_lower_cleanup:" + stableHash(fmt.Sprintf(
"%s|%d|%s|%d|%d",
appcode.FromContext(ctx), userID, state.EquippedTrialCard.EntitlementID, state.PaidVip.Level, nowMS,
))
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
resourceOutboxEvent("UserResourceChanged", cleanupCommandID, userID, state.EquippedTrialCard.ResourceID, map[string]any{
"action": "unequip_lower_vip_trial", "resource_type": resourcedomain.TypeVIPTrialCard,
"entitlement_id": state.EquippedTrialCard.EntitlementID, "updated_at_ms": nowMS,
}, nowMS),
resourceOutboxEvent("VipEffectiveChanged", cleanupCommandID, userID, state.EquippedTrialCard.ResourceID, map[string]any{
"source": ledger.VipEffectiveSourcePaid, "level": state.PaidVip.Level,
"updated_at_ms": nowMS,
}, nowMS),
}); err != nil {
return err
}
}
state.EquippedTrialCard = nil
}
if program.Status == ledger.VipStatusActive && state.PaidVip.Active {
paid := state.PaidVip
paid.ProgramType = program.ProgramType
@ -742,7 +781,7 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
access := levels[userID]
effectiveLevel := access.Paid
effectiveSource := ledger.VipEffectiveSourcePaid
if trialCardEnabled && access.Trial > 0 {
if trialCardEnabled && access.Trial > 0 && access.Trial >= access.Paid {
effectiveLevel = access.Trial
effectiveSource = ledger.VipEffectiveSourceTrial
}

View File

@ -51,7 +51,7 @@ func (r *Repository) GetVipProgramConfig(ctx context.Context) (ledger.VipProgram
return config, benefits, err
}
// GetVipState 合并付费会员和当前佩戴体验卡。启用体验卡时不按等级限制切换
// GetVipState 合并付费会员和当前佩戴体验卡。低等级切换由 EquipVipTrialCard 在写事务拦截
// 卡片过期、卸下或 App 关闭体验卡能力后都会自然回落到仍有效的付费会员。
func (r *Repository) GetVipState(ctx context.Context, userID int64) (ledger.VipState, error) {
if r == nil || r.db == nil {
@ -180,7 +180,7 @@ func (r *Repository) batchAllowedVipBenefits(ctx context.Context, userIDs []int6
access := levels[userID]
effectiveLevel := access.Paid
effectiveSource := ledger.VipEffectiveSourcePaid
if program.TrialCardEnabled && access.Trial > 0 {
if program.TrialCardEnabled && access.Trial > 0 && access.Trial >= access.Paid {
effectiveLevel = access.Trial
effectiveSource = ledger.VipEffectiveSourceTrial
}
@ -320,7 +320,9 @@ func (r *Repository) getVipStateFromSnapshot(ctx context.Context, querier vipSna
state.EffectiveVip = paid
state.EffectiveSource = ledger.VipEffectiveSourcePaid
}
if config.TrialCardEnabled && trial != nil {
// 等级相同的体验卡仍按产品规则临时替换 paid更低等级的历史装备只保留 raw
// 事实,最终身份继续使用 paidensureTieredVipResourceState 会在写事务中解除旧指针。
if config.TrialCardEnabled && trial != nil && (!paid.Active || trial.Level >= paid.Level) {
state.EffectiveVip = ledger.UserVip{
UserID: userID,
Level: trial.Level,

View File

@ -269,7 +269,8 @@ func (r *Repository) vipTrialCardReceiptForCommand(ctx context.Context, command
return ledger.GrantVipTrialCardReceipt{TrialCard: card, State: state, ServerTimeMS: nowMS}, nil
}
// EquipVipTrialCard 只更新 vip_trial_card 单选装备槽。目标 entitlement 必须属于当前用户且仍在绝对有效期内。
// EquipVipTrialCard 只更新 vip_trial_card 单选装备槽。目标 entitlement 必须属于当前用户、
// 仍在绝对有效期内且等级不低于用户当前 paid/trial 最终等级。
func (r *Repository) EquipVipTrialCard(ctx context.Context, command ledger.EquipVipTrialCardCommand) (ledger.VipTrialCard, ledger.VipState, error) {
if r == nil || r.db == nil {
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -308,6 +309,24 @@ func (r *Repository) EquipVipTrialCard(ctx context.Context, command ledger.Equip
if program.Status != ledger.VipStatusActive || !program.TrialCardEnabled {
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPProgramInactive, "vip trial card is disabled for this app")
}
// membership 主键行既提供付费 VIP 快照,也作为同一用户体验卡切换的稳定串行化点。
// 即使 equipment 尚无记录,并发 VIP6/VIP3 佩戴也会按此锁排队,后到的低等级卡
// 必须看到先提交的高等级卡,不能再次把最终 VIP 覆盖回低等级。
currentPaid, err := r.ensureUserVipForUpdate(ctx, tx, command.UserID, nowMS)
if err != nil {
return ledger.VipTrialCard{}, ledger.VipState{}, err
}
currentEffectiveLevel := int32(0)
if currentPaid.Active {
currentEffectiveLevel = currentPaid.Level
}
currentTrial, err := r.queryEquippedVipTrialCardWithQuery(ctx, tx, command.UserID, nowMS)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return ledger.VipTrialCard{}, ledger.VipState{}, err
}
if err == nil && currentTrial.Level > currentEffectiveLevel {
currentEffectiveLevel = currentTrial.Level
}
// 锁序必须与 RevokeResourceGrant 保持 entitlement -> trial_card反向先锁 card 会和后台撤销形成死锁。
// entitlement 同时承担并发有效性门禁,避免撤销在校验后抢先提交又被重新写回 equipment。
entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, command.EntitlementID)
@ -335,6 +354,17 @@ func (r *Repository) EquipVipTrialCard(ctx context.Context, command ledger.Equip
if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeVIPTrialCard {
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Conflict, "vip trial card resource type mismatch")
}
if card.Level < currentEffectiveLevel {
level := fmt.Sprintf("%d", currentEffectiveLevel)
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.NewWithMetadata(
xerr.VIPDowngradeNotAllowed,
"vip trial card level is below current effective vip",
map[string]string{
"current_effective_level": level,
"required_level": level,
},
)
}
var currentEntitlementID string
err = tx.QueryRowContext(ctx, `