2026-05-28 15:11:37 +08:00

2365 lines
91 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"
"testing"
"time"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
"hyapp/services/wallet-service/internal/testutil/mysqltest"
)
// TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。
func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(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 != 6 || receipt.HeatValue != 22 || 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) != 6 {
t.Fatalf("target GIFT_POINT balance mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
t.Fatalf("gift debit should write two entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 3 {
t.Fatalf("gift debit should write balance and gift outbox events, got %d", got)
}
}
// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼,扣 COIN 和收 GIFT_POINT 仍然分别落账。
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 != 6 || receipt.BalanceAfter != 86 {
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) != 86 || balanceAmount(balances, ledger.AssetGiftPoint) != 6 {
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 still write debit and credit entries, 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)
}
processed, err := svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute)
if err != nil {
t.Fatalf("ProjectPendingGiftWallEvents failed: %v", err)
}
if processed != 3 {
t.Fatalf("projection should process three gift events, got %d", processed)
}
processed, err = svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute)
if err != nil {
t.Fatalf("ProjectPendingGiftWallEvents retry failed: %v", err)
}
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 != 255 {
t.Fatalf("gift wall summary mismatch: %+v", wall)
}
if len(wall.Items) != 2 || wall.Items[0].GiftID != "rose" || wall.Items[0].GiftCount != 5 || wall.Items[0].TotalValue != 35 {
t.Fatalf("rose gift wall item mismatch: %+v", wall.Items)
}
if wall.Items[0].Resource.ResourceID == 0 || wall.Items[0].ResourceSnapshotJSON == "" {
t.Fatalf("gift wall should keep display resource snapshot: %+v", wall.Items[0])
}
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)
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)
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)
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)
}
}
// 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 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,
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",
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)
}
}
// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。
func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetRechargePolicy(1001, "recharge-v1", 80000, 100)
_, _, 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,
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 != 100 {
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)
}
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)
}
}
// 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,
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)
}
}
// TestTransferCoinFromSellerRequiresRechargePolicy 验证缺少区域充值政策时不会产生半成品充值事实。
func TestTransferCoinFromSellerRequiresRechargePolicy(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)
}
_, err = svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
CommandID: "cmd-seller-no-policy",
SellerUserID: 32001,
TargetUserID: 32002,
SellerRegionID: 2001,
TargetRegionID: 2001,
Amount: 80000,
Reason: "player recharge",
})
if !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("expected NOT_FOUND when recharge policy is missing, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-no-policy"); got != 0 {
t.Fatalf("missing recharge policy must not write transaction, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id LIKE ?", "wtx_%"); got != 0 {
t.Fatalf("missing recharge policy must not write recharge record, 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,
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)
}
}
// 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,
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,
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,
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.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,
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 != 4 || receipt.HeatValue != 18 {
t.Fatalf("resource-backed gift settlement mismatch: %+v", receipt)
}
}
func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(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: ledger.AssetDiamond,
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.AssetDiamond || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
t.Fatalf("diamond gift config mismatch: %+v", gift)
}
repository.SetAssetBalance(52001, ledger.AssetDiamond, 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.AssetDiamond || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
t.Fatalf("diamond gift settlement mismatch: %+v", receipt)
}
}
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 != 77 {
t.Fatalf("resource gift point should sync coin price: %+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: ledger.AssetDiamond,
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 != 77 {
t.Fatalf("gift price should be overridden by resource price: %+v", gift)
}
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)
}
}
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)
}
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)
}
}
// 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)
}
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)
}
}
// 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_frame_gold",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "Gold Frame",
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-frame",
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)
}
again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
CommandID: "cmd-shop-frame",
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-frame", "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)
}
}
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)
}
processed, err := svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute)
if err != nil {
t.Fatalf("ProjectPendingBadgeGrantEvents failed: %v", err)
}
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, err = svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute)
if err != nil || processed != 0 || len(activity.requests) != 1 {
t.Fatalf("badge projection must be idempotent: processed=%d requests=%d err=%v", processed, len(activity.requests), err)
}
}
// 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)
}
}
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)
}
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)
}
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) != 1 || !resources[0].Equipped || resources[0].EntitlementID != equipped.EntitlementID {
t.Fatalf("resource list should expose equipped entitlement: resources=%+v equipped=%+v", resources, equipped)
}
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 != 1 {
t.Fatalf("equipment should keep one active badge, 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)
}
}
// TestApplyWithdrawalFreezesUSDBalance 验证提现申请只创建待审核事实,并把可提现美元余额转入冻结。
func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetAssetBalance(50001, ledger.AssetUSDBalance, 3000)
svc := walletservice.New(repository)
withdrawal, balance, err := svc.ApplyWithdrawal(context.Background(), ledger.WithdrawalCommand{
CommandID: "cmd-withdraw-usd",
UserID: 50001,
Amount: 1200,
PayoutAccount: "paypal:host@example.com",
Reason: "host payout",
})
if err != nil {
t.Fatalf("ApplyWithdrawal failed: %v", err)
}
if withdrawal.WithdrawalID == "" || withdrawal.Status != ledger.WithdrawalStatusPending || withdrawal.Amount != 1200 {
t.Fatalf("withdrawal response mismatch: %+v", withdrawal)
}
if balance.AvailableAmount != 1800 || balance.FrozenAmount != 1200 {
t.Fatalf("withdrawal balance mismatch: %+v", balance)
}
if got := repository.CountRows("wallet_withdrawal_requests", "user_id = ? AND status = ?", int64(50001), ledger.WithdrawalStatusPending); got != 1 {
t.Fatalf("withdrawal request should be persisted once, got %d", got)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-withdraw-usd", "withdrawal_apply"); got != 1 {
t.Fatalf("withdrawal should write one transaction, got %d", got)
}
}
// TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(51001, 20000)
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)
}
}
// 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)
}
}
// 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
}
// TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService 验证我的页开关和钻石兑换配置不再由 gateway 硬编码。
func TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(52001, 700)
repository.SetAssetBalance(52001, ledger.AssetDiamond, 300)
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 || !overview.FeatureFlags.WithdrawEnabled {
t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
}
if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetDiamond) != 300 {
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) != 2 || rules[0].FromAssetType != ledger.AssetDiamond || !rules[0].Enabled {
t.Fatalf("diamond exchange rules mismatch: %+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.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)
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)
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)
}
_, 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)
}
inactiveProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid)
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)
}
}
// 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("test requires internal product_code to differ from 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,
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)
}
}
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 giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
for _, item := range items {
if item.GiftID == giftID {
return true
}
}
return false
}
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
}