From 42e4adab564bc503855a2ef8a0a54591deb7c12c Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 23 Jul 2026 14:12:44 +0800 Subject: [PATCH] =?UTF-8?q?vip=E4=BD=93=E9=AA=8C=E5=8D=A1=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/xerr/errors.go | 2 +- .../internal/service/wallet/vip.go | 3 +- .../vip_configurable_integration_test.go | 42 ++++++++++--------- .../mysql/vip_benefit_resource_repository.go | 41 +++++++++++++++++- .../storage/mysql/vip_program_repository.go | 8 ++-- .../mysql/vip_trial_card_repository.go | 32 +++++++++++++- 6 files changed, 101 insertions(+), 27 deletions(-) diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index feb016c8..ce8706b6 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -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" diff --git a/services/wallet-service/internal/service/wallet/vip.go b/services/wallet-service/internal/service/wallet/vip.go index 8a864674..06d25f75 100644 --- a/services/wallet-service/internal/service/wallet/vip.go +++ b/services/wallet-service/internal/service/wallet/vip.go @@ -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) diff --git a/services/wallet-service/internal/service/wallet/vip_configurable_integration_test.go b/services/wallet-service/internal/service/wallet/vip_configurable_integration_test.go index 72f7ab9e..3d865f81 100644 --- a/services/wallet-service/internal/service/wallet/vip_configurable_integration_test.go +++ b/services/wallet-service/internal/service/wallet/vip_configurable_integration_test.go @@ -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, diff --git a/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go b/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go index c1992b71..22a1c89d 100644 --- a/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go @@ -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 } diff --git a/services/wallet-service/internal/storage/mysql/vip_program_repository.go b/services/wallet-service/internal/storage/mysql/vip_program_repository.go index a92aece1..9dbc9d2a 100644 --- a/services/wallet-service/internal/storage/mysql/vip_program_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_program_repository.go @@ -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 + // 事实,最终身份继续使用 paid,ensureTieredVipResourceState 会在写事务中解除旧指针。 + if config.TrialCardEnabled && trial != nil && (!paid.Active || trial.Level >= paid.Level) { state.EffectiveVip = ledger.UserVip{ UserID: userID, Level: trial.Level, diff --git a/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go b/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go index 0eb9745d..d46a28cf 100644 --- a/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go @@ -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, `