2026-05-13 10:58:33 +08:00

1500 lines
58 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"
"testing"
"time"
"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)
}
}
// 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)
}
}
// 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)
}
}
// 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)
}
}
// 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)
}
}
// 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)
}
}
// 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)
}
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)
}
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)
}
}
// 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)
}
}
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
}