package wallet_test import ( "context" "encoding/json" "strings" "testing" "time" "google.golang.org/grpc" activityv1 "hyapp.local/api/proto/activity/v1" "hyapp/pkg/appcode" "hyapp/pkg/walletmq" "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/service/wallet/ports" mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" "hyapp/services/wallet-service/internal/testutil/mysqltest" ) func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walletmq.WalletOutboxMessage { return walletmq.WalletOutboxMessage{ AppCode: record.AppCode, EventID: record.EventID, EventType: record.EventType, TransactionID: record.TransactionID, CommandID: record.CommandID, UserID: record.UserID, AssetType: record.AssetType, AvailableDelta: record.AvailableDelta, FrozenDelta: record.FrozenDelta, PayloadJSON: record.PayloadJSON, OccurredAtMS: record.CreatedAtMS, } } func waitForNextWalletVersionMillisecond(t *testing.T) { t.Helper() start := time.Now().UnixMilli() for time.Now().UnixMilli() <= start { time.Sleep(time.Millisecond) } } func requireWalletVersionAdvanced(t *testing.T, change string, before int64, after int64) { t.Helper() if after <= before { t.Fatalf("%s must advance gift catalog version: before=%d after=%d", change, before, after) } } // TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格,收礼收入进入 COIN,历史 GIFT_POINT 仍保持下线。 func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-gift-v2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9", }) if err != nil { t.Fatalf("DebitGift failed: %v", err) } if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.HeatValue != 14 || receipt.BalanceAfter != 86 || receipt.PriceVersion != "v9" { t.Fatalf("server price settlement mismatch: %+v", receipt) } if receipt.TransactionID == "" || receipt.BillingReceiptID == "" { t.Fatalf("receipt identifiers missing: %+v", receipt) } balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances sender failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 86 { t.Fatalf("sender COIN balance mismatch: %+v", balances) } balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetGiftPoint}) if err != nil { t.Fatalf("GetBalances target failed: %v", err) } if balanceAmount(balances, ledger.AssetGiftPoint) != 0 { t.Fatalf("target GIFT_POINT must stay zero after point removal: %+v", balances) } balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances target COIN failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 4 { t.Fatalf("target COIN income mismatch: %+v", balances) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 { t.Fatalf("gift debit should write sender debit and target income entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 4 { t.Fatalf("gift debit should write sender balance, target balance, debit and income outbox events, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 { t.Fatalf("gift debit should publish target income fact, got %d", got) } if receipt.HostPeriodDiamondAdded != 0 || receipt.HostPeriodCycleKey != "" { t.Fatalf("non-host gift must not credit host period diamonds: %+v", receipt) } if got := repository.CountRows("host_period_diamond_accounts", "user_id = ?", int64(10002)); got != 0 { t.Fatalf("non-host gift must not create host period diamond account, got %d", got) } } func TestHuwaaHostGiftPolicyCreditsPointWithoutCoinIncomeAndAllowsSelfGift(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalanceForApp("huwaa", 31001, 1000) repository.SetGiftPriceForApp("huwaa", "huwaa-rose", "v1", 100, 0, 100) repository.SeedWalletPolicyInstance("huwaa", "huwaa-point-live", 0, "active", 700000) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-self-host-point", RoomID: "room-huwaa", SenderUserID: 31001, TargetUserID: 31001, TargetIsHost: true, TargetHostRegionID: 12, GiftID: "huwaa-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, }) if err != nil { t.Fatalf("DebitGift with Huwaa point policy failed: %v", err) } if receipt.CoinSpent != 100 || receipt.BalanceAfter != 900 || receipt.GiftIncomeCoinAmount != 0 || receipt.GiftIncomeBalanceAfter != 0 || receipt.HostPeriodDiamondAdded != 0 || receipt.HostPointAdded != 70 || receipt.HostPointBalanceAfter != 70 || receipt.HostPointAssetType != ledger.AssetPoint || receipt.HostPointPolicyInstanceCode != "huwaa-point-live" || receipt.HostPointTemplateVersion != "v1" { t.Fatalf("Huwaa point gift receipt mismatch: %+v", receipt) } assertBalanceForApp(t, svc, "huwaa", 31001, ledger.AssetCoin, 900) assertBalanceForApp(t, svc, "huwaa", 31001, ledger.AssetPoint, 70) if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 { t.Fatalf("Huwaa point gift should publish one HostPointCredited event, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 0 { t.Fatalf("Huwaa point gift must not also publish legacy COIN income, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND asset_type = ?", receipt.TransactionID, ledger.AssetPoint); got != 1 { t.Fatalf("Huwaa point gift should write one POINT entry, got %d", got) } } func TestHostGiftWithoutActiveHuwaaPolicyKeepsLegacyCoinIncome(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(31011, 1000) repository.SetGiftPrice("lalu-rose", "v1", 100, 0, 100) repository.SetBalanceForApp("huwaa", 31021, 1000) repository.SetGiftPriceForApp("huwaa", "huwaa-disabled-rose", "v1", 100, 0, 100) repository.SeedWalletPolicyInstance("huwaa", "huwaa-point-disabled", 0, "disabled", 700000) svc := walletservice.New(repository) laluReceipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ AppCode: "lalu", CommandID: "cmd-lalu-host-coin-income", RoomID: "room-lalu", SenderUserID: 31011, TargetUserID: 31012, TargetIsHost: true, TargetHostRegionID: 20, GiftID: "lalu-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 20, SenderRegionID: 20, }) if err != nil { t.Fatalf("DebitGift without Huwaa policy failed: %v", err) } if laluReceipt.HostPointAdded != 0 || laluReceipt.GiftIncomeCoinAmount != 30 || laluReceipt.HostPeriodDiamondAdded != 100 { t.Fatalf("legacy Lalu gift income mismatch: %+v", laluReceipt) } assertBalance(t, svc, 31012, ledger.AssetCoin, 30) assertBalance(t, svc, 31012, ledger.AssetPoint, 0) disabledReceipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-disabled-host-coin-income", RoomID: "room-huwaa-disabled", SenderUserID: 31021, TargetUserID: 31022, TargetIsHost: true, TargetHostRegionID: 20, GiftID: "huwaa-disabled-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 20, SenderRegionID: 20, }) if err != nil { t.Fatalf("DebitGift with disabled Huwaa policy failed: %v", err) } if disabledReceipt.HostPointAdded != 0 || disabledReceipt.GiftIncomeCoinAmount != 30 || disabledReceipt.HostPeriodDiamondAdded != 100 { t.Fatalf("disabled policy should keep legacy income: %+v", disabledReceipt) } assertBalanceForApp(t, svc, "huwaa", 31022, ledger.AssetCoin, 30) assertBalanceForApp(t, svc, "huwaa", 31022, ledger.AssetPoint, 0) } func TestDebitDirectGiftCreditsReceiverAndKeepsDirectBizType(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(12001, 1000) repository.SetGiftPrice("direct-rose", "v1", 100, 0, 100) svc := walletservice.New(repository) receipt, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-direct-rose", SenderUserID: 12001, SenderRegionID: 1001, RegionID: 1001, TargetUserID: 12002, TargetIsHost: true, TargetHostRegionID: 7701, GiftID: "direct-rose", GiftCount: 1, PriceVersion: "v1", }) if err != nil { t.Fatalf("DebitDirectGift failed: %v", err) } if receipt.CoinSpent != 100 || receipt.BalanceAfter != 900 || receipt.GiftIncomeCoinAmount != 30 || receipt.GiftIncomeBalanceAfter != 30 || receipt.HostPeriodDiamondAdded != 100 { t.Fatalf("direct gift settlement mismatch: %+v", receipt) } assertBalance(t, svc, 12001, ledger.AssetCoin, 900) assertBalance(t, svc, 12002, ledger.AssetCoin, 30) if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ?", receipt.TransactionID, "direct_gift_debit"); got != 1 { t.Fatalf("direct gift transaction must use direct biz type, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND room_id = ''", receipt.TransactionID); got != 2 { t.Fatalf("direct gift entries must not pretend to belong to a room, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 { t.Fatalf("direct gift should publish receiver income fact, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPeriodDiamondCredited"); got != 1 { t.Fatalf("direct gift to active host should credit host period diamonds, got %d", got) } replayed, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-direct-rose", SenderUserID: 12001, SenderRegionID: 1001, RegionID: 1001, TargetUserID: 12002, TargetIsHost: true, TargetHostRegionID: 7701, GiftID: "direct-rose", GiftCount: 1, PriceVersion: "v1", }) if err != nil { t.Fatalf("DebitDirectGift replay failed: %v", err) } if replayed.TransactionID != receipt.TransactionID || replayed.GiftIncomeCoinAmount != receipt.GiftIncomeCoinAmount { t.Fatalf("direct gift replay receipt mismatch: got %+v want %+v", replayed, receipt) } } // TestBatchDebitGiftSettlesAllTargetsAtomically 验证多目标送礼一次扣总额,并为每个接收方写独立收礼事实。 func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) svc := walletservice.New(repository) receipt, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ CommandID: "cmd-gift-batch", RoomID: "room-1", SenderUserID: 10001, GiftID: "rose", GiftCount: 2, PriceVersion: "v9", SenderRegionID: 1001, Targets: []ledger.DebitGiftTargetCommand{ {CommandID: "cmd-gift-batch:target:10002", TargetUserID: 10002}, {CommandID: "cmd-gift-batch:target:10003", TargetUserID: 10003}, }, }) if err != nil { t.Fatalf("BatchDebitGift failed: %v", err) } if receipt.Aggregate.CoinSpent != 28 || receipt.Aggregate.GiftPointAdded != 0 || receipt.Aggregate.HeatValue != 28 || receipt.Aggregate.BalanceAfter != 72 || len(receipt.Targets) != 2 { t.Fatalf("batch aggregate mismatch: %+v", receipt) } for _, userID := range []int64{10002, 10003} { balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetGiftPoint}) if err != nil { t.Fatalf("GetBalances target %d failed: %v", userID, err) } if balanceAmount(balances, ledger.AssetGiftPoint) != 0 { t.Fatalf("target %d GIFT_POINT must stay zero after point removal: %+v", userID, balances) } balances, err = svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances target COIN %d failed: %v", userID, err) } if balanceAmount(balances, ledger.AssetCoin) != 4 { t.Fatalf("target %d COIN income mismatch: %+v", userID, balances) } } balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances sender failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 72 { t.Fatalf("sender COIN balance mismatch: %+v", balances) } if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003"); got != 2 { t.Fatalf("batch gift should write one transaction per target, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id IN (?, ?)", receipt.Targets[0].Receipt.TransactionID, receipt.Targets[1].Receipt.TransactionID); got != 4 { t.Fatalf("batch gift should write sender debit and target income entries per target, got %d", got) } again, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ CommandID: "cmd-gift-batch", RoomID: "room-1", SenderUserID: 10001, GiftID: "rose", GiftCount: 2, PriceVersion: "v9", SenderRegionID: 1001, Targets: []ledger.DebitGiftTargetCommand{ {CommandID: "cmd-gift-batch:target:10002", TargetUserID: 10002}, {CommandID: "cmd-gift-batch:target:10003", TargetUserID: 10003}, }, }) if err != nil { t.Fatalf("BatchDebitGift replay failed: %v", err) } if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 { t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again) } for _, userID := range []int64{10002, 10003} { balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances target COIN %d after replay failed: %v", userID, err) } if balanceAmount(balances, ledger.AssetCoin) != 4 { t.Fatalf("batch replay must not duplicate target income for %d: %+v", userID, balances) } } } // TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。 func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) svc := walletservice.New(repository) cycleBefore := time.Now().UTC().Format("2006-01") command := ledger.DebitGiftCommand{ CommandID: "cmd-gift-host-period", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9", TargetIsHost: true, TargetHostRegionID: 8801, } first, err := svc.DebitGift(context.Background(), command) if err != nil { t.Fatalf("host DebitGift failed: %v", err) } cycleAfter := time.Now().UTC().Format("2006-01") if first.HostPeriodDiamondAdded != 14 || first.HostPeriodCycleKey == "" { t.Fatalf("host period diamond receipt mismatch: %+v", first) } if first.HostPeriodCycleKey != cycleBefore && first.HostPeriodCycleKey != cycleAfter { t.Fatalf("host period cycle must use current UTC month, got %q", first.HostPeriodCycleKey) } if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND region_id = ? AND total_diamonds = ? AND gift_diamond_total = ?", int64(10002), first.HostPeriodCycleKey, int64(8801), int64(14), int64(14)); got != 1 { t.Fatalf("host period diamond account mismatch, got %d", got) } if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ? AND diamond_delta = ? AND diamond_after = ?", int64(10002), first.HostPeriodCycleKey, int64(14), int64(14)); got != 1 { t.Fatalf("host period diamond entry mismatch, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "HostPeriodDiamondCredited"); got != 1 { t.Fatalf("host period diamond outbox event mismatch, got %d", got) } second, err := svc.DebitGift(context.Background(), command) if err != nil { t.Fatalf("host DebitGift replay failed: %v", err) } if second.TransactionID != first.TransactionID || second.HostPeriodDiamondAdded != 14 || second.HostPeriodCycleKey != first.HostPeriodCycleKey { t.Fatalf("host period idempotent receipt mismatch: first=%+v second=%+v", first, second) } if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND total_diamonds = ?", int64(10002), first.HostPeriodCycleKey, int64(14)); got != 1 { t.Fatalf("host period replay must not add diamonds again, got %d", got) } if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 1 { t.Fatalf("host period replay must not add duplicate entries, got %d", got) } } func TestDebitGiftCreditsHostPeriodDiamondsByGlobalDefaultAndGiftType(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00") repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeLucky, "40.00") repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeSuperLucky, "50.00") cases := []struct { giftID string giftType string commandID string senderID int64 coinPrice int64 wantDiamond int64 }{ {giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, coinPrice: 100, wantDiamond: 100}, {giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, coinPrice: 100, wantDiamond: 10}, {giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, coinPrice: 99, wantDiamond: 0}, } for _, tc := range cases { repository.SetBalance(tc.senderID, 100) repository.SetGiftPrice(tc.giftID, "v1", tc.coinPrice, 100, tc.coinPrice) repository.SetGiftType(tc.giftID, tc.giftType) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: tc.commandID, RoomID: "room-ratio", SenderUserID: tc.senderID, SenderRegionID: 1001, TargetUserID: 12001, GiftID: tc.giftID, GiftCount: 1, PriceVersion: "v1", TargetIsHost: true, TargetHostRegionID: 8801, }) if err != nil { t.Fatalf("DebitGift %s failed: %v", tc.giftType, err) } if receipt.HostPeriodDiamondAdded != tc.wantDiamond { t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond) } } if got := repository.HostPeriodGiftDiamondTotal(12001); got != 110 { t.Fatalf("host period account total mismatch, got %d", got) } } func TestDebitGiftAppliesGlobalDefaultGiftDiamondRatioToHeatValueByGiftType(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeNormal, "30.00") repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeLucky, "40.00") repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeSuperLucky, "50.00") cases := []struct { giftID string giftType string command string senderID int64 targetID int64 coinPrice int64 heatUnit int64 wantHeat int64 wantIncome int64 }{ {giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, targetID: 14001, coinPrice: 100, heatUnit: 999, wantHeat: 100, wantIncome: 30}, {giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, targetID: 14002, coinPrice: 100, heatUnit: 999, wantHeat: 10, wantIncome: 10}, {giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, targetID: 14003, coinPrice: 200, heatUnit: 500, wantHeat: 2, wantIncome: 2}, } for _, tc := range cases { repository.SetBalance(tc.senderID, 500) repository.SetGiftPrice(tc.giftID, "v1", tc.coinPrice, 100, tc.heatUnit) repository.SetGiftType(tc.giftID, tc.giftType) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: tc.command, RoomID: "room-ratio", SenderUserID: tc.senderID, SenderRegionID: 1001, RegionID: 8801, TargetUserID: tc.targetID, GiftID: tc.giftID, GiftCount: 1, PriceVersion: "v1", }) if err != nil { t.Fatalf("DebitGift %s failed: %v", tc.giftType, err) } if receipt.HeatValue != tc.wantHeat || receipt.GiftPointAdded != 0 || receipt.CoinSpent != tc.coinPrice { t.Fatalf("%s room contribution ratio mismatch: %+v want heat %d", tc.giftType, receipt, tc.wantHeat) } balances, err := svc.GetBalances(context.Background(), tc.targetID, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances target income %s failed: %v", tc.giftType, err) } if balanceAmount(balances, ledger.AssetCoin) != tc.wantIncome { t.Fatalf("%s target income mismatch: got %+v want %d", tc.giftType, balances, tc.wantIncome) } } } func TestDebitGiftUsesRegionalReturnCoinRatioWithoutChangingHostDiamonds(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetBalance(14501, 500) repository.SetGiftPrice("regional-return-gift", "v1", 100, 100, 100) repository.SetGiftType("regional-return-gift", resourcedomain.GiftTypeNormal) repository.SetGiftDiamondRatio(7701, resourcedomain.GiftTypeNormal, "80.00") repository.SetGiftReturnCoinRatio(7701, resourcedomain.GiftTypeNormal, "45.00") receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-regional-return-coin", RoomID: "room-regional-return", SenderUserID: 14501, SenderRegionID: 1001, RegionID: 7701, TargetUserID: 14502, GiftID: "regional-return-gift", GiftCount: 1, PriceVersion: "v1", TargetIsHost: true, TargetHostRegionID: 7701, }) if err != nil { t.Fatalf("DebitGift regional return failed: %v", err) } if receipt.HeatValue != 100 || receipt.HostPeriodDiamondAdded != 100 { t.Fatalf("return coin ratio must not change heat or host diamonds: %+v", receipt) } balances, err := svc.GetBalances(context.Background(), 14502, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances target income failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 45 { t.Fatalf("regional return coin income mismatch: %+v", balances) } } func TestDebitGiftAllowsZeroReturnCoinRatio(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) cases := []struct { name string giftID string giftType string regionID int64 senderID int64 targetID int64 wantHeat int64 }{ {name: "lucky", giftID: "zero-return-lucky-gift", giftType: resourcedomain.GiftTypeLucky, regionID: 7702, senderID: 14601, targetID: 14602, wantHeat: 10}, {name: "super-lucky", giftID: "zero-return-super-lucky-gift", giftType: resourcedomain.GiftTypeSuperLucky, regionID: 7703, senderID: 14603, targetID: 14604, wantHeat: 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { repository.SetBalance(tc.senderID, 500) repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100) repository.SetGiftType(tc.giftID, tc.giftType) // 0% 返币是后台可配置的合法业务结果;扣费和房间贡献仍要按礼物真实价格结算。 repository.SetGiftReturnCoinRatio(tc.regionID, tc.giftType, "0.00") receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-zero-return-" + tc.name, RoomID: "room-zero-return-" + tc.name, SenderUserID: tc.senderID, SenderRegionID: 1001, RegionID: tc.regionID, TargetUserID: tc.targetID, GiftID: tc.giftID, GiftCount: 1, PriceVersion: "v1", }) if err != nil { t.Fatalf("DebitGift with zero return ratio failed: %v", err) } if receipt.CoinSpent != 100 || receipt.HeatValue != tc.wantHeat || receipt.BalanceAfter != 400 { t.Fatalf("zero return ratio settlement mismatch: %+v want heat %d", receipt, tc.wantHeat) } assertBalance(t, svc, tc.senderID, ledger.AssetCoin, 400) assertBalance(t, svc, tc.targetID, ledger.AssetCoin, 0) if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 0 { t.Fatalf("zero return ratio must not publish gift income event, got %d", got) } }) } } func TestBatchDebitGiftAppliesGlobalDefaultGiftDiamondRatioToEachTargetHeatValue(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(15001, 1000) repository.SetGiftPrice("batch-room-ratio-gift", "v1", 100, 100, 999) repository.SetGiftType("batch-room-ratio-gift", resourcedomain.GiftTypeLucky) repository.SetGiftDiamondRatio(9901, resourcedomain.GiftTypeLucky, "25.00") svc := walletservice.New(repository) receipt, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ CommandID: "cmd-batch-room-ratio", RoomID: "room-ratio", SenderUserID: 15001, SenderRegionID: 1001, RegionID: 9901, GiftID: "batch-room-ratio-gift", GiftCount: 1, PriceVersion: "v1", Targets: []ledger.DebitGiftTargetCommand{ {CommandID: "cmd-batch-room-ratio:target:15002", TargetUserID: 15002}, {CommandID: "cmd-batch-room-ratio:target:15003", TargetUserID: 15003}, }, }) if err != nil { t.Fatalf("BatchDebitGift failed: %v", err) } if receipt.Aggregate.HeatValue != 20 || len(receipt.Targets) != 2 { t.Fatalf("batch aggregate room contribution ratio mismatch: %+v", receipt) } for _, target := range receipt.Targets { if target.Receipt.HeatValue != 10 || target.Receipt.GiftPointAdded != 0 { t.Fatalf("target room contribution ratio mismatch: %+v", target) } } } // TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。 func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-gift-host-no-region", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, TargetIsHost: true, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT, got %v", err) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-gift-host-no-region"); got != 0 { t.Fatalf("invalid host period command must not write transaction, got %d", got) } } // TestGetActiveHostSalaryPolicyReadsRuntimePolicy 验证 H5 展示读取的是工资结算 runtime 表里的生效政策和档位。 func TestGetActiveHostSalaryPolicyReadsRuntimePolicy(t *testing.T) { repository := mysqltest.NewRepository(t) nowMs := time.Now().UnixMilli() repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 901, Name: "current host policy", RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeHalfMonth, SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, GiftCoinToDiamondRatio: "1.0000", ResidualDiamondToUSDRate: "0.0100", EffectiveFromMs: nowMs - 1000, EffectiveToMs: 0, Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUSDMinor: 1500, HostCoinReward: 200, AgencySalaryUSDMinor: 300, SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUSDMinor: 4500, HostCoinReward: 500, AgencySalaryUSDMinor: 900, SortOrder: 2}, }, }) svc := walletservice.New(repository) policy, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", "", nowMs) if err != nil { t.Fatalf("GetActiveHostSalaryPolicy failed: %v", err) } if !found { t.Fatal("expected active host salary policy") } if policy.PolicyID != 901 || policy.Name != "current host policy" || policy.SettlementMode != ledger.HostSalarySettlementModeHalfMonth || policy.RegionID != 8801 { t.Fatalf("policy mismatch: %+v", policy) } if policy.SettlementTriggerMode != ledger.HostSalarySettlementTriggerManual { t.Fatalf("policy trigger mode mismatch: %+v", policy) } if len(policy.Levels) != 2 || policy.Levels[1].LevelNo != 2 || policy.Levels[1].HostSalaryUSDMinor != 4500 || policy.Levels[1].AgencySalaryUSDMinor != 900 { t.Fatalf("policy levels mismatch: %+v", policy.Levels) } if _, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", ledger.HostSalarySettlementTriggerAutomatic, nowMs); err != nil { t.Fatalf("GetActiveHostSalaryPolicy with trigger failed: %v", err) } else if found { t.Fatal("automatic trigger query should not match manual policy") } } // TestGetHostSalaryProgressReadsCurrentCycleDiamonds 验证 H5 等级进度读取工资周期钻石账户,查询不到时保持 0 累计。 func TestGetHostSalaryProgressReadsCurrentCycleDiamonds(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "salary-progress", 200, 10, 10) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-salary-progress", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 13002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-progress", SenderRegionID: 8801, TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("salary gift failed: %v", err) } progress, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 13002, receipt.HostPeriodCycleKey, 0) if err != nil { t.Fatalf("GetHostSalaryProgress failed: %v", err) } if progress.HostUserID != 13002 || progress.CycleKey != receipt.HostPeriodCycleKey || progress.RegionID != 8801 || progress.AgencyOwnerUserID != 30001 || progress.TotalDiamonds != 14 || progress.GiftDiamondTotal != 14 { t.Fatalf("host salary progress mismatch: %+v", progress) } missing, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 99999, "2026-06", 0) if err != nil { t.Fatalf("GetHostSalaryProgress missing account failed: %v", err) } if missing.HostUserID != 99999 || missing.CycleKey != "2026-06" || missing.TotalDiamonds != 0 { t.Fatalf("missing progress must return zero account: %+v", missing) } } // TestHostSalarySettlementCreditsIncrementalRewards 走真实送礼入钻石账户后,验证日结只发放等级累计差额。 func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1000) repository.SetGiftPrice("rose", "salary", 200, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 101, Name: "daily salary", RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, }, }) svc := walletservice.New(repository) first, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-salary-gift-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("first salary gift failed: %v", err) } result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-run-1", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: first.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("first salary settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("first settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) assertBalance(t, svc, 10002, ledger.AssetCoin, 90) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-salary-gift-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("second salary gift failed: %v", err) } if second.HostPeriodCycleKey != first.HostPeriodCycleKey { t.Fatalf("salary gifts should be in same cycle: first=%q second=%q", first.HostPeriodCycleKey, second.HostPeriodCycleKey) } result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-run-2", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: first.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("second salary settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("second settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300) assertBalance(t, svc, 10002, ledger.AssetCoin, 198) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80) if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 { t.Fatalf("salary settlement records mismatch, got %d", got) } result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-run-3", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: first.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("noop salary settlement failed: %v", err) } if result.ClaimedCount != 0 || result.ProcessedCount != 0 { t.Fatalf("settlement without new diamonds should not claim rows: %+v", result) } } // TestHostSalaryManualPolicyRequiresManualTrigger 验证手动政策不会被 cron 默认自动结算,必须由后台人工入口显式传 manual。 func TestHostSalaryManualPolicyRequiresManualTrigger(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 500) repository.SetGiftPrice("rose", "salary-manual", 200, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 105, Name: "manual salary", RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, }, }) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-salary-manual-gift", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-manual", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("manual salary gift failed: %v", err) } result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-manual-auto", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("automatic scan for manual policy failed: %v", err) } if result.ClaimedCount != 0 || result.ProcessedCount != 0 { t.Fatalf("manual policy must not be claimed by automatic scan: %+v", result) } result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-manual-explicit", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, TriggerMode: ledger.HostSalarySettlementTriggerManual, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("manual salary settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 { t.Fatalf("manual settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) } // TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets 验证后台人工结算按身份拆分钱包,Agency 行不会顺手发 Host 工资。 func TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1000) repository.SetGiftPrice("rose", "salary-role-split", 120, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 303, Name: "manual role split salary", RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, }, }) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-role-split-gift", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-role-split", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("role split gift failed: %v", err) } agencyResult, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "role-split-agency", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, TriggerMode: ledger.HostSalarySettlementTriggerManual, SettlementRole: "agency", CycleKey: receipt.HostPeriodCycleKey, UserIDs: []int64{10002}, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("agency-only settlement failed: %v", err) } if agencyResult.ProcessedCount != 1 || agencyResult.SuccessCount != 1 || agencyResult.FailureCount != 0 { t.Fatalf("agency-only result mismatch: %+v", agencyResult) } assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 0) assertBalance(t, svc, 10002, ledger.AssetCoin, 0) if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND settlement_role = ? AND agency_salary_usd_minor_delta = ? AND host_salary_usd_minor_delta = 0", int64(10002), receipt.HostPeriodCycleKey, "agency", int64(50)); got != 1 { t.Fatalf("agency-only record mismatch, got %d", got) } if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND settled_agency_salary_usd_minor = ? AND settled_host_salary_usd_minor = 0 AND last_settled_total_diamonds = 0", int64(10002), receipt.HostPeriodCycleKey, int64(50)); got != 1 { t.Fatalf("agency-only progress should not close host side, got %d", got) } hostResult, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "role-split-host", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, TriggerMode: ledger.HostSalarySettlementTriggerManual, SettlementRole: "host", CycleKey: receipt.HostPeriodCycleKey, UserIDs: []int64{10002}, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("host-only settlement failed: %v", err) } if hostResult.ProcessedCount != 1 || hostResult.SuccessCount != 1 || hostResult.FailureCount != 0 { t.Fatalf("host-only result mismatch: %+v", hostResult) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) assertBalance(t, svc, 10002, ledger.AssetCoin, 90) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND settlement_role = ? AND host_salary_usd_minor_delta = ? AND agency_salary_usd_minor_delta = 0", int64(10002), receipt.HostPeriodCycleKey, "host", int64(150)); got != 1 { t.Fatalf("host-only record mismatch, got %d", got) } if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND settled_host_salary_usd_minor = ? AND settled_host_coin_reward = ? AND settled_agency_salary_usd_minor = ? AND last_settled_total_diamonds = ?", int64(10002), receipt.HostPeriodCycleKey, int64(150), int64(90), int64(50), receipt.HostPeriodDiamondAdded); got != 1 { t.Fatalf("split settlement should close progress after both roles are paid, got %d", got) } } // TestHostSalaryMonthEndCreditsResidualAndClearsCycle 验证月底结算补发剩余钻石折美元,并逻辑关闭当月周期。 func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1000) repository.SetGiftPrice("rose", "salary-residual", 350, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 102, Name: "daily residual", RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, ResidualDiamondToUSDRate: "0.01", Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 500, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, }, }) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-salary-residual-gift", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-residual", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("residual salary gift failed: %v", err) } if _, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-residual-daily", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }); err != nil { t.Fatalf("daily residual salary settlement failed: %v", err) } result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-residual-month-end", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementTypeMonthEnd, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("month-end residual salary settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("month-end settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 400) if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), receipt.HostPeriodCycleKey, int64(250)); got != 1 { t.Fatalf("month-end progress should store residual and cleared marker, got %d", got) } result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "salary-residual-after-clear", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("after-clear salary settlement failed: %v", err) } if result.ClaimedCount != 0 { t.Fatalf("cleared cycle must not be claimed again: %+v", result) } } // TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle 走通“已发布政策 -> 主播收礼 -> 多次增量结算 -> 月底清算 -> 下周期重新起算”的真实账务链路。 func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 2000) repository.SetGiftPrice("rose", "salary-full-lifecycle", 120, 10, 10) // 管理后台发布后 wallet-service 消费的是 host_agency_salary_policies 运行时快照;这里直接 seed 该表来锁定送礼和结算链路。 repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 301, Name: "published daily salary", RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, ResidualDiamondToUSDRate: "0.01", Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, }, }) svc := walletservice.New(repository) ctx := context.Background() previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01") first, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-full-lifecycle-gift-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-full-lifecycle", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("first lifecycle gift failed: %v", err) } if first.HostPeriodDiamondAdded != 120 { t.Fatalf("first gift should add host diamonds: %+v", first) } // DebitGift 使用真实当前月份入账;测试把真实送礼产生的账户移动到上月,模拟月底清算前的完整历史周期。 repository.MoveHostPeriodCycle(10002, first.HostPeriodCycleKey, previousCycle) result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "full-lifecycle-daily-level-1", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: previousCycle, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("level 1 daily settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("level 1 daily settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) assertBalance(t, svc, 10002, ledger.AssetCoin, 90) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle) if len(previousIDs) != 1 || previousIDs[0] == "" { t.Fatalf("level 1 settlement id missing: %v", previousIDs) } t.Logf("settlement ids after level 1: %v", previousIDs) result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "full-lifecycle-daily-repeat", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: previousCycle, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("repeat daily settlement failed: %v", err) } if result.ClaimedCount != 0 || result.ProcessedCount != 0 { t.Fatalf("repeat daily settlement should not claim unchanged account: %+v", result) } if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) { t.Fatalf("repeat daily settlement must not create record: before=%v after=%v", previousIDs, ids) } second, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-full-lifecycle-gift-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 3, PriceVersion: "salary-full-lifecycle", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("second lifecycle gift failed: %v", err) } if second.HostPeriodDiamondAdded != 360 { t.Fatalf("second gift should add host diamonds: %+v", second) } repository.MoveHostPeriodCycle(10002, second.HostPeriodCycleKey, previousCycle) result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "full-lifecycle-daily-level-2", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: previousCycle, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("level 2 daily settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("level 2 daily settlement result mismatch: %+v", result) } // 第二次只补发累计 2 级与已发 1 级之间的差额,最终余额等于 2 级累计权益。 assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300) assertBalance(t, svc, 10002, ledger.AssetCoin, 198) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80) previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) if len(previousIDs) != 2 { t.Fatalf("level 2 settlement should create second record: %v", previousIDs) } t.Logf("settlement ids after level 2: %v", previousIDs) result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "full-lifecycle-month-end", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementTypeMonthEnd, CycleKey: previousCycle, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("month-end lifecycle settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("month-end lifecycle result mismatch: %+v", result) } // 480 钻石达到 2 级后剩余 80 钻石,按 0.01 USD/diamond 折算为 80 美分并标记周期已清算。 assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 380) if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND last_settled_total_diamonds = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), previousCycle, int64(480), int64(80)); got != 1 { t.Fatalf("month-end progress should store residual and clear marker, got %d", got) } previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) if len(previousIDs) != 3 { t.Fatalf("month-end settlement should create third record: %v", previousIDs) } t.Logf("settlement ids after month-end clear: %v", previousIDs) result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "full-lifecycle-month-end-repeat", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementTypeMonthEnd, CycleKey: previousCycle, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("repeat month-end settlement failed: %v", err) } if result.ClaimedCount != 0 || result.ProcessedCount != 0 { t.Fatalf("cleared cycle must not be claimed by repeat month-end: %+v", result) } if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) { t.Fatalf("repeat month-end must not create record: before=%v after=%v", previousIDs, ids) } third, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-full-lifecycle-gift-next-cycle", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-full-lifecycle", TargetIsHost: true, TargetHostRegionID: 8801, TargetAgencyOwnerUserID: 30001, }) if err != nil { t.Fatalf("next-cycle gift failed: %v", err) } result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "full-lifecycle-next-cycle-level-1", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: third.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("next-cycle daily settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("next-cycle daily result mismatch: %+v", result) } // 新 cycle_key 使用独立进度,因此月末清算后的下一周期从 1 级累计权益重新开始发放。 assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 530) assertBalance(t, svc, 10002, ledger.AssetCoin, 288) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 130) nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey) if len(nextIDs) != 1 || nextIDs[0] == "" { t.Fatalf("next-cycle settlement id missing: %v", nextIDs) } t.Logf("settlement ids after next cycle level 1: %v", nextIDs) } // TestHostSalaryHalfMonthSettlementUsesPolicyMode 验证半月结政策不会被日结批次误结算,只会由 half-month 批次处理。 func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 500) repository.SetGiftPrice("rose", "salary-half-month", 200, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ PolicyID: 302, Name: "published half-month salary", RegionID: 8802, Status: "active", SettlementMode: ledger.HostSalarySettlementModeHalfMonth, Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, }, }) svc := walletservice.New(repository) ctx := context.Background() receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-half-month-policy-gift", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10003, GiftID: "rose", GiftCount: 1, PriceVersion: "salary-half-month", TargetIsHost: true, TargetHostRegionID: 8802, TargetAgencyOwnerUserID: 30002, }) if err != nil { t.Fatalf("half-month gift failed: %v", err) } result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "half-month-daily-should-skip", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeDaily, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("daily settlement for half-month policy failed: %v", err) } if result.ClaimedCount != 0 || result.ProcessedCount != 0 { t.Fatalf("daily batch must skip half-month policy: %+v", result) } result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ AppCode: "lalu", RunID: "half-month-policy-settlement", WorkerID: "salary-test", BatchSize: 10, SettlementType: ledger.HostSalarySettlementModeHalfMonth, CycleKey: receipt.HostPeriodCycleKey, NowMs: time.Now().UnixMilli(), }) if err != nil { t.Fatalf("half-month settlement failed: %v", err) } if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("half-month settlement result mismatch: %+v", result) } assertBalance(t, svc, 10003, ledger.AssetHostSalaryUSD, 150) assertBalance(t, svc, 10003, ledger.AssetCoin, 90) assertBalance(t, svc, 30002, ledger.AssetAgencySalaryUSD, 50) if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" { t.Fatalf("half-month settlement id missing: %v", ids) } } // TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼;同一 COIN 账户保留扣费和收礼收入两条分录,但余额事件只发净变更。 func TestDebitGiftAllowsSelfGift(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-self-gift", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10001, GiftID: "rose", GiftCount: 2, PriceVersion: "v9", }) if err != nil { t.Fatalf("self DebitGift failed: %v", err) } if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.BalanceAfter != 90 { t.Fatalf("self gift settlement mismatch: %+v", receipt) } balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 90 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 { t.Fatalf("self gift balances mismatch: %+v", balances) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 { t.Fatalf("self gift should write debit and income entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletBalanceChanged"); got != 1 { t.Fatalf("self gift should publish one net balance event, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ?", receipt.TransactionID, "WalletBalanceChanged", int64(-10)); got != 1 { t.Fatalf("self gift balance event should carry net -10 delta, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 { t.Fatalf("self gift should still publish income fact, got %d", got) } } // TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。 func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1000) repository.SetGiftPrice("rose", "v9", 7, 3, 11) repository.SetGiftPrice("rocket", "v2", 100, 50, 200) svc := walletservice.New(repository) commands := []ledger.DebitGiftCommand{ {CommandID: "cmd-wall-rose-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9"}, {CommandID: "cmd-wall-rose-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 3, PriceVersion: "v9"}, {CommandID: "cmd-wall-rocket", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rocket", GiftCount: 1, PriceVersion: "v2"}, } for _, command := range commands { if _, err := svc.DebitGift(context.Background(), command); err != nil { t.Fatalf("DebitGift %s failed: %v", command.CommandID, err) } } if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 { t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got) } records, err := repository.ClaimPendingWalletOutbox(context.Background(), "gift-wall-mq-test", 20, time.Now().Add(time.Minute).UnixMilli()) if err != nil { t.Fatalf("ClaimPendingWalletOutbox failed: %v", err) } processed := 0 for _, record := range records { handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute) if err != nil { t.Fatalf("ProcessWalletProjectionMessage failed: %v", err) } if handled { processed++ } } if processed != 3 { t.Fatalf("projection should process three gift events, got %d", processed) } processed = 0 for _, record := range records { handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute) if err != nil { t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err) } if handled { processed++ } } if processed != 0 { t.Fatalf("projection must be idempotent after done events, got %d", processed) } wall, err := svc.GetUserGiftWall(context.Background(), 10002) if err != nil { t.Fatalf("GetUserGiftWall failed: %v", err) } if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 135 { t.Fatalf("gift wall summary mismatch: %+v", wall) } rose, ok := giftWallItemByID(wall.Items, "rose") if len(wall.Items) != 2 || !ok || rose.GiftCount != 5 || rose.TotalValue != 35 { t.Fatalf("rose gift wall item mismatch: %+v", wall.Items) } if rose.Resource.ResourceID == 0 || rose.ResourceSnapshotJSON == "" { t.Fatalf("gift wall should keep display resource snapshot: %+v", rose) } if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 { t.Fatalf("gift wall should keep one row per gift_id, got %d", got) } if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "user_gift_wall", "done"); got != 3 { t.Fatalf("projection should mark each gift event done, got %d", got) } } // TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。 func TestDebitGiftIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{ CommandID: "cmd-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, } first, err := svc.DebitGift(context.Background(), command) if err != nil { t.Fatalf("first debit failed: %v", err) } second, err := svc.DebitGift(context.Background(), command) if err != nil { t.Fatalf("second debit failed: %v", err) } if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 { t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second) } if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent command should write one transaction, got %d", got) } } // TestDebitGiftRejectsCommandPayloadConflict 锁定同 command_id 不同业务 payload 的冲突语义。 func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1} if _, err := svc.DebitGift(context.Background(), command); err != nil { t.Fatalf("first debit failed: %v", err) } command.GiftCount = 2 _, err := svc.DebitGift(context.Background(), command) if !xerr.IsCode(err, xerr.LedgerConflict) { t.Fatalf("expected LEDGER_CONFLICT, got %v", err) } } // TestDebitGiftInsufficientBalance 锁定余额不足时不写成功交易、分录或 outbox。 func TestDebitGiftInsufficientBalance(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1) repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, }) if !xerr.IsCode(err, xerr.InsufficientBalance) { t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-2"); got != 0 { t.Fatalf("insufficient balance must not write transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id LIKE ?", "wtx_%"); got != 0 { t.Fatalf("insufficient balance must not write entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "command_id = ?", "cmd-2"); got != 0 { t.Fatalf("insufficient balance must not write outbox, got %d", got) } } func TestDebitRobotGiftCreatesRobotCoinAccountAndAutoTopsUp(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetGiftPrice("robot-rose", "default", 10, 10, 10) svc := walletservice.New(repository) receipt, err := svc.DebitRobotGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-robot-gift-new-account", RoomID: "robot-room-1", SenderUserID: 20001, TargetUserID: 20002, GiftID: "robot-rose", GiftCount: 2, AppCode: "lalu", }) if err != nil { t.Fatalf("DebitRobotGift should auto fund missing ROBOT_COIN account: %v", err) } if receipt.ChargeAssetType != ledger.AssetRobotCoin || receipt.CoinSpent != 20 || receipt.BalanceAfter != 0 { t.Fatalf("robot gift receipt mismatch: %+v", receipt) } if got := repository.CountRows("wallet_accounts", "user_id = ? AND asset_type = ? AND available_amount = 0", int64(20001), ledger.AssetRobotCoin); got != 1 { t.Fatalf("robot gift should create empty ROBOT_COIN account after debit, got %d", got) } if got := repository.CountRows("wallet_accounts", "user_id = ? AND asset_type = ?", int64(20002), ledger.AssetCoin); got != 0 { t.Fatalf("robot gift must not create target COIN income account, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(20001), ledger.AssetRobotCoin); got != 2 { t.Fatalf("robot gift should write top-up and debit entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 0 { t.Fatalf("robot gift should not publish private wallet outbox, got %d", got) } } // TestAdminCreditAssetIsIdempotent 验证后台入账有审计字段、幂等和余额查询闭环。 func TestAdminCreditAssetIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.AdminCreditAssetCommand{ CommandID: "cmd-admin-credit", TargetUserID: 20001, AssetType: "coin", Amount: 500, OperatorUserID: 90001, Reason: "manual recharge", Remark: "finance ticket approved", EvidenceRef: "ticket-1", } first, txID, err := svc.AdminCreditAsset(context.Background(), command) if err != nil { t.Fatalf("AdminCreditAsset failed: %v", err) } second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command) if err != nil { t.Fatalf("AdminCreditAsset retry failed: %v", err) } if txID == "" || txID != secondTxID || first.AvailableAmount != 500 || second.AvailableAmount != 500 || first.AssetType != ledger.AssetCoin { t.Fatalf("admin credit idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID) } balances, err := svc.GetBalances(context.Background(), 20001, []string{ledger.AssetCoin, ledger.AssetGiftPoint}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 500 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 { t.Fatalf("balances mismatch: %+v", balances) } if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.remark')) = ?", txID, "finance ticket approved"); got != 1 { t.Fatalf("admin credit metadata should carry remark, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.metadata.remark')) = ?", txID, "WalletBalanceChanged", "finance ticket approved"); got != 1 { t.Fatalf("admin credit outbox payload should carry remark, got %d", got) } conflict := command conflict.Remark = "different finance ticket" if _, _, err := svc.AdminCreditAsset(context.Background(), conflict); !xerr.IsCode(err, xerr.LedgerConflict) { t.Fatalf("same command with different remark should conflict, got %v", err) } } // TestAdminCreditAssetKeepsLegacyIdempotencyWithoutRemark 验证未传 remark 的旧调用仍沿用原幂等哈希。 func TestAdminCreditAssetKeepsLegacyIdempotencyWithoutRemark(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.AdminCreditAssetCommand{ CommandID: "cmd-admin-credit-legacy", TargetUserID: 20003, AssetType: ledger.AssetCoin, Amount: 300, OperatorUserID: 90001, Reason: "legacy manual recharge", EvidenceRef: "ticket-legacy", } first, txID, err := svc.AdminCreditAsset(context.Background(), command) if err != nil { t.Fatalf("legacy AdminCreditAsset failed: %v", err) } second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command) if err != nil { t.Fatalf("legacy AdminCreditAsset retry failed: %v", err) } if txID == "" || txID != secondTxID || first.AvailableAmount != 300 || second.AvailableAmount != 300 { t.Fatalf("legacy idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID) } if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.remark')) = ?", txID, ""); got != 1 { t.Fatalf("legacy admin credit metadata should keep empty remark, got %d", got) } withRemark := command withRemark.Remark = "added after legacy request" if _, _, err := svc.AdminCreditAsset(context.Background(), withRemark); !xerr.IsCode(err, xerr.LedgerConflict) { t.Fatalf("legacy command retried with remark should conflict, got %v", err) } } // TestAdminCreditAssetAllowsSignedAdjustment 验证后台调账同一 RPC 可表达加金币和扣金币,且扣账不能穿透余额约束。 func TestAdminCreditAssetAllowsSignedAdjustment(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) credit := ledger.AdminCreditAssetCommand{ CommandID: "cmd-admin-adjust-credit", TargetUserID: 20002, AssetType: ledger.AssetCoin, Amount: 500, OperatorUserID: 90001, Reason: "manual credit", EvidenceRef: "ticket-credit", } if _, _, err := svc.AdminCreditAsset(context.Background(), credit); err != nil { t.Fatalf("credit adjustment failed: %v", err) } debit := credit debit.CommandID = "cmd-admin-adjust-debit" debit.Amount = -120 debit.Reason = "manual debit" debit.EvidenceRef = "ticket-debit" balance, txID, err := svc.AdminCreditAsset(context.Background(), debit) if err != nil { t.Fatalf("debit adjustment failed: %v", err) } if txID == "" || balance.AvailableAmount != 380 { t.Fatalf("debit balance mismatch: balance=%+v tx=%s", balance, txID) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", txID, int64(-120)); got != 1 { t.Fatalf("debit adjustment should write one negative entry, got %d", got) } overdraw := credit overdraw.CommandID = "cmd-admin-adjust-overdraw" overdraw.Amount = -1000 overdraw.Reason = "manual overdraw" overdraw.EvidenceRef = "ticket-overdraw" if _, _, err := svc.AdminCreditAsset(context.Background(), overdraw); err == nil { t.Fatalf("overdraw adjustment must fail") } } func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(19001, 1000) svc := walletservice.New(repository) ctx := context.Background() config, err := svc.UpdateRedPacketConfig(ctx, ledger.RedPacketConfig{ AppCode: "lalu", Enabled: true, CountTiers: []int32{5}, AmountTiers: []int64{10}, DelayedOpenSeconds: 1, ExpireSeconds: 2, DailySendLimit: 5, UpdatedByAdminID: 9001, }) if err != nil { t.Fatalf("UpdateRedPacketConfig failed: %v", err) } if !config.Enabled || len(config.CountTiers) != 1 || len(config.AmountTiers) != 1 { t.Fatalf("red packet config mismatch: %+v", config) } normal, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{ AppCode: "lalu", CommandID: "cmd-red-packet-normal", SenderUserID: 19001, RoomID: "room-190", RegionID: 190, PacketType: ledger.RedPacketTypeNormal, TotalAmount: 10, PacketCount: 5, }) if err != nil { t.Fatalf("CreateRedPacket normal failed: %v", err) } if normal.BalanceAfter != 990 || normal.Packet.Status != ledger.RedPacketStatusActive { t.Fatalf("normal red packet receipt mismatch: %+v", normal) } normalClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{ AppCode: "lalu", CommandID: "cmd-red-packet-normal-claim", PacketID: normal.Packet.PacketID, UserID: 19002, }) if err != nil { t.Fatalf("ClaimRedPacket normal failed: %v", err) } normalAfterClaim, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 19002, true) if err != nil { t.Fatalf("GetRedPacket normal failed: %v", err) } if !normalAfterClaim.ClaimedByViewer || normalAfterClaim.ViewerClaimAmount != normalClaim.Claim.Amount { t.Fatalf("normal viewer claim mismatch: packet=%+v claim=%+v", normalAfterClaim, normalClaim) } time.Sleep(2100 * time.Millisecond) expired, err := svc.ExpireRedPackets(ctx, "lalu", 10) if err != nil { t.Fatalf("ExpireRedPackets normal failed: %v", err) } if expired.ExpiredCount != 1 || expired.RefundedAmount != normalAfterClaim.RemainingAmount { t.Fatalf("normal expire result mismatch: %+v remaining=%d", expired, normalAfterClaim.RemainingAmount) } normalExpired, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 0, false) if err != nil { t.Fatalf("GetRedPacket normal expired failed: %v", err) } if normalExpired.Status != ledger.RedPacketStatusRefunded || normalExpired.RemainingAmount != 0 || normalExpired.RemainingCount != 0 { t.Fatalf("normal red packet should be refunded: %+v", normalExpired) } delayed, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{ AppCode: "lalu", CommandID: "cmd-red-packet-delayed", SenderUserID: 19001, RoomID: "room-190", RegionID: 190, PacketType: ledger.RedPacketTypeDelayed, TotalAmount: 10, PacketCount: 5, }) if err != nil { t.Fatalf("CreateRedPacket delayed failed: %v", err) } if delayed.Packet.Status != ledger.RedPacketStatusWaitingOpen { t.Fatalf("delayed red packet should wait to open: %+v", delayed.Packet) } _, err = svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{ AppCode: "lalu", CommandID: "cmd-red-packet-delayed-too-early", PacketID: delayed.Packet.PacketID, UserID: 19003, }) if xerr.CodeOf(err) != xerr.RedPacketNotOpen { t.Fatalf("delayed claim before open code=%s err=%v", xerr.CodeOf(err), err) } time.Sleep(1100 * time.Millisecond) delayedClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{ AppCode: "lalu", CommandID: "cmd-red-packet-delayed-claim", PacketID: delayed.Packet.PacketID, UserID: 19003, }) if err != nil { t.Fatalf("ClaimRedPacket delayed after open failed: %v", err) } if delayedClaim.Claim.Amount <= 0 { t.Fatalf("delayed claim amount should be positive: %+v", delayedClaim) } } func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(19101, 1000) svc := walletservice.New(repository) if _, err := svc.UpdateRedPacketConfig(context.Background(), ledger.RedPacketConfig{ AppCode: "lalu", Enabled: true, CountTiers: []int32{5}, AmountTiers: []int64{10}, DelayedOpenSeconds: 1, ExpireSeconds: 2, DailySendLimit: 5, UpdatedByAdminID: 9001, }); err != nil { t.Fatalf("UpdateRedPacketConfig failed: %v", err) } receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{ AppCode: "lalu", CommandID: "cmd-red-packet-realtime-outbox", SenderUserID: 19101, RoomID: "room-191", RegionID: 191, PacketType: ledger.RedPacketTypeNormal, TotalAmount: 10, PacketCount: 5, }) if err != nil { t.Fatalf("CreateRedPacket failed: %v", err) } ctx := appcode.WithContext(context.Background(), "lalu") realtimeTypes := []string{"WalletRedPacketCreated", "WalletRedPacketClaimed", "WalletRedPacketRefunded"} realtimeRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-realtime-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{ IncludeEventTypes: realtimeTypes, }) if err != nil { t.Fatalf("ClaimPendingWalletOutboxFiltered realtime failed: %v", err) } if len(realtimeRecords) != 1 || realtimeRecords[0].EventType != "WalletRedPacketCreated" { t.Fatalf("realtime claim should only pick red packet fact, got %+v", realtimeRecords) } normalRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-normal-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{ ExcludeEventTypes: realtimeTypes, }) if err != nil { t.Fatalf("ClaimPendingWalletOutboxFiltered normal failed: %v", err) } if len(normalRecords) == 0 { t.Fatalf("normal claim should still pick non-realtime wallet facts for packet %s", receipt.Packet.PacketID) } for _, record := range normalRecords { if record.EventType == "WalletRedPacketCreated" || record.EventType == "WalletRedPacketClaimed" || record.EventType == "WalletRedPacketRefunded" { t.Fatalf("normal claim must exclude realtime red packet fact: %+v", normalRecords) } } if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND worker_id = ?", realtimeRecords[0].EventID, "delivering", "wallet-realtime-outbox-test"); got != 1 { t.Fatalf("realtime fact should be locked by realtime worker, got %d", got) } } func TestWalletOutboxClaimUsesRealMySQLStatusFlow(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetAssetBalance(30001, ledger.AssetCoinSellerCoin, 1000) repository.SetRechargePolicy(86, "policy-real-outbox", 100, 100) svc := walletservice.New(repository) ctx := appcode.WithContext(context.Background(), "lalu") receipt, err := svc.TransferCoinFromSeller(ctx, ledger.CoinSellerTransferCommand{ AppCode: "lalu", CommandID: "cmd-real-outbox-transfer", SellerUserID: 30001, TargetUserID: 30002, TargetCountryID: 63, SellerRegionID: 86, TargetRegionID: 86, Amount: 200, Reason: "real outbox claim test", }) if err != nil { t.Fatalf("TransferCoinFromSeller failed: %v", err) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ?", receipt.TransactionID, "pending"); got != 4 { t.Fatalf("transfer should create four pending wallet outbox facts, got %d", got) } records, err := repository.ClaimPendingWalletOutbox(ctx, "wallet-outbox-real-test", 4, time.Now().Add(time.Minute).UnixMilli()) if err != nil { t.Fatalf("ClaimPendingWalletOutbox failed: %v", err) } if len(records) != 4 { t.Fatalf("expected four claimed records, got %d: %+v", len(records), records) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ? AND worker_id = ?", receipt.TransactionID, "delivering", "wallet-outbox-real-test"); got != 4 { t.Fatalf("claim should mark all rows delivering for worker, got %d", got) } var rechargeEventID string for _, record := range records { if record.EventType == "WalletRechargeRecorded" { rechargeEventID = record.EventID } if record.AppCode != "lalu" || record.TransactionID != receipt.TransactionID || record.PayloadJSON == "" { t.Fatalf("claimed record should contain stable wallet fact: %+v", record) } } if rechargeEventID == "" { t.Fatalf("claimed records should include WalletRechargeRecorded: %+v", records) } if err := repository.MarkWalletOutboxDelivered(ctx, rechargeEventID); err != nil { t.Fatalf("MarkWalletOutboxDelivered failed: %v", err) } if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND (worker_id = '' OR worker_id IS NULL)", rechargeEventID, "delivered"); got != 1 { t.Fatalf("delivered record should release worker lock, got %d", got) } } // TestCreditTaskRewardIsIdempotent 验证任务奖励走独立账务类型且重复领奖命令不会重复发金币。 func TestCreditTaskRewardIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.TaskRewardCommand{ CommandID: "cmd-task-reward", TargetUserID: 21001, Amount: 88, TaskType: "daily", TaskID: "task-1", CycleKey: "2026-05-09", Reason: "daily_task_reward", } first, err := svc.CreditTaskReward(context.Background(), command) if err != nil { t.Fatalf("CreditTaskReward failed: %v", err) } second, err := svc.CreditTaskReward(context.Background(), command) if err != nil { t.Fatalf("CreditTaskReward retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.Amount != 88 || second.Balance.AvailableAmount != 88 { t.Fatalf("task reward receipt mismatch: first=%+v second=%+v", first, second) } balances, err := svc.GetBalances(context.Background(), 21001, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 88 { t.Fatalf("task reward balance mismatch: %+v", balances) } if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "task_reward"); got != 1 { t.Fatalf("task reward should write one transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 { t.Fatalf("task reward should write one entry, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("task reward should write balance and task outbox events, got %d", got) } } func TestCreditTaskRewardPointAppGateWithoutMySQL(t *testing.T) { repository := &fakePointGateRepository{} svc := walletservice.New(repository) _, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ AppCode: "lalu", CommandID: "cmd-fake-lalu-point-task-reward", TargetUserID: 23001, AssetType: ledger.AssetPoint, Amount: 88, TaskType: "daily", TaskID: "task-point", CycleKey: "2026-05-09", Reason: "daily_task_reward", }) if err == nil { t.Fatal("Lalu POINT task reward must be rejected before repository call") } if repository.creditTaskRewardCalls != 0 { t.Fatalf("rejected POINT task reward must not call repository, got %d calls", repository.creditTaskRewardCalls) } receipt, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ AppCode: "huwaa", CommandID: "cmd-fake-huwaa-point-task-reward", TargetUserID: 23002, AssetType: ledger.AssetPoint, Amount: 99, TaskType: "daily", TaskID: "task-point", CycleKey: "2026-05-09", Reason: "daily_task_reward", }) if err != nil { t.Fatalf("Huwaa POINT task reward should reach repository: %v", err) } if receipt.Balance.AssetType != ledger.AssetPoint || repository.creditTaskRewardCalls != 1 || repository.lastTaskRewardAppFromContext != "huwaa" || repository.lastTaskReward.AssetType != ledger.AssetPoint || repository.lastTaskReward.AppCode != "huwaa" { t.Fatalf("Huwaa POINT task reward repository payload mismatch: receipt=%+v repo=%+v", receipt, repository) } } func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ AppCode: "lalu", CommandID: "cmd-lalu-point-task-reward", TargetUserID: 21011, AssetType: ledger.AssetPoint, Amount: 88, TaskType: "daily", TaskID: "task-point", CycleKey: "2026-05-09", Reason: "daily_task_reward", }) if err == nil { t.Fatal("Lalu POINT task reward must be rejected") } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-lalu-point-task-reward"); got != 0 { t.Fatalf("rejected POINT task reward must not write transaction, got %d", got) } command := ledger.TaskRewardCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-point-task-reward", TargetUserID: 21012, AssetType: ledger.AssetPoint, Amount: 99, TaskType: "daily", TaskID: "task-point", CycleKey: "2026-05-09", Reason: "daily_task_reward", } first, err := svc.CreditTaskReward(context.Background(), command) if err != nil { t.Fatalf("Huwaa POINT task reward failed: %v", err) } second, err := svc.CreditTaskReward(context.Background(), command) if err != nil { t.Fatalf("Huwaa POINT task reward retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.Balance.AssetType != ledger.AssetPoint || second.Balance.AvailableAmount != 99 { t.Fatalf("Huwaa POINT task reward receipt mismatch: first=%+v second=%+v", first, second) } assertBalanceForApp(t, svc, "huwaa", 21012, ledger.AssetPoint, 99) if got := repository.CountRows("wallet_entries", "transaction_id = ? AND asset_type = ?", first.TransactionID, ledger.AssetPoint); got != 1 { t.Fatalf("Huwaa POINT task reward should write one POINT entry, got %d", got) } } func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) { repository := &fakePointGateRepository{} svc := walletservice.New(repository) _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "lalu", CommandID: "cmd-fake-lalu-point-freeze", UserID: 23011, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, }) if err == nil { t.Fatal("Lalu POINT withdrawal must be rejected before repository call") } if repository.freezeSalaryCalls != 0 { t.Fatalf("rejected POINT withdrawal must not call repository, got %d calls", repository.freezeSalaryCalls) } receipt, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "cmd-fake-huwaa-point-freeze", UserID: 23012, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, }) if err != nil { t.Fatalf("Huwaa POINT withdrawal should reach repository: %v", err) } if receipt.AssetType != ledger.AssetPoint || receipt.FeePointAmount != 50_000 || receipt.NetPointAmount != 950_000 || repository.freezeSalaryCalls != 1 || repository.lastFreezeSalaryAppFromContext != "huwaa" || repository.lastFreezeSalary.AppCode != "huwaa" || repository.lastFreezeSalary.SalaryAssetType != ledger.AssetPoint || repository.lastFreezeSalary.SalaryUSDMinor != 1_000_000 { t.Fatalf("Huwaa POINT withdrawal repository payload mismatch: receipt=%+v repo=%+v", receipt, repository) } } func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetAssetBalanceForApp("huwaa", 22001, ledger.AssetPoint, 2_000_000) repository.SetAssetBalanceForApp("huwaa", 22002, ledger.AssetPoint, 2_000_000) svc := walletservice.New(repository) _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "lalu", CommandID: "cmd-lalu-point-freeze", UserID: 22001, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, }) if err == nil { t.Fatal("POINT withdrawal must be rejected outside Huwaa") } requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 2_000_000, 0) freeze, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-point-freeze", UserID: 22001, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, }) if err != nil { t.Fatalf("FreezePointWithdrawal failed: %v", err) } if freeze.AvailableAfter != 1_000_000 || freeze.FrozenAfter != 1_000_000 || freeze.FeePointAmount != 50_000 || freeze.NetPointAmount != 950_000 { t.Fatalf("point freeze receipt mismatch: %+v", freeze) } requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 1_000_000, 1_000_000) settle, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-point-settle", UserID: 22001, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, OperatorUserID: 90001, WithdrawalApplicationID: "77", }) if err != nil { t.Fatalf("SettlePointWithdrawal failed: %v", err) } if settle.AvailableAfter != 1_000_000 || settle.FrozenAfter != 0 { t.Fatalf("point settle receipt mismatch: %+v", settle) } requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 1_000_000, 0) if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-point-freeze-release", UserID: 22002, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, }); err != nil { t.Fatalf("FreezePointWithdrawal for release failed: %v", err) } release, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-point-release", UserID: 22002, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, WithdrawalApplicationID: "78", }) if err != nil { t.Fatalf("ReleasePointWithdrawal failed: %v", err) } if release.AvailableAfter != 2_000_000 || release.FrozenAfter != 0 { t.Fatalf("point release receipt mismatch: %+v", release) } requireWalletBalanceForApp(t, svc, "huwaa", 22002, ledger.AssetPoint, 2_000_000, 0) } // TestCreditLuckyGiftRewardIsIdempotent 验证幸运礼物返奖只按 draw_id 派一次,并产出余额通知事实。 func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.LuckyGiftRewardCommand{ CommandID: "lucky_reward:lucky_draw_1", TargetUserID: 21002, Amount: 188, DrawID: "lucky_draw_1", RoomID: "room-1", GiftID: "rose", PoolID: "super_lucky", CountryID: 15, VisibleRegionID: 10, Reason: "lucky_gift_reward", } first, err := svc.CreditLuckyGiftReward(context.Background(), command) if err != nil { t.Fatalf("CreditLuckyGiftReward failed: %v", err) } second, err := svc.CreditLuckyGiftReward(context.Background(), command) if err != nil { t.Fatalf("CreditLuckyGiftReward retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || second.Balance.AvailableAmount != 188 { t.Fatalf("lucky reward receipt mismatch: first=%+v second=%+v", first, second) } if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "lucky_gift_reward"); got != 1 { t.Fatalf("lucky reward should write one transaction, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletBalanceChanged"); got != 1 { t.Fatalf("lucky reward should write one balance change event, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 { t.Fatalf("lucky reward should write one reward fact event, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.country_id') = 15 AND JSON_EXTRACT(payload_json, '$.region_id') = 10", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 { t.Fatalf("lucky reward fact should carry country and region dimensions, got %d", got) } } // TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。 func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{ CommandID: "cmd-credit-seller", TargetUserID: 30001, AssetType: ledger.AssetCoinSellerCoin, Amount: 160000, OperatorUserID: 90001, Reason: "seed seller balance", EvidenceRef: "ticket-seller", }) if err != nil { t.Fatalf("seed seller asset failed: %v", err) } command := ledger.CoinSellerTransferCommand{ CommandID: "cmd-seller-transfer", SellerUserID: 30001, TargetUserID: 30002, TargetCountryID: 63, SellerRegionID: 1001, TargetRegionID: 1001, Amount: 80000, Reason: "player recharge", } first, err := svc.TransferCoinFromSeller(context.Background(), command) if err != nil { t.Fatalf("TransferCoinFromSeller failed: %v", err) } second, err := svc.TransferCoinFromSeller(context.Background(), command) if err != nil { t.Fatalf("TransferCoinFromSeller retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SellerBalanceAfter != 80000 || first.TargetBalanceAfter != 80000 || first.RechargeUSDMinor != 0 { t.Fatalf("transfer receipt mismatch: first=%+v second=%+v", first, second) } sellerBalances, err := svc.GetBalances(context.Background(), 30001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances seller failed: %v", err) } if balanceAmount(sellerBalances, ledger.AssetCoinSellerCoin) != 80000 || balanceAmount(sellerBalances, ledger.AssetCoin) != 0 { t.Fatalf("seller balances mismatch: %+v", sellerBalances) } targetBalances, err := svc.GetBalances(context.Background(), 30002, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances target failed: %v", err) } if balanceAmount(targetBalances, ledger.AssetCoin) != 80000 { t.Fatalf("target COIN balance mismatch: %+v", targetBalances) } if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent seller transfer should write one transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("seller transfer should write two entries, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 { t.Fatalf("seller transfer should write one recharge record, got %d", got) } bills, total, err := svc.ListRechargeBills(context.Background(), ledger.ListRechargeBillsQuery{ AppCode: appcode.Default, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListRechargeBills after seller transfer failed: %v", err) } if total != 0 || len(bills) != 0 { t.Fatalf("finance recharge bills must hide coin seller user transfers: total=%d bills=%+v", total, bills) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 { t.Fatalf("seller transfer should write two balance events, one transfer event and one recharge event, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.target_country_id') = 63 AND JSON_EXTRACT(payload_json, '$.country_id') = 63 AND JSON_EXTRACT(payload_json, '$.target_region_id') = 1001", first.TransactionID, "WalletRechargeRecorded"); got != 1 { t.Fatalf("seller transfer recharge event must carry target country/region for statistics, got %d", got) } } // TestTransferCoinFromSellerAllowsSelfTransfer 验证币商可以把自己的专用库存转入自己的普通金币账户。 func TestTransferCoinFromSellerAllowsSelfTransfer(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{ CommandID: "cmd-credit-self-seller", TargetUserID: 30003, AssetType: ledger.AssetCoinSellerCoin, Amount: 100000, OperatorUserID: 90001, Reason: "seed seller balance", EvidenceRef: "ticket-self-seller", }) if err != nil { t.Fatalf("seed seller asset failed: %v", err) } command := ledger.CoinSellerTransferCommand{ CommandID: "cmd-seller-self-transfer", SellerUserID: 30003, TargetUserID: 30003, TargetCountryID: 63, SellerRegionID: 1001, TargetRegionID: 1001, Amount: 30000, Reason: "self recharge", } first, err := svc.TransferCoinFromSeller(context.Background(), command) if err != nil { t.Fatalf("TransferCoinFromSeller self transfer failed: %v", err) } second, err := svc.TransferCoinFromSeller(context.Background(), command) if err != nil { t.Fatalf("TransferCoinFromSeller self transfer retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SellerBalanceAfter != 70000 || first.TargetBalanceAfter != 30000 || first.RechargeUSDMinor != 0 { t.Fatalf("self transfer receipt mismatch: first=%+v second=%+v", first, second) } balances, err := svc.GetBalances(context.Background(), 30003, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 70000 || balanceAmount(balances, ledger.AssetCoin) != 30000 { t.Fatalf("self transfer balances mismatch: %+v", balances) } if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent self transfer should write one transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("self transfer should write two entries for two asset accounts, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 { t.Fatalf("self transfer should write one recharge record, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 { t.Fatalf("self transfer should write two balance events, one transfer event and one recharge event, got %d", got) } } // TestTransferCoinFromSellerRecordsCoinsWithoutUSDConversion 验证币商转账只按金币记录充值事实,不再走 USD 换算。 func TestTransferCoinFromSellerRecordsCoinsWithoutUSDConversion(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{ CommandID: "cmd-credit-small-seller", TargetUserID: 30004, AssetType: ledger.AssetCoinSellerCoin, Amount: 11, OperatorUserID: 90001, Reason: "seed small seller balance", EvidenceRef: "ticket-small-seller", }) if err != nil { t.Fatalf("seed seller asset failed: %v", err) } receipt, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{ CommandID: "cmd-seller-small-transfer", SellerUserID: 30004, TargetUserID: 30004, TargetCountryID: 63, SellerRegionID: 1001, TargetRegionID: 1001, Amount: 1, Reason: "self recharge", }) if err != nil { t.Fatalf("TransferCoinFromSeller small amount failed: %v", err) } if receipt.SellerBalanceAfter != 10 || receipt.TargetBalanceAfter != 1 || receipt.RechargeUSDMinor != 0 { t.Fatalf("small transfer receipt mismatch: %+v", receipt) } balances, err := svc.GetBalances(context.Background(), 30004, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 10 || balanceAmount(balances, ledger.AssetCoin) != 1 { t.Fatalf("small transfer balances mismatch: %+v", balances) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND policy_id = ? AND policy_version = ? AND exchange_coin_amount = ? AND exchange_usd_minor_amount = ?", receipt.TransactionID, int64(1), int64(0), int64(0), "", int64(1), int64(0)); got != 1 { t.Fatalf("small transfer should write recharge record with zero USD minor, got %d", got) } } // TestTransferCoinFromSellerInsufficientBalance 验证币商余额不足时不生成转账交易。 func TestTransferCoinFromSellerInsufficientBalance(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{ CommandID: "cmd-seller-low", SellerUserID: 31001, TargetUserID: 31002, TargetCountryID: 63, SellerRegionID: 1001, TargetRegionID: 1001, Amount: 1, Reason: "player recharge", }) if !xerr.IsCode(err, xerr.InsufficientBalance) { t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-low"); got != 0 { t.Fatalf("insufficient seller balance must not write transaction, got %d", got) } } // TestTransferCoinFromSellerDoesNotRequireRechargePolicy 验证币商转账不依赖外部充值定价策略。 func TestTransferCoinFromSellerDoesNotRequireRechargePolicy(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{ CommandID: "cmd-credit-no-policy", TargetUserID: 32001, AssetType: ledger.AssetCoinSellerCoin, Amount: 80000, OperatorUserID: 90001, Reason: "seed seller balance", EvidenceRef: "ticket-no-policy", }) if err != nil { t.Fatalf("seed seller asset failed: %v", err) } receipt, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{ CommandID: "cmd-seller-no-policy", SellerUserID: 32001, TargetUserID: 32002, TargetCountryID: 63, SellerRegionID: 2001, TargetRegionID: 2001, Amount: 80000, Reason: "player recharge", }) if err != nil { t.Fatalf("TransferCoinFromSeller without recharge policy failed: %v", err) } if receipt.RechargeUSDMinor != 0 || receipt.RechargePolicyID != 0 || receipt.RechargePolicyVersion != "" { t.Fatalf("policy-free transfer receipt mismatch: %+v", receipt) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-no-policy"); got != 1 { t.Fatalf("policy-free seller transfer should write transaction, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND policy_id = ?", receipt.TransactionID, int64(80000), int64(0), int64(0)); got != 1 { t.Fatalf("policy-free seller transfer should write coin-only recharge record, got %d", got) } } // TestExchangeSalaryToCoinMovesSalaryIntoPlayerCoin 验证工资兑换按固定 1 USD = 80000 COIN 扣工资并加普通金币,重复 command 只返回同一笔事实。 func TestExchangeSalaryToCoinMovesSalaryIntoPlayerCoin(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(34001, ledger.AssetAdminSalaryUSD, 2500) command := ledger.SalaryExchangeCommand{ AppCode: appcode.Default, CommandID: "cmd-salary-exchange", UserID: 34001, SalaryAssetType: ledger.AssetAdminSalaryUSD, SalaryUSDMinor: 1000, Reason: "salary exchange", } first, err := svc.ExchangeSalaryToCoin(context.Background(), command) if err != nil { t.Fatalf("ExchangeSalaryToCoin failed: %v", err) } second, err := svc.ExchangeSalaryToCoin(context.Background(), command) if err != nil { t.Fatalf("ExchangeSalaryToCoin retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SalaryBalanceAfter != 1500 || first.CoinBalanceAfter != 800000 || first.CoinAmount != 800000 || first.CoinPerUSD != ledger.SalaryExchangeCoinPerUSD { t.Fatalf("salary exchange receipt mismatch: first=%+v second=%+v", first, second) } assertBalance(t, svc, 34001, ledger.AssetAdminSalaryUSD, 1500) assertBalance(t, svc, 34001, ledger.AssetCoin, 800000) if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent salary exchange should write one transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("salary exchange should write two entries, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 { t.Fatalf("salary exchange must not write player recharge record, got %d", got) } } // TestExchangeSalaryToCoinRejectsCommandPayloadConflict 验证同 command_id 只能代表同一笔工资兑换请求。 func TestExchangeSalaryToCoinRejectsCommandPayloadConflict(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(34002, ledger.AssetHostSalaryUSD, 3000) command := ledger.SalaryExchangeCommand{ AppCode: appcode.Default, CommandID: "cmd-salary-exchange-conflict", UserID: 34002, SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 500, Reason: "salary exchange", } if _, err := svc.ExchangeSalaryToCoin(context.Background(), command); err != nil { t.Fatalf("ExchangeSalaryToCoin initial command failed: %v", err) } command.SalaryUSDMinor = 600 if _, err := svc.ExchangeSalaryToCoin(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) { t.Fatalf("expected IDEMPOTENCY_CONFLICT for changed salary exchange payload, got %v", err) } assertBalance(t, svc, 34002, ledger.AssetHostSalaryUSD, 2500) assertBalance(t, svc, 34002, ledger.AssetCoin, 400000) } // TestTransferSalaryToCoinSellerUsesRegionRateTier 验证工资转币商按命中的区域区间扣工资并加币商专用金币,不写玩家充值事实。 func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(35001, ledger.AssetBDSalaryUSD, 10000) repository.SetAssetBalance(35001, ledger.AssetCoin, 1234) repository.SetAssetBalance(35001, ledger.AssetCoinSellerCoin, 0) repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7001, 1100, 5000, 92000) command := ledger.SalaryTransferToCoinSellerCommand{ AppCode: appcode.Default, CommandID: "cmd-salary-transfer-seller", SourceUserID: 35001, SellerUserID: 35002, SalaryAssetType: ledger.AssetBDSalaryUSD, SalaryUSDMinor: 4000, RegionID: 7001, Reason: "salary transfer", } first, err := svc.TransferSalaryToCoinSeller(context.Background(), command) if err != nil { t.Fatalf("TransferSalaryToCoinSeller failed: %v", err) } second, err := svc.TransferSalaryToCoinSeller(context.Background(), command) if err != nil { t.Fatalf("TransferSalaryToCoinSeller retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SourceSalaryBalanceAfter != 6000 || first.SellerBalanceAfter != 3680000 || first.CoinAmount != 3680000 || first.CoinPerUSD != 92000 || first.RateMinUSDMinor != 1100 || first.RateMaxUSDMinor != 5000 { t.Fatalf("salary transfer receipt mismatch: first=%+v second=%+v", first, second) } assertBalance(t, svc, 35001, ledger.AssetBDSalaryUSD, 6000) assertBalance(t, svc, 35002, ledger.AssetCoinSellerCoin, 3680000) assertBalance(t, svc, 35001, ledger.AssetCoin, 1234) assertBalance(t, svc, 35001, ledger.AssetCoinSellerCoin, 0) if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent salary transfer should write one transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35001), ledger.AssetCoinSellerCoin, int64(3680000)); got != 0 { t.Fatalf("salary transfer must not credit sender coin seller wallet, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35002), ledger.AssetCoinSellerCoin, int64(3680000)); got != 1 { t.Fatalf("salary transfer must credit target coin seller wallet once, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("salary transfer should write two entries, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 { t.Fatalf("salary transfer must not write player recharge record, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.biz_type')) = ?", first.TransactionID, "WalletBalanceChanged", "salary_transfer_to_coin_seller"); got != 2 { t.Fatalf("salary transfer balance events must expose biz_type for statistics, got %d", got) } transactions, total, err := svc.ListWalletTransactions(context.Background(), ledger.ListWalletTransactionsQuery{ AppCode: appcode.Default, UserID: 35002, AssetType: ledger.AssetCoinSellerCoin, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListWalletTransactions for seller failed: %v", err) } if total != 1 || len(transactions) != 1 || transactions[0].TransferUSDMinor != 4000 || transactions[0].TransferCurrencyCode != "USD" { t.Fatalf("seller transfer amount history mismatch: total=%d transactions=%+v", total, transactions) } } // TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly 验证别人主播把工资转给币商时, // 只扣主播工资并给目标币商的币商专用金币入账,主播自己的普通金币和币商金币都不能增加。 func TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(35011, ledger.AssetHostSalaryUSD, 10000) repository.SetAssetBalance(35011, ledger.AssetCoin, 1234) repository.SetAssetBalance(35011, ledger.AssetCoinSellerCoin, 0) repository.SetAssetBalance(35012, ledger.AssetCoinSellerCoin, 1000) repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7002, 10, 10000, 81000) receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{ AppCode: appcode.Default, CommandID: "cmd-host-salary-transfer-target-seller", SourceUserID: 35011, SellerUserID: 35012, SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 50, RegionID: 7002, Reason: "host salary transfer to seller", }) if err != nil { t.Fatalf("TransferSalaryToCoinSeller host to target seller failed: %v", err) } if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 41500 || receipt.CoinAmount != 40500 { t.Fatalf("host salary transfer receipt mismatch: %+v", receipt) } assertBalance(t, svc, 35011, ledger.AssetHostSalaryUSD, 9950) assertBalance(t, svc, 35011, ledger.AssetCoin, 1234) assertBalance(t, svc, 35011, ledger.AssetCoinSellerCoin, 0) assertBalance(t, svc, 35012, ledger.AssetCoinSellerCoin, 41500) if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoin, int64(40500)); got != 0 { t.Fatalf("host salary transfer must not credit host player coin wallet, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoinSellerCoin, int64(40500)); got != 0 { t.Fatalf("host salary transfer must not credit host coin seller wallet, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35012), ledger.AssetCoinSellerCoin, int64(40500)); got != 1 { t.Fatalf("host salary transfer must credit target seller coin seller wallet once, got %d", got) } } // TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets 验证同一用户同时具备主播/代理/BD/Admin 和币商身份时, // 工资转币商允许 source_user_id == seller_user_id;账务只扣对应身份工资钱包并增加币商专用金币,不增加玩家普通金币。 func TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets(t *testing.T) { tests := []struct { name string assetType string userID int64 regionID int64 }{ {name: "host", assetType: ledger.AssetHostSalaryUSD, userID: 35101, regionID: 7101}, {name: "agency", assetType: ledger.AssetAgencySalaryUSD, userID: 35102, regionID: 7102}, {name: "bd", assetType: ledger.AssetBDSalaryUSD, userID: 35103, regionID: 7103}, {name: "admin", assetType: ledger.AssetAdminSalaryUSD, userID: 35104, regionID: 7104}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(test.userID, test.assetType, 10000) repository.SetAssetBalance(test.userID, ledger.AssetCoin, 1234) repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, test.regionID, 10, 10000, 81000) receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{ AppCode: appcode.Default, CommandID: "cmd-salary-self-" + test.name, SourceUserID: test.userID, SellerUserID: test.userID, SalaryAssetType: test.assetType, SalaryUSDMinor: 50, RegionID: test.regionID, Reason: "salary transfer to own coin seller wallet", }) if err != nil { t.Fatalf("TransferSalaryToCoinSeller self %s failed: %v", test.name, err) } if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 40500 || receipt.CoinAmount != 40500 || receipt.CoinPerUSD != 81000 || receipt.RateMinUSDMinor != 10 || receipt.RateMaxUSDMinor != 10000 { t.Fatalf("self salary transfer receipt mismatch: %+v", receipt) } assertBalance(t, svc, test.userID, test.assetType, 9950) assertBalance(t, svc, test.userID, ledger.AssetCoinSellerCoin, 40500) assertBalance(t, svc, test.userID, ledger.AssetCoin, 1234) if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, test.assetType, int64(-50)); got != 1 { t.Fatalf("self transfer must debit salary asset once, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, ledger.AssetCoinSellerCoin, int64(40500)); got != 1 { t.Fatalf("self transfer must credit coin seller asset once, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, test.userID, ledger.AssetCoin); got != 0 { t.Fatalf("self transfer must not credit player coin wallet, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", receipt.TransactionID); got != 0 { t.Fatalf("salary transfer must not write player recharge record, got %d", got) } }) } } // TestTransferSalaryToCoinSellerRequiresConfiguredRate 验证当前区域没有可命中的工资转币商比例时不会产生半成品账务。 func TestTransferSalaryToCoinSellerRequiresConfiguredRate(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(35003, ledger.AssetAgencySalaryUSD, 10000) _, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{ AppCode: appcode.Default, CommandID: "cmd-salary-transfer-no-rate", SourceUserID: 35003, SellerUserID: 35004, SalaryAssetType: ledger.AssetAgencySalaryUSD, SalaryUSDMinor: 4000, RegionID: 8001, Reason: "salary transfer", }) if !xerr.IsCode(err, xerr.NotFound) { t.Fatalf("expected NOT_FOUND when salary transfer rate is missing, got %v", err) } assertBalance(t, svc, 35003, ledger.AssetAgencySalaryUSD, 10000) assertBalance(t, svc, 35004, ledger.AssetCoinSellerCoin, 0) if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-salary-transfer-no-rate"); got != 0 { t.Fatalf("missing salary transfer rate must not write transaction, got %d", got) } } // TestH5RechargeOptionsFilterMethodsByUserCountry 验证 H5 支付方式严格按目标用户国家返回,同时按普通用户/币商区分商品档位。 func TestH5RechargeOptionsFilterMethodsByUserCountry(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) svc.SetMifaPayClient(&fakeMifaPayClient{}) normalProduct := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7100, 1_500_000, 120000) sellerProduct := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7100, 10_000_000, 880000) normalOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{ AppCode: appcode.Default, TargetUserID: 41001, TargetRegionID: 7100, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceNormal, }) if err != nil { t.Fatalf("ListH5RechargeOptions normal failed: %v", err) } if len(normalOptions.Products) != 1 || normalOptions.Products[0].ProductID != normalProduct.ProductID { t.Fatalf("normal h5 products must only include normal product: %+v", normalOptions.Products) } if !paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Card", "MADA") || !paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Ewallet", "ApplePay") || !paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Ewallet", "STCPay") || paymentMethodsContain(normalOptions.PaymentMethods, "ID", "BankTransfer", "BCA") { t.Fatalf("SA options must only expose SA MiFaPay methods: %+v", normalOptions.PaymentMethods) } for _, method := range normalOptions.PaymentMethods { if method.CountryCode != "SA" && method.CountryCode != "GLOBAL" { t.Fatalf("payment method country leaked for SA user: %+v", method) } } sellerOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{ AppCode: appcode.Default, TargetUserID: 41002, TargetRegionID: 7100, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceCoinSeller, }) if err != nil { t.Fatalf("ListH5RechargeOptions coin seller failed: %v", err) } if len(sellerOptions.Products) != 1 || sellerOptions.Products[0].ProductID != sellerProduct.ProductID { t.Fatalf("coin seller h5 products must only include seller product: %+v", sellerOptions.Products) } if !paymentMethodsContain(sellerOptions.PaymentMethods, "SA", "Card", "MADA") || paymentMethodsContain(sellerOptions.PaymentMethods, "ID", "BankTransfer", "BCA") { t.Fatalf("coin seller SA options must use the same country-filtered MiFaPay methods: %+v", sellerOptions.PaymentMethods) } } func TestH5RechargeOptionsFilterV5PayByRuntimeProductTypes(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) v5Pay := &fakeV5PayClient{ productTypes: map[string]walletservice.V5PayProductTypesResponse{ "PH|PHP": { PayinList: []walletservice.V5PayProductType{ {ProductType: "COINS_ONLINE", ProductName: "Coins.ph", Currency: "PHP", SupportCashierMode: 1}, {ProductType: "GCASH_ONLINE", ProductName: "GCash", Currency: "PHP", SupportCashierMode: 1}, {ProductType: "INSTAPAY_QR", ProductName: "QRPH", Currency: "PHP", SupportCashierMode: 1}, {ProductType: "QRPH_GCASH", ProductName: "QRPh GCash", Currency: "PHP", SupportCashierMode: 1}, {ProductType: "TEST_BANK", ProductName: "Test Bank", Currency: "PHP", SupportCashierMode: 1}, {ProductType: "SHOPEE_PAY", ProductName: "Shopee Pay", Currency: "PHP", SupportCashierMode: 1}, }, }, }, productErrs: map[string]error{ "SA|SAR": xerr.New(xerr.Unavailable, "v5pay product types rejected: 1013: app is invalid"), }, } svc.SetV5PayClient(v5Pay) repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7150, 1_000_000, 800000) phOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{ AppCode: appcode.Default, TargetUserID: 41101, TargetRegionID: 7150, TargetCountryCode: "PH", AudienceType: ledger.RechargeAudienceCoinSeller, }) if err != nil { t.Fatalf("ListH5RechargeOptions PH failed: %v", err) } if !paymentMethodsContain(phOptions.PaymentMethods, "PH", "Ewallet", "COINS_ONLINE") || !paymentMethodsContain(phOptions.PaymentMethods, "PH", "Ewallet", "GCASH_ONLINE") || !paymentMethodsContain(phOptions.PaymentMethods, "PH", "QR", "INSTAPAY_QR") || paymentMethodsContain(phOptions.PaymentMethods, "PH", "BankTransfer", "UB_ONLINE") { t.Fatalf("PH options must only expose V5Pay runtime-enabled cashier products: %+v", phOptions.PaymentMethods) } saOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{ AppCode: appcode.Default, TargetUserID: 41102, TargetRegionID: 7150, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceCoinSeller, }) if err != nil { t.Fatalf("ListH5RechargeOptions SA failed: %v", err) } if paymentMethodsContain(saOptions.PaymentMethods, "SA", "Ewallet", "STCPAY") || paymentMethodsContain(saOptions.PaymentMethods, "SA", "Card", "MADA") { t.Fatalf("SA V5Pay methods must be hidden when app is invalid for SA/SAR: %+v", saOptions.PaymentMethods) } } func TestSyncThirdPartyPaymentMethodsAddsV5PayRuntimeProducts(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) phCoinsMethodID := repository.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderV5Pay, "PH", "Ewallet", "COINS_ONLINE") repository.SetThirdPartyPaymentRate(phCoinsMethodID, "52.50000000") v5Pay := &fakeV5PayClient{ productTypes: map[string]walletservice.V5PayProductTypesResponse{ "PH|PHP": { PayinList: []walletservice.V5PayProductType{ {ProductType: "COINS_ONLINE", ProductName: "Coins.ph Online Updated", Currency: "PHP", ProductMode: "Ewallet", SupportCashierMode: 1}, }, }, "TR|TRY": { PayinList: []walletservice.V5PayProductType{ {ProductType: "CREDIT_CARD", ProductName: "Credit Card", Currency: "TRY", ProductMode: "Card", SupportCashierMode: 1}, {ProductType: "PAPARA_ONLINE", ProductName: "Papara Online", Currency: "TRY", ProductMode: "Ewallet", SupportCashierMode: 1}, {ProductType: "PAPARA_QR", ProductName: "Papara QR", Currency: "TRY", ProductMode: "QR", SupportCashierMode: 0}, }, }, }, productErrs: map[string]error{ "MX|MXN": fakeProviderPayloadError{message: "v5pay product types rejected", rawJSON: `{"code":"1013","message":"app is invalid"}`}, }, } svc.SetV5PayClient(v5Pay) result, err := svc.SyncThirdPartyPaymentMethods(context.Background(), ledger.ThirdPartyPaymentMethodSyncCommand{ AppCode: appcode.Default, ProviderCode: ledger.PaymentProviderV5Pay, OperatorUserID: 90001, }) if err != nil { t.Fatalf("SyncThirdPartyPaymentMethods failed: %v", err) } if result.CreatedCount != 2 || result.UpdatedCount != 1 || result.SkippedCount != 1 || len(result.Issues) != 1 || result.Issues[0].Code != "1013" { t.Fatalf("sync result mismatch: %+v", result) } if !v5PayProductRequestSeen(v5Pay.productReqs, "TR", "TRY") || !v5PayProductRequestSeen(v5Pay.productReqs, "MX", "MXN") { t.Fatalf("sync must scan configured V5Pay country/currency candidates: %+v", v5Pay.productReqs) } channels, err := svc.ListThirdPartyPaymentChannels(context.Background(), ledger.ListThirdPartyPaymentChannelsQuery{ AppCode: appcode.Default, ProviderCode: ledger.PaymentProviderV5Pay, IncludeDisabledMethods: true, }) if err != nil { t.Fatalf("ListThirdPartyPaymentChannels after sync failed: %v", err) } methods := channels[0].Methods creditCard, ok := thirdPartyPaymentMethodByKey(methods, "TR", "Card", "CREDIT_CARD") if !ok || creditCard.CurrencyCode != "TRY" || creditCard.USDToCurrencyRate != "0.00000000" { t.Fatalf("TR credit card should be inserted with unset rate: %+v ok=%v", creditCard, ok) } papara, ok := thirdPartyPaymentMethodByKey(methods, "TR", "Ewallet", "PAPARA_ONLINE") if !ok || papara.MethodName != "Papara Online" { t.Fatalf("TR Papara should be inserted from V5Pay product: %+v ok=%v", papara, ok) } phCoins, ok := thirdPartyPaymentMethodByKey(methods, "PH", "Ewallet", "COINS_ONLINE") if !ok || phCoins.MethodName != "Coins.ph Online Updated" || phCoins.USDToCurrencyRate != "52.50000000" { t.Fatalf("existing PH method should update display fields but preserve rate: %+v ok=%v", phCoins, ok) } } // TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry 锁定下单阶段再次校验国家,防止前端伪造 method_id 绕过 options 过滤。 func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) svc.SetMifaPayClient(&fakeMifaPayClient{}) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7200, 1_500_000, 120000) idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA") _, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-cross-country-method", TargetUserID: 42001, TargetRegionID: 7200, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: idBankMethodID, }) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("expected CONFLICT for cross-country MiFaPay method, got %v", err) } } // TestH5MifaPayOrderRoundsZeroDecimalCurrency 锁定 IDR 等零小数币种的三方下单金额: // MiFaPay 按 fixed minor 接收 amount,但会校验 IDR 主单位不能带小数,因此本地要把主单位先归整。 func TestH5MifaPayOrderRoundsZeroDecimalCurrency(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7201, 300_000_000, 28_200_000) idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA") repository.SetThirdPartyPaymentRate(idBankMethodID, "17706.65883333") order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-mifapay-idr-integer", TargetUserID: 42002, TargetRegionID: 7201, TargetCountryCode: "ID", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: idBankMethodID, }) if err != nil { t.Fatalf("CreateH5RechargeOrder IDR failed: %v", err) } if order.ProviderAmountMinor != 531_199_800 || mifaPay.createReq.ProviderAmountMinor != 531_199_800 { t.Fatalf("IDR amount must be rounded to whole major units before fixed-minor submit: order=%+v req=%+v", order, mifaPay.createReq) } } // TestH5MifaPayOrderUsesWholeINRProviderAmount 锁定印度 MiFaPay 的金额边界:上游 amount 传整数卢比, // 且 1 USD 档位按 99 INR 汇率会低于网关 100 INR 下限,本地订单必须保存实际提交的 100.00 INR。 func TestH5MifaPayOrderUsesWholeINRProviderAmount(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7210, 1_000_000, 80300) inPaytmMethodID := repository.ThirdPartyPaymentMethodID("IN", "Ewallet", "Paytm") repository.SetThirdPartyPaymentRate(inPaytmMethodID, "99.00000000") order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-mifapay-inr-minimum", TargetUserID: 42003, TargetRegionID: 7210, TargetCountryCode: "IN", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: inPaytmMethodID, PayerName: "normal55", PayerAccount: "163055", PayerEmail: "normal55@lalu.com", }) if err != nil { t.Fatalf("CreateH5RechargeOrder INR failed: %v", err) } if order.ProviderAmountMinor != 10_000 || mifaPay.createReq.ProviderAmountMinor != 10_000 { t.Fatalf("INR amount must respect MiFaPay 100 INR minimum: order=%+v req=%+v", order, mifaPay.createReq) } mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ OrderID: order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: "1", Amount: "100", Currency: order.CurrencyCode, PayType: order.PayType, RawJSON: `{"status":"1","amount":"100","currency":"INR"}`, } credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder INR query failed: %v", err) } if credited.Status != ledger.ExternalRechargeStatusCredited || credited.ProviderAmountMinor != 10_000 { t.Fatalf("INR query should match whole-major MiFaPay amount and credit: %+v", credited) } } // TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障, // 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。 func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{err: fakeMifaPayProviderPayloadError{ cause: xerr.New(xerr.Unavailable, "mifapay order rejected: 100999: System exception"), payload: `{"code":"100999","msg":"System exception"}`, }} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7250, 1_500_000, 120000) saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA") _, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-mifapay-system-error", TargetUserID: 42501, TargetRegionID: 7250, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: saCardMethodID, ReturnURL: "https://h5.global-interaction.com/recharge/index.html?app_code=lalu#checkout", Language: "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", PayerName: "normal55", PayerAccount: "163055", PayerEmail: "163055@lalu.com", }) if !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("expected UNAVAILABLE for MiFaPay gateway rejection, got %v", err) } if mifaPay.createReq.PayerName != "normal55" || mifaPay.createReq.PayerAccount != "163055" || mifaPay.createReq.PayerEmail != "163055@lalu.com" { t.Fatalf("mifapay payer snapshot mismatch: %+v", mifaPay.createReq) } if mifaPay.createReq.Language != "zh-CN" { t.Fatalf("mifapay language should be normalized before upstream call: %+v", mifaPay.createReq) } if mifaPay.createReq.ReturnURL != "https://h5.global-interaction.com/recharge/index.html" { t.Fatalf("mifapay return url should be sent as stable provider redirect URL: %+v", mifaPay.createReq) } if len(mifaPay.createReq.OrderID) != 32 { t.Fatalf("mifapay order_id must be 32 chars for upstream compatibility, got %q", mifaPay.createReq.OrderID) } if got := repository.CountRows("external_recharge_orders", "command_id = ? AND CHAR_LENGTH(order_id) = 32", "cmd-h5-mifapay-system-error"); got != 1 { t.Fatalf("mifapay local external order should also use 32-char order_id, got %d", got) } if got := repository.CountRows("external_recharge_orders", "command_id = ? AND status = ? AND failure_reason LIKE ? AND JSON_UNQUOTE(JSON_EXTRACT(provider_payload, '$.code')) = ?", "cmd-h5-mifapay-system-error", ledger.ExternalRechargeStatusFailed, "UNAVAILABLE:%System exception%", "100999"); got != 1 { t.Fatalf("mifapay upstream rejection should persist one failed external order, got %d", got) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-h5-mifapay-system-error"); got != 0 { t.Fatalf("failed mifapay order must not write wallet transaction, got %d", got) } } func TestH5MifaPayStatusQueryCreditsOrder(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-success", 42601, 7260) mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ OrderID: order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: "1", Amount: "100", Currency: order.CurrencyCode, PayType: order.PayType, RawJSON: `{"status":"1"}`, } credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder query success failed: %v", err) } if mifaPay.queryReq.ProviderOrderID != order.ProviderOrderID || mifaPay.queryReq.OrderID != order.OrderID { t.Fatalf("mifapay query input mismatch: %+v", mifaPay.queryReq) } if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" { t.Fatalf("mifapay query should credit successful order: %+v", credited) } assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 120000) if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 { t.Fatalf("mifapay query credit should write one recharge record, got %d", got) } bills, total, err := svc.ListRechargeBills(context.Background(), ledger.ListRechargeBillsQuery{ AppCode: appcode.Default, Status: "succeeded", Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListRechargeBills failed: %v", err) } if total != 1 || len(bills) != 1 || bills[0].TransactionID != credited.TransactionID || bills[0].ProviderAmountMinor != order.ProviderAmountMinor { t.Fatalf("recharge bill should expose provider paid amount: total=%d bills=%+v order=%+v", total, bills, order) } if got := repository.CountRows("external_recharge_orders", "order_id = ? AND target_country_id = ?", order.OrderID, order.TargetCountryID); got != 1 { t.Fatalf("mifapay local order should persist target country id, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ?", credited.TransactionID, "WalletRechargeRecorded", order.TargetCountryID, order.TargetCountryID, order.TargetRegionID); got != 1 { t.Fatalf("mifapay recharge fact should carry target country/region for BI, got %d", got) } } func TestH5MifaPayCoinSellerCreditsSellerStockUSDTEquivalent(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7265, 10_000_000, 880000) saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA") order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-mifapay-seller-query-success", TargetUserID: 42605, TargetRegionID: 7265, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceCoinSeller, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: saCardMethodID, PayerName: "seller55", PayerAccount: "163055", PayerEmail: "seller55@lalu.com", }) if err != nil { t.Fatalf("CreateH5RechargeOrder seller mifapay failed: %v", err) } mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ OrderID: order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: "1", Amount: "1000", Currency: order.CurrencyCode, PayType: order.PayType, RawJSON: `{"status":"1"}`, } credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder seller query success failed: %v", err) } if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" { t.Fatalf("mifapay seller query should credit order: %+v", credited) } assertBalance(t, svc, order.TargetUserID, ledger.AssetCoinSellerCoin, 880000) assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 0) if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 0 { t.Fatalf("seller mifapay recharge must not write user recharge record, got %d", got) } if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_currency_code = ? AND paid_amount_micro = ?", credited.TransactionID, int64(880000), ledger.PaidCurrencyUSDT, int64(10_000_000)); got != 1 { t.Fatalf("seller mifapay recharge should write one usdt-equivalent seller stock record, got %d", got) } if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.paid_currency_code')) = ? AND JSON_EXTRACT(metadata_json, '$.paid_amount_micro') = 10000000", credited.TransactionID, "coin_seller_recharge", ledger.PaidCurrencyUSDT); got != 1 { t.Fatalf("seller mifapay transaction metadata should use usdt-equivalent cumulative amount, got %d", got) } } func TestH5MifaPayStatusQueryMarksFailedOrder(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-failed", 42602, 7261) mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ OrderID: order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: "4", Amount: "100", Currency: order.CurrencyCode, PayType: order.PayType, RawJSON: `{"status":"4"}`, } failed, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder query failed status failed: %v", err) } if failed.Status != ledger.ExternalRechargeStatusFailed || !strings.Contains(failed.FailureReason, "mifapay order status 4") { t.Fatalf("mifapay query should mark failed status: %+v", failed) } if got := repository.CountRows("wallet_transactions", "command_id = ?", order.CommandID); got != 0 { t.Fatalf("failed mifapay query must not write wallet transaction, got %d", got) } } func TestH5MifaPayStatusQueryRejectsOrderMismatch(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-mismatch", 42603, 7262) mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ OrderID: "other_" + order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: "1", Amount: "100", Currency: order.CurrencyCode, PayType: order.PayType, RawJSON: `{"status":"1","orderId":"other"}`, } failed, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder mismatch query failed: %v", err) } if failed.Status != ledger.ExternalRechargeStatusFailed || !strings.Contains(failed.FailureReason, "mifapay query order data mismatch") { t.Fatalf("mifapay query mismatch should fail local order: %+v", failed) } if got := repository.CountRows("wallet_transactions", "command_id = ?", order.CommandID); got != 0 { t.Fatalf("mifapay query mismatch must not write wallet transaction, got %d", got) } } func TestH5MifaPayStatusQueryKeepsWaitingOnPendingAndQueryError(t *testing.T) { for _, tc := range []struct { name string status string queryErr error }{ {name: "unpaid", status: "0"}, {name: "processing", status: "5"}, {name: "query-error", queryErr: xerr.New(xerr.Unavailable, "mifapay query temporary error")}, } { t.Run(tc.name, func(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) mifaPay := &fakeMifaPayClient{queryErr: tc.queryErr} svc.SetMifaPayClient(mifaPay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-"+tc.name, 42610, 7270) mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ OrderID: order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: tc.status, Amount: "100", Currency: order.CurrencyCode, PayType: order.PayType, RawJSON: `{"status":"` + tc.status + `"}`, } current, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder should keep local order on %s: %v", tc.name, err) } if current.Status != ledger.ExternalRechargeStatusRedirected || current.TransactionID != "" { t.Fatalf("mifapay query should keep waiting order on %s: %+v", tc.name, current) } if got := repository.CountRows("wallet_transactions", "command_id = ?", order.CommandID); got != 0 { t.Fatalf("waiting mifapay query must not write wallet transaction, got %d", got) } }) } } func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository, svc *walletservice.Service, commandID string, targetUserID int64, targetRegionID int64) ledger.ExternalRechargeOrder { t.Helper() product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, targetRegionID, 1_000_000, 120000) saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA") order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: commandID, TargetUserID: targetUserID, TargetRegionID: targetRegionID, TargetCountryID: 63, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: saCardMethodID, PayerName: "normal55", PayerAccount: "163055", PayerEmail: "163055@lalu.com", }) if err != nil { t.Fatalf("CreateH5RechargeOrder mifapay failed: %v", err) } if order.Status != ledger.ExternalRechargeStatusRedirected || order.ProviderOrderID == "" || order.PayURL == "" { t.Fatalf("mifapay test order should be redirected: %+v", order) } return order } func TestExternalRechargeReconcileCreditsV5PayOrderWithoutH5Return(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) v5Pay := &fakeV5PayClient{} svc.SetV5PayClient(v5Pay) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{V5PayNotifyURL: "https://api.example.com/api/v1/payment/v5pay/notify"}) order := createRedirectedV5PayOrder(t, repository, svc, "cmd-h5-v5pay-reconcile-success", 42620, 7280) v5Pay.queryResp = walletservice.V5PayQueryOrderResponse{ OrderID: order.OrderID, ProviderOrderID: order.ProviderOrderID, Status: "2", Amount: "1.00", Currency: order.CurrencyCode, ProductType: order.PayType, RawJSON: `{"orderStatus":"2"}`, } processed, err := svc.ReconcileExternalRechargeOrders(context.Background(), appcode.Default, 10) if err != nil { t.Fatalf("ReconcileExternalRechargeOrders v5pay failed: %v", err) } if processed != 1 { t.Fatalf("v5pay reconcile should process one redirected order, got %d", processed) } if v5Pay.queryReq.ProviderOrderID != order.ProviderOrderID || v5Pay.queryReq.OrderID != order.OrderID || v5Pay.queryReq.CountryCode != "PH" || v5Pay.queryReq.CurrencyCode != "PHP" { t.Fatalf("v5pay reconcile query input mismatch: %+v", v5Pay.queryReq) } credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) if err != nil { t.Fatalf("GetH5RechargeOrder after reconcile failed: %v", err) } if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" { t.Fatalf("v5pay reconcile should credit successful order: %+v", credited) } assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 120000) if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 { t.Fatalf("v5pay reconcile credit should write one recharge record, got %d", got) } processed, err = svc.ReconcileExternalRechargeOrders(context.Background(), appcode.Default, 10) if err != nil { t.Fatalf("second v5pay reconcile failed: %v", err) } if processed != 0 { t.Fatalf("credited v5pay order must not stay in reconcile queue, got %d", processed) } } func createRedirectedV5PayOrder(t *testing.T, repository *mysqltest.Repository, svc *walletservice.Service, commandID string, targetUserID int64, targetRegionID int64) ledger.ExternalRechargeOrder { t.Helper() product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, targetRegionID, 1_000_000, 120000) phQRMethodID := repository.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderV5Pay, "PH", "QR", "INSTAPAY_QR") order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: commandID, TargetUserID: targetUserID, TargetRegionID: targetRegionID, TargetCountryCode: "PH", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderV5Pay, PaymentMethodID: phQRMethodID, PayerName: "normal65", PayerAccount: "163065", PayerEmail: "normal65@lalu.com", }) if err != nil { t.Fatalf("CreateH5RechargeOrder v5pay failed: %v", err) } if order.Status != ledger.ExternalRechargeStatusRedirected || order.ProviderOrderID == "" || order.PayURL == "" || order.PayType != "INSTAPAY_QR" { t.Fatalf("v5pay test order should be redirected: %+v", order) } return order } // TestH5USDTRechargeCreditsNormalUserCoinWallet 验证 H5 普通用户 USDT 充值只进入普通 COIN 钱包,并写普通充值记录。 func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) tron := &fakeTronUSDTClient{} svc.SetTronUSDTClient(tron) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ USDTTRC20Enabled: true, USDTTRC20Address: "TPlatformReceiveAddress", }) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7300, 1_500_000, 120000) order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-normal-usdt", TargetUserID: 43001, TargetRegionID: 7300, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceNormal, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderUSDTTRC20, }) if err != nil { t.Fatalf("CreateH5RechargeOrder normal usdt failed: %v", err) } if order.Status != ledger.ExternalRechargeStatusPending || order.ReceiveAddress != "TPlatformReceiveAddress" || order.USDMinorAmount != 150 { t.Fatalf("normal usdt order snapshot mismatch: %+v", order) } credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{ AppCode: appcode.Default, OrderID: order.OrderID, TargetUserID: 43001, TxHash: "tx-normal-usdt", }) if err != nil { t.Fatalf("SubmitH5RechargeTx normal failed: %v", err) } if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" { t.Fatalf("normal usdt credited order mismatch: %+v", credited) } if tron.lastTxHash != "tx-normal-usdt" || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 150 { t.Fatalf("tron verification input mismatch: %+v", tron) } assertBalance(t, svc, 43001, ledger.AssetCoin, 120000) assertBalance(t, svc, 43001, ledger.AssetCoinSellerCoin, 0) if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 { t.Fatalf("normal h5 recharge should write one user recharge record, got %d", got) } if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ?", int64(43001)); got != 1 { t.Fatalf("normal h5 recharge should update user recharge stats, got %d", got) } if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ?", credited.TransactionID); got != 0 { t.Fatalf("normal h5 recharge must not write seller stock record, got %d", got) } } // TestH5USDTRechargeCreditsCoinSellerWalletOnly 验证 H5 币商充值只进入币商专用钱包,并写币商库存记录而不是普通用户充值记录。 func TestH5USDTRechargeCreditsCoinSellerWalletOnly(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) svc.SetTronUSDTClient(&fakeTronUSDTClient{}) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ USDTTRC20Enabled: true, USDTTRC20Address: "TPlatformReceiveAddress", }) product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7400, 10_000_000, 880000) order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ AppCode: appcode.Default, CommandID: "cmd-h5-seller-usdt", TargetUserID: 44001, TargetRegionID: 7400, TargetCountryCode: "SA", AudienceType: ledger.RechargeAudienceCoinSeller, ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderUSDTTRC20, }) if err != nil { t.Fatalf("CreateH5RechargeOrder seller usdt failed: %v", err) } credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{ AppCode: appcode.Default, OrderID: order.OrderID, TargetUserID: 44001, TxHash: "tx-seller-usdt", }) if err != nil { t.Fatalf("SubmitH5RechargeTx seller failed: %v", err) } assertBalance(t, svc, 44001, ledger.AssetCoinSellerCoin, 880000) assertBalance(t, svc, 44001, ledger.AssetCoin, 0) if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 0 { t.Fatalf("seller h5 recharge must not write user recharge record, got %d", got) } if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ?", int64(44001)); got != 0 { t.Fatalf("seller h5 recharge must not update user recharge stats, got %d", got) } if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_currency_code = ? AND paid_amount_micro = ?", credited.TransactionID, int64(880000), ledger.PaidCurrencyUSDT, int64(10_000_000)); got != 1 { t.Fatalf("seller h5 recharge should write one seller stock record, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", credited.TransactionID, "WalletCoinSellerStockPurchased"); got != 1 { t.Fatalf("seller h5 recharge should emit seller stock outbox event, got %d", got) } } // TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。 func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-usdt", SellerUserID: 33001, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeUSDTPurchase, CoinAmount: 8000000, PaidAmountMicro: 100000000, OperatorUserID: 90001, } first, err := svc.AdminCreditCoinSellerStock(context.Background(), command) if err != nil { t.Fatalf("AdminCreditCoinSellerStock purchase failed: %v", err) } second, err := svc.AdminCreditCoinSellerStock(context.Background(), command) if err != nil { t.Fatalf("AdminCreditCoinSellerStock retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 8000000 || !first.CountsAsSellerRecharge || first.PaidCurrencyCode != ledger.PaidCurrencyUSDT { t.Fatalf("stock purchase receipt mismatch: first=%+v second=%+v", first, second) } balances, err := svc.GetBalances(context.Background(), 33001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances seller failed: %v", err) } if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 8000000 || balanceAmount(balances, ledger.AssetCoin) != 0 { t.Fatalf("seller stock balances mismatch: %+v", balances) } if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE", first.TransactionID); got != 1 { t.Fatalf("stock purchase should write one seller recharge record, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 { t.Fatalf("stock purchase should write one wallet entry, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 { t.Fatalf("seller stock purchase must not write player recharge record, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("stock purchase should write balance and stock outbox events, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.seller_country_id') = 63 AND JSON_EXTRACT(payload_json, '$.seller_region_id') = 7 AND JSON_EXTRACT(payload_json, '$.country_id') = 63 AND JSON_EXTRACT(payload_json, '$.region_id') = 7", first.TransactionID, "WalletCoinSellerStockPurchased"); got != 1 { t.Fatalf("stock outbox must carry seller country/region for statistics, got %d", got) } } // TestAdminCreditCoinSellerStockDeductionDebitsDedicatedBalanceAndRechargeAmount 验证 USDT 扣除会同时冲回库存和币商充值金额。 func TestAdminCreditCoinSellerStockDeductionDebitsDedicatedBalanceAndRechargeAmount(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) credit := ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-usdt-deduct-seed", SellerUserID: 33011, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeUSDTPurchase, CoinAmount: 8000000, PaidAmountMicro: 100000000, OperatorUserID: 90001, } if _, err := svc.AdminCreditCoinSellerStock(context.Background(), credit); err != nil { t.Fatalf("seed stock purchase failed: %v", err) } deduction := ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-usdt-deduct", SellerUserID: 33011, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeUSDTDeduction, CoinAmount: 3000000, PaidAmountMicro: 37500000, OperatorUserID: 90002, Reason: "reverse bad stock", } first, err := svc.AdminCreditCoinSellerStock(context.Background(), deduction) if err != nil { t.Fatalf("AdminCreditCoinSellerStock deduction failed: %v", err) } second, err := svc.AdminCreditCoinSellerStock(context.Background(), deduction) if err != nil { t.Fatalf("AdminCreditCoinSellerStock deduction retry failed: %v", err) } if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 5000000 || first.CoinAmount != -3000000 || first.PaidAmountMicro != -37500000 || !first.CountsAsSellerRecharge { t.Fatalf("stock deduction receipt mismatch: first=%+v second=%+v", first, second) } assertBalance(t, svc, 33011, ledger.AssetCoinSellerCoin, 5000000) if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND stock_type = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_amount_micro = ?", first.TransactionID, ledger.StockTypeUSDTDeduction, int64(-3000000), int64(-37500000)); got != 1 { t.Fatalf("stock deduction should write one negative seller recharge record, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", first.TransactionID, int64(-3000000)); got != 1 { t.Fatalf("stock deduction should write one negative wallet entry, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ? AND JSON_EXTRACT(payload_json, '$.paid_amount_micro') = ?", first.TransactionID, "WalletCoinSellerStockPurchased", int64(-3000000), int64(-37500000)); got != 1 { t.Fatalf("stock deduction should emit negative seller stock outbox event, got %d", got) } } // TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount 锁定补偿不能携带充值金额的边界。 func TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-comp-invalid", SellerUserID: 34001, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeCoinCompensation, CoinAmount: 50000, PaidCurrencyCode: ledger.PaidCurrencyUSDT, PaidAmountMicro: 1, OperatorUserID: 90001, }) if !xerr.IsCode(err, xerr.CoinSellerStockAmountInvalid) { t.Fatalf("expected COIN_SELLER_STOCK_AMOUNT_INVALID, got %v", err) } if got := repository.CountRows("coin_seller_stock_records", "command_id = ?", "cmd-stock-comp-invalid"); got != 0 { t.Fatalf("invalid compensation must not write stock record, got %d", got) } } // TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount 验证金币补偿不计入币商进货金额。 func TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) receipt, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-comp", SellerUserID: 35001, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeCoinCompensation, CoinAmount: 50000, OperatorUserID: 90001, }) if err != nil { t.Fatalf("AdminCreditCoinSellerStock compensation failed: %v", err) } if receipt.CountsAsSellerRecharge || receipt.PaidAmountMicro != 0 || receipt.PaidCurrencyCode != "" || receipt.BalanceAfter != 50000 { t.Fatalf("stock compensation receipt mismatch: %+v", receipt) } if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = FALSE AND paid_amount_micro = 0", receipt.TransactionID); got != 1 { t.Fatalf("compensation should write one non-recharge stock record, got %d", got) } } // TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef 防止同一 USDT 凭证重复给库存入账。 func TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-ref-1", SellerUserID: 36001, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeUSDTPurchase, CoinAmount: 100, PaidAmountMicro: 1000000, PaymentRef: "usdt-ref-dup", EvidenceRef: "oss://receipt-dup-1", OperatorUserID: 90001, Reason: "first purchase", } if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil { t.Fatalf("first stock purchase failed: %v", err) } command.CommandID = "cmd-stock-ref-2" command.SellerUserID = 36002 command.SellerCountryID = 971 command.SellerRegionID = 8 command.EvidenceRef = "oss://receipt-dup-2" _, err := svc.AdminCreditCoinSellerStock(context.Background(), command) if !xerr.IsCode(err, xerr.CoinSellerPaymentRefDuplicated) { t.Fatalf("expected COIN_SELLER_PAYMENT_REF_DUPLICATED, got %v", err) } if got := repository.CountRows("coin_seller_stock_records", "payment_ref = ?", "usdt-ref-dup"); got != 1 { t.Fatalf("duplicate payment_ref should keep one stock record, got %d", got) } } // TestAdminCreditCoinSellerStockRejectsIdempotencyConflict 锁定 command_id 相同但 payload 不同必须 fail-closed。 func TestAdminCreditCoinSellerStockRejectsIdempotencyConflict(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.CoinSellerStockCreditCommand{ CommandID: "cmd-stock-conflict", SellerUserID: 37001, SellerCountryID: 63, SellerRegionID: 7, StockType: ledger.StockTypeUSDTPurchase, CoinAmount: 100, PaidAmountMicro: 1000000, PaymentRef: "usdt-ref-conflict", EvidenceRef: "oss://receipt-conflict", OperatorUserID: 90001, Reason: "first purchase", } if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil { t.Fatalf("first stock purchase failed: %v", err) } command.CoinAmount = 200 _, err := svc.AdminCreditCoinSellerStock(context.Background(), command) if !xerr.IsCode(err, xerr.IdempotencyConflict) { t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err) } } // TestGiftConfigMustSelectGiftResource 验证后台新增礼物只能绑定 resource_type=gift 的资源。 func TestGiftConfigMustSelectGiftResource(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() frame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "frame_gold", ResourceType: resourcedomain.TypeAvatarFrame, Name: "Gold Frame", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyExtendExpiry, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create frame resource failed: %v", err) } _, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "bad-gift", ResourceID: frame.ResourceID, Status: resourcedomain.StatusActive, Name: "Bad Gift", PriceVersion: "v1", CoinPrice: 1, GiftPointAmount: 1, HeatValue: 1, OperatorUserID: 90001, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for non-gift resource, got %v", err) } giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_spark", ResourceType: resourcedomain.TypeGift, Name: "Spark", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create gift resource failed: %v", err) } gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "spark", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Spark", PriceVersion: "v1", CoinPrice: 5, GiftPointAmount: 2, HeatValue: 9, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create gift config failed: %v", err) } if gift.Resource.ResourceType != resourcedomain.TypeGift || gift.CoinPrice != 5 { t.Fatalf("gift config mismatch: %+v", gift) } repository.SetBalance(41001, 100) receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-spark-gift", RoomID: "room-1", SenderUserID: 41001, TargetUserID: 41002, GiftID: "spark", GiftCount: 2, }) if err != nil { t.Fatalf("DebitGift with resource-backed gift failed: %v", err) } if receipt.CoinSpent != 10 || receipt.GiftPointAdded != 0 || receipt.HeatValue != 10 { t.Fatalf("resource-backed gift settlement mismatch: %+v", receipt) } } // TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit 验证背包送礼只扣礼物权益库存,但房间贡献仍按礼物原价结算。 func TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_bag_spark", ResourceType: resourcedomain.TypeGift, Name: "Bag Spark", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create bag gift resource failed: %v", err) } if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "bag-spark", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Bag Spark", PriceVersion: "v1", CoinPrice: 5, OperatorUserID: 90001, }); err != nil { t.Fatalf("create bag gift config failed: %v", err) } grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-bag-spark", TargetUserID: 61001, ResourceID: giftResource.ResourceID, Quantity: 3, Reason: "bag gift test", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("GrantResource bag gift failed: %v", err) } if len(grant.Items) != 1 || grant.Items[0].EntitlementID == "" { t.Fatalf("bag gift grant must return entitlement: %+v", grant) } receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-send-bag-spark", RoomID: "room-bag", SenderUserID: 61001, TargetUserID: 61002, GiftID: "bag-spark", GiftCount: 2, EntitlementID: grant.Items[0].EntitlementID, ChargeSource: "bag", }) if err != nil { t.Fatalf("DebitGift from bag failed: %v", err) } if receipt.CoinSpent != 10 || receipt.ChargeAmount != 10 || receipt.HeatValue != 10 || receipt.ChargeAssetType != ledger.AssetBagGift { t.Fatalf("bag gift must keep original gift value in receipt: %+v", receipt) } if receipt.ChargeSource != "bag" || receipt.EntitlementID != grant.Items[0].EntitlementID || receipt.BalanceAfter != 0 { t.Fatalf("bag gift source/entitlement/balance mismatch: %+v", receipt) } _, quantity, remaining := repository.ActiveResourceEntitlement(61001, giftResource.ResourceID) if quantity != 3 || remaining != 1 { t.Fatalf("bag gift entitlement quantity mismatch: quantity=%d remaining=%d", quantity, remaining) } balances, err := svc.GetBalances(ctx, 61002, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances bag gift target failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 3 { t.Fatalf("bag gift target income mismatch: %+v", balances) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 { t.Fatalf("bag gift must only write target COIN income entry, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftDebited"); got != 1 { t.Fatalf("bag gift must still publish WalletGiftDebited for normal room projections, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 { t.Fatalf("bag gift must publish target income fact, got %d", got) } if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", "cmd-send-bag-spark", "UserResourceChanged"); got != 1 { t.Fatalf("bag gift must publish resource change event, got %d", got) } } func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() nowMs := time.Now().UnixMilli() giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_diamond_magic", ResourceType: resourcedomain.TypeGift, Name: "Diamond Magic", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create diamond gift resource failed: %v", err) } gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "diamond-magic", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Diamond Magic", PriceVersion: "v1", ChargeAssetType: "DIAMOND", CoinPrice: 7, GiftPointAmount: 3, HeatValue: 9, GiftTypeCode: resourcedomain.GiftTypeMagic, EffectiveFromMS: nowMs - 1000, EffectiveToMS: nowMs + 60_000, EffectTypes: []string{resourcedomain.GiftEffectAnimation, resourcedomain.GiftEffectMusic}, OperatorUserID: 90001, RegionIDs: []int64{0}, }) if err != nil { t.Fatalf("create diamond gift config failed: %v", err) } if gift.ChargeAssetType != ledger.AssetCoin || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 { t.Fatalf("gift config must not keep DIAMOND charge: %+v", gift) } repository.SetBalance(52001, 100) receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-diamond-gift", RoomID: "room-diamond", SenderUserID: 52001, TargetUserID: 52002, GiftID: "diamond-magic", GiftCount: 2, }) if err != nil { t.Fatalf("DebitGift with diamond-backed gift failed: %v", err) } if receipt.ChargeAssetType != ledger.AssetCoin || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 { t.Fatalf("gift settlement must use COIN after DIAMOND removal: %+v", receipt) } } func TestDeleteGiftConfigKeepsResourceData(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_delete_keep_resource", ResourceType: resourcedomain.TypeGift, Name: "Delete Keep Resource", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create gift resource failed: %v", err) } gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "delete-keep-resource", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Delete Keep Resource", PriceVersion: "v1", CoinPrice: 9, HeatValue: 9, OperatorUserID: 90001, RegionIDs: []int64{0}, }) if err != nil { t.Fatalf("create gift config failed: %v", err) } if gift.ResourceID != giftResource.ResourceID { t.Fatalf("gift resource id mismatch: %+v", gift) } if got := repository.CountRows("gift_configs", "gift_id = ?", "delete-keep-resource"); got != 1 { t.Fatalf("expected gift config before delete, got %d", got) } if got := repository.CountRows("wallet_gift_prices", "gift_id = ?", "delete-keep-resource"); got != 1 { t.Fatalf("expected gift price before delete, got %d", got) } if got := repository.CountRows("gift_config_regions", "gift_id = ?", "delete-keep-resource"); got != 1 { t.Fatalf("expected gift region before delete, got %d", got) } deleted, err := svc.DeleteGiftConfig(ctx, resourcedomain.StatusCommand{ StringID: "delete-keep-resource", OperatorUserID: 90002, }) if err != nil { t.Fatalf("delete gift config failed: %v", err) } if deleted.GiftID != "delete-keep-resource" || deleted.ResourceID != giftResource.ResourceID { t.Fatalf("deleted gift snapshot mismatch: %+v", deleted) } if got := repository.CountRows("gift_configs", "gift_id = ?", "delete-keep-resource"); got != 0 { t.Fatalf("gift config should be deleted, got %d", got) } if got := repository.CountRows("wallet_gift_prices", "gift_id = ?", "delete-keep-resource"); got != 0 { t.Fatalf("gift price should be deleted with gift config, got %d", got) } if got := repository.CountRows("gift_config_regions", "gift_id = ?", "delete-keep-resource"); got != 0 { t.Fatalf("gift region should be deleted with gift config, got %d", got) } if got := repository.CountRows("resources", "resource_id = ?", giftResource.ResourceID); got != 1 { t.Fatalf("gift resource must stay in resource list, got %d", got) } if _, err := svc.GetResource(ctx, "", giftResource.ResourceID); err != nil { t.Fatalf("gift resource should still be readable after deleting gift config: %v", err) } items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{ Keyword: "delete-keep-resource", Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list gifts after delete failed: %v", err) } if total != 0 || len(items) != 0 { t.Fatalf("deleted gift should not stay in gift list total=%d items=%+v", total, items) } } func TestDeleteResourceHidesFromDefaultList(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "delete_resource_hidden", ResourceType: resourcedomain.TypeBadge, Name: "Delete Resource Hidden", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyNewEntitlement, UsageScopes: []string{"badge"}, OperatorUserID: 91001, }) if err != nil { t.Fatalf("create resource failed: %v", err) } deleted, err := svc.DeleteResource(ctx, resourcedomain.StatusCommand{ ID: resource.ResourceID, OperatorUserID: 91002, }) if err != nil { t.Fatalf("delete resource failed: %v", err) } if deleted.ResourceID != resource.ResourceID || deleted.Status != resourcedomain.StatusDeleted { t.Fatalf("deleted resource snapshot mismatch: %+v", deleted) } if got := repository.CountRows("resources", "resource_id = ? AND status = ?", resource.ResourceID, resourcedomain.StatusDeleted); got != 1 { t.Fatalf("resource should be soft deleted, got %d", got) } if got := repository.CountRows("resources", "resource_code = ?", "delete_resource_hidden"); got != 0 { t.Fatalf("deleted resource should release resource_code uniqueness, got %d", got) } items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{ Keyword: "delete_resource_hidden", Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list resources failed: %v", err) } if total != 0 || len(items) != 0 { t.Fatalf("deleted resource should be hidden from default list: total=%d items=%+v", total, items) } items, total, err = svc.ListResources(ctx, resourcedomain.ListResourcesQuery{ Keyword: "delete_resource_hidden", Status: resourcedomain.StatusDeleted, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list deleted status resources failed: %v", err) } if total != 0 || len(items) != 0 { t.Fatalf("deleted resource should not be exposed by status filter: total=%d items=%+v", total, items) } loaded, err := svc.GetResource(ctx, "", resource.ResourceID) if err != nil { t.Fatalf("deleted resource should remain readable for historical references: %v", err) } if loaded.Status != resourcedomain.StatusDeleted { t.Fatalf("loaded deleted resource status mismatch: %+v", loaded) } recreated, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "delete_resource_hidden", ResourceType: resourcedomain.TypeBadge, Name: "Recreated Resource", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyNewEntitlement, UsageScopes: []string{"badge"}, OperatorUserID: 91003, }) if err != nil { t.Fatalf("recreate resource with deleted code failed: %v", err) } if recreated.ResourceID == resource.ResourceID { t.Fatalf("recreated resource should be a new row: old=%d new=%d", resource.ResourceID, recreated.ResourceID) } } func TestDeleteGiftResourceHidesGiftConfigList(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "delete_gift_resource_hidden", ResourceType: resourcedomain.TypeGift, Name: "Delete Gift Resource Hidden", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 91101, }) if err != nil { t.Fatalf("create gift resource failed: %v", err) } if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "delete-resource-hide-gift", ResourceID: resource.ResourceID, Status: resourcedomain.StatusActive, Name: "Delete Resource Hide Gift", PriceVersion: "default", CoinPrice: 10, HeatValue: 10, OperatorUserID: 91101, RegionIDs: []int64{0}, }); err != nil { t.Fatalf("create gift config failed: %v", err) } if _, err := svc.DeleteResource(ctx, resourcedomain.StatusCommand{ ID: resource.ResourceID, OperatorUserID: 91102, }); err != nil { t.Fatalf("delete gift resource failed: %v", err) } gifts, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{ Keyword: "delete-resource-hide-gift", Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list gift configs failed: %v", err) } if total != 0 || len(gifts) != 0 { t.Fatalf("gift config should be hidden when resource is deleted: total=%d gifts=%+v", total, gifts) } if got := repository.CountRows("gift_configs", "gift_id = ?", "delete-resource-hide-gift"); got != 1 { t.Fatalf("soft-deleting resource should keep gift config row for history, got %d", got) } } func TestBatchCreateGiftConfigsRollsBackOnFailure(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "batch_gift_valid_resource", ResourceType: resourcedomain.TypeGift, Name: "Batch Gift Valid Resource", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 91201, }) if err != nil { t.Fatalf("create gift resource failed: %v", err) } _, err = svc.BatchCreateGiftConfigs(ctx, "", []resourcedomain.GiftConfigCommand{ { GiftID: "batch-create-rollback-ok", ResourceID: resource.ResourceID, Status: resourcedomain.StatusActive, Name: "Batch Create Rollback OK", PriceVersion: "default", CoinPrice: 10, HeatValue: 10, OperatorUserID: 91201, RegionIDs: []int64{0}, }, { GiftID: "batch-create-rollback-bad", ResourceID: 99999999, Status: resourcedomain.StatusActive, Name: "Batch Create Rollback Bad", PriceVersion: "default", CoinPrice: 10, HeatValue: 10, OperatorUserID: 91201, RegionIDs: []int64{0}, }, }, 91201) if err == nil { t.Fatalf("batch create should fail when any item is invalid") } if got := repository.CountRows("gift_configs", "gift_id IN (?, ?)", "batch-create-rollback-ok", "batch-create-rollback-bad"); got != 0 { t.Fatalf("batch create should roll back all gift rows, got %d", got) } if got := repository.CountRows("wallet_gift_prices", "gift_id IN (?, ?)", "batch-create-rollback-ok", "batch-create-rollback-bad"); got != 0 { t.Fatalf("batch create should roll back all gift prices, got %d", got) } } func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() paidResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_price_sync", ResourceType: resourcedomain.TypeGift, Name: "Price Sync Gift", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, PriceType: resourcedomain.PriceTypeCoin, CoinPrice: 77, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create priced gift resource failed: %v", err) } if paidResource.GiftPointAmount != 0 { t.Fatalf("resource gift point should stay disabled: %+v", paidResource) } gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "price-sync", ResourceID: paidResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Price Sync Gift", PriceVersion: "v1", ChargeAssetType: "DIAMOND", CoinPrice: 1, GiftPointAmount: 1, HeatValue: 3, GiftTypeCode: resourcedomain.GiftTypeNormal, OperatorUserID: 90001, RegionIDs: []int64{0}, }) if err != nil { t.Fatalf("create synced gift config failed: %v", err) } if gift.ChargeAssetType != ledger.AssetCoin || gift.CoinPrice != 77 || gift.GiftPointAmount != 0 { t.Fatalf("gift price should be overridden by resource price: %+v", gift) } updatedResource, err := svc.UpdateResource(ctx, resourcedomain.ResourceCommand{ ResourceID: paidResource.ResourceID, ResourceCode: paidResource.ResourceCode, ResourceType: resourcedomain.TypeGift, Name: paidResource.Name, Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, PriceType: resourcedomain.PriceTypeCoin, CoinPrice: 88, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("update priced gift resource failed: %v", err) } if updatedResource.CoinPrice != 88 || updatedResource.GiftPointAmount != 0 { t.Fatalf("updated resource price mismatch: %+v", updatedResource) } repository.SetBalance(54001, 100) updatedReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-price-sync-after-resource-update", RoomID: "room-price-sync", SenderUserID: 54001, TargetUserID: 54002, GiftID: "price-sync", GiftCount: 1, }) if err != nil { t.Fatalf("debit gift after resource price update failed: %v", err) } if updatedReceipt.CoinSpent != 88 || updatedReceipt.GiftPointAdded != 0 { t.Fatalf("gift price should follow updated resource price: %+v", updatedReceipt) } freeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_free_sync", ResourceType: resourcedomain.TypeGift, Name: "Free Sync Gift", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, PriceType: resourcedomain.PriceTypeFree, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create free gift resource failed: %v", err) } freeGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "free-sync", ResourceID: freeResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Free Sync Gift", PriceVersion: "v1", CoinPrice: 9, GiftPointAmount: 9, HeatValue: 0, GiftTypeCode: resourcedomain.GiftTypeNormal, OperatorUserID: 90001, RegionIDs: []int64{0}, }) if err != nil { t.Fatalf("create free synced gift config failed: %v", err) } if freeGift.CoinPrice != 0 || freeGift.GiftPointAmount != 0 { t.Fatalf("free gift price should sync to zero: %+v", freeGift) } receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-free-sync", RoomID: "room-free", SenderUserID: 53001, TargetUserID: 53002, GiftID: "free-sync", GiftCount: 1, }) if err != nil { t.Fatalf("free gift debit should not require existing balance: %v", err) } if receipt.ChargeAmount != 0 || receipt.GiftPointAdded != 0 { t.Fatalf("free gift receipt mismatch: %+v", receipt) } } func TestGiftTypeConfigDefaultsAndUpsert(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() items, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{}) if err != nil { t.Fatalf("list gift type configs failed: %v", err) } if len(items) != 10 || items[0].TypeCode != resourcedomain.GiftTypeNormal || items[0].TabKey != resourcedomain.GiftTypeTabKeyNormal { t.Fatalf("default gift type configs mismatch: %+v", items) } updated, err := svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{ TypeCode: resourcedomain.GiftTypeNormal, Name: "普通礼物", TabKey: "Gift", Status: resourcedomain.StatusDisabled, SortOrder: 11, OperatorUserID: 90001, }) if err != nil { t.Fatalf("update gift type config failed: %v", err) } if updated.Status != resourcedomain.StatusDisabled || updated.SortOrder != 11 { t.Fatalf("updated gift type config mismatch: %+v", updated) } activeItems, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{ActiveOnly: true}) if err != nil { t.Fatalf("list active gift type configs failed: %v", err) } if giftTypeCodesContain(activeItems, resourcedomain.GiftTypeNormal) { t.Fatalf("disabled gift type must not be returned as active: %+v", activeItems) } } func TestGiftCatalogVersionTracksPanelConfigChanges(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() baseVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get empty gift catalog version failed: %v", err) } waitForNextWalletVersionMillisecond(t) giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ AppCode: "lalu", ResourceCode: "gift_catalog_version", ResourceType: resourcedomain.TypeGift, Name: "Version Gift", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create version gift resource failed: %v", err) } resourceVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get resource catalog version failed: %v", err) } requireWalletVersionAdvanced(t, "gift resource create", baseVersion, resourceVersion) waitForNextWalletVersionMillisecond(t) _, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ AppCode: "lalu", GiftID: "catalog-version", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Version Gift", PriceVersion: "v1", CoinPrice: 5, GiftTypeCode: resourcedomain.GiftTypeNormal, RegionIDs: []int64{0}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create version gift config failed: %v", err) } giftVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get gift config catalog version failed: %v", err) } requireWalletVersionAdvanced(t, "gift config create", resourceVersion, giftVersion) waitForNextWalletVersionMillisecond(t) _, err = svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ AppCode: "lalu", GiftID: "catalog-version", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Version Gift Updated", PriceVersion: "v2", CoinPrice: 9, GiftTypeCode: resourcedomain.GiftTypeNormal, RegionIDs: []int64{0, 42}, OperatorUserID: 90002, }) if err != nil { t.Fatalf("update version gift config failed: %v", err) } priceRegionVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get gift price/region catalog version failed: %v", err) } requireWalletVersionAdvanced(t, "gift price and region update", giftVersion, priceRegionVersion) waitForNextWalletVersionMillisecond(t) _, err = svc.UpdateResource(ctx, resourcedomain.ResourceCommand{ AppCode: "lalu", ResourceID: giftResource.ResourceID, ResourceCode: giftResource.ResourceCode, ResourceType: resourcedomain.TypeGift, Name: "Version Gift Asset Updated", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, AssetURL: "https://static.example.test/gift-version.png", OperatorUserID: 90003, }) if err != nil { t.Fatalf("update version gift resource failed: %v", err) } resourceUpdateVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get updated resource catalog version failed: %v", err) } requireWalletVersionAdvanced(t, "gift resource update", priceRegionVersion, resourceUpdateVersion) waitForNextWalletVersionMillisecond(t) _, err = svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{ AppCode: "lalu", TypeCode: resourcedomain.GiftTypeNormal, Name: "Normal Gifts", TabKey: resourcedomain.GiftTypeTabKeyNormal, Status: resourcedomain.StatusActive, SortOrder: 12, OperatorUserID: 90004, }) if err != nil { t.Fatalf("update gift type config failed: %v", err) } typeVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get gift type catalog version failed: %v", err) } requireWalletVersionAdvanced(t, "gift type update", resourceUpdateVersion, typeVersion) waitForNextWalletVersionMillisecond(t) if _, err := svc.DeleteGiftConfig(ctx, resourcedomain.StatusCommand{ AppCode: "lalu", StringID: "catalog-version", OperatorUserID: 90005, }); err != nil { t.Fatalf("delete gift config failed: %v", err) } deleteVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu") if err != nil { t.Fatalf("get deleted gift catalog version failed: %v", err) } requireWalletVersionAdvanced(t, "gift config delete", typeVersion, deleteVersion) } // TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验,region_id=0 作为 GLOBAL 兜底区域。 func TestGiftConfigRegionFilter(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() globalResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_regional_global", ResourceType: resourcedomain.TypeGift, Name: "Regional Global", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create global gift resource failed: %v", err) } saudiResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_regional_saudi", ResourceType: resourcedomain.TypeGift, Name: "Regional Saudi", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create saudi gift resource failed: %v", err) } egyptResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_regional_egypt", ResourceType: resourcedomain.TypeGift, Name: "Regional Egypt", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create egypt gift resource failed: %v", err) } commands := []resourcedomain.GiftConfigCommand{ { GiftID: "regional-global", ResourceID: globalResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Regional Global", PriceVersion: "v1", CoinPrice: 1, GiftPointAmount: 1, HeatValue: 1, OperatorUserID: 90001, RegionIDs: []int64{0}, }, { GiftID: "regional-saudi", ResourceID: saudiResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Regional Saudi", PriceVersion: "v1", CoinPrice: 2, GiftPointAmount: 2, HeatValue: 2, OperatorUserID: 90001, RegionIDs: []int64{1001}, }, { GiftID: "regional-egypt", ResourceID: egyptResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Regional Egypt", PriceVersion: "v1", CoinPrice: 3, GiftPointAmount: 3, HeatValue: 3, OperatorUserID: 90001, RegionIDs: []int64{2002}, }, } for _, command := range commands { if _, err := svc.CreateGiftConfig(ctx, command); err != nil { t.Fatalf("create gift config %s failed: %v", command.GiftID, err) } } if _, err := svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ GiftID: "regional-saudi", ResourceID: saudiResource.ResourceID, Status: resourcedomain.StatusActive, Name: "Regional Saudi", PriceVersion: "v2", CoinPrice: 22, HeatValue: 22, OperatorUserID: 90001, RegionIDs: []int64{1002, 1001}, }); err != nil { t.Fatalf("update regional saudi gift price failed: %v", err) } repository.InsertRawGiftPrice("regional-saudi", "v8-inactive", "inactive", 0) repository.InsertRawGiftPrice("regional-saudi", "v9-future", "active", time.Now().Add(time.Hour).UnixMilli()) items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{ Keyword: "regional-", ActiveOnly: true, FilterRegion: true, RegionID: 1001, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list region gifts failed: %v", err) } if total != 2 || !giftIDsContain(items, "regional-global") || !giftIDsContain(items, "regional-saudi") || giftIDsContain(items, "regional-egypt") { t.Fatalf("region gift list mismatch total=%d items=%+v", total, items) } saudiGift, ok := giftConfigByID(items, "regional-saudi") if !ok { t.Fatalf("regional saudi gift must be returned: %+v", items) } if saudiGift.PriceVersion != "v2" || saudiGift.CoinPrice != 22 || saudiGift.GiftPointAmount != 0 || saudiGift.HeatValue != 22 { t.Fatalf("batch latest gift price mismatch: %+v", saudiGift) } if !int64sEqual(saudiGift.RegionIDs, []int64{1001, 1002}) { t.Fatalf("batch region ids must return full configured regions, got %+v", saudiGift.RegionIDs) } repository.SetBalance(51001, 100) if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-regional-egypt-in-saudi", RoomID: "room-region-1001", SenderUserID: 51001, TargetUserID: 51002, GiftID: "regional-egypt", GiftCount: 1, RegionID: 1001, }); !xerr.IsCode(err, xerr.NotFound) { t.Fatalf("expected NOT_FOUND when sending gift outside configured region, got %v", err) } } // TestListGiftConfigsWithoutTypeFilterIncludesLuckyTypes 验证礼物面板预加载全部礼物时不会被空类型过滤误收窄到普通礼物。 func TestListGiftConfigsWithoutTypeFilterIncludesLuckyTypes(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() normalResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_all_normal", ResourceType: resourcedomain.TypeGift, Name: "All Normal", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create normal gift resource failed: %v", err) } luckyResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_all_lucky", ResourceType: resourcedomain.TypeGift, Name: "All Lucky", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create lucky gift resource failed: %v", err) } superLuckyResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_all_super_lucky", ResourceType: resourcedomain.TypeGift, Name: "All Super Lucky", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create super lucky gift resource failed: %v", err) } commands := []resourcedomain.GiftConfigCommand{ { GiftID: "all-normal", ResourceID: normalResource.ResourceID, Status: resourcedomain.StatusActive, Name: "All Normal", PriceVersion: "v1", CoinPrice: 100, HeatValue: 100, OperatorUserID: 90001, RegionIDs: []int64{0}, }, { GiftID: "all-lucky", ResourceID: luckyResource.ResourceID, Status: resourcedomain.StatusActive, Name: "All Lucky", GiftTypeCode: resourcedomain.GiftTypeLucky, PriceVersion: "v1", CoinPrice: 200, HeatValue: 200, OperatorUserID: 90001, RegionIDs: []int64{0}, }, { GiftID: "all-super-lucky", ResourceID: superLuckyResource.ResourceID, Status: resourcedomain.StatusActive, Name: "All Super Lucky", GiftTypeCode: resourcedomain.GiftTypeSuperLucky, PriceVersion: "v1", CoinPrice: 300, HeatValue: 300, OperatorUserID: 90001, RegionIDs: []int64{0}, }, } for _, command := range commands { if _, err := svc.CreateGiftConfig(ctx, command); err != nil { t.Fatalf("create gift config %s failed: %v", command.GiftID, err) } } // 空 gift_type_code 表示“不按类型过滤”,gateway 的 gift-tabs 会依赖这次查询一次性拿到所有礼物类型。 items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{ ActiveOnly: true, FilterRegion: true, RegionID: 0, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list all gift configs failed: %v", err) } if total != 3 || !giftIDsContain(items, "all-normal") || !giftIDsContain(items, "all-lucky") || !giftIDsContain(items, "all-super-lucky") { t.Fatalf("all gift type list mismatch total=%d items=%+v", total, items) } // 显式传入 lucky 时仍保留类型过滤能力,避免修复全部礼物预加载时破坏后台按类型筛选。 luckyItems, luckyTotal, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{ GiftTypeCode: resourcedomain.GiftTypeLucky, ActiveOnly: true, FilterRegion: true, RegionID: 0, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("list lucky gift configs failed: %v", err) } if luckyTotal != 1 || !giftIDsContain(luckyItems, "all-lucky") || giftIDsContain(luckyItems, "all-normal") || giftIDsContain(luckyItems, "all-super-lucky") { t.Fatalf("lucky gift type filter mismatch total=%d items=%+v", luckyTotal, luckyItems) } } // TestGrantResourceGroupExpandsWalletAssetAndEntitlement 验证资源组赠送会原子展开钱包资产入账和非钱包权益。 func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_vip", ResourceType: resourcedomain.TypeBadge, Name: "VIP Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create badge resource failed: %v", err) } group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "starter_pack", Name: "Starter Pack", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{ {ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1}, {ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2}, }, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create resource group failed: %v", err) } command := resourcedomain.GrantResourceGroupCommand{ CommandID: "cmd-grant-starter-pack", TargetUserID: 42001, GroupID: group.GroupID, Reason: "test grant", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, } first, err := svc.GrantResourceGroup(ctx, command) if err != nil { t.Fatalf("GrantResourceGroup failed: %v", err) } second, err := svc.GrantResourceGroup(ctx, command) if err != nil { t.Fatalf("GrantResourceGroup retry failed: %v", err) } if first.GrantID == "" || first.GrantID != second.GrantID || len(first.Items) != 2 { t.Fatalf("grant idempotency mismatch: first=%+v second=%+v", first, second) } balances, err := svc.GetBalances(ctx, 42001, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 2000 { t.Fatalf("coin grant balance mismatch: %+v", balances) } if got := repository.CountRows("resource_grants", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent group grant should write one grant, got %d", got) } if got := repository.CountRows("resource_grant_items", "grant_id = ?", first.GrantID); got != 2 { t.Fatalf("group grant should write two grant items, got %d", got) } if got := repository.CountRows("user_resource_entitlements", "user_id = ?", int64(42001)); got != 1 { t.Fatalf("group grant should write one non-coin entitlement, got %d", got) } equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ UserIDs: []int64{42001}, ResourceTypes: []string{resourcedomain.TypeBadge}, }) if err != nil { t.Fatalf("batch get group-granted badge equipment failed: %v", err) } if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].ResourceID != badgeResource.ResourceID { t.Fatalf("group-granted badge should be auto equipped: %+v", equippedResources) } if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ?", int64(42001), ledger.AssetCoin); got != 1 { t.Fatalf("group grant should write one coin wallet entry, got %d", got) } if got := repository.CountRows("wallet_transactions", "biz_type = ? AND command_id LIKE 'rgg:%' AND CHAR_LENGTH(command_id) <= 128", "resource_grant"); got != 1 { t.Fatalf("group wallet asset grant should use one short internal wallet command, got %d", got) } longCommand := resourcedomain.GrantResourceGroupCommand{ CommandID: "cp_weekly_rank:1781481600000:1:cp_rel:327091463391285248:327104359357747200:327091463391285248", TargetUserID: 42002, GroupID: group.GroupID, Reason: "test grant long command", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, } if _, err := svc.GrantResourceGroup(ctx, longCommand); err != nil { t.Fatalf("GrantResourceGroup should accept long upstream command by shortening internal item commands: %v", err) } if got := repository.CountRows("resource_grants", "command_id = ?", longCommand.CommandID); got != 1 { t.Fatalf("long upstream command should still be preserved on resource grant, got %d", got) } if got := repository.CountRows("wallet_transactions", "biz_type = ? AND command_id LIKE 'rgg:%' AND CHAR_LENGTH(command_id) <= 128", "resource_grant"); got != 2 { t.Fatalf("long upstream group wallet asset grant should use a second short internal wallet command, got %d", got) } if got := repository.CountRows("wallet_transactions", "biz_type = ? AND CHAR_LENGTH(command_id) > 128", "resource_grant"); got != 0 { t.Fatalf("resource grant wallet transactions must not exceed command_id storage length, got %d", got) } } // TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement 锁定撤销只按原发放明细反向处理,不受资源组后续配置变化影响。 func TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_revoke_vip", ResourceType: resourcedomain.TypeBadge, Name: "Revoke VIP Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create revoke badge resource failed: %v", err) } group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "revoke_pack", Name: "Revoke Pack", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{ {ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1}, {ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2}, }, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create revoke resource group failed: %v", err) } grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{ CommandID: "cmd-grant-revoke-pack", TargetUserID: 42101, GroupID: group.GroupID, Reason: "test revoke group", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("GrantResourceGroup for revoke failed: %v", err) } entitlementID := entitlementIDFromGrant(t, grant) revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-pack", GrantID: grant.GrantID, Reason: "test revoke", OperatorUserID: 90002, }) if err != nil { t.Fatalf("RevokeResourceGrant failed: %v", err) } if revoked.Status != resourcedomain.GrantStatusRevoked || revoked.RevokedAtMS == 0 || revoked.RevokedByUserID != 90002 || revoked.RevokeReason != "test revoke" { t.Fatalf("revoked grant metadata mismatch: %+v", revoked) } balances, err := svc.GetBalances(ctx, 42101, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances after revoke failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 0 { t.Fatalf("revoked coin balance mismatch: %+v", balances) } if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42101), ledger.AssetCoin, int64(-2000)); got != 1 { t.Fatalf("revoke should write one reverse coin entry, got %d", got) } if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 1 { t.Fatalf("revoke should write one reverse wallet transaction, got %d", got) } if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 { t.Fatalf("revoke should mark entitlement unavailable, got %d", got) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42101), entitlementID); got != 0 { t.Fatalf("revoke should remove equipment row, got %d", got) } again, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-pack-again", GrantID: grant.GrantID, Reason: "test revoke again", OperatorUserID: 90003, }) if err != nil { t.Fatalf("RevokeResourceGrant retry failed: %v", err) } if again.Status != resourcedomain.GrantStatusRevoked || again.RevokedByUserID != 90002 { t.Fatalf("idempotent revoke should keep first metadata: %+v", again) } if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42101), ledger.AssetCoin, int64(-2000)); got != 1 { t.Fatalf("idempotent revoke must not double debit, got %d", got) } } // TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient 验证撤回金币时余额不足也不能扣成负数。 func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_revoke_insufficient", ResourceType: resourcedomain.TypeBadge, Name: "Revoke Insufficient Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create insufficient badge resource failed: %v", err) } group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "revoke_insufficient_pack", Name: "Revoke Insufficient Pack", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{ {ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1}, {ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2}, }, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create insufficient resource group failed: %v", err) } grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{ CommandID: "cmd-grant-revoke-insufficient", TargetUserID: 42102, GroupID: group.GroupID, Reason: "test revoke insufficient", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("GrantResourceGroup insufficient setup failed: %v", err) } entitlementID := entitlementIDFromGrant(t, grant) repository.SetBalance(42102, 100) revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-insufficient", GrantID: grant.GrantID, Reason: "test insufficient", OperatorUserID: 90002, }) if err != nil { t.Fatalf("RevokeResourceGrant should clamp insufficient balance: %v", err) } if revoked.Status != resourcedomain.GrantStatusRevoked { t.Fatalf("insufficient balance revoke should still mark grant revoked: %+v", revoked) } assertBalance(t, svc, 42102, ledger.AssetCoin, 0) if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusRevoked); got != 1 { t.Fatalf("clamped revoke must mark grant revoked, got %d", got) } if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 { t.Fatalf("clamped revoke must still revoke entitlement, got %d", got) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 0 { t.Fatalf("clamped revoke must remove equipment row, got %d", got) } if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42102), ledger.AssetCoin, int64(-100)); got != 1 { t.Fatalf("clamped revoke should write one partial reverse coin entry, got %d", got) } if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 1 { t.Fatalf("clamped revoke should write one reverse transaction, got %d", got) } } // TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration 锁定单资源撤回的金币夹零和素材天数扣减语义。 func TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "coin_single_revoke_reject", ResourceType: resourcedomain.TypeCoin, Name: "Coin Single Revoke Reject", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyWalletCredit, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 100, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create single coin resource failed: %v", err) } singleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-single-coin-revoke", TargetUserID: 42103, ResourceID: coinResource.ResourceID, Quantity: 2, Reason: "single coin revoke", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("single coin GrantResource setup failed: %v", err) } repository.SetBalance(42103, 50) coinRevoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-single-coin", GrantID: singleGrant.GrantID, Reason: "single coin revoke", OperatorUserID: 90002, }) if err != nil { t.Fatalf("single coin RevokeResourceGrant failed: %v", err) } if coinRevoked.Status != resourcedomain.GrantStatusRevoked { t.Fatalf("single coin revoke status mismatch: %+v", coinRevoked) } assertBalance(t, svc, 42103, ledger.AssetCoin, 0) if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42103), ledger.AssetCoin, int64(-50)); got != 1 { t.Fatalf("single coin revoke should clamp reverse entry to available balance, got %d", got) } frameResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "frame_single_revoke_duration", ResourceType: resourcedomain.TypeAvatarFrame, Name: "Frame Single Revoke Duration", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyExtendExpiry, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create frame resource failed: %v", err) } durationMS := int64((7 * 24 * time.Hour) / time.Millisecond) firstFrameGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-frame-revoke-duration-first", TargetUserID: 42105, ResourceID: frameResource.ResourceID, Quantity: 1, DurationMS: durationMS, Reason: "frame duration first", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("first frame GrantResource setup failed: %v", err) } firstExpiresAt, firstQuantity, firstRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID) if firstQuantity != 1 || firstRemaining != 1 { t.Fatalf("first frame entitlement mismatch quantity=%d remaining=%d", firstQuantity, firstRemaining) } secondFrameGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-frame-revoke-duration-second", TargetUserID: 42105, ResourceID: frameResource.ResourceID, Quantity: 1, DurationMS: durationMS, Reason: "frame duration second", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("second frame GrantResource setup failed: %v", err) } secondExpiresAt, secondQuantity, secondRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID) if secondQuantity != 2 || secondRemaining != 2 || secondExpiresAt != firstExpiresAt+durationMS { t.Fatalf("second frame entitlement should extend by one duration: first=%d second=%d quantity=%d remaining=%d", firstExpiresAt, secondExpiresAt, secondQuantity, secondRemaining) } if _, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-frame-second", GrantID: secondFrameGrant.GrantID, Reason: "frame duration second revoke", OperatorUserID: 90002, }); err != nil { t.Fatalf("second frame RevokeResourceGrant failed: %v", err) } afterSecondRevokeExpiresAt, afterSecondQuantity, afterSecondRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID) if afterSecondExpiresAt := afterSecondRevokeExpiresAt; afterSecondExpiresAt != firstExpiresAt || afterSecondQuantity != 1 || afterSecondRemaining != 1 { t.Fatalf("second revoke should deduct exactly one duration: expires=%d want=%d quantity=%d remaining=%d", afterSecondExpiresAt, firstExpiresAt, afterSecondQuantity, afterSecondRemaining) } if _, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-frame-first", GrantID: firstFrameGrant.GrantID, Reason: "frame duration first revoke", OperatorUserID: 90002, }); err != nil { t.Fatalf("first frame RevokeResourceGrant failed: %v", err) } if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ? AND status = ? AND remaining_quantity = 0 AND expires_at_ms <= ?", int64(42105), frameResource.ResourceID, resourcedomain.GrantStatusRevoked, time.Now().UnixMilli()); got != 1 { t.Fatalf("first revoke should clamp material entitlement to zero remaining duration, got %d", got) } } // TestRevokeResourceGrantRejectsUnsupportedRecords 固定异常状态和不存在记录不能误处理。 func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "failed_revoke_reject_pack", Name: "Failed Revoke Reject Pack", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 10}}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create failed-status resource group failed: %v", err) } groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{ CommandID: "cmd-failed-revoke-reject", TargetUserID: 42104, GroupID: group.GroupID, Reason: "failed reject", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("group GrantResourceGroup setup failed: %v", err) } repository.SetResourceGrantStatus(groupGrant.GrantID, "failed") _, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-failed-reject", GrantID: groupGrant.GrantID, Reason: "failed reject", OperatorUserID: 90002, }) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("failed grant revoke should conflict, got %v", err) } _, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{ RequestID: "cmd-revoke-missing", GrantID: "rgr_missing_revoke", Reason: "missing reject", OperatorUserID: 90002, }) if !xerr.IsCode(err, xerr.NotFound) { t.Fatalf("missing grant revoke should be not found, got %v", err) } } // TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。 func TestGrantMicSeatAnimationAutoEquips(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() firstMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "mic_wave_blue", ResourceType: resourcedomain.TypeMicSeatAnimation, Name: "Blue Mic Wave", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"mic_seat"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create first mic seat animation resource failed: %v", err) } secondMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "mic_wave_gold", ResourceType: resourcedomain.TypeMicSeatAnimation, Name: "Gold Mic Wave", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"mic_seat"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create second mic seat animation resource failed: %v", err) } firstGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-mic-wave-blue", TargetUserID: 42003, ResourceID: firstMic.ResourceID, Quantity: 1, Reason: "test mic auto equip", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("grant first mic seat animation resource failed: %v", err) } resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ UserID: 42003, ResourceType: resourcedomain.TypeMicSeatAnimation, ActiveOnly: true, }) if err != nil { t.Fatalf("list mic seat animation resources failed: %v", err) } if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != firstGrant.Items[0].EntitlementID { t.Fatalf("single resource grant should auto equip mic seat animation: resources=%+v grant=%+v", resources, firstGrant) } group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "mic_wave_group", Name: "Mic Wave Group", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{{ResourceID: secondMic.ResourceID, Quantity: 1, SortOrder: 1}}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create mic seat animation group failed: %v", err) } groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{ CommandID: "cmd-grant-mic-wave-group", TargetUserID: 42003, GroupID: group.GroupID, Reason: "test group mic auto equip", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("grant mic seat animation group failed: %v", err) } equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ UserIDs: []int64{42003}, ResourceTypes: []string{resourcedomain.TypeMicSeatAnimation}, }) if err != nil { t.Fatalf("batch get mic seat animation equipment failed: %v", err) } if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].EntitlementID != groupGrant.Items[0].EntitlementID { t.Fatalf("group grant should auto equip the latest mic seat animation: equipped=%+v group_grant=%+v", equippedResources, groupGrant) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourcedomain.TypeMicSeatAnimation); got != 1 { t.Fatalf("mic seat animation equipment should keep one active row, got %d", got) } } // TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。 func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(53001, 1000) svc := walletservice.New(repository) ctx := context.Background() resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "shop_badge_gold", ResourceType: resourcedomain.TypeBadge, Name: "Gold Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyExtendExpiry, PriceType: resourcedomain.PriceTypeCoin, CoinPrice: 100, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create shop resource failed: %v", err) } shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{ Items: []resourcedomain.ResourceShopItemInput{{ ResourceID: resource.ResourceID, Status: resourcedomain.StatusActive, DurationDays: resourcedomain.ShopDurationThreeDays, SortOrder: 1, }}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("upsert shop item failed: %v", err) } receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{ CommandID: "cmd-shop-badge", UserID: 53001, ShopItemID: shopItems[0].ShopItemID, }) if err != nil { t.Fatalf("PurchaseResourceShopItem failed: %v", err) } if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 300 || receipt.Balance.AvailableAmount != 700 { t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt) } if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() { t.Fatalf("resource shop entitlement mismatch: %+v", receipt.Resource) } if !receipt.Resource.Equipped { t.Fatalf("resource shop badge purchase should auto equip receipt resource: %+v", receipt.Resource) } again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{ CommandID: "cmd-shop-badge", UserID: 53001, ShopItemID: shopItems[0].ShopItemID, }) if err != nil { t.Fatalf("PurchaseResourceShopItem retry failed: %v", err) } if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount { t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again) } if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 { t.Fatalf("resource shop order should be persisted once, got %d", got) } if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-badge", "resource_shop_purchase"); got != 1 { t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-300)); got != 1 { t.Fatalf("resource shop purchase should write one debit entry, got %d", got) } if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 { t.Fatalf("resource shop purchase should write one resource grant, got %d", got) } if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(53001), resource.ResourceID); got != 1 { t.Fatalf("resource shop purchase should grant one entitlement, got %d", got) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ? AND resource_id = ?", int64(53001), resourcedomain.TypeBadge, resource.ResourceID); got != 1 { t.Fatalf("resource shop badge purchase should write one equipment row, got %d", got) } } func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) { repository := mysqltest.NewRepository(t) activity := &fakeActivityBadgeClient{status: "consumed"} svc := walletservice.New(repository, activity) ctx := context.Background() badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_projection_tile", ResourceType: resourcedomain.TypeBadge, Name: "Projection Tile Badge", Status: resourcedomain.StatusActive, Grantable: true, ManagerGrantEnabled: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, MetadataJSON: `{"badge_form":"tile"}`, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create badge resource failed: %v", err) } grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-badge-projection", TargetUserID: 42002, ResourceID: badgeResource.ResourceID, Quantity: 1, Reason: "system badge grant", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("GrantResource failed: %v", err) } records, err := repository.ClaimPendingWalletOutbox(ctx, "badge-mq-test", 20, time.Now().Add(time.Minute).UnixMilli()) if err != nil { t.Fatalf("ClaimPendingWalletOutbox failed: %v", err) } processed := 0 for _, record := range records { handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute) if err != nil { t.Fatalf("ProcessWalletProjectionMessage failed: %v", err) } if handled { processed++ } } if processed != 1 || len(activity.requests) != 1 { t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests)) } req := activity.requests[0] if req.GetEventType() != "ResourceGranted" || req.GetSourceService() != "wallet-service" || req.GetMetricType() != "wallet_badge_grant" || req.GetUserId() != 42002 { t.Fatalf("activity request mismatch: %+v", req) } var payload struct { GrantID string `json:"grant_id"` GrantSource string `json:"grant_source"` Items []struct { ResourceID int64 `json:"resource_id"` ResourceType string `json:"resource_type"` EntitlementID string `json:"entitlement_id"` MetadataJSON string `json:"metadata_json"` } `json:"items"` } if err := json.Unmarshal([]byte(req.GetDimensionsJson()), &payload); err != nil { t.Fatalf("decode badge projection payload failed: %v", err) } if payload.GrantID != grant.GrantID || payload.GrantSource != resourcedomain.GrantSourceAdmin || len(payload.Items) != 1 { t.Fatalf("badge projection payload mismatch: %+v", payload) } if payload.Items[0].ResourceID != badgeResource.ResourceID || payload.Items[0].ResourceType != resourcedomain.TypeBadge || payload.Items[0].EntitlementID == "" { t.Fatalf("badge projection item mismatch: %+v", payload.Items[0]) } if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 { t.Fatalf("badge projection should mark event done, got %d", got) } processed = 0 for _, record := range records { handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute) if err != nil { t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err) } if handled { processed++ } } if processed != 0 || len(activity.requests) != 1 { t.Fatalf("badge projection must be idempotent: processed=%d requests=%d", processed, len(activity.requests)) } } // TestManagerCenterResourceGrantFiltersAndRechecksSwitch 锁定经理中心资源开关必须同时约束列表和写事务。 func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() enabled, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_manager_enabled", ResourceType: resourcedomain.TypeBadge, Name: "Manager Enabled Badge", Status: resourcedomain.StatusActive, Grantable: true, ManagerGrantEnabled: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create manager enabled resource failed: %v", err) } disabledForManager, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_manager_disabled", ResourceType: resourcedomain.TypeBadge, Name: "Manager Disabled Badge", Status: resourcedomain.StatusActive, Grantable: true, ManagerGrantEnabled: false, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create manager disabled resource failed: %v", err) } if _, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_disabled_status", ResourceType: resourcedomain.TypeBadge, Name: "Disabled Badge", Status: resourcedomain.StatusDisabled, Grantable: true, ManagerGrantEnabled: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }); err != nil { t.Fatalf("create disabled status resource failed: %v", err) } items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{ ResourceType: resourcedomain.TypeBadge, ManagerGrantOnly: true, Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("ListResources manager grant only failed: %v", err) } if total != 1 || len(items) != 1 || items[0].ResourceID != enabled.ResourceID { t.Fatalf("manager grant resource list mismatch: total=%d items=%+v", total, items) } grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-manager-grant-enabled", TargetUserID: 43001, ResourceID: enabled.ResourceID, Quantity: 1, Reason: "manager center", OperatorUserID: 91001, GrantSource: resourcedomain.GrantSourceManagerCenter, }) if err != nil { t.Fatalf("manager center grant enabled resource failed: %v", err) } if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 { t.Fatalf("manager grant response mismatch: %+v", grant) } _, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-manager-grant-disabled", TargetUserID: 43001, ResourceID: disabledForManager.ResourceID, Quantity: 1, Reason: "manager center", OperatorUserID: 91001, GrantSource: resourcedomain.GrantSourceManagerCenter, }) if !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("expected PERMISSION_DENIED for manager disabled resource, got %v", err) } } // TestGameRobotInitCanGrantActiveNonGrantableResource 锁定机器人初始化发放不跟随人工发放开关; // 只要资源仍处于 active,就允许机器人账号获得装扮,避免运营库没有打开 grantable 时生成失败。 func TestGameRobotInitCanGrantActiveNonGrantableResource(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "robot_init_frame", ResourceType: resourcedomain.TypeAvatarFrame, Name: "Robot Init Frame", Status: resourcedomain.StatusActive, Grantable: false, ManagerGrantEnabled: false, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create robot init resource failed: %v", err) } grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-game-robot-init-non-grantable", TargetUserID: 43009, ResourceID: resource.ResourceID, Quantity: 1, DurationMS: int64(24 * time.Hour / time.Millisecond), Reason: "game robot init", OperatorUserID: 91001, GrantSource: resourcedomain.GrantSourceGameRobotInit, }) if err != nil { t.Fatalf("game robot init should grant active non-grantable resource: %v", err) } if grant.GrantSource != resourcedomain.GrantSourceGameRobotInit || len(grant.Items) != 1 { t.Fatalf("game robot init grant response mismatch: %+v", grant) } } func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() command := resourcedomain.ResourceCommand{ ResourceCode: "duplicate_badge", ResourceType: resourcedomain.TypeBadge, Name: "Duplicate Badge", Status: resourcedomain.StatusActive, Grantable: true, ManagerGrantEnabled: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, } if _, err := svc.CreateResource(ctx, command); err != nil { t.Fatalf("first CreateResource failed: %v", err) } command.Name = "Duplicate Badge Again" if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("duplicate resource_code should be conflict, got %v", err) } } // TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。 func TestManagerCenterCannotGrantResourceGroup(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) _, err := svc.GrantResourceGroup(context.Background(), resourcedomain.GrantResourceGroupCommand{ CommandID: "cmd-manager-grant-group", TargetUserID: 43002, GroupID: 101, Reason: "manager center", OperatorUserID: 91001, GrantSource: resourcedomain.GrantSourceManagerCenter, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for manager center group grant, got %v", err) } } // TestActiveResourceGroupRejectsUngrantableResource 验证 active 资源组不能保存或启用当前不可发放的资源项。 func TestActiveResourceGroupRejectsUngrantableResource(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() disabledBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_disabled", ResourceType: resourcedomain.TypeBadge, Name: "Disabled Badge", Status: resourcedomain.StatusDisabled, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create disabled badge failed: %v", err) } _, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "active_badge_disabled", Name: "Active Disabled Badge", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{ {ResourceID: disabledBadge.ResourceID, Quantity: 1}, }, OperatorUserID: 90001, }) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("expected CONFLICT for disabled group resource, got %v", err) } coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "coin_resource_forbidden_in_group", ResourceType: resourcedomain.TypeCoin, Name: "Coin Resource", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyWalletCredit, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 1000, UsageScopes: []string{"grant"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create coin resource failed: %v", err) } _, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "active_coin_resource", Name: "Active Coin Resource", Status: resourcedomain.StatusActive, Items: []resourcedomain.ResourceGroupItemInput{ {ResourceID: coinResource.ResourceID, Quantity: 1}, }, OperatorUserID: 90001, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for coin resource group item, got %v", err) } group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ GroupCode: "disabled_badge_staging", Name: "Disabled Badge Staging", Status: resourcedomain.StatusDisabled, Items: []resourcedomain.ResourceGroupItemInput{ {ResourceID: disabledBadge.ResourceID, Quantity: 1}, }, OperatorUserID: 90001, }) if err != nil { t.Fatalf("disabled resource group should be saveable before enable: %v", err) } _, err = svc.SetResourceGroupStatus(ctx, resourcedomain.StatusCommand{ ID: group.GroupID, Status: resourcedomain.StatusActive, OperatorUserID: 90001, }) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("expected CONFLICT when enabling group with disabled resource, got %v", err) } } // TestEquipUserResourceRequiresActiveEquipableEntitlement 验证用户只能佩戴自己有效的可佩戴权益。 func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_equippable", ResourceType: resourcedomain.TypeBadge, Name: "Equippable Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create badge resource failed: %v", err) } grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-equip-badge", TargetUserID: 43001, ResourceID: badgeResource.ResourceID, Quantity: 1, Reason: "test equip", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("grant badge resource failed: %v", err) } resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ UserID: 43001, ResourceType: resourcedomain.TypeBadge, ActiveOnly: true, }) if err != nil { t.Fatalf("list auto-equipped badge resources failed: %v", err) } if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != grant.Items[0].EntitlementID { t.Fatalf("badge grant should auto equip entitlement: resources=%+v grant=%+v", resources, grant) } equipped, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43001, ResourceID: badgeResource.ResourceID, }) if err != nil { t.Fatalf("equip badge resource failed: %v", err) } if equipped.EntitlementID == "" || equipped.EntitlementID != grant.Items[0].EntitlementID { t.Fatalf("equipped entitlement mismatch: equipped=%+v grant=%+v", equipped, grant) } if !equipped.Equipped { t.Fatalf("equip response should mark entitlement as equipped: %+v", equipped) } secondBadgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "badge_equippable_short", ResourceType: resourcedomain.TypeBadge, Name: "Second Equippable Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create second badge resource failed: %v", err) } secondGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-equip-badge-second", TargetUserID: 43001, ResourceID: secondBadgeResource.ResourceID, Quantity: 1, Reason: "test second badge equip", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("grant second badge resource failed: %v", err) } resources, err = svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ UserID: 43001, ResourceType: resourcedomain.TypeBadge, ActiveOnly: true, }) if err != nil { t.Fatalf("list user resources failed: %v", err) } if len(resources) != 2 { t.Fatalf("badge grants should keep multiple equipped resources: resources=%+v", resources) } equippedBadgeIDs := map[string]bool{} for _, item := range resources { if item.Equipped { equippedBadgeIDs[item.EntitlementID] = true } } if !equippedBadgeIDs[equipped.EntitlementID] || !equippedBadgeIDs[secondGrant.Items[0].EntitlementID] { t.Fatalf("resource list should expose both equipped badges: resources=%+v first=%+v second=%+v", resources, equipped, secondGrant) } repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43001, ResourceID: badgeResource.ResourceID, EntitlementID: equipped.EntitlementID, }) if err != nil { t.Fatalf("repeat equip badge resource failed: %v", err) } if repeated.EntitlementID != equipped.EntitlementID { t.Fatalf("repeat equip should keep entitlement: repeated=%+v equipped=%+v", repeated, equipped) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 2 { t.Fatalf("repeat equip should not duplicate and should keep both active badges, got %d", got) } equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ UserIDs: []int64{43001}, ResourceTypes: []string{resourcedomain.TypeBadge}, }) if err != nil { t.Fatalf("batch get equipped resources failed: %v", err) } if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 2 { t.Fatalf("batch equipped resources mismatch: %+v", equippedResources) } unequipped, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{ UserID: 43001, ResourceType: resourcedomain.TypeBadge, }) if err != nil { t.Fatalf("unequip badge resource failed: %v", err) } if unequipped.Unequipped || unequipped.ResourceType != resourcedomain.TypeBadge { t.Fatalf("unequip result mismatch: %+v", unequipped) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 2 { t.Fatalf("badge equipment should remain after no-op unequip, got %d", got) } emptyUnequip, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{ UserID: 43001, ResourceType: resourcedomain.TypeBadge, }) if err != nil { t.Fatalf("repeat unequip badge resource failed: %v", err) } if emptyUnequip.Unequipped { t.Fatalf("repeat unequip should be idempotent false: %+v", emptyUnequip) } vehicleResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "vehicle_expiring", ResourceType: resourcedomain.TypeVehicle, Name: "Expiring Vehicle", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"room_entry"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create vehicle resource failed: %v", err) } vehicleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-expiring-vehicle", TargetUserID: 43001, ResourceID: vehicleResource.ResourceID, Quantity: 1, DurationMS: int64(time.Hour / time.Millisecond), Reason: "test expired equipment", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }) if err != nil { t.Fatalf("grant vehicle resource failed: %v", err) } if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43001, ResourceID: vehicleResource.ResourceID, }); err != nil { t.Fatalf("equip vehicle resource failed: %v", err) } repository.ExpireEntitlement(vehicleGrant.Items[0].EntitlementID, time.Now().Add(-time.Second).UnixMilli()) expiredEquipped, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ UserIDs: []int64{43001}, ResourceTypes: []string{resourcedomain.TypeVehicle}, }) if err != nil { t.Fatalf("batch get expired equipped resources failed: %v", err) } if len(expiredEquipped) != 1 || len(expiredEquipped[0].Resources) != 0 { t.Fatalf("expired equipment should be filtered: %+v", expiredEquipped) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeVehicle); got != 0 { t.Fatalf("expired equipment should be pruned, got %d", got) } giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "gift_not_equipable", ResourceType: resourcedomain.TypeGift, Name: "Not Equipable Gift", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"gift"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create gift resource failed: %v", err) } if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-non-equipable-gift", TargetUserID: 43001, ResourceID: giftResource.ResourceID, Quantity: 1, Reason: "test non equip", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }); err != nil { t.Fatalf("grant gift resource failed: %v", err) } _, err = svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43001, ResourceID: giftResource.ResourceID, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for non-equipable resource, got %v", err) } } // TestEquipUserResourceKeepsSingleEquipmentForNonBadgeTypes 验证多徽章 schema 不会改变其它装扮类型的单选语义。 func TestEquipUserResourceKeepsSingleEquipmentForNonBadgeTypes(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() firstFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "frame_single_first", ResourceType: resourcedomain.TypeAvatarFrame, Name: "First Frame", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create first frame failed: %v", err) } secondFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ ResourceCode: "frame_single_second", ResourceType: resourcedomain.TypeAvatarFrame, Name: "Second Frame", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, UsageScopes: []string{"profile"}, OperatorUserID: 90001, }) if err != nil { t.Fatalf("create second frame failed: %v", err) } if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-first-frame", TargetUserID: 43002, ResourceID: firstFrame.ResourceID, Quantity: 1, Reason: "test first frame", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }); err != nil { t.Fatalf("grant first frame failed: %v", err) } if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ CommandID: "cmd-grant-second-frame", TargetUserID: 43002, ResourceID: secondFrame.ResourceID, Quantity: 1, Reason: "test second frame", OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceAdmin, }); err != nil { t.Fatalf("grant second frame failed: %v", err) } if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43002, ResourceID: firstFrame.ResourceID, }); err != nil { t.Fatalf("equip first frame failed: %v", err) } if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43002, ResourceID: secondFrame.ResourceID, }); err != nil { t.Fatalf("equip second frame failed: %v", err) } if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43002), resourcedomain.TypeAvatarFrame); got != 1 { t.Fatalf("avatar_frame equipment should keep one active row, got %d", got) } equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ UserIDs: []int64{43002}, ResourceTypes: []string{resourcedomain.TypeAvatarFrame}, }) if err != nil { t.Fatalf("batch get equipped frames failed: %v", err) } if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].ResourceID != secondFrame.ResourceID { t.Fatalf("avatar_frame should expose only the last equipped resource: %+v", equippedResources) } } // TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。 func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(51001, 20000) repository.SeedVIPRewardGroup(1, 71001, []mysqltest.VipRewardResourceSeed{ { ResourceID: 7100101, ResourceCode: "vip1_avatar_frame", ResourceType: resourcedomain.TypeAvatarFrame, Name: "VIP1 Avatar Frame", GrantStrategy: resourcedomain.GrantStrategyExtendExpiry, }, }) svc := walletservice.New(repository) receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip1", UserID: 51001, Level: 1, }) if err != nil { t.Fatalf("PurchaseVip failed: %v", err) } if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.CoinSpent != 1000 || receipt.CoinBalanceAfter != 19000 { t.Fatalf("vip receipt mismatch: %+v", receipt) } if !receipt.Vip.Active || receipt.Vip.Level != 1 || receipt.Vip.ExpiresAtMS <= receipt.Vip.StartedAtMS { t.Fatalf("vip membership mismatch: %+v", receipt.Vip) } if len(receipt.RewardItems) != 1 || receipt.RewardItems[0].ResourceType != resourcedomain.TypeAvatarFrame { t.Fatalf("vip reward items mismatch: %+v", receipt.RewardItems) } if got := repository.CountRows("vip_purchase_orders", "user_id = ? AND target_level = ?", int64(51001), int32(1)); got != 1 { t.Fatalf("vip order should be persisted once, got %d", got) } if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND status = 'active'", int64(51001)); got != 1 { t.Fatalf("vip reward entitlement should be active, got %d", got) } if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51001), "VipActivated"); got != 1 { t.Fatalf("vip purchase should enqueue one activation IM event, got %d", got) } again, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip1", UserID: 51001, Level: 1, }) if err != nil { t.Fatalf("PurchaseVip retry failed: %v", err) } if again.OrderID != receipt.OrderID || again.CoinBalanceAfter != receipt.CoinBalanceAfter { t.Fatalf("vip idempotency mismatch: first=%+v again=%+v", receipt, again) } if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip-renew", UserID: 51001, Level: 1, }); err != nil { t.Fatalf("same level renewal should be allowed: %v", err) } upgrade, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip3", UserID: 51001, Level: 3, }) if err != nil { t.Fatalf("vip upgrade failed: %v", err) } if upgrade.Vip.Level != 3 { t.Fatalf("vip upgrade level mismatch: %+v", upgrade.Vip) } _, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip-down-to-1", UserID: 51001, Level: 1, }) if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) { t.Fatalf("expected VIP_DOWNGRADE_NOT_ALLOWED, got %v", err) } } func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(51004, 20000) resources := []mysqltest.VipRewardResourceSeed{ { ResourceID: 7100401, ResourceCode: "vip_renew_frame", ResourceType: resourcedomain.TypeAvatarFrame, Name: "VIP Renew Frame", GrantStrategy: resourcedomain.GrantStrategyExtendExpiry, }, { ResourceID: 7100402, ResourceCode: "vip_renew_mic", ResourceType: resourcedomain.TypeMicSeatAnimation, Name: "VIP Renew Mic", GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, }, { ResourceID: 7100403, ResourceCode: "vip_renew_badge", ResourceType: resourcedomain.TypeBadge, Name: "VIP Renew Badge", GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, }, } repository.SeedVIPRewardGroup(1, 71004, resources) svc := walletservice.New(repository) first, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip-renew-expiry-first", UserID: 51004, Level: 1, }) if err != nil { t.Fatalf("first VIP purchase failed: %v", err) } if len(first.RewardItems) != len(resources) { t.Fatalf("first reward item count mismatch: %+v", first.RewardItems) } for _, resource := range resources { expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID) if expiresAt != first.Vip.ExpiresAtMS { t.Fatalf("first entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, first.Vip.ExpiresAtMS) } } renew, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip-renew-expiry-second", UserID: 51004, Level: 1, }) if err != nil { t.Fatalf("VIP renewal failed: %v", err) } if renew.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS+604800000 { t.Fatalf("renewed VIP expiry mismatch: first=%d renewed=%d", first.Vip.ExpiresAtMS, renew.Vip.ExpiresAtMS) } if len(renew.RewardItems) != len(resources) { t.Fatalf("renew reward item count mismatch: %+v", renew.RewardItems) } for _, item := range renew.RewardItems { if item.ExpiresAtMS != renew.Vip.ExpiresAtMS { t.Fatalf("renew reward response expiry mismatch for %d: got %d want %d", item.ResourceID, item.ExpiresAtMS, renew.Vip.ExpiresAtMS) } } for _, resource := range resources { expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID) if expiresAt != renew.Vip.ExpiresAtMS { t.Fatalf("renew entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, renew.Vip.ExpiresAtMS) } } } // TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。 func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) adminGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{ CommandID: "cmd-admin-vip1", TargetUserID: 51003, Level: 1, GrantSource: ledger.VipGrantSourceAdmin, OperatorUserID: 9001, Reason: "manual vip grant", }) if err != nil { t.Fatalf("GrantVip admin failed: %v", err) } if adminGrant.TransactionID == "" || !adminGrant.Vip.Active || adminGrant.Vip.Level != 1 || len(adminGrant.RewardItems) != 1 { t.Fatalf("admin vip grant receipt mismatch: %+v", adminGrant) } if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-admin-vip1", "vip_grant"); got != 1 { t.Fatalf("vip grant should write one wallet transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", adminGrant.TransactionID); got != 0 { t.Fatalf("vip grant should not write coin entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 1 { t.Fatalf("vip grant should enqueue one activation IM event, got %d", got) } again, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{ CommandID: "cmd-admin-vip1", TargetUserID: 51003, Level: 1, GrantSource: ledger.VipGrantSourceAdmin, OperatorUserID: 9001, Reason: "manual vip grant", }) if err != nil { t.Fatalf("GrantVip retry failed: %v", err) } if again.TransactionID != adminGrant.TransactionID { t.Fatalf("vip grant idempotency mismatch: first=%+v again=%+v", adminGrant, again) } activityGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{ CommandID: "cmd-activity-vip3", TargetUserID: 51003, Level: 3, GrantSource: ledger.VipGrantSourceActivity, Reason: "activity vip reward", }) if err != nil { t.Fatalf("GrantVip activity failed: %v", err) } if activityGrant.Vip.Level != 3 { t.Fatalf("activity vip grant should upgrade to level 3: %+v", activityGrant) } if got := repository.CountRows("user_vip_history", "user_id = ? AND action = ?", int64(51003), ledger.VipGrantSourceActivity); got != 1 { t.Fatalf("activity vip grant should write history action, got %d", got) } if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 2 { t.Fatalf("both vip grants should enqueue activation IM events, got %d", got) } managerGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{ CommandID: "cmd-manager-vip2", TargetUserID: 51004, Level: 2, GrantSource: ledger.VipGrantSourceManagerCenter, OperatorUserID: 9002, Reason: "manager center vip grant", }) if err != nil { t.Fatalf("GrantVip manager center failed: %v", err) } if gotDuration := managerGrant.Vip.ExpiresAtMS - managerGrant.Vip.StartedAtMS; gotDuration != int64(30*24*time.Hour/time.Millisecond) { t.Fatalf("manager center VIP grant duration mismatch: got %d receipt=%+v", gotDuration, managerGrant) } } // TestVipLevelSixIgnoresLegacyRechargeThreshold 验证 VIP6 即使存在旧累充字段,也能按金币价格直接购买。 func TestVipLevelSixIgnoresLegacyRechargeThreshold(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(51002, 50000) svc := walletservice.New(repository) currentLevels, err := svc.ListAdminVipLevels(context.Background()) if err != nil { t.Fatalf("ListAdminVipLevels failed: %v", err) } if len(currentLevels) != 10 { t.Fatalf("expected 10 vip levels, got %d", len(currentLevels)) } rewardGroupID := currentLevels[0].RewardResourceGroupID commands := make([]ledger.AdminVipLevelCommand, 0, len(currentLevels)) for _, level := range currentLevels { command := ledger.AdminVipLevelCommand{ Level: level.Level, Name: level.Name, Status: level.Status, PriceCoin: level.PriceCoin, DurationMS: level.DurationMS, RewardResourceGroupID: level.RewardResourceGroupID, RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount, SortOrder: level.SortOrder, } if command.Level == 6 { command.Status = ledger.VipStatusActive command.PriceCoin = 12000 command.RewardResourceGroupID = rewardGroupID command.RequiredRechargeCoinAmount = 5000 } commands = append(commands, command) } if _, err := svc.UpdateAdminVipLevels(context.Background(), commands, 9001); err != nil { t.Fatalf("UpdateAdminVipLevels failed: %v", err) } repository.SetVIPLevelRechargeThreshold(6, 5000) _, packages, err := svc.ListVipPackages(context.Background(), 51002) if err != nil { t.Fatalf("ListVipPackages failed: %v", err) } level6 := vipLevelByNumber(packages, 6) if level6 == nil || !level6.CanPurchase || level6.PurchaseLockedReason != "" || level6.RechargeGateRequired || level6.RequiredRechargeCoinAmount != 0 || level6.UserRechargeCoinAmount != 0 { t.Fatalf("vip level 6 should ignore legacy recharge gate: %+v", level6) } receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ CommandID: "cmd-vip6", UserID: 51002, Level: 6, }) if err != nil { t.Fatalf("PurchaseVip level 6 failed: %v", err) } if receipt.Vip.Level != 6 || receipt.CoinSpent != 12000 { t.Fatalf("vip level 6 receipt mismatch: %+v", receipt) } } func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel { for index := range levels { if levels[index].Level == target { return &levels[index] } } return nil } // TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后钱包概览不再返回钻石余额或兑换入口。 func TestWalletOverviewDisablesDiamondExchange(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(52001, 700) svc := walletservice.New(repository) overview, err := svc.GetWalletOverview(context.Background(), 52001) if err != nil { t.Fatalf("GetWalletOverview failed: %v", err) } if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled { t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags) } if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, "DIAMOND") != 0 { t.Fatalf("overview balances mismatch: %+v", overview.Balances) } rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001) if err != nil { t.Fatalf("GetDiamondExchangeConfig failed: %v", err) } if len(rules) != 0 { t.Fatalf("diamond exchange rules must be empty after DIAMOND removal: %+v", rules) } } // TestRechargeProductsAreConfiguredByPlatformRegionAndStatus 锁定内购配置由 wallet-service 持久化并按平台、区域和上架态投放。 func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 1500000, CoinAmount: 1500, ProductName: "1500 Coins", Description: "coin pack", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1002, 1001}, Enabled: true, OperatorUserID: 90001, }) if err != nil { t.Fatalf("CreateRechargeProduct failed: %v", err) } if product.ProductID <= 0 || product.ProductCode != product.ProductName || product.Channel != ledger.RechargeChannelGoogle { t.Fatalf("created product identifiers mismatch: %+v", product) } if !product.AppVisible { t.Fatalf("new recharge product should be visible in app by default: %+v", product) } if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 { t.Fatalf("created product regions mismatch: %+v", product.RegionIDs) } products, channels, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal) if err != nil { t.Fatalf("ListRechargeProducts android failed: %v", err) } if len(products) != 1 || products[0].ProductName != "1500 Coins" || products[0].CoinAmount != 1500 || products[0].AmountMicro != 1500000 { t.Fatalf("android recharge product mismatch: products=%+v", products) } if len(channels) != 1 || channels[0] != ledger.RechargeChannelGoogle { t.Fatalf("android recharge channels mismatch: %+v", channels) } iosProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformIOS, ledger.RechargeAudienceNormal) if err != nil { t.Fatalf("ListRechargeProducts ios failed: %v", err) } if len(iosProducts) != 0 { t.Fatalf("ios list must not include android product: %+v", iosProducts) } updatedProduct, err := svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", ProductID: product.ProductID, AmountMicro: 2500000, CoinAmount: 2600, ProductName: "2600 Coins", Description: "updated pack", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1002}, Enabled: false, OperatorUserID: 90002, }) if err != nil { t.Fatalf("UpdateRechargeProduct failed: %v", err) } if updatedProduct.ProductCode != updatedProduct.ProductName { t.Fatalf("updated product_code should follow product_name: %+v", updatedProduct) } inactiveProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal) if err != nil { t.Fatalf("ListRechargeProducts inactive failed: %v", err) } if len(inactiveProducts) != 0 { t.Fatalf("disabled product must not be exposed to app: %+v", inactiveProducts) } adminProducts, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{ AppCode: "lalu", Status: ledger.RechargeProductStatusDisabled, RegionID: 1002, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListAdminRechargeProducts failed: %v", err) } if total != 1 || len(adminProducts) != 1 || adminProducts[0].Enabled || adminProducts[0].CoinAmount != 2600 { t.Fatalf("admin disabled product mismatch: total=%d products=%+v", total, adminProducts) } hidden := false hiddenProduct, err := svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", ProductID: product.ProductID, AmountMicro: 2500000, CoinAmount: 0, ProductName: "2600 Coins", Description: "hidden google shell", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1002}, Enabled: true, AppVisible: &hidden, OperatorUserID: 90003, }) if err != nil { t.Fatalf("UpdateRechargeProduct hidden failed: %v", err) } if !hiddenProduct.Enabled || hiddenProduct.AppVisible || hiddenProduct.CoinAmount != 0 { t.Fatalf("hidden product state mismatch: %+v", hiddenProduct) } hiddenProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal) if err != nil { t.Fatalf("ListRechargeProducts hidden failed: %v", err) } if len(hiddenProducts) != 0 { t.Fatalf("hidden active product must not be exposed to app: %+v", hiddenProducts) } adminProducts, total, err = svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{ AppCode: "lalu", Status: ledger.RechargeProductStatusActive, RegionID: 1002, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListAdminRechargeProducts hidden failed: %v", err) } if total != 1 || len(adminProducts) != 1 || adminProducts[0].AppVisible || adminProducts[0].CoinAmount != 0 { t.Fatalf("admin hidden product mismatch: total=%d products=%+v", total, adminProducts) } if err := svc.DeleteRechargeProduct(context.Background(), "lalu", product.ProductID); err != nil { t.Fatalf("DeleteRechargeProduct failed: %v", err) } remaining, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{AppCode: "lalu", Page: 1, PageSize: 10}) if err != nil { t.Fatalf("ListAdminRechargeProducts after delete failed: %v", err) } if total != 0 || len(remaining) != 0 { t.Fatalf("deleted product must disappear: total=%d products=%+v", total, remaining) } } // TestListAdminRechargeProductsWithoutAudienceOrPlatformFilterIncludesCoinSellerWeb 锁定后台“全部用户类型/全部平台”的语义。 // App/H5 购买入口可以把空 audience_type 解释成普通用户,但后台配置列表的空值必须表示不筛选, // 否则运营选择“全部”时只看到 normal/android,刚配置的 coin_seller/web 档位会被后端静默过滤。 func TestListAdminRechargeProductsWithoutAudienceOrPlatformFilterIncludesCoinSellerWeb(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) normalProduct, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 1000000, CoinAmount: 1000000, ProductName: "Normal Android", Description: "normal android tier", Platform: ledger.RechargeProductPlatformAndroid, AudienceType: ledger.RechargeAudienceNormal, RegionIDs: []int64{1001}, Enabled: true, OperatorUserID: 90001, }) if err != nil { t.Fatalf("CreateRechargeProduct normal failed: %v", err) } coinSellerProduct, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 5000000, CoinAmount: 5000000, ProductName: "Coin Seller Web", Description: "coin seller web tier", Platform: ledger.RechargeProductPlatformWeb, AudienceType: ledger.RechargeAudienceCoinSeller, RegionIDs: []int64{1001}, Enabled: true, OperatorUserID: 90002, }) if err != nil { t.Fatalf("CreateRechargeProduct coin seller failed: %v", err) } products, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{ AppCode: "lalu", Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListAdminRechargeProducts all filters failed: %v", err) } if total != 2 || len(products) != 2 { t.Fatalf("admin all filters must include normal and coin seller products: total=%d products=%+v", total, products) } assertRechargeProductListed(t, products, normalProduct.ProductID, ledger.RechargeAudienceNormal, ledger.RechargeProductPlatformAndroid) assertRechargeProductListed(t, products, coinSellerProduct.ProductID, ledger.RechargeAudienceCoinSeller, ledger.RechargeProductPlatformWeb) coinSellerOnly, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{ AppCode: "lalu", AudienceType: ledger.RechargeAudienceCoinSeller, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListAdminRechargeProducts coin seller failed: %v", err) } if total != 1 || len(coinSellerOnly) != 1 || coinSellerOnly[0].ProductID != coinSellerProduct.ProductID { t.Fatalf("coin seller filter must only include coin seller product: total=%d products=%+v", total, coinSellerOnly) } } func assertRechargeProductListed(t *testing.T, products []ledger.RechargeProduct, productID int64, audienceType string, platform string) { t.Helper() for _, product := range products { if product.ProductID != productID { continue } if product.AudienceType != audienceType || product.Platform != platform { t.Fatalf("product %d metadata mismatch: want audience=%s platform=%s got=%+v", productID, audienceType, platform, product) } return } t.Fatalf("product %d not found in admin list: %+v", productID, products) } // TestConfirmGooglePaymentUsesProductNameAsGoogleProductID 锁定后台 product_name 作为 Google Play 商品 ID。 func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) const googleProductID = "coins_1500_google" product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 1500000, CoinAmount: 1500, ProductName: googleProductID, Description: "google coin pack", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1001}, Enabled: true, OperatorUserID: 90001, }) if err != nil { t.Fatalf("CreateRechargeProduct failed: %v", err) } if product.ProductCode != googleProductID { t.Fatalf("android product_code should match Google product ID: %+v", product) } svc.SetGooglePlayClient(&fakeGooglePlayClient{ purchase: ledger.GooglePlayPurchase{ ProductID: googleProductID, OrderID: "GPA.1", PurchaseState: ledger.GooglePurchaseStatePurchased, ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", }, }) receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{ AppCode: "lalu", CommandID: "google-pay-product-name", UserID: 53001, RegionID: 1001, CountryID: 199, ProductID: product.ProductID, ProductCode: googleProductID, PackageName: "com.org.laluparty", PurchaseToken: "purchase-token-product-name", OrderID: "GPA.1", }) if err != nil { t.Fatalf("ConfirmGooglePayment failed: %v", err) } if receipt.Status != ledger.PaymentStatusCredited || receipt.CoinAmount != 1500 || receipt.ProductCode != product.ProductCode { t.Fatalf("google payment receipt mismatch: %+v", receipt) } if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ?", product.ProductID, product.ProductCode); got != 1 { t.Fatalf("google payment should write one payment order with internal product_code, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 { t.Fatalf("google payment should write one wallet entry, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND exchange_usd_minor_amount = ?", receipt.TransactionID, int64(1500), int64(150), int64(150)); got != 1 { t.Fatalf("google payment should write recharge USD minor from micro amount, got %d", got) } if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ? AND total_coin_amount = ? AND total_usd_minor_amount = ?", int64(53001), int64(1500), int64(150)); got != 1 { t.Fatalf("google payment should aggregate recharge USD minor from micro amount, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.google_product_id')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.product_name')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.product_code')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(199), int64(199), int64(1001), ledger.RechargeChannelGoogle, googleProductID, googleProductID, googleProductID); got != 1 { t.Fatalf("google recharge fact should carry target country/region and product identity for BI/activity, got %d", got) } } func TestConfirmGooglePaymentAcceptsHiddenZeroCoinProduct(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) const googleProductID = "coins_hidden_first_recharge" hidden := false product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 3990000, CoinAmount: 0, ProductName: googleProductID, Description: "hidden first recharge google shell", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1001}, Enabled: true, AppVisible: &hidden, OperatorUserID: 90001, }) if err != nil { t.Fatalf("CreateRechargeProduct hidden failed: %v", err) } products, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal) if err != nil { t.Fatalf("ListRechargeProducts hidden failed: %v", err) } if len(products) != 0 { t.Fatalf("hidden google shell must not be listed in app products: %+v", products) } svc.SetGooglePlayClient(&fakeGooglePlayClient{ purchase: ledger.GooglePlayPurchase{ ProductID: googleProductID, OrderID: "GPA.hidden", PurchaseState: ledger.GooglePurchaseStatePurchased, ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", }, }) receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{ AppCode: "lalu", CommandID: "google-pay-hidden-zero", UserID: 53001, RegionID: 1001, CountryID: 199, ProductCode: googleProductID, PackageName: "com.org.laluparty", PurchaseToken: "purchase-token-hidden-zero", OrderID: "GPA.hidden", }) if err != nil { t.Fatalf("ConfirmGooglePayment hidden failed: %v", err) } if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductID != product.ProductID || receipt.CoinAmount != 0 || receipt.Balance.AvailableAmount != 0 { t.Fatalf("hidden google receipt mismatch: %+v", receipt) } if got := repository.CountRows("payment_orders", "provider_order_id = ? AND product_code = ? AND coin_amount = ?", "GPA.hidden", googleProductID, int64(0)); got != 1 { t.Fatalf("hidden google payment should write one zero-coin payment order, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(0)); got != 1 { t.Fatalf("hidden google payment should write one zero-delta wallet entry, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ?", receipt.TransactionID, int64(0), int64(399)); got != 1 { t.Fatalf("hidden google payment should keep paid USD recharge fact, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.google_product_id')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(0), googleProductID); got != 1 { t.Fatalf("hidden google payment should publish zero-coin recharge fact for activity, got %d", got) } } func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) const googleProductID = "coins_legacy_first_recharge" const legacyProductCode = "iap_android_legacy" product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 1990000, CoinAmount: 160000, ProductName: googleProductID, Description: "legacy first recharge pack", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1001}, Enabled: true, OperatorUserID: 90001, }) if err != nil { t.Fatalf("CreateRechargeProduct failed: %v", err) } repository.SetRechargeProductCode(product.ProductID, legacyProductCode) svc.SetGooglePlayClient(&fakeGooglePlayClient{ purchase: ledger.GooglePlayPurchase{ ProductID: googleProductID, OrderID: "GPA.legacy", PurchaseState: ledger.GooglePurchaseStatePurchased, ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", }, }) receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{ AppCode: "lalu", CommandID: "google-pay-legacy-product-code", UserID: 53002, RegionID: 1001, ProductID: product.ProductID, ProductCode: legacyProductCode, PackageName: "com.org.laluparty", PurchaseToken: "purchase-token-legacy-product-code", OrderID: "GPA.legacy", }) if err != nil { t.Fatalf("ConfirmGooglePayment should accept legacy product_code input: %v", err) } if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductCode != legacyProductCode || receipt.CoinAmount != 160000 { t.Fatalf("legacy google payment receipt mismatch: %+v", receipt) } if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ? AND product_name = ?", product.ProductID, legacyProductCode, googleProductID); got != 1 { t.Fatalf("google payment should keep legacy product_code snapshot while validating product_name, got %d", got) } } func TestConfirmGooglePaymentFindsGoogleProductWithoutProductID(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) const googleProductID = "first_recharge_google_999" product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AmountMicro: 9990000, CoinAmount: 90000, ProductName: googleProductID, Description: "first recharge pack", Platform: ledger.RechargeProductPlatformAndroid, RegionIDs: []int64{1001}, Enabled: true, OperatorUserID: 90001, }) if err != nil { t.Fatalf("CreateRechargeProduct failed: %v", err) } svc.SetGooglePlayClient(&fakeGooglePlayClient{ purchase: ledger.GooglePlayPurchase{ ProductID: googleProductID, OrderID: "GPA.first", PurchaseState: ledger.GooglePurchaseStatePurchased, ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", }, }) receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{ AppCode: "lalu", CommandID: "google-pay-first-recharge", UserID: 53003, RegionID: 1001, ProductCode: googleProductID, PackageName: "com.org.laluparty", PurchaseToken: "purchase-token-first-recharge", OrderID: "GPA.first", }) if err != nil { t.Fatalf("ConfirmGooglePayment should find google product without product_id: %v", err) } if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductID != product.ProductID || receipt.ProductCode != googleProductID || receipt.CoinAmount != 90000 { t.Fatalf("first recharge google payment receipt mismatch: %+v", receipt) } if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ? AND product_name = ?", product.ProductID, googleProductID, googleProductID); got != 1 { t.Fatalf("google payment should write internal product snapshot, got %d", got) } if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ?", receipt.TransactionID, int64(90000), int64(999)); got != 1 { t.Fatalf("google payment should write recharge record from matched product, got %d", got) } } func TestApplyGameCoinChangeDebitCreditAndIdempotency(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(88001, 500) svc := walletservice.New(repository) debit := ledger.GameCoinChangeCommand{ AppCode: "lalu", CommandID: "game:demo:order-1", UserID: 88001, PlatformCode: "demo", GameID: "demo_rocket_001", ProviderOrderID: "order-1", ProviderRoundID: "round-1", OpType: ledger.GameOpDebit, CoinAmount: 120, RoomID: "room-game-1", RequestHash: "hash-order-1", } first, err := svc.ApplyGameCoinChange(context.Background(), debit) if err != nil { t.Fatalf("game debit failed: %v", err) } if first.BalanceAfter != 380 || first.IdempotentReplay { t.Fatalf("game debit receipt mismatch: %+v", first) } second, err := svc.ApplyGameCoinChange(context.Background(), debit) if err != nil { t.Fatalf("game debit replay failed: %v", err) } if second.TransactionID != first.TransactionID || !second.IdempotentReplay || second.BalanceAfter != 380 { t.Fatalf("game debit replay mismatch: first=%+v second=%+v", first, second) } credit, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{ AppCode: "lalu", CommandID: "game:demo:order-2", UserID: 88001, PlatformCode: "demo", GameID: "demo_rocket_001", ProviderOrderID: "order-2", OpType: ledger.GameOpCredit, CoinAmount: 70, RequestHash: "hash-order-2", }) if err != nil { t.Fatalf("game credit failed: %v", err) } if credit.BalanceAfter != 450 { t.Fatalf("game credit balance mismatch: %+v", credit) } balances, err := svc.GetBalances(context.Background(), 88001, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 450 { t.Fatalf("final balance mismatch: %+v", balances) } if got := repository.CountRows("wallet_transactions", "biz_type IN ('game_debit','game_credit')"); got != 2 { t.Fatalf("game coin changes should write two transactions, got %d", got) } if got := repository.CountRows("wallet_outbox", "event_type = ?", "WalletBalanceChanged"); got < 2 { t.Fatalf("game coin changes should publish balance outbox events, got %d", got) } } func TestApplyGameCoinChangeRejectsHashConflictAndInsufficientBalance(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(88002, 50) svc := walletservice.New(repository) command := ledger.GameCoinChangeCommand{ AppCode: "lalu", CommandID: "game:demo:order-conflict", UserID: 88002, PlatformCode: "demo", GameID: "demo_rocket_001", ProviderOrderID: "order-conflict", OpType: ledger.GameOpDebit, CoinAmount: 20, RequestHash: "hash-a", } if _, err := svc.ApplyGameCoinChange(context.Background(), command); err != nil { t.Fatalf("first game debit failed: %v", err) } command.RequestHash = "hash-b" if _, err := svc.ApplyGameCoinChange(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) { t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err) } _, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{ AppCode: "lalu", CommandID: "game:demo:order-insufficient", UserID: 88002, PlatformCode: "demo", GameID: "demo_rocket_001", ProviderOrderID: "order-insufficient", OpType: ledger.GameOpDebit, CoinAmount: 1000, RequestHash: "hash-insufficient", }) if !xerr.IsCode(err, xerr.InsufficientBalance) { t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "game:demo:order-insufficient"); got != 0 { t.Fatalf("insufficient game debit must not write transaction, got %d", got) } } func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 { for _, balance := range balances { if balance.AssetType == assetType { return balance.AvailableAmount } } return -1 } func entitlementIDFromGrant(t *testing.T, grant resourcedomain.ResourceGrant) string { t.Helper() for _, item := range grant.Items { if item.ResultType == resourcedomain.ResultEntitlement && item.EntitlementID != "" { return item.EntitlementID } } t.Fatalf("grant has no entitlement item: %+v", grant) return "" } func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) { assertBalanceForApp(t, svc, "lalu", userID, assetType, want) } func assertBalanceForApp(t *testing.T, svc *walletservice.Service, appCode string, userID int64, assetType string, want int64) { t.Helper() balances, err := svc.GetBalances(appcode.WithContext(context.Background(), appCode), userID, []string{assetType}) if err != nil { t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err) } if got := balanceAmount(balances, assetType); got != want { t.Fatalf("balance user=%d asset=%s mismatch: got %d want %d", userID, assetType, got, want) } } func requireWalletBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, wantAvailable int64, wantFrozen int64) { requireWalletBalanceForApp(t, svc, "lalu", userID, assetType, wantAvailable, wantFrozen) } func requireWalletBalanceForApp(t *testing.T, svc *walletservice.Service, appCode string, userID int64, assetType string, wantAvailable int64, wantFrozen int64) { t.Helper() balances, err := svc.GetBalances(appcode.WithContext(context.Background(), appCode), userID, []string{assetType}) if err != nil { t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err) } for _, balance := range balances { if balance.AssetType == assetType { if balance.AvailableAmount != wantAvailable || balance.FrozenAmount != wantFrozen { t.Fatalf("balance user=%d asset=%s mismatch: got available=%d frozen=%d want available=%d frozen=%d", userID, assetType, balance.AvailableAmount, balance.FrozenAmount, wantAvailable, wantFrozen) } return } } t.Fatalf("balance user=%d asset=%s missing in %+v", userID, assetType, balances) } type fakePointGateRepository struct { ports.Repository creditTaskRewardCalls int lastTaskReward ledger.TaskRewardCommand lastTaskRewardAppFromContext string freezeSalaryCalls int lastFreezeSalary ledger.SalaryWithdrawalCommand lastFreezeSalaryAppFromContext string } func (f *fakePointGateRepository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) { f.creditTaskRewardCalls++ f.lastTaskReward = command f.lastTaskRewardAppFromContext = appcode.FromContext(ctx) return ledger.TaskRewardReceipt{ TransactionID: "fake-task-reward-tx", Amount: command.Amount, GrantedAtMS: 1700000000000, Balance: ledger.AssetBalance{ AppCode: appcode.FromContext(ctx), UserID: command.TargetUserID, AssetType: command.AssetType, AvailableAmount: command.Amount, }, }, nil } func (f *fakePointGateRepository) FreezeSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) { f.freezeSalaryCalls++ f.lastFreezeSalary = command f.lastFreezeSalaryAppFromContext = appcode.FromContext(ctx) return ledger.SalaryWithdrawalReceipt{ TransactionID: "fake-point-freeze-tx", UserID: command.UserID, SalaryAssetType: command.SalaryAssetType, SalaryUSDMinor: command.SalaryUSDMinor, PointFeeAmount: command.PointFeeAmount, PointNetAmount: command.PointNetAmount, PointsPerUSD: command.PointsPerUSD, PointWithdrawFeeBPS: command.PointWithdrawFeeBPS, AvailableAfter: 0, FrozenAfter: command.SalaryUSDMinor, Version: 1, CreatedAtMS: 1700000000000, }, nil } func paymentMethodsContain(methods []ledger.ThirdPartyPaymentMethod, countryCode string, payWay string, payType string) bool { for _, method := range methods { if method.CountryCode == countryCode && method.PayWay == payWay && method.PayType == payType { return true } } return false } func thirdPartyPaymentMethodByKey(methods []ledger.ThirdPartyPaymentMethod, countryCode string, payWay string, payType string) (ledger.ThirdPartyPaymentMethod, bool) { for _, method := range methods { if method.CountryCode == countryCode && method.PayWay == payWay && method.PayType == payType { return method, true } } return ledger.ThirdPartyPaymentMethod{}, false } func v5PayProductRequestSeen(requests []walletservice.V5PayProductTypesRequest, countryCode string, currencyCode string) bool { for _, request := range requests { if strings.EqualFold(request.CountryCode, countryCode) && strings.EqualFold(request.CurrencyCode, currencyCode) { return true } } return false } func giftWallItemByID(items []ledger.GiftWallItem, giftID string) (ledger.GiftWallItem, bool) { for _, item := range items { if item.GiftID == giftID { return item, true } } return ledger.GiftWallItem{}, false } func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool { for _, item := range items { if item.GiftID == giftID { return true } } return false } func giftConfigByID(items []resourcedomain.GiftConfig, giftID string) (resourcedomain.GiftConfig, bool) { for _, item := range items { if item.GiftID == giftID { return item, true } } return resourcedomain.GiftConfig{}, false } func int64sEqual(left []int64, right []int64) bool { if len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool { for _, item := range items { if item.TypeCode == typeCode { return true } } return false } type fakeActivityBadgeClient struct { status string err error requests []*activityv1.ConsumeAchievementEventRequest } func (f *fakeActivityBadgeClient) ConsumeAchievementEvent(_ context.Context, req *activityv1.ConsumeAchievementEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error) { f.requests = append(f.requests, req) if f.err != nil { return nil, f.err } status := f.status if status == "" { status = "consumed" } return &activityv1.ConsumeAchievementEventResponse{EventId: req.GetEventId(), Status: status}, nil } type fakeGooglePlayClient struct { purchase ledger.GooglePlayPurchase err error consumed []string } func (f *fakeGooglePlayClient) GetProductPurchase(_ context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) { if f.err != nil { return ledger.GooglePlayPurchase{}, f.err } purchase := f.purchase if purchase.PackageName == "" { purchase.PackageName = packageName } return purchase, nil } func (f *fakeGooglePlayClient) ConsumeProduct(_ context.Context, _ string, productID string, _ string) error { f.consumed = append(f.consumed, productID) return nil } func (f *fakeGooglePlayClient) GetOrder(_ context.Context, _ string, _ string) (ledger.GooglePlayOrder, error) { // 实付明细刷新是入账后的异步补数动作;fake 直接失败,让确认链路测试不产生额外副作用。 return ledger.GooglePlayOrder{}, xerr.New(xerr.Unavailable, "orders api is not faked") } type fakeMifaPayClient struct { createReq walletservice.MifaPayCreateOrderRequest createResp walletservice.MifaPayCreateOrderResponse queryReq walletservice.MifaPayQueryOrderRequest queryReqs []walletservice.MifaPayQueryOrderRequest queryResp walletservice.MifaPayQueryOrderResponse querySeq []fakeMifaPayQueryResult queryErr error notifyData walletservice.MifaPayNotifyData err error } type fakeMifaPayQueryResult struct { resp walletservice.MifaPayQueryOrderResponse err error } type fakeMifaPayProviderPayloadError struct { cause error payload string } func (e fakeMifaPayProviderPayloadError) Error() string { return e.cause.Error() } func (e fakeMifaPayProviderPayloadError) Unwrap() error { return e.cause } func (e fakeMifaPayProviderPayloadError) ProviderPayloadJSON() string { return e.payload } func (f *fakeMifaPayClient) CreateOrder(_ context.Context, req walletservice.MifaPayCreateOrderRequest) (walletservice.MifaPayCreateOrderResponse, error) { f.createReq = req if f.err != nil { return walletservice.MifaPayCreateOrderResponse{}, f.err } if f.createResp.PayURL == "" { return walletservice.MifaPayCreateOrderResponse{OrderID: req.OrderID, ProviderOrderID: "mb_" + req.OrderID, PayURL: "https://pay.example/" + req.OrderID, RawJSON: `{"ok":true}`}, nil } return f.createResp, nil } func (f *fakeMifaPayClient) QueryOrder(_ context.Context, req walletservice.MifaPayQueryOrderRequest) (walletservice.MifaPayQueryOrderResponse, error) { f.queryReq = req f.queryReqs = append(f.queryReqs, req) if index := len(f.queryReqs) - 1; index < len(f.querySeq) { result := f.querySeq[index] return result.resp, result.err } if f.queryErr != nil { return walletservice.MifaPayQueryOrderResponse{}, f.queryErr } return f.queryResp, nil } func (f *fakeMifaPayClient) ParseNotification(ledger.MifaPayNotification) (walletservice.MifaPayNotifyData, error) { if f.err != nil { return walletservice.MifaPayNotifyData{}, f.err } return f.notifyData, nil } type fakeV5PayClient struct { createReq walletservice.V5PayCreateOrderRequest createResp walletservice.V5PayCreateOrderResponse queryReq walletservice.V5PayQueryOrderRequest queryReqs []walletservice.V5PayQueryOrderRequest queryResp walletservice.V5PayQueryOrderResponse querySeq []fakeV5PayQueryResult notifyData walletservice.V5PayNotifyData productReqs []walletservice.V5PayProductTypesRequest productTypes map[string]walletservice.V5PayProductTypesResponse productErrs map[string]error err error } type fakeV5PayQueryResult struct { resp walletservice.V5PayQueryOrderResponse err error } type fakeProviderPayloadError struct { message string rawJSON string } func (e fakeProviderPayloadError) Error() string { return e.message } func (e fakeProviderPayloadError) ProviderPayloadJSON() string { return e.rawJSON } func (f *fakeV5PayClient) CreateOrder(_ context.Context, req walletservice.V5PayCreateOrderRequest) (walletservice.V5PayCreateOrderResponse, error) { f.createReq = req if f.err != nil { return walletservice.V5PayCreateOrderResponse{}, f.err } if f.createResp.PayURL == "" { return walletservice.V5PayCreateOrderResponse{OrderID: req.OrderID, ProviderOrderID: "v5_" + req.OrderID, PayURL: "https://v5pay.example/" + req.OrderID, RawJSON: `{"ok":true}`}, nil } return f.createResp, nil } func (f *fakeV5PayClient) QueryOrder(_ context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) { f.queryReq = req f.queryReqs = append(f.queryReqs, req) if index := len(f.queryReqs) - 1; index < len(f.querySeq) { result := f.querySeq[index] return result.resp, result.err } if f.err != nil { return walletservice.V5PayQueryOrderResponse{}, f.err } return f.queryResp, nil } func (f *fakeV5PayClient) ListProductTypes(_ context.Context, req walletservice.V5PayProductTypesRequest) (walletservice.V5PayProductTypesResponse, error) { f.productReqs = append(f.productReqs, req) key := strings.ToUpper(strings.TrimSpace(req.CountryCode)) + "|" + strings.ToUpper(strings.TrimSpace(req.CurrencyCode)) if err := f.productErrs[key]; err != nil { return walletservice.V5PayProductTypesResponse{}, err } return f.productTypes[key], nil } func (f *fakeV5PayClient) ParseNotification(ledger.V5PayNotification) (walletservice.V5PayNotifyData, error) { if f.err != nil { return walletservice.V5PayNotifyData{}, f.err } return f.notifyData, nil } type fakeTronUSDTClient struct { lastTxHash string lastToAddress string lastAmountMinor int64 rawJSON string err error } func (f *fakeTronUSDTClient) VerifyUSDTTransfer(_ context.Context, txHash string, toAddress string, amountMinor int64) (string, error) { f.lastTxHash = txHash f.lastToAddress = toAddress f.lastAmountMinor = amountMinor if f.err != nil { return f.rawJSON, f.err } if f.rawJSON != "" { return f.rawJSON, nil } return `{"confirmed":true}`, nil }