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) } } // 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) } } // 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) } } 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 }