hyapp-server/services/wallet-service/internal/service/wallet/vip_configurable_integration_test.go
2026-07-23 17:54:44 +08:00

851 lines
40 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)
}
vipResources, _, err := service.ListResources(ctx, resourcedomain.ListResourcesQuery{
AppCode: "fami", ResourceType: resourcedomain.TypeVIPTrialCard,
ActiveOnly: true, ManagerGrantOnly: true, Page: 1, PageSize: 20,
})
if err != nil {
t.Fatalf("list Fami VIP trial resources failed: %v", err)
}
var vip7ResourceID int64
for _, resource := range vipResources {
if resource.ResourceCode == "vip_trial_card_7" {
vip7ResourceID = resource.ResourceID
break
}
}
if vip7ResourceID <= 0 {
t.Fatal("Fami VIP7 trial resource is missing")
}
if _, err := service.GrantResource(ctx, resourcedomain.GrantResourceCommand{
AppCode: "fami", CommandID: "manager-center-generic-vip7-denied",
TargetUserID: 880002, ResourceID: vip7ResourceID, Quantity: 1,
DurationMS: int64(24 * time.Hour / time.Millisecond), Reason: "external trial",
OperatorUserID: 9001, GrantSource: resourcedomain.GrantSourceManagerCenter,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("generic manager-center VIP7 must be rejected by wallet owner, got %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.VIPProgramInactive) {
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")
_, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
AppCode: "fami", RequestID: "generic-equip-trial-3", UserID: userID,
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
})
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
t.Fatalf("generic backpack must reject VIP3 trial card below paid VIP6, got %v", err)
}
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 {
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 {
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 rejected VIP3 trial equip failed: %v", err)
}
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,
ResourceID: card6.TrialCard.ResourceID, EntitlementID: card6.TrialCard.EntitlementID,
})
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)
}
if state6.EffectiveVip.Level != 6 || state6.EquippedTrialCard == nil || state6.EquippedTrialCard.EntitlementID != card6.TrialCard.EntitlementID {
t.Fatalf("trial card switch mismatch: %+v", state6)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-trial-3-again", UserID: userID, EntitlementID: card3.TrialCard.EntitlementID,
}); !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,
}); 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)
}
// 体验卡错误必须稳定区分“实例不存在”和“绝对时间已过期”Flutter 不解析 message 猜分支。
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-missing-trial", UserID: userID, EntitlementID: "missing-entitlement",
}); !xerr.IsCode(err, xerr.VIPTrialCardNotFound) {
t.Fatalf("missing trial card must return VIP_TRIAL_CARD_NOT_FOUND, got %v", err)
}
repository.ExpireEntitlement(card6.TrialCard.EntitlementID, time.Now().Add(-time.Minute).UnixMilli())
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-expired-trial", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID,
}); !xerr.IsCode(err, xerr.VIPTrialCardExpired) {
t.Fatalf("expired trial card must return VIP_TRIAL_CARD_EXPIRED, got %v", err)
}
}
// TestFamiVIPPurchaseReplacesCoveredTrialCard 锁定购买与体验卡的三条产品边界:
// 购买等级高于或等于体验等级时回收卡片及全部体验资源;低于体验等级时保留体验态,
// 付费会员独立计时并在体验卡自然失效后作为回落身份。
func TestFamiVIPPurchaseReplacesCoveredTrialCard(t *testing.T) {
repository := mysqltest.NewRepository(t)
service := walletservice.New(repository)
ctx := appcode.WithContext(context.Background(), "fami")
createBadge := func(code string) resourcedomain.Resource {
t.Helper()
resource, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{
AppCode: "fami",
ResourceCode: code,
ResourceType: resourcedomain.TypeBadge,
Name: code,
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
AssetURL: "https://cdn.example/" + code + ".png",
MetadataJSON: `{"badge_form":"strip","badge_kind":"normal"}`,
OperatorUserID: 9001,
})
if err != nil {
t.Fatalf("create %s failed: %v", code, err)
}
return resource
}
vip2Badge := createBadge("vip2_trial_replace_badge")
vip3Badge := createBadge("vip3_paid_replace_badge")
vip5Badge := createBadge("vip5_trial_keep_badge")
for _, binding := range []struct {
level int32
resource resourcedomain.Resource
}{
{level: 2, resource: vip2Badge},
{level: 3, resource: vip3Badge},
{level: 5, resource: vip5Badge},
} {
repository.SetVIPLevelStatusForApp("fami", binding.level, ledger.VipStatusActive, 100)
repository.BindVIPBenefitResourceForApp(
"fami",
binding.level,
ledger.VipBenefitCodeMedal,
binding.resource.ResourceID,
resourcedomain.TypeBadge,
true,
)
if got := repository.CountRows(
"vip_level_benefits",
"app_code = ? AND level = ? AND benefit_code = ? AND resource_id = ? AND resource_type = ?",
"fami", binding.level, ledger.VipBenefitCodeMedal, binding.resource.ResourceID, resourcedomain.TypeBadge,
); got != 1 {
t.Fatalf("VIP%d badge binding was not persisted: resource=%+v rows=%d", binding.level, binding.resource, got)
}
if got := repository.CountRows(
"resources",
"app_code = ? AND resource_id = ? AND resource_type = ? AND status = ?",
"fami", binding.resource.ResourceID, resourcedomain.TypeBadge, resourcedomain.StatusActive,
); got != 1 {
t.Fatalf("VIP%d badge resource was not active: resource=%+v rows=%d", binding.level, binding.resource, got)
}
}
_, levels, err := service.ListAdminVipLevels(ctx)
if err != nil {
t.Fatalf("list Fami VIP levels failed: %v", err)
}
var vip3DurationMS int64
for _, level := range levels {
if level.Level == 3 {
vip3DurationMS = level.DurationMS
break
}
}
if vip3DurationMS <= 0 {
t.Fatalf("VIP3 duration is not configured: %+v", levels)
}
equippedBadges := func(userID int64) []resourcedomain.UserResourceEntitlement {
t.Helper()
items, err := service.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
AppCode: "fami", UserIDs: []int64{userID}, ResourceTypes: []string{resourcedomain.TypeBadge},
})
if err != nil {
t.Fatalf("list user %d equipped badges failed: %v", userID, err)
}
if len(items) != 1 {
t.Fatalf("user %d equipped badge projection mismatch: %+v", userID, items)
}
return items[0].Resources
}
const upgradedUserID int64 = 880011
repository.SetBalanceForApp("fami", upgradedUserID, 1_000)
vip2Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
AppCode: "fami", CommandID: "replace-trial-vip2", TargetUserID: upgradedUserID, Level: 2,
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001, Reason: "purchase replacement test",
})
if err != nil {
t.Fatalf("grant VIP2 trial card failed: %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-replaced-vip2", UserID: upgradedUserID,
EntitlementID: vip2Card.TrialCard.EntitlementID,
}); err != nil {
t.Fatalf("equip VIP2 trial card failed: %v", err)
}
beforeReplace := equippedBadges(upgradedUserID)
if len(beforeReplace) != 1 || beforeReplace[0].ResourceID != vip2Badge.ResourceID {
t.Fatalf(
"VIP2 trial resource was not equipped before purchase: equipped=%+v trial_grants=%d active_entitlements=%d equipment=%d",
beforeReplace,
repository.CountRows("resource_grants", "app_code = ? AND target_user_id = ? AND grant_source = ?", "fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial),
repository.CountRows("user_resource_entitlements", "app_code = ? AND user_id = ? AND status = 'active'", "fami", upgradedUserID),
repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ?", "fami", upgradedUserID),
)
}
if got := repository.CountRows(
"resource_grants",
"app_code = ? AND target_user_id = ? AND grant_source = ? AND status = ?",
"fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial, resourcedomain.GrantStatusDone,
); got == 0 {
t.Fatal("equipped VIP2 trial card did not create a trial benefit grant")
}
purchasedAt := time.Now().UnixMilli()
upgrade, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip3-over-trial-vip2", UserID: upgradedUserID, Level: 3,
})
if err != nil {
t.Fatalf("purchase VIP3 over VIP2 trial failed: %v", err)
}
if upgrade.State.PaidVip.Level != 3 || upgrade.State.EffectiveSource != ledger.VipEffectiveSourcePaid ||
upgrade.State.EffectiveVip.Level != 3 || upgrade.State.EquippedTrialCard != nil {
t.Fatalf("VIP3 purchase must atomically replace VIP2 trial identity: %+v", upgrade.State)
}
if upgrade.Vip.StartedAtMS < purchasedAt-2_000 ||
upgrade.Vip.ExpiresAtMS-upgrade.Vip.StartedAtMS != vip3DurationMS {
t.Fatalf("paid VIP3 must start a fresh configured window without trial remainder: vip=%+v duration=%d", upgrade.Vip, vip3DurationMS)
}
if got := repository.CountRows(
"user_vip_trial_cards",
"app_code = ? AND trial_card_id = ? AND status = ?",
"fami", vip2Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusRevoked,
); got != 1 {
t.Fatalf("replaced VIP2 trial card must be revoked, got %d", got)
}
if got := repository.CountRows(
"user_resource_entitlements",
"app_code = ? AND entitlement_id = ? AND status = ? AND remaining_quantity = 0",
"fami", vip2Card.TrialCard.EntitlementID, resourcedomain.GrantStatusRevoked,
); got != 1 {
t.Fatalf("replaced VIP2 card entitlement must be recovered, got %d", got)
}
if got := repository.CountRows(
"user_resource_entitlements",
`app_code = ? AND user_id = ? AND status = 'active' AND source_grant_id IN (
SELECT grant_id FROM resource_grants
WHERE app_code = ? AND target_user_id = ? AND grant_source = ?
)`,
"fami", upgradedUserID, "fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial,
); got != 0 {
t.Fatalf("replaced VIP2 trial resources must not remain active, got %d", got)
}
if got := repository.CountRows(
"user_resource_equipment",
`app_code = ? AND user_id = ? AND entitlement_id IN (
SELECT entitlement_id FROM user_resource_entitlements
WHERE app_code = ? AND user_id = ? AND source_grant_id IN (
SELECT grant_id FROM resource_grants
WHERE app_code = ? AND target_user_id = ? AND grant_source = ?
)
)`,
"fami", upgradedUserID, "fami", upgradedUserID,
"fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial,
); got != 0 {
t.Fatalf("replaced VIP2 trial equipment must be removed, got %d", got)
}
afterReplace := equippedBadges(upgradedUserID)
if len(afterReplace) != 1 || afterReplace[0].ResourceID != vip3Badge.ResourceID {
t.Fatalf("paid VIP3 resource must replace the worn VIP2 trial resource: %+v", afterReplace)
}
const equalUserID int64 = 880012
repository.SetBalanceForApp("fami", equalUserID, 1_000)
vip3Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
AppCode: "fami", CommandID: "replace-trial-vip3", TargetUserID: equalUserID, Level: 3,
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001, Reason: "equal purchase replacement test",
})
if err != nil {
t.Fatalf("grant equal VIP3 trial card failed: %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-replaced-vip3", UserID: equalUserID,
EntitlementID: vip3Card.TrialCard.EntitlementID,
}); err != nil {
t.Fatalf("equip equal VIP3 trial card failed: %v", err)
}
equalPurchase, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip3-over-trial-vip3", UserID: equalUserID, Level: 3,
})
if err != nil {
t.Fatalf("purchase VIP3 over equal trial failed: %v", err)
}
if equalPurchase.State.EffectiveSource != ledger.VipEffectiveSourcePaid ||
equalPurchase.State.EquippedTrialCard != nil ||
repository.CountRows(
"user_vip_trial_cards",
"app_code = ? AND trial_card_id = ? AND status = ?",
"fami", vip3Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusRevoked,
) != 1 {
t.Fatalf("equal paid level must replace and recover the trial card: %+v", equalPurchase.State)
}
const lowerPaidUserID int64 = 880013
repository.SetBalanceForApp("fami", lowerPaidUserID, 1_000)
vip5Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
AppCode: "fami", CommandID: "keep-trial-vip5", TargetUserID: lowerPaidUserID, Level: 5,
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001, Reason: "lower paid purchase test",
})
if err != nil {
t.Fatalf("grant VIP5 trial card failed: %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-kept-vip5", UserID: lowerPaidUserID,
EntitlementID: vip5Card.TrialCard.EntitlementID,
}); err != nil {
t.Fatalf("equip VIP5 trial card failed: %v", err)
}
lowerPurchase, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip3-under-trial-vip5", UserID: lowerPaidUserID, Level: 3,
})
if err != nil {
t.Fatalf("purchase VIP3 under VIP5 trial failed: %v", err)
}
if lowerPurchase.State.PaidVip.Level != 3 ||
lowerPurchase.State.EffectiveSource != ledger.VipEffectiveSourceTrial ||
lowerPurchase.State.EffectiveVip.Level != 5 ||
lowerPurchase.State.EquippedTrialCard == nil ||
lowerPurchase.State.EquippedTrialCard.EntitlementID != vip5Card.TrialCard.EntitlementID {
t.Fatalf("lower paid VIP must remain behind the higher trial identity: %+v", lowerPurchase.State)
}
if got := repository.CountRows(
"user_vip_trial_cards",
"app_code = ? AND trial_card_id = ? AND status = ?",
"fami", vip5Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusActive,
); got != 1 {
t.Fatalf("higher VIP5 trial card must remain active, got %d", got)
}
keptTrialBadges := equippedBadges(lowerPaidUserID)
if len(keptTrialBadges) != 1 || keptTrialBadges[0].ResourceID != vip5Badge.ResourceID {
t.Fatalf("higher VIP5 trial resource must remain equipped: %+v", keptTrialBadges)
}
}
// TestVipReadSyncPreservesLowerLevelEquipmentAndReplaysOutbox 验证 VIP8 手动佩戴 VIP6
// 麦位皮肤后,两个读取入口都不会恢复 VIP8 默认值;槽位确实丢失时仍会懒恢复,
// 且相同 vip_state_sync 事实再次生成只重放既有 outbox 行,不向接口泄漏 1062。
func TestVipReadSyncPreservesLowerLevelEquipmentAndReplaysOutbox(t *testing.T) {
repository := mysqltest.NewRepository(t)
service := walletservice.New(repository)
ctx := appcode.WithContext(context.Background(), "fami")
createMicSkin := func(code string) resourcedomain.Resource {
t.Helper()
resource, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{
AppCode: "fami",
ResourceCode: code,
ResourceType: resourcedomain.TypeMicSeatIcon,
Name: code,
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"mic_seat"},
AssetURL: "https://cdn.example/" + code + ".png",
OperatorUserID: 9001,
})
if err != nil {
t.Fatalf("create %s failed: %v", code, err)
}
return resource
}
vip6Skin := createMicSkin("vip6_read_sync_mic_skin")
vip8Skin := createMicSkin("vip8_read_sync_mic_skin")
repository.BindVIPBenefitResourceForApp(
"fami", 6, ledger.VipBenefitCodeMicSkin,
vip6Skin.ResourceID, resourcedomain.TypeMicSeatIcon, true,
)
repository.BindVIPBenefitResourceForApp(
"fami", 8, ledger.VipBenefitCodeMicSkin,
vip8Skin.ResourceID, resourcedomain.TypeMicSeatIcon, true,
)
repository.SetVIPLevelStatusForApp("fami", 8, ledger.VipStatusActive, 100)
const userID int64 = 880014
repository.SetBalanceForApp("fami", userID, 1_000)
if _, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip8-read-sync", UserID: userID, Level: 8,
}); err != nil {
t.Fatalf("purchase VIP8 failed: %v", err)
}
resources, err := service.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
AppCode: "fami", UserID: userID, ResourceType: resourcedomain.TypeMicSeatIcon, ActiveOnly: true,
})
if err != nil {
t.Fatalf("list VIP mic skins failed: %v", err)
}
var vip6EntitlementID string
for _, item := range resources {
if item.ResourceID == vip6Skin.ResourceID {
vip6EntitlementID = item.EntitlementID
break
}
}
if vip6EntitlementID == "" {
t.Fatalf("VIP8 purchase did not grant inherited VIP6 mic skin: %+v", resources)
}
if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
AppCode: "fami", CommandID: "equip-vip6-read-sync", UserID: userID,
ResourceID: vip6Skin.ResourceID, EntitlementID: vip6EntitlementID,
}); err != nil {
t.Fatalf("VIP8 user equip VIP6 mic skin failed: %v", err)
}
assertEquippedSkin := func(wantResourceID int64) {
t.Helper()
items, err := service.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
AppCode: "fami", UserID: userID, ResourceType: resourcedomain.TypeMicSeatIcon, ActiveOnly: true,
})
if err != nil {
t.Fatalf("list equipped VIP mic skin failed: %v", err)
}
var equippedResourceID int64
for _, item := range items {
if item.Equipped {
if equippedResourceID != 0 {
t.Fatalf("mic skin slot must remain single-select: %+v", items)
}
equippedResourceID = item.ResourceID
}
}
if equippedResourceID != wantResourceID {
t.Fatalf("equipped mic skin mismatch: got=%d want=%d resources=%+v", equippedResourceID, wantResourceID, items)
}
}
if _, err := service.GetVipState(ctx, userID); err != nil {
t.Fatalf("GetVipState after VIP6 selection failed: %v", err)
}
assertEquippedSkin(vip6Skin.ResourceID)
if _, _, err := service.ListVipPackages(ctx, userID); err != nil {
t.Fatalf("ListVipPackages after VIP6 selection failed: %v", err)
}
assertEquippedSkin(vip6Skin.ResourceID)
if got := repository.CountRows(
"wallet_outbox",
"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
); got != 0 {
t.Fatalf("read sync must not publish a default-equip event for a valid lower-level selection, got %d", got)
}
if _, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
AppCode: "fami", RequestID: "clear-vip-mic-before-first-sync", UserID: userID,
ResourceType: resourcedomain.TypeMicSeatIcon,
}); err != nil {
t.Fatalf("clear mic skin before first lazy recovery failed: %v", err)
}
if _, err := service.GetVipState(ctx, userID); err != nil {
t.Fatalf("first missing-slot lazy recovery failed: %v", err)
}
assertEquippedSkin(vip8Skin.ResourceID)
if got := repository.CountRows(
"wallet_outbox",
"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
); got != 1 {
t.Fatalf("first missing-slot recovery must publish exactly one sync fact, got %d", got)
}
if _, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
AppCode: "fami", RequestID: "clear-vip-mic-before-replay", UserID: userID,
ResourceType: resourcedomain.TypeMicSeatIcon,
}); err != nil {
t.Fatalf("clear mic skin before lazy replay failed: %v", err)
}
if _, _, err := service.ListVipPackages(ctx, userID); err != nil {
t.Fatalf("replayed missing-slot lazy recovery must not return duplicate-key error: %v", err)
}
assertEquippedSkin(vip8Skin.ResourceID)
if got := repository.CountRows(
"wallet_outbox",
"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
); got != 1 {
t.Fatalf("replayed sync fact must retain one immutable outbox row, got %d", got)
}
}
// 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 first.receipt.CoinBalance.Version <= 0 || first.receipt.CoinBalance.Version != second.receipt.CoinBalance.Version {
t.Fatalf("idempotent purchase must preserve original balance version: first=%+v second=%+v", first.receipt.CoinBalance, second.receipt.CoinBalance)
}
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.VIPPackageNotPurchasable) {
t.Fatalf("level above current level_count must be unreachable, got %v", err)
}
}