hyapp-server/services/wallet-service/internal/service/wallet/vip_configurable_integration_test.go

406 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}