package wallet_test import ( "context" "strings" "testing" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" walletservice "hyapp/services/wallet-service/internal/service/wallet" "hyapp/services/wallet-service/internal/testutil/mysqltest" ) // TestFamiVIPReplaceAndTrialCardSwitch 使用真实 MySQL repository 验证 P1 最容易回归的三条状态边界: // 高级购买丢弃剩余时间、体验卡可跨等级自由切换且旧卡不改期、体验卡排除金币返现。 func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) { repository := mysqltest.NewRepository(t) service := walletservice.New(repository) ctx := appcode.WithContext(context.Background(), "fami") _, levels, err := service.ListAdminVipLevels(ctx) if err != nil { t.Fatalf("list Fami VIP levels failed: %v", err) } if len(levels) != 9 { t.Fatalf("Fami must expose 9 configurable levels, got %d", len(levels)) } durationMS := int64(30 * 24 * time.Hour / time.Millisecond) commands := make([]ledger.AdminVipLevelCommand, 0, len(levels)) for _, level := range levels { for index := range level.Benefits { if level.Benefits[index].BenefitCode == ledger.VipBenefitCodeDailyCoinRebate { // 模拟后台误把体验返现打开;保存与运行时都必须把这项硬规则收敛为 false。 level.Benefits[index].TrialEnabled = true level.Benefits[index].Status = ledger.VipStatusActive level.Benefits[index].MetadataJSON = `{"coin_amount":100}` } } commands = append(commands, ledger.AdminVipLevelCommand{ Level: level.Level, Name: level.Name, Status: ledger.VipStatusActive, PriceCoin: 100, DurationMS: durationMS, RewardResourceGroupID: 0, SortOrder: level.SortOrder, Benefits: level.Benefits, }) } if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil { t.Fatalf("enable Fami VIP levels failed: %v", err) } const userID int64 = 880001 repository.SetBalanceForApp("fami", userID, 10_000) first, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-3", UserID: userID, Level: 3}) if err != nil { t.Fatalf("purchase Fami VIP3 failed: %v", err) } // command_id 与 user_id 只在 app_code 内幂等;Lalu 使用相同值必须形成独立订单和状态。 laluCtx := appcode.WithContext(context.Background(), "lalu") repository.SetBalanceForApp("lalu", userID, 10_000) laluPurchase, err := service.PurchaseVip(laluCtx, ledger.PurchaseVipCommand{AppCode: "lalu", CommandID: "fami-vip-3", UserID: userID, Level: 3}) if err != nil { t.Fatalf("same command in Lalu app scope failed: %v", err) } if laluPurchase.State.ProgramConfig.ProgramType != ledger.VipProgramTypeLegacyTimed || laluPurchase.OrderID == first.OrderID { t.Fatalf("VIP purchase must stay app-scoped: fami=%+v lalu=%+v", first, laluPurchase) } upgrade, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-4", UserID: userID, Level: 4}) if err != nil { t.Fatalf("upgrade Fami VIP4 failed: %v", err) } if upgrade.Vip.ExpiresAtMS >= first.Vip.ExpiresAtMS+durationMS/2 { t.Fatalf("Fami upgrade incorrectly retained VIP3 remainder: first=%d upgrade=%d", first.Vip.ExpiresAtMS, upgrade.Vip.ExpiresAtMS) } if got := upgrade.Vip.ExpiresAtMS - upgrade.Vip.UpdatedAtMS; got < durationMS-2_000 || got > durationMS+2_000 { t.Fatalf("Fami upgrade must reset to one configured duration, got %d want %d", got, durationMS) } replayedFirst, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-3", UserID: userID, Level: 3}) if err != nil { t.Fatalf("replay original VIP3 purchase failed: %v", err) } if replayedFirst.Vip.Level != 3 || replayedFirst.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS || replayedFirst.State.PaidVip.Level != 4 { t.Fatalf("idempotent receipt must keep original order snapshot and current state separately: first=%+v replay=%+v", first, replayedFirst) } paid, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-6", UserID: userID, Level: 6}) if err != nil { t.Fatalf("upgrade Fami VIP6 failed: %v", err) } if paid.State.PaidVip.Level != 6 { t.Fatalf("paid VIP state mismatch: %+v", paid.State) } card3, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{ AppCode: "fami", CommandID: "trial-vip-3", TargetUserID: userID, Level: 3, DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin, OperatorUserID: 9001, Reason: "test trial 3", }) if err != nil { t.Fatalf("grant VIP3 trial card failed: %v", err) } card6, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{ AppCode: "fami", CommandID: "trial-vip-6", TargetUserID: userID, Level: 6, DurationMS: int64(20 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin, OperatorUserID: 9001, Reason: "test trial 6", }) if err != nil { t.Fatalf("grant VIP6 trial card failed: %v", err) } if _, err := service.GrantResource(ctx, resourcedomain.GrantResourceCommand{ AppCode: "fami", CommandID: "generic-trial-bypass", TargetUserID: userID, ResourceID: card6.TrialCard.ResourceID, Quantity: 1, DurationMS: durationMS, Reason: "must use dedicated vip trial grant", OperatorUserID: 9001, GrantSource: resourcedomain.GrantSourceAdmin, }); !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("generic resource grant must not bypass VIP trial metadata, got %v", err) } if card3.State.EffectiveSource != ledger.VipEffectiveSourcePaid || card6.State.EffectiveSource != ledger.VipEffectiveSourcePaid { t.Fatalf("granting a card must not auto-equip it: card3=%+v card6=%+v", card3.State, card6.State) } // repository 仍保留通用 equipment 能力供专用状态机内部复用,但必须拒绝外层直接调用,避免绕过 program 与 VIP outbox。 if _, err := repository.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ AppCode: "fami", RequestID: "raw-equip-trial-3", UserID: userID, ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID, }); !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "VIP trial-card state machine") { t.Fatalf("raw repository equip must reject vip_trial_card bypass, got %v", err) } if _, err := repository.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{ AppCode: "fami", RequestID: "raw-unequip-trial", UserID: userID, ResourceType: resourcedomain.TypeVIPTrialCard, }); !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "VIP trial-card state machine") { t.Fatalf("raw repository unequip must reject vip_trial_card bypass, got %v", err) } // 通用背包入口必须委托专用状态机,因此体验卡开关关闭时不能偷偷写入 equipment。 disabledProgram := card3.State.ProgramConfig disabledProgram.GrantMode = ledger.VipGrantModeDirectMembership disabledProgram.TrialCardEnabled = false if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: disabledProgram, OperatorUserID: 9001}); err != nil { t.Fatalf("disable trial cards before generic equip failed: %v", err) } if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ AppCode: "fami", RequestID: "generic-equip-disabled", UserID: userID, ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID, }); !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("generic backpack equip must preserve trial_card_enabled validation, got %v", err) } if got := repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ? AND resource_type = ?", "fami", userID, resourcedomain.TypeVIPTrialCard); got != 0 { t.Fatalf("disabled generic trial equip must not mutate equipment, got %d rows", got) } disabledProgram.GrantMode = ledger.VipGrantModeTrialCard disabledProgram.TrialCardEnabled = true if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: disabledProgram, OperatorUserID: 9001}); err != nil { t.Fatalf("restore trial cards before generic equip failed: %v", err) } 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{ 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 !genericCard3.Equipped || genericCard3.EntitlementID != card3.TrialCard.EntitlementID { t.Fatalf("generic backpack response must return the selected equipped entitlement: %+v", genericCard3) } 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, "VipEffectiveChanged"); got != vipEventsBefore+1 { t.Fatalf("generic trial equip must publish VipEffectiveChanged exactly once: 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) } 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) } _, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ AppCode: "fami", RequestID: "generic-equip-trial-6", UserID: userID, ResourceID: card6.TrialCard.ResourceID, EntitlementID: card6.TrialCard.EntitlementID, }) if err != nil { t.Fatalf("generic backpack switch to VIP6 trial card failed: %v", err) } state6, err := service.GetVipState(ctx, userID) if err != nil { t.Fatalf("get state after generic VIP6 trial equip failed: %v", err) } 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{ 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) } if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{ AppCode: "fami", RequestID: "equip-trial-6-again", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID, }); err != nil { t.Fatalf("re-equip VIP6 trial card failed: %v", err) } trialRebate, err := service.CheckVipBenefit(ctx, userID, ledger.VipBenefitCodeDailyCoinRebate) if err != nil { t.Fatalf("check trial rebate failed: %v", err) } if trialRebate.Allowed { t.Fatalf("trial card must not receive daily coin rebate: %+v", trialRebate) } // App 级体验卡开关只控制是否参与 effective 合并,不能删除用户背包事实;关闭后应立即回落付费 VIP。 trialProgram := trialRebate.State.ProgramConfig trialProgram.GrantMode = ledger.VipGrantModeDirectMembership trialProgram.TrialCardEnabled = false if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: trialProgram, OperatorUserID: 9001}); err != nil { t.Fatalf("disable Fami trial-card program failed: %v", err) } trialOffState, err := service.GetVipState(ctx, userID) if err != nil { t.Fatalf("get VIP state with trial cards disabled failed: %v", err) } if trialOffState.EquippedTrialCard == nil || trialOffState.EffectiveSource != ledger.VipEffectiveSourcePaid || trialOffState.EffectiveVip.Level != 6 { t.Fatalf("disabled trial cards must remain raw inventory and fall back to paid VIP: %+v", trialOffState) } trialProgram.GrantMode = ledger.VipGrantModeTrialCard trialProgram.TrialCardEnabled = true if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: trialProgram, OperatorUserID: 9001}); err != nil { t.Fatalf("restore Fami trial-card program failed: %v", err) } restoredTrialState, err := service.GetVipState(ctx, userID) if err != nil || restoredTrialState.EffectiveSource != ledger.VipEffectiveSourceTrial || restoredTrialState.EquippedTrialCard == nil { t.Fatalf("restored trial-card switch must reuse the original equipped card: state=%+v err=%v", restoredTrialState, err) } programOff := restoredTrialState.ProgramConfig programOff.Status = ledger.VipStatusDisabled if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: programOff, OperatorUserID: 9001}); err != nil { t.Fatalf("disable Fami VIP program failed: %v", err) } disabledState, err := service.GetVipState(ctx, userID) if err != nil || disabledState.PaidVip.Level != 6 || disabledState.EquippedTrialCard == nil || disabledState.EffectiveSource != ledger.VipEffectiveSourceNone || disabledState.EffectiveVip.Active { t.Fatalf("disabled program must expose raw facts without authorizing VIP: state=%+v err=%v", disabledState, err) } programOff.Status = ledger.VipStatusActive if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: programOff, OperatorUserID: 9001}); err != nil { t.Fatalf("restore Fami VIP program failed: %v", err) } unequipResult, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{ AppCode: "fami", RequestID: "generic-unequip-trial", UserID: userID, ResourceType: resourcedomain.TypeVIPTrialCard, }) unequipped := unequipResult.Unequipped if err != nil || !unequipped { t.Fatalf("generic backpack unequip trial card failed: result=%+v err=%v", unequipResult, err) } paidState, err := service.GetVipState(ctx, userID) if err != nil { t.Fatalf("get state after generic trial unequip failed: %v", err) } if paidState.EffectiveSource != ledger.VipEffectiveSourcePaid || paidState.EffectiveVip.Level != 6 { t.Fatalf("unequip must fall back to paid VIP6: %+v", paidState) } paidRebate, err := service.CheckVipBenefit(ctx, userID, ledger.VipBenefitCodeDailyCoinRebate) if err != nil || !paidRebate.Allowed { t.Fatalf("paid VIP6 must retain configured rebate benefit: allowed=%v err=%v result=%+v", paidRebate.Allowed, err, paidRebate) } } // TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前: // 两个并发首发即使余额只够一单,也都返回同一成功回执,而不是让后到请求误报余额不足。 func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) { repository := mysqltest.NewRepository(t) service := walletservice.New(repository) ctx := appcode.WithContext(context.Background(), "lalu") const userID int64 = 880003 const commandID = "vip-concurrent-exact-balance" repository.SetBalanceForApp("lalu", userID, 1_000) type purchaseResult struct { receipt ledger.PurchaseVipReceipt err error } start := make(chan struct{}) results := make(chan purchaseResult, 2) for range 2 { go func() { <-start receipt, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{ AppCode: "lalu", CommandID: commandID, UserID: userID, Level: 1, }) results <- purchaseResult{receipt: receipt, err: err} }() } close(start) first := <-results second := <-results if first.err != nil || second.err != nil { t.Fatalf("concurrent VIP replay must return two successes: first=%v second=%v", first.err, second.err) } if first.receipt.OrderID == "" || first.receipt.OrderID != second.receipt.OrderID || first.receipt.TransactionID != second.receipt.TransactionID { t.Fatalf("concurrent VIP replay returned different receipts: first=%+v second=%+v", first.receipt, second.receipt) } if first.receipt.CoinBalanceAfter != 0 || second.receipt.CoinBalanceAfter != 0 { t.Fatalf("idempotent purchase must debit exact balance once: first=%d second=%d", first.receipt.CoinBalanceAfter, second.receipt.CoinBalanceAfter) } if got := repository.CountRows("vip_purchase_orders", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 { t.Fatalf("concurrent replay persisted %d VIP orders, want 1", got) } if got := repository.CountRows("vip_command_locks", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 { t.Fatalf("concurrent replay persisted %d VIP command guards, want 1", got) } } // TestVipConfigurationListsDoNotExhaustPool 覆盖曾经的连接池自锁:主 levels rows 未关闭时, // 每个请求再从 r.db 借连接查权益,20 个并发会占满池后相互等待。当前实现先关闭游标并在原事务内装配。 func TestVipConfigurationListsDoNotExhaustPool(t *testing.T) { repository := mysqltest.NewRepository(t) service := walletservice.New(repository) base := appcode.WithContext(context.Background(), "fami") ctx, cancel := context.WithTimeout(base, 5*time.Second) defer cancel() start := make(chan struct{}) results := make(chan error, 24) for index := range 24 { go func(index int) { <-start if index%2 == 0 { _, _, err := service.ListAdminVipLevels(ctx) results <- err return } _, _, err := service.ListVipPackages(ctx, int64(990000+index)) results <- err }(index) } close(start) for range 24 { if err := <-results; err != nil { t.Fatalf("concurrent VIP list failed before pool drain: %v", err) } } } func TestVipProgramCanCreateAndResizeAppLevels(t *testing.T) { repository := mysqltest.NewRepository(t) service := walletservice.New(repository) ctx := appcode.WithContext(context.Background(), "future_app") base := ledger.VipProgramConfig{ AppCode: "future_app", ProgramType: ledger.VipProgramTypeTieredPrivilegeV1, LevelCount: 2, SameLevelExpiryPolicy: ledger.VipExpiryPolicyExtendRemaining, UpgradeExpiryPolicy: ledger.VipExpiryPolicyReplaceFromNow, DowngradePurchasePolicy: ledger.VipDowngradePurchasePolicyReject, BenefitInheritancePolicy: ledger.VipBenefitInheritanceTargetOnly, GrantMode: ledger.VipGrantModeTrialCard, TrialCardEnabled: true, Status: ledger.VipStatusActive, } created, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}) if err != nil { t.Fatalf("create VIP program for a new app failed: %v", err) } if created.ConfigVersion != 1 { t.Fatalf("new VIP program version mismatch: %+v", created) } _, levels, err := service.ListAdminVipLevels(ctx) if err != nil || len(levels) != 2 || levels[0].Status != ledger.VipStatusDisabled || levels[1].DurationMS <= 0 { t.Fatalf("new program must materialize disabled structural levels: levels=%+v err=%v", levels, err) } base.LevelCount = 3 if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}); err != nil { t.Fatalf("grow VIP program level_count failed: %v", err) } _, levels, err = service.ListAdminVipLevels(ctx) if err != nil || len(levels) != 3 || levels[2].Level != 3 || levels[2].Status != ledger.VipStatusDisabled { t.Fatalf("grown program missing disabled placeholder: levels=%+v err=%v", levels, err) } commands := make([]ledger.AdminVipLevelCommand, 0, 3) for _, level := range levels { commands = append(commands, ledger.AdminVipLevelCommand{ Level: level.Level, Name: level.Name, Status: ledger.VipStatusActive, PriceCoin: 100, DurationMS: level.DurationMS, SortOrder: level.SortOrder, }) } if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil { t.Fatalf("activate grown VIP levels failed: %v", err) } base.LevelCount = 2 if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}); err != nil { t.Fatalf("shrink VIP program level_count failed: %v", err) } repository.SetBalanceForApp("future_app", 880002, 1_000) _, err = service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "future_app", CommandID: "buy-hidden-level", UserID: 880002, Level: 3}) if !xerr.IsCode(err, xerr.VIPLevelNotFound) { t.Fatalf("level above current level_count must be unreachable, got %v", err) } }