vip体验卡问题
This commit is contained in:
parent
4fb1a69353
commit
42e4adab56
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, `
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user