2026-07-03 13:09:01 +08:00

6333 lines
265 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package wallet_test
import (
"context"
"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"
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,
}
}
// 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 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",
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)
}
}
// 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)
}
}
// 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)
}
}
// 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 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)
}
}
// 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)
}
}
// TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient 验证余额不足时整个撤销事务回滚,权益和发放记录仍保持成功状态。
func TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient(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)
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
RequestID: "cmd-revoke-insufficient",
GrantID: grant.GrantID,
Reason: "test insufficient",
OperatorUserID: 90002,
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("revoke should fail with insufficient balance, got %v", err)
}
if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusDone); got != 1 {
t.Fatalf("failed revoke must keep grant succeeded, got %d", got)
}
if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = 'active'", entitlementID); got != 1 {
t.Fatalf("failed revoke must keep entitlement active, got %d", got)
}
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 1 {
t.Fatalf("failed revoke must keep equipment row, got %d", got)
}
if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 0 {
t.Fatalf("failed revoke must not write reverse transaction, got %d", got)
}
}
// TestRevokeResourceGrantRejectsUnsupportedRecords 固定撤销范围:只允许成功的资源组发放,单资源、异常状态和不存在记录都不能误处理。
func TestRevokeResourceGrantRejectsUnsupportedRecords(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-revoke-reject",
TargetUserID: 42103,
ResourceID: coinResource.ResourceID,
Quantity: 1,
Reason: "single reject",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("single GrantResource setup failed: %v", err)
}
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
RequestID: "cmd-revoke-single-reject",
GrantID: singleGrant.GrantID,
Reason: "single reject",
OperatorUserID: 90002,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("single resource revoke should be invalid, got %v", err)
}
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)
}
}
// TestVipLevelSixRequiresRechargeThreshold 验证 6-10 级 VIP 必须先达到累计充值门槛。
func TestVipLevelSixRequiresRechargeThreshold(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)
}
_, 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 != "recharge_required" || level6.RequiredRechargeCoinAmount != 5000 {
t.Fatalf("vip level 6 should be locked by recharge gate: %+v", level6)
}
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip6-locked",
UserID: 51002,
Level: 6,
})
if !xerr.IsCode(err, xerr.VIPRechargeRequired) {
t.Fatalf("expected VIP_RECHARGE_REQUIRED, got %v", err)
}
repository.SetRechargeStats(51002, 5000)
_, packages, err = svc.ListVipPackages(context.Background(), 51002)
if err != nil {
t.Fatalf("ListVipPackages after recharge failed: %v", err)
}
level6 = vipLevelByNumber(packages, 6)
if level6 == nil || !level6.CanPurchase || level6.UserRechargeCoinAmount != 5000 {
t.Fatalf("vip level 6 should be purchasable after recharge: %+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 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)
}
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 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) {
t.Helper()
balances, err := svc.GetBalances(context.Background(), 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 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
queryResp walletservice.MifaPayQueryOrderResponse
queryErr error
notifyData walletservice.MifaPayNotifyData
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
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
queryResp walletservice.V5PayQueryOrderResponse
notifyData walletservice.V5PayNotifyData
productReqs []walletservice.V5PayProductTypesRequest
productTypes map[string]walletservice.V5PayProductTypesResponse
productErrs map[string]error
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
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
}