2026-06-04 18:31:06 +08:00

3467 lines
136 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package wallet_test
import (
"context"
"encoding/json"
"testing"
"time"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
"hyapp/services/wallet-service/internal/testutil/mysqltest"
)
func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walletmq.WalletOutboxMessage {
return walletmq.WalletOutboxMessage{
AppCode: record.AppCode,
EventID: record.EventID,
EventType: record.EventType,
TransactionID: record.TransactionID,
CommandID: record.CommandID,
UserID: record.UserID,
AssetType: record.AssetType,
AvailableDelta: record.AvailableDelta,
FrozenDelta: record.FrozenDelta,
PayloadJSON: record.PayloadJSON,
OccurredAtMS: record.CreatedAtMS,
}
}
// 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)
}
if receipt.HostPeriodDiamondAdded != 0 || receipt.HostPeriodCycleKey != "" {
t.Fatalf("non-host gift must not credit host period diamonds: %+v", receipt)
}
if got := repository.CountRows("host_period_diamond_accounts", "user_id = ?", int64(10002)); got != 0 {
t.Fatalf("non-host gift must not create host period diamond account, got %d", got)
}
}
// TestBatchDebitGiftSettlesAllTargetsAtomically 验证多目标送礼一次扣总额,并为每个接收方写独立收礼事实。
func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
svc := walletservice.New(repository)
receipt, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
CommandID: "cmd-gift-batch",
RoomID: "room-1",
SenderUserID: 10001,
GiftID: "rose",
GiftCount: 2,
PriceVersion: "v9",
SenderRegionID: 1001,
Targets: []ledger.DebitGiftTargetCommand{
{CommandID: "cmd-gift-batch:target:10002", TargetUserID: 10002},
{CommandID: "cmd-gift-batch:target:10003", TargetUserID: 10003},
},
})
if err != nil {
t.Fatalf("BatchDebitGift failed: %v", err)
}
if receipt.Aggregate.CoinSpent != 28 || receipt.Aggregate.GiftPointAdded != 12 || receipt.Aggregate.HeatValue != 44 || receipt.Aggregate.BalanceAfter != 72 || len(receipt.Targets) != 2 {
t.Fatalf("batch aggregate mismatch: %+v", receipt)
}
for _, userID := range []int64{10002, 10003} {
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetGiftPoint})
if err != nil {
t.Fatalf("GetBalances target %d failed: %v", userID, err)
}
if balanceAmount(balances, ledger.AssetGiftPoint) != 6 {
t.Fatalf("target %d GIFT_POINT mismatch: %+v", userID, balances)
}
}
balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances sender failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 72 {
t.Fatalf("sender COIN balance mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003"); got != 2 {
t.Fatalf("batch gift should write one transaction per target, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id IN (?, ?)", receipt.Targets[0].Receipt.TransactionID, receipt.Targets[1].Receipt.TransactionID); got != 4 {
t.Fatalf("batch gift should write sender and target entries per target, got %d", got)
}
again, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
CommandID: "cmd-gift-batch",
RoomID: "room-1",
SenderUserID: 10001,
GiftID: "rose",
GiftCount: 2,
PriceVersion: "v9",
SenderRegionID: 1001,
Targets: []ledger.DebitGiftTargetCommand{
{CommandID: "cmd-gift-batch:target:10002", TargetUserID: 10002},
{CommandID: "cmd-gift-batch:target:10003", TargetUserID: 10003},
},
})
if err != nil {
t.Fatalf("BatchDebitGift replay failed: %v", err)
}
if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
}
}
// TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。
func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
svc := walletservice.New(repository)
cycleBefore := time.Now().UTC().Format("2006-01")
command := ledger.DebitGiftCommand{
CommandID: "cmd-gift-host-period",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 2,
PriceVersion: "v9",
TargetIsHost: true,
TargetHostRegionID: 8801,
}
first, err := svc.DebitGift(context.Background(), command)
if err != nil {
t.Fatalf("host DebitGift failed: %v", err)
}
cycleAfter := time.Now().UTC().Format("2006-01")
if first.HostPeriodDiamondAdded != 14 || first.HostPeriodCycleKey == "" {
t.Fatalf("host period diamond receipt mismatch: %+v", first)
}
if first.HostPeriodCycleKey != cycleBefore && first.HostPeriodCycleKey != cycleAfter {
t.Fatalf("host period cycle must use current UTC month, got %q", first.HostPeriodCycleKey)
}
if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND region_id = ? AND total_diamonds = ? AND gift_diamond_total = ?", int64(10002), first.HostPeriodCycleKey, int64(8801), int64(14), int64(14)); got != 1 {
t.Fatalf("host period diamond account mismatch, got %d", got)
}
if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ? AND diamond_delta = ? AND diamond_after = ?", int64(10002), first.HostPeriodCycleKey, int64(14), int64(14)); got != 1 {
t.Fatalf("host period diamond entry mismatch, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "HostPeriodDiamondCredited"); got != 1 {
t.Fatalf("host period diamond outbox event mismatch, got %d", got)
}
second, err := svc.DebitGift(context.Background(), command)
if err != nil {
t.Fatalf("host DebitGift replay failed: %v", err)
}
if second.TransactionID != first.TransactionID || second.HostPeriodDiamondAdded != 14 || second.HostPeriodCycleKey != first.HostPeriodCycleKey {
t.Fatalf("host period idempotent receipt mismatch: first=%+v second=%+v", first, second)
}
if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND total_diamonds = ?", int64(10002), first.HostPeriodCycleKey, int64(14)); got != 1 {
t.Fatalf("host period replay must not add diamonds again, got %d", got)
}
if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 1 {
t.Fatalf("host period replay must not add duplicate entries, got %d", got)
}
}
func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00")
repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeLucky, "40.00")
repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeSuperLucky, "50.00")
cases := []struct {
giftID string
giftType string
commandID string
senderID int64
wantDiamond int64
}{
{giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, wantDiamond: 30},
{giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, wantDiamond: 40},
{giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, wantDiamond: 50},
}
for _, tc := range cases {
repository.SetBalance(tc.senderID, 100)
repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100)
repository.SetGiftType(tc.giftID, tc.giftType)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: tc.commandID,
RoomID: "room-ratio",
SenderUserID: tc.senderID,
SenderRegionID: 1001,
TargetUserID: 12001,
GiftID: tc.giftID,
GiftCount: 1,
PriceVersion: "v1",
TargetIsHost: true,
TargetHostRegionID: 8801,
})
if err != nil {
t.Fatalf("DebitGift %s failed: %v", tc.giftType, err)
}
if receipt.HostPeriodDiamondAdded != tc.wantDiamond {
t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond)
}
}
if got := repository.HostPeriodGiftDiamondTotal(12001); got != 120 {
t.Fatalf("host period account total mismatch, got %d", got)
}
}
// TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。
func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "default", 10, 10, 10)
svc := walletservice.New(repository)
_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-gift-host-no-region",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
TargetIsHost: true,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-gift-host-no-region"); got != 0 {
t.Fatalf("invalid host period command must not write transaction, got %d", got)
}
}
// TestGetActiveHostSalaryPolicyReadsRuntimePolicy 验证 H5 展示读取的是工资结算 runtime 表里的生效政策和档位。
func TestGetActiveHostSalaryPolicyReadsRuntimePolicy(t *testing.T) {
repository := mysqltest.NewRepository(t)
nowMs := time.Now().UnixMilli()
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
PolicyID: 901,
Name: "current host policy",
RegionID: 8801,
Status: "active",
SettlementMode: ledger.HostSalarySettlementModeHalfMonth,
SettlementTriggerMode: ledger.HostSalarySettlementTriggerAutomatic,
GiftCoinToDiamondRatio: "1.0000",
ResidualDiamondToUSDRate: "0.0100",
EffectiveFromMs: nowMs - 1000,
EffectiveToMs: 0,
Levels: []ledger.HostSalaryPolicyLevel{
{LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUSDMinor: 1500, HostCoinReward: 200, AgencySalaryUSDMinor: 300, SortOrder: 1},
{LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUSDMinor: 4500, HostCoinReward: 500, AgencySalaryUSDMinor: 900, SortOrder: 2},
},
})
svc := walletservice.New(repository)
policy, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", ledger.HostSalarySettlementTriggerAutomatic, nowMs)
if err != nil {
t.Fatalf("GetActiveHostSalaryPolicy failed: %v", err)
}
if !found {
t.Fatal("expected active host salary policy")
}
if policy.PolicyID != 901 || policy.Name != "current host policy" || policy.SettlementMode != ledger.HostSalarySettlementModeHalfMonth || policy.RegionID != 8801 {
t.Fatalf("policy mismatch: %+v", policy)
}
if len(policy.Levels) != 2 || policy.Levels[1].LevelNo != 2 || policy.Levels[1].HostSalaryUSDMinor != 4500 || policy.Levels[1].AgencySalaryUSDMinor != 900 {
t.Fatalf("policy levels mismatch: %+v", policy.Levels)
}
}
// TestGetHostSalaryProgressReadsCurrentCycleDiamonds 验证 H5 等级进度读取工资周期钻石账户,查询不到时保持 0 累计。
func TestGetHostSalaryProgressReadsCurrentCycleDiamonds(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "salary-progress", 200, 10, 10)
svc := walletservice.New(repository)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-salary-progress",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 13002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary-progress",
SenderRegionID: 8801,
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("salary gift failed: %v", err)
}
progress, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 13002, receipt.HostPeriodCycleKey, 0)
if err != nil {
t.Fatalf("GetHostSalaryProgress failed: %v", err)
}
if progress.HostUserID != 13002 || progress.CycleKey != receipt.HostPeriodCycleKey || progress.RegionID != 8801 || progress.AgencyOwnerUserID != 30001 || progress.TotalDiamonds != 14 || progress.GiftDiamondTotal != 14 {
t.Fatalf("host salary progress mismatch: %+v", progress)
}
missing, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 99999, "2026-06", 0)
if err != nil {
t.Fatalf("GetHostSalaryProgress missing account failed: %v", err)
}
if missing.HostUserID != 99999 || missing.CycleKey != "2026-06" || missing.TotalDiamonds != 0 {
t.Fatalf("missing progress must return zero account: %+v", missing)
}
}
// TestHostSalarySettlementCreditsIncrementalRewards 走真实送礼入钻石账户后,验证日结只发放等级累计差额。
func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 1000)
repository.SetGiftPrice("rose", "salary", 200, 10, 10)
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
PolicyID: 101,
Name: "daily salary",
RegionID: 8801,
Status: "active",
SettlementMode: ledger.HostSalarySettlementModeDaily,
Levels: []ledger.HostSalaryPolicyLevel{
{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
{LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2},
},
})
svc := walletservice.New(repository)
first, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-salary-gift-1",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("first salary gift failed: %v", err)
}
result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-run-1",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: first.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("first salary settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("first settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 10002, ledger.AssetCoin, 90)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-salary-gift-2",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("second salary gift failed: %v", err)
}
if second.HostPeriodCycleKey != first.HostPeriodCycleKey {
t.Fatalf("salary gifts should be in same cycle: first=%q second=%q", first.HostPeriodCycleKey, second.HostPeriodCycleKey)
}
result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-run-2",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: first.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("second salary settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("second settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300)
assertBalance(t, svc, 10002, ledger.AssetCoin, 198)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80)
if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 {
t.Fatalf("salary settlement records mismatch, got %d", got)
}
result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-run-3",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: first.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("noop salary settlement failed: %v", err)
}
if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
t.Fatalf("settlement without new diamonds should not claim rows: %+v", result)
}
}
// TestHostSalaryManualPolicyRequiresManualTrigger 验证手动政策不会被 cron 默认自动结算,必须由后台人工入口显式传 manual。
func TestHostSalaryManualPolicyRequiresManualTrigger(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 500)
repository.SetGiftPrice("rose", "salary-manual", 200, 10, 10)
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
PolicyID: 105,
Name: "manual salary",
RegionID: 8801,
Status: "active",
SettlementMode: ledger.HostSalarySettlementModeDaily,
SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual,
Levels: []ledger.HostSalaryPolicyLevel{
{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
},
})
svc := walletservice.New(repository)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-salary-manual-gift",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary-manual",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("manual salary gift failed: %v", err)
}
result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-manual-auto",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("automatic scan for manual policy failed: %v", err)
}
if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
t.Fatalf("manual policy must not be claimed by automatic scan: %+v", result)
}
result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-manual-explicit",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
TriggerMode: ledger.HostSalarySettlementTriggerManual,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("manual salary settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 {
t.Fatalf("manual settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
}
// TestHostSalaryMonthEndCreditsResidualAndClearsCycle 验证月底结算补发剩余钻石折美元,并逻辑关闭当月周期。
func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 1000)
repository.SetGiftPrice("rose", "salary-residual", 350, 10, 10)
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
PolicyID: 102,
Name: "daily residual",
RegionID: 8801,
Status: "active",
SettlementMode: ledger.HostSalarySettlementModeDaily,
ResidualDiamondToUSDRate: "0.01",
Levels: []ledger.HostSalaryPolicyLevel{
{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
{LevelNo: 2, RequiredDiamonds: 500, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2},
},
})
svc := walletservice.New(repository)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-salary-residual-gift",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary-residual",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("residual salary gift failed: %v", err)
}
if _, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-residual-daily",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
}); err != nil {
t.Fatalf("daily residual salary settlement failed: %v", err)
}
result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-residual-month-end",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("month-end residual salary settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("month-end settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 400)
if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), receipt.HostPeriodCycleKey, int64(250)); got != 1 {
t.Fatalf("month-end progress should store residual and cleared marker, got %d", got)
}
result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "salary-residual-after-clear",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("after-clear salary settlement failed: %v", err)
}
if result.ClaimedCount != 0 {
t.Fatalf("cleared cycle must not be claimed again: %+v", result)
}
}
// TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle 走通“已发布政策 -> 主播收礼 -> 多次增量结算 -> 月底清算 -> 下周期重新起算”的真实账务链路。
func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 2000)
repository.SetGiftPrice("rose", "salary-full-lifecycle", 120, 10, 10)
// 管理后台发布后 wallet-service 消费的是 host_agency_salary_policies 运行时快照;这里直接 seed 该表来锁定送礼和结算链路。
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
PolicyID: 301,
Name: "published daily salary",
RegionID: 8801,
Status: "active",
SettlementMode: ledger.HostSalarySettlementModeDaily,
ResidualDiamondToUSDRate: "0.01",
Levels: []ledger.HostSalaryPolicyLevel{
{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
{LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2},
},
})
svc := walletservice.New(repository)
ctx := context.Background()
previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01")
first, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-full-lifecycle-gift-1",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary-full-lifecycle",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("first lifecycle gift failed: %v", err)
}
if first.HostPeriodDiamondAdded != 120 {
t.Fatalf("first gift should add host diamonds: %+v", first)
}
// DebitGift 使用真实当前月份入账;测试把真实送礼产生的账户移动到上月,模拟月底清算前的完整历史周期。
repository.MoveHostPeriodCycle(10002, first.HostPeriodCycleKey, previousCycle)
result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "full-lifecycle-daily-level-1",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: previousCycle,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("level 1 daily settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("level 1 daily settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 10002, ledger.AssetCoin, 90)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle)
if len(previousIDs) != 1 || previousIDs[0] == "" {
t.Fatalf("level 1 settlement id missing: %v", previousIDs)
}
t.Logf("settlement ids after level 1: %v", previousIDs)
result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "full-lifecycle-daily-repeat",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: previousCycle,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("repeat daily settlement failed: %v", err)
}
if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
t.Fatalf("repeat daily settlement should not claim unchanged account: %+v", result)
}
if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) {
t.Fatalf("repeat daily settlement must not create record: before=%v after=%v", previousIDs, ids)
}
second, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-full-lifecycle-gift-2",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 3,
PriceVersion: "salary-full-lifecycle",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("second lifecycle gift failed: %v", err)
}
if second.HostPeriodDiamondAdded != 360 {
t.Fatalf("second gift should add host diamonds: %+v", second)
}
repository.MoveHostPeriodCycle(10002, second.HostPeriodCycleKey, previousCycle)
result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "full-lifecycle-daily-level-2",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: previousCycle,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("level 2 daily settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("level 2 daily settlement result mismatch: %+v", result)
}
// 第二次只补发累计 2 级与已发 1 级之间的差额,最终余额等于 2 级累计权益。
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300)
assertBalance(t, svc, 10002, ledger.AssetCoin, 198)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80)
previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle)
if len(previousIDs) != 2 {
t.Fatalf("level 2 settlement should create second record: %v", previousIDs)
}
t.Logf("settlement ids after level 2: %v", previousIDs)
result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "full-lifecycle-month-end",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
CycleKey: previousCycle,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("month-end lifecycle settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("month-end lifecycle result mismatch: %+v", result)
}
// 480 钻石达到 2 级后剩余 80 钻石,按 0.01 USD/diamond 折算为 80 美分并标记周期已清算。
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 380)
if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND last_settled_total_diamonds = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), previousCycle, int64(480), int64(80)); got != 1 {
t.Fatalf("month-end progress should store residual and clear marker, got %d", got)
}
previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle)
if len(previousIDs) != 3 {
t.Fatalf("month-end settlement should create third record: %v", previousIDs)
}
t.Logf("settlement ids after month-end clear: %v", previousIDs)
result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "full-lifecycle-month-end-repeat",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
CycleKey: previousCycle,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("repeat month-end settlement failed: %v", err)
}
if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
t.Fatalf("cleared cycle must not be claimed by repeat month-end: %+v", result)
}
if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) {
t.Fatalf("repeat month-end must not create record: before=%v after=%v", previousIDs, ids)
}
third, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-full-lifecycle-gift-next-cycle",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary-full-lifecycle",
TargetIsHost: true,
TargetHostRegionID: 8801,
TargetAgencyOwnerUserID: 30001,
})
if err != nil {
t.Fatalf("next-cycle gift failed: %v", err)
}
result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "full-lifecycle-next-cycle-level-1",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: third.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("next-cycle daily settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("next-cycle daily result mismatch: %+v", result)
}
// 新 cycle_key 使用独立进度,因此月末清算后的下一周期从 1 级累计权益重新开始发放。
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 530)
assertBalance(t, svc, 10002, ledger.AssetCoin, 288)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 130)
nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey)
if len(nextIDs) != 1 || nextIDs[0] == "" {
t.Fatalf("next-cycle settlement id missing: %v", nextIDs)
}
t.Logf("settlement ids after next cycle level 1: %v", nextIDs)
}
// TestHostSalaryHalfMonthSettlementUsesPolicyMode 验证半月结政策不会被日结批次误结算,只会由 half-month 批次处理。
func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 500)
repository.SetGiftPrice("rose", "salary-half-month", 200, 10, 10)
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
PolicyID: 302,
Name: "published half-month salary",
RegionID: 8802,
Status: "active",
SettlementMode: ledger.HostSalarySettlementModeHalfMonth,
Levels: []ledger.HostSalaryPolicyLevel{
{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
},
})
svc := walletservice.New(repository)
ctx := context.Background()
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-half-month-policy-gift",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10003,
GiftID: "rose",
GiftCount: 1,
PriceVersion: "salary-half-month",
TargetIsHost: true,
TargetHostRegionID: 8802,
TargetAgencyOwnerUserID: 30002,
})
if err != nil {
t.Fatalf("half-month gift failed: %v", err)
}
result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "half-month-daily-should-skip",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeDaily,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("daily settlement for half-month policy failed: %v", err)
}
if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
t.Fatalf("daily batch must skip half-month policy: %+v", result)
}
result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
AppCode: "lalu",
RunID: "half-month-policy-settlement",
WorkerID: "salary-test",
BatchSize: 10,
SettlementType: ledger.HostSalarySettlementModeHalfMonth,
CycleKey: receipt.HostPeriodCycleKey,
NowMs: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("half-month settlement failed: %v", err)
}
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("half-month settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10003, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 10003, ledger.AssetCoin, 90)
assertBalance(t, svc, 30002, ledger.AssetAgencySalaryUSD, 50)
if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" {
t.Fatalf("half-month settlement id missing: %v", ids)
}
}
// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼,扣 COIN 和收 GIFT_POINT 仍然分别落账。
func TestDebitGiftAllowsSelfGift(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
svc := walletservice.New(repository)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-self-gift",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10001,
GiftID: "rose",
GiftCount: 2,
PriceVersion: "v9",
})
if err != nil {
t.Fatalf("self DebitGift failed: %v", err)
}
if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 6 || receipt.BalanceAfter != 86 {
t.Fatalf("self gift settlement mismatch: %+v", receipt)
}
balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 86 || balanceAmount(balances, ledger.AssetGiftPoint) != 6 {
t.Fatalf("self gift balances mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
t.Fatalf("self gift should still write debit and credit entries, got %d", got)
}
}
// TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。
func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 1000)
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
repository.SetGiftPrice("rocket", "v2", 100, 50, 200)
svc := walletservice.New(repository)
commands := []ledger.DebitGiftCommand{
{CommandID: "cmd-wall-rose-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9"},
{CommandID: "cmd-wall-rose-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 3, PriceVersion: "v9"},
{CommandID: "cmd-wall-rocket", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rocket", GiftCount: 1, PriceVersion: "v2"},
}
for _, command := range commands {
if _, err := svc.DebitGift(context.Background(), command); err != nil {
t.Fatalf("DebitGift %s failed: %v", command.CommandID, err)
}
}
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 {
t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got)
}
records, err := repository.ClaimPendingWalletOutbox(context.Background(), "gift-wall-mq-test", 20, time.Now().Add(time.Minute).UnixMilli())
if err != nil {
t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
}
processed := 0
for _, record := range records {
handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage failed: %v", err)
}
if handled {
processed++
}
}
if processed != 3 {
t.Fatalf("projection should process three gift events, got %d", processed)
}
processed = 0
for _, record := range records {
handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err)
}
if handled {
processed++
}
}
if processed != 0 {
t.Fatalf("projection must be idempotent after done events, got %d", processed)
}
wall, err := svc.GetUserGiftWall(context.Background(), 10002)
if err != nil {
t.Fatalf("GetUserGiftWall failed: %v", err)
}
if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 255 {
t.Fatalf("gift wall summary mismatch: %+v", wall)
}
rose, ok := giftWallItemByID(wall.Items, "rose")
if len(wall.Items) != 2 || !ok || rose.GiftCount != 5 || rose.TotalValue != 35 {
t.Fatalf("rose gift wall item mismatch: %+v", wall.Items)
}
if rose.Resource.ResourceID == 0 || rose.ResourceSnapshotJSON == "" {
t.Fatalf("gift wall should keep display resource snapshot: %+v", rose)
}
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 {
t.Fatalf("gift wall should keep one row per gift_id, got %d", got)
}
if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "user_gift_wall", "done"); got != 3 {
t.Fatalf("projection should mark each gift event done, got %d", got)
}
}
// TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。
func TestDebitGiftIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "default", 10, 10, 10)
svc := walletservice.New(repository)
command := ledger.DebitGiftCommand{
CommandID: "cmd-1",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 2,
}
first, err := svc.DebitGift(context.Background(), command)
if err != nil {
t.Fatalf("first debit failed: %v", err)
}
second, err := svc.DebitGift(context.Background(), command)
if err != nil {
t.Fatalf("second debit failed: %v", err)
}
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent command should write one transaction, got %d", got)
}
}
// TestDebitGiftRejectsCommandPayloadConflict 锁定同 command_id 不同业务 payload 的冲突语义。
func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
repository.SetGiftPrice("rose", "default", 10, 10, 10)
svc := walletservice.New(repository)
command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1}
if _, err := svc.DebitGift(context.Background(), command); err != nil {
t.Fatalf("first debit failed: %v", err)
}
command.GiftCount = 2
_, err := svc.DebitGift(context.Background(), command)
if !xerr.IsCode(err, xerr.LedgerConflict) {
t.Fatalf("expected LEDGER_CONFLICT, got %v", err)
}
}
// TestDebitGiftInsufficientBalance 锁定余额不足时不写成功交易、分录或 outbox。
func TestDebitGiftInsufficientBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 1)
repository.SetGiftPrice("rose", "default", 10, 10, 10)
svc := walletservice.New(repository)
_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-2",
RoomID: "room-1",
SenderUserID: 10001,
TargetUserID: 10002,
GiftID: "rose",
GiftCount: 1,
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-2"); got != 0 {
t.Fatalf("insufficient balance must not write transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id LIKE ?", "wtx_%"); got != 0 {
t.Fatalf("insufficient balance must not write entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "command_id = ?", "cmd-2"); got != 0 {
t.Fatalf("insufficient balance must not write outbox, got %d", got)
}
}
// TestAdminCreditAssetIsIdempotent 验证后台入账有审计字段、幂等和余额查询闭环。
func TestAdminCreditAssetIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.AdminCreditAssetCommand{
CommandID: "cmd-admin-credit",
TargetUserID: 20001,
AssetType: "coin",
Amount: 500,
OperatorUserID: 90001,
Reason: "manual recharge",
EvidenceRef: "ticket-1",
}
first, txID, err := svc.AdminCreditAsset(context.Background(), command)
if err != nil {
t.Fatalf("AdminCreditAsset failed: %v", err)
}
second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command)
if err != nil {
t.Fatalf("AdminCreditAsset retry failed: %v", err)
}
if txID == "" || txID != secondTxID || first.AvailableAmount != 500 || second.AvailableAmount != 500 || first.AssetType != ledger.AssetCoin {
t.Fatalf("admin credit idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID)
}
balances, err := svc.GetBalances(context.Background(), 20001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 500 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
t.Fatalf("balances mismatch: %+v", balances)
}
}
// TestAdminCreditAssetAllowsSignedAdjustment 验证后台调账同一 RPC 可表达加金币和扣金币,且扣账不能穿透余额约束。
func TestAdminCreditAssetAllowsSignedAdjustment(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
credit := ledger.AdminCreditAssetCommand{
CommandID: "cmd-admin-adjust-credit",
TargetUserID: 20002,
AssetType: ledger.AssetCoin,
Amount: 500,
OperatorUserID: 90001,
Reason: "manual credit",
EvidenceRef: "ticket-credit",
}
if _, _, err := svc.AdminCreditAsset(context.Background(), credit); err != nil {
t.Fatalf("credit adjustment failed: %v", err)
}
debit := credit
debit.CommandID = "cmd-admin-adjust-debit"
debit.Amount = -120
debit.Reason = "manual debit"
debit.EvidenceRef = "ticket-debit"
balance, txID, err := svc.AdminCreditAsset(context.Background(), debit)
if err != nil {
t.Fatalf("debit adjustment failed: %v", err)
}
if txID == "" || balance.AvailableAmount != 380 {
t.Fatalf("debit balance mismatch: balance=%+v tx=%s", balance, txID)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", txID, int64(-120)); got != 1 {
t.Fatalf("debit adjustment should write one negative entry, got %d", got)
}
overdraw := credit
overdraw.CommandID = "cmd-admin-adjust-overdraw"
overdraw.Amount = -1000
overdraw.Reason = "manual overdraw"
overdraw.EvidenceRef = "ticket-overdraw"
if _, _, err := svc.AdminCreditAsset(context.Background(), overdraw); err == nil {
t.Fatalf("overdraw adjustment must fail")
}
}
func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(19001, 1000)
svc := walletservice.New(repository)
ctx := context.Background()
config, err := svc.UpdateRedPacketConfig(ctx, ledger.RedPacketConfig{
AppCode: "lalu",
Enabled: true,
CountTiers: []int32{5},
AmountTiers: []int64{10},
DelayedOpenSeconds: 1,
ExpireSeconds: 2,
DailySendLimit: 5,
UpdatedByAdminID: 9001,
})
if err != nil {
t.Fatalf("UpdateRedPacketConfig failed: %v", err)
}
if !config.Enabled || len(config.CountTiers) != 1 || len(config.AmountTiers) != 1 {
t.Fatalf("red packet config mismatch: %+v", config)
}
normal, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-normal",
SenderUserID: 19001,
RoomID: "room-190",
RegionID: 190,
PacketType: ledger.RedPacketTypeNormal,
TotalAmount: 10,
PacketCount: 5,
})
if err != nil {
t.Fatalf("CreateRedPacket normal failed: %v", err)
}
if normal.BalanceAfter != 990 || normal.Packet.Status != ledger.RedPacketStatusActive {
t.Fatalf("normal red packet receipt mismatch: %+v", normal)
}
normalClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-normal-claim",
PacketID: normal.Packet.PacketID,
UserID: 19002,
})
if err != nil {
t.Fatalf("ClaimRedPacket normal failed: %v", err)
}
normalAfterClaim, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 19002, true)
if err != nil {
t.Fatalf("GetRedPacket normal failed: %v", err)
}
if !normalAfterClaim.ClaimedByViewer || normalAfterClaim.ViewerClaimAmount != normalClaim.Claim.Amount {
t.Fatalf("normal viewer claim mismatch: packet=%+v claim=%+v", normalAfterClaim, normalClaim)
}
time.Sleep(2100 * time.Millisecond)
expired, err := svc.ExpireRedPackets(ctx, "lalu", 10)
if err != nil {
t.Fatalf("ExpireRedPackets normal failed: %v", err)
}
if expired.ExpiredCount != 1 || expired.RefundedAmount != normalAfterClaim.RemainingAmount {
t.Fatalf("normal expire result mismatch: %+v remaining=%d", expired, normalAfterClaim.RemainingAmount)
}
normalExpired, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 0, false)
if err != nil {
t.Fatalf("GetRedPacket normal expired failed: %v", err)
}
if normalExpired.Status != ledger.RedPacketStatusRefunded || normalExpired.RemainingAmount != 0 || normalExpired.RemainingCount != 0 {
t.Fatalf("normal red packet should be refunded: %+v", normalExpired)
}
delayed, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-delayed",
SenderUserID: 19001,
RoomID: "room-190",
RegionID: 190,
PacketType: ledger.RedPacketTypeDelayed,
TotalAmount: 10,
PacketCount: 5,
})
if err != nil {
t.Fatalf("CreateRedPacket delayed failed: %v", err)
}
if delayed.Packet.Status != ledger.RedPacketStatusWaitingOpen {
t.Fatalf("delayed red packet should wait to open: %+v", delayed.Packet)
}
_, err = svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-delayed-too-early",
PacketID: delayed.Packet.PacketID,
UserID: 19003,
})
if xerr.CodeOf(err) != xerr.RedPacketNotOpen {
t.Fatalf("delayed claim before open code=%s err=%v", xerr.CodeOf(err), err)
}
time.Sleep(1100 * time.Millisecond)
delayedClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-delayed-claim",
PacketID: delayed.Packet.PacketID,
UserID: 19003,
})
if err != nil {
t.Fatalf("ClaimRedPacket delayed after open failed: %v", err)
}
if delayedClaim.Claim.Amount <= 0 {
t.Fatalf("delayed claim amount should be positive: %+v", delayedClaim)
}
}
func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(19101, 1000)
svc := walletservice.New(repository)
if _, err := svc.UpdateRedPacketConfig(context.Background(), ledger.RedPacketConfig{
AppCode: "lalu",
Enabled: true,
CountTiers: []int32{5},
AmountTiers: []int64{10},
DelayedOpenSeconds: 1,
ExpireSeconds: 2,
DailySendLimit: 5,
UpdatedByAdminID: 9001,
}); err != nil {
t.Fatalf("UpdateRedPacketConfig failed: %v", err)
}
receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-realtime-outbox",
SenderUserID: 19101,
RoomID: "room-191",
RegionID: 191,
PacketType: ledger.RedPacketTypeNormal,
TotalAmount: 10,
PacketCount: 5,
})
if err != nil {
t.Fatalf("CreateRedPacket failed: %v", err)
}
ctx := appcode.WithContext(context.Background(), "lalu")
realtimeTypes := []string{"WalletRedPacketCreated", "WalletRedPacketClaimed", "WalletRedPacketRefunded"}
realtimeRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-realtime-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
IncludeEventTypes: realtimeTypes,
})
if err != nil {
t.Fatalf("ClaimPendingWalletOutboxFiltered realtime failed: %v", err)
}
if len(realtimeRecords) != 1 || realtimeRecords[0].EventType != "WalletRedPacketCreated" {
t.Fatalf("realtime claim should only pick red packet fact, got %+v", realtimeRecords)
}
normalRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-normal-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
ExcludeEventTypes: realtimeTypes,
})
if err != nil {
t.Fatalf("ClaimPendingWalletOutboxFiltered normal failed: %v", err)
}
if len(normalRecords) == 0 {
t.Fatalf("normal claim should still pick non-realtime wallet facts for packet %s", receipt.Packet.PacketID)
}
for _, record := range normalRecords {
if record.EventType == "WalletRedPacketCreated" || record.EventType == "WalletRedPacketClaimed" || record.EventType == "WalletRedPacketRefunded" {
t.Fatalf("normal claim must exclude realtime red packet fact: %+v", normalRecords)
}
}
if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND worker_id = ?", realtimeRecords[0].EventID, "delivering", "wallet-realtime-outbox-test"); got != 1 {
t.Fatalf("realtime fact should be locked by realtime worker, got %d", got)
}
}
func TestWalletOutboxClaimUsesRealMySQLStatusFlow(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetAssetBalance(30001, ledger.AssetCoinSellerCoin, 1000)
repository.SetRechargePolicy(86, "policy-real-outbox", 100, 100)
svc := walletservice.New(repository)
ctx := appcode.WithContext(context.Background(), "lalu")
receipt, err := svc.TransferCoinFromSeller(ctx, ledger.CoinSellerTransferCommand{
AppCode: "lalu",
CommandID: "cmd-real-outbox-transfer",
SellerUserID: 30001,
TargetUserID: 30002,
SellerRegionID: 86,
TargetRegionID: 86,
Amount: 200,
Reason: "real outbox claim test",
})
if err != nil {
t.Fatalf("TransferCoinFromSeller failed: %v", err)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ?", receipt.TransactionID, "pending"); got != 4 {
t.Fatalf("transfer should create four pending wallet outbox facts, got %d", got)
}
records, err := repository.ClaimPendingWalletOutbox(ctx, "wallet-outbox-real-test", 4, time.Now().Add(time.Minute).UnixMilli())
if err != nil {
t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
}
if len(records) != 4 {
t.Fatalf("expected four claimed records, got %d: %+v", len(records), records)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ? AND worker_id = ?", receipt.TransactionID, "delivering", "wallet-outbox-real-test"); got != 4 {
t.Fatalf("claim should mark all rows delivering for worker, got %d", got)
}
var rechargeEventID string
for _, record := range records {
if record.EventType == "WalletRechargeRecorded" {
rechargeEventID = record.EventID
}
if record.AppCode != "lalu" || record.TransactionID != receipt.TransactionID || record.PayloadJSON == "" {
t.Fatalf("claimed record should contain stable wallet fact: %+v", record)
}
}
if rechargeEventID == "" {
t.Fatalf("claimed records should include WalletRechargeRecorded: %+v", records)
}
if err := repository.MarkWalletOutboxDelivered(ctx, rechargeEventID); err != nil {
t.Fatalf("MarkWalletOutboxDelivered failed: %v", err)
}
if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND (worker_id = '' OR worker_id IS NULL)", rechargeEventID, "delivered"); got != 1 {
t.Fatalf("delivered record should release worker lock, got %d", got)
}
}
// TestCreditTaskRewardIsIdempotent 验证任务奖励走独立账务类型且重复领奖命令不会重复发金币。
func TestCreditTaskRewardIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.TaskRewardCommand{
CommandID: "cmd-task-reward",
TargetUserID: 21001,
Amount: 88,
TaskType: "daily",
TaskID: "task-1",
CycleKey: "2026-05-09",
Reason: "daily_task_reward",
}
first, err := svc.CreditTaskReward(context.Background(), command)
if err != nil {
t.Fatalf("CreditTaskReward failed: %v", err)
}
second, err := svc.CreditTaskReward(context.Background(), command)
if err != nil {
t.Fatalf("CreditTaskReward retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.Amount != 88 || second.Balance.AvailableAmount != 88 {
t.Fatalf("task reward receipt mismatch: first=%+v second=%+v", first, second)
}
balances, err := svc.GetBalances(context.Background(), 21001, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 88 {
t.Fatalf("task reward balance mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "task_reward"); got != 1 {
t.Fatalf("task reward should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
t.Fatalf("task reward should write one entry, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
t.Fatalf("task reward should write balance and task outbox events, got %d", got)
}
}
// TestCreditLuckyGiftRewardIsIdempotent 验证幸运礼物返奖只按 draw_id 派一次,并产出余额通知事实。
func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.LuckyGiftRewardCommand{
CommandID: "lucky_reward:lucky_draw_1",
TargetUserID: 21002,
Amount: 188,
DrawID: "lucky_draw_1",
RoomID: "room-1",
GiftID: "rose",
PoolID: "super_lucky",
Reason: "lucky_gift_reward",
}
first, err := svc.CreditLuckyGiftReward(context.Background(), command)
if err != nil {
t.Fatalf("CreditLuckyGiftReward failed: %v", err)
}
second, err := svc.CreditLuckyGiftReward(context.Background(), command)
if err != nil {
t.Fatalf("CreditLuckyGiftReward retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || second.Balance.AvailableAmount != 188 {
t.Fatalf("lucky reward receipt mismatch: first=%+v second=%+v", first, second)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "lucky_gift_reward"); got != 1 {
t.Fatalf("lucky reward should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletBalanceChanged"); got != 1 {
t.Fatalf("lucky reward should write one balance change event, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
t.Fatalf("lucky reward should write one reward fact event, got %d", got)
}
}
// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。
func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetRechargePolicy(1001, "recharge-v1", 80000, 100)
_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
CommandID: "cmd-credit-seller",
TargetUserID: 30001,
AssetType: ledger.AssetCoinSellerCoin,
Amount: 160000,
OperatorUserID: 90001,
Reason: "seed seller balance",
EvidenceRef: "ticket-seller",
})
if err != nil {
t.Fatalf("seed seller asset failed: %v", err)
}
command := ledger.CoinSellerTransferCommand{
CommandID: "cmd-seller-transfer",
SellerUserID: 30001,
TargetUserID: 30002,
SellerRegionID: 1001,
TargetRegionID: 1001,
Amount: 80000,
Reason: "player recharge",
}
first, err := svc.TransferCoinFromSeller(context.Background(), command)
if err != nil {
t.Fatalf("TransferCoinFromSeller failed: %v", err)
}
second, err := svc.TransferCoinFromSeller(context.Background(), command)
if err != nil {
t.Fatalf("TransferCoinFromSeller retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SellerBalanceAfter != 80000 || first.TargetBalanceAfter != 80000 || first.RechargeUSDMinor != 100 {
t.Fatalf("transfer receipt mismatch: first=%+v second=%+v", first, second)
}
sellerBalances, err := svc.GetBalances(context.Background(), 30001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances seller failed: %v", err)
}
if balanceAmount(sellerBalances, ledger.AssetCoinSellerCoin) != 80000 || balanceAmount(sellerBalances, ledger.AssetCoin) != 0 {
t.Fatalf("seller balances mismatch: %+v", sellerBalances)
}
targetBalances, err := svc.GetBalances(context.Background(), 30002, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances target failed: %v", err)
}
if balanceAmount(targetBalances, ledger.AssetCoin) != 80000 {
t.Fatalf("target COIN balance mismatch: %+v", targetBalances)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent seller transfer should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
t.Fatalf("seller transfer should write two entries, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 {
t.Fatalf("seller transfer should write one recharge record, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 {
t.Fatalf("seller transfer should write two balance events, one transfer event and one recharge event, got %d", got)
}
}
// TestTransferCoinFromSellerInsufficientBalance 验证币商余额不足时不生成转账交易。
func TestTransferCoinFromSellerInsufficientBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
_, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
CommandID: "cmd-seller-low",
SellerUserID: 31001,
TargetUserID: 31002,
SellerRegionID: 1001,
TargetRegionID: 1001,
Amount: 1,
Reason: "player recharge",
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-low"); got != 0 {
t.Fatalf("insufficient seller balance must not write transaction, got %d", got)
}
}
// TestTransferCoinFromSellerRequiresRechargePolicy 验证缺少区域充值政策时不会产生半成品充值事实。
func TestTransferCoinFromSellerRequiresRechargePolicy(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
CommandID: "cmd-credit-no-policy",
TargetUserID: 32001,
AssetType: ledger.AssetCoinSellerCoin,
Amount: 80000,
OperatorUserID: 90001,
Reason: "seed seller balance",
EvidenceRef: "ticket-no-policy",
})
if err != nil {
t.Fatalf("seed seller asset failed: %v", err)
}
_, err = svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
CommandID: "cmd-seller-no-policy",
SellerUserID: 32001,
TargetUserID: 32002,
SellerRegionID: 2001,
TargetRegionID: 2001,
Amount: 80000,
Reason: "player recharge",
})
if !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("expected NOT_FOUND when recharge policy is missing, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-no-policy"); got != 0 {
t.Fatalf("missing recharge policy must not write transaction, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id LIKE ?", "wtx_%"); got != 0 {
t.Fatalf("missing recharge policy must not write recharge record, got %d", got)
}
}
// TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。
func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.CoinSellerStockCreditCommand{
CommandID: "cmd-stock-usdt",
SellerUserID: 33001,
StockType: ledger.StockTypeUSDTPurchase,
CoinAmount: 8000000,
PaidAmountMicro: 100000000,
OperatorUserID: 90001,
}
first, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
if err != nil {
t.Fatalf("AdminCreditCoinSellerStock purchase failed: %v", err)
}
second, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
if err != nil {
t.Fatalf("AdminCreditCoinSellerStock retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 8000000 || !first.CountsAsSellerRecharge || first.PaidCurrencyCode != ledger.PaidCurrencyUSDT {
t.Fatalf("stock purchase receipt mismatch: first=%+v second=%+v", first, second)
}
balances, err := svc.GetBalances(context.Background(), 33001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances seller failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 8000000 || balanceAmount(balances, ledger.AssetCoin) != 0 {
t.Fatalf("seller stock balances mismatch: %+v", balances)
}
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE", first.TransactionID); got != 1 {
t.Fatalf("stock purchase should write one seller recharge record, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
t.Fatalf("stock purchase should write one wallet entry, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
t.Fatalf("seller stock purchase must not write player recharge record, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
t.Fatalf("stock purchase should write balance and stock outbox events, got %d", got)
}
}
// TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount 锁定补偿不能携带充值金额的边界。
func TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
_, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
CommandID: "cmd-stock-comp-invalid",
SellerUserID: 34001,
StockType: ledger.StockTypeCoinCompensation,
CoinAmount: 50000,
PaidCurrencyCode: ledger.PaidCurrencyUSDT,
PaidAmountMicro: 1,
OperatorUserID: 90001,
})
if !xerr.IsCode(err, xerr.CoinSellerStockAmountInvalid) {
t.Fatalf("expected COIN_SELLER_STOCK_AMOUNT_INVALID, got %v", err)
}
if got := repository.CountRows("coin_seller_stock_records", "command_id = ?", "cmd-stock-comp-invalid"); got != 0 {
t.Fatalf("invalid compensation must not write stock record, got %d", got)
}
}
// TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount 验证金币补偿不计入币商进货金额。
func TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
receipt, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
CommandID: "cmd-stock-comp",
SellerUserID: 35001,
StockType: ledger.StockTypeCoinCompensation,
CoinAmount: 50000,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("AdminCreditCoinSellerStock compensation failed: %v", err)
}
if receipt.CountsAsSellerRecharge || receipt.PaidAmountMicro != 0 || receipt.PaidCurrencyCode != "" || receipt.BalanceAfter != 50000 {
t.Fatalf("stock compensation receipt mismatch: %+v", receipt)
}
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = FALSE AND paid_amount_micro = 0", receipt.TransactionID); got != 1 {
t.Fatalf("compensation should write one non-recharge stock record, got %d", got)
}
}
// TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef 防止同一 USDT 凭证重复给库存入账。
func TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.CoinSellerStockCreditCommand{
CommandID: "cmd-stock-ref-1",
SellerUserID: 36001,
StockType: ledger.StockTypeUSDTPurchase,
CoinAmount: 100,
PaidAmountMicro: 1000000,
PaymentRef: "usdt-ref-dup",
EvidenceRef: "oss://receipt-dup-1",
OperatorUserID: 90001,
Reason: "first purchase",
}
if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
t.Fatalf("first stock purchase failed: %v", err)
}
command.CommandID = "cmd-stock-ref-2"
command.SellerUserID = 36002
command.EvidenceRef = "oss://receipt-dup-2"
_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
if !xerr.IsCode(err, xerr.CoinSellerPaymentRefDuplicated) {
t.Fatalf("expected COIN_SELLER_PAYMENT_REF_DUPLICATED, got %v", err)
}
if got := repository.CountRows("coin_seller_stock_records", "payment_ref = ?", "usdt-ref-dup"); got != 1 {
t.Fatalf("duplicate payment_ref should keep one stock record, got %d", got)
}
}
// TestAdminCreditCoinSellerStockRejectsIdempotencyConflict 锁定 command_id 相同但 payload 不同必须 fail-closed。
func TestAdminCreditCoinSellerStockRejectsIdempotencyConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.CoinSellerStockCreditCommand{
CommandID: "cmd-stock-conflict",
SellerUserID: 37001,
StockType: ledger.StockTypeUSDTPurchase,
CoinAmount: 100,
PaidAmountMicro: 1000000,
PaymentRef: "usdt-ref-conflict",
EvidenceRef: "oss://receipt-conflict",
OperatorUserID: 90001,
Reason: "first purchase",
}
if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
t.Fatalf("first stock purchase failed: %v", err)
}
command.CoinAmount = 200
_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
if !xerr.IsCode(err, xerr.IdempotencyConflict) {
t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
}
}
// TestGiftConfigMustSelectGiftResource 验证后台新增礼物只能绑定 resource_type=gift 的资源。
func TestGiftConfigMustSelectGiftResource(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
frame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "frame_gold",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "Gold Frame",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create frame resource failed: %v", err)
}
_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "bad-gift",
ResourceID: frame.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Bad Gift",
PriceVersion: "v1",
CoinPrice: 1,
GiftPointAmount: 1,
HeatValue: 1,
OperatorUserID: 90001,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for non-gift resource, got %v", err)
}
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_spark",
ResourceType: resourcedomain.TypeGift,
Name: "Spark",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create gift resource failed: %v", err)
}
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "spark",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Spark",
PriceVersion: "v1",
CoinPrice: 5,
GiftPointAmount: 2,
HeatValue: 9,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create gift config failed: %v", err)
}
if gift.Resource.ResourceType != resourcedomain.TypeGift || gift.CoinPrice != 5 {
t.Fatalf("gift config mismatch: %+v", gift)
}
repository.SetBalance(41001, 100)
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-spark-gift",
RoomID: "room-1",
SenderUserID: 41001,
TargetUserID: 41002,
GiftID: "spark",
GiftCount: 2,
})
if err != nil {
t.Fatalf("DebitGift with resource-backed gift failed: %v", err)
}
if receipt.CoinSpent != 10 || receipt.GiftPointAdded != 4 || receipt.HeatValue != 18 {
t.Fatalf("resource-backed gift settlement mismatch: %+v", receipt)
}
}
func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
nowMs := time.Now().UnixMilli()
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_diamond_magic",
ResourceType: resourcedomain.TypeGift,
Name: "Diamond Magic",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create diamond gift resource failed: %v", err)
}
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "diamond-magic",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Diamond Magic",
PriceVersion: "v1",
ChargeAssetType: "DIAMOND",
CoinPrice: 7,
GiftPointAmount: 3,
HeatValue: 9,
GiftTypeCode: resourcedomain.GiftTypeMagic,
EffectiveFromMS: nowMs - 1000,
EffectiveToMS: nowMs + 60_000,
EffectTypes: []string{resourcedomain.GiftEffectAnimation, resourcedomain.GiftEffectMusic},
OperatorUserID: 90001,
RegionIDs: []int64{0},
})
if err != nil {
t.Fatalf("create diamond gift config failed: %v", err)
}
if gift.ChargeAssetType != ledger.AssetCoin || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
t.Fatalf("gift config must not keep DIAMOND charge: %+v", gift)
}
repository.SetBalance(52001, 100)
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-diamond-gift",
RoomID: "room-diamond",
SenderUserID: 52001,
TargetUserID: 52002,
GiftID: "diamond-magic",
GiftCount: 2,
})
if err != nil {
t.Fatalf("DebitGift with diamond-backed gift failed: %v", err)
}
if receipt.ChargeAssetType != ledger.AssetCoin || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
t.Fatalf("gift settlement must use COIN after DIAMOND removal: %+v", receipt)
}
}
func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
paidResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_price_sync",
ResourceType: resourcedomain.TypeGift,
Name: "Price Sync Gift",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
PriceType: resourcedomain.PriceTypeCoin,
CoinPrice: 77,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create priced gift resource failed: %v", err)
}
if paidResource.GiftPointAmount != 77 {
t.Fatalf("resource gift point should sync coin price: %+v", paidResource)
}
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "price-sync",
ResourceID: paidResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Price Sync Gift",
PriceVersion: "v1",
ChargeAssetType: "DIAMOND",
CoinPrice: 1,
GiftPointAmount: 1,
HeatValue: 3,
GiftTypeCode: resourcedomain.GiftTypeNormal,
OperatorUserID: 90001,
RegionIDs: []int64{0},
})
if err != nil {
t.Fatalf("create synced gift config failed: %v", err)
}
if gift.ChargeAssetType != ledger.AssetCoin || gift.CoinPrice != 77 || gift.GiftPointAmount != 77 {
t.Fatalf("gift price should be overridden by resource price: %+v", gift)
}
freeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_free_sync",
ResourceType: resourcedomain.TypeGift,
Name: "Free Sync Gift",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
PriceType: resourcedomain.PriceTypeFree,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create free gift resource failed: %v", err)
}
freeGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "free-sync",
ResourceID: freeResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Free Sync Gift",
PriceVersion: "v1",
CoinPrice: 9,
GiftPointAmount: 9,
HeatValue: 0,
GiftTypeCode: resourcedomain.GiftTypeNormal,
OperatorUserID: 90001,
RegionIDs: []int64{0},
})
if err != nil {
t.Fatalf("create free synced gift config failed: %v", err)
}
if freeGift.CoinPrice != 0 || freeGift.GiftPointAmount != 0 {
t.Fatalf("free gift price should sync to zero: %+v", freeGift)
}
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-free-sync",
RoomID: "room-free",
SenderUserID: 53001,
TargetUserID: 53002,
GiftID: "free-sync",
GiftCount: 1,
})
if err != nil {
t.Fatalf("free gift debit should not require existing balance: %v", err)
}
if receipt.ChargeAmount != 0 || receipt.GiftPointAdded != 0 {
t.Fatalf("free gift receipt mismatch: %+v", receipt)
}
}
func TestGiftTypeConfigDefaultsAndUpsert(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
items, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{})
if err != nil {
t.Fatalf("list gift type configs failed: %v", err)
}
if len(items) != 10 || items[0].TypeCode != resourcedomain.GiftTypeNormal || items[0].TabKey != resourcedomain.GiftTypeTabKeyNormal {
t.Fatalf("default gift type configs mismatch: %+v", items)
}
updated, err := svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{
TypeCode: resourcedomain.GiftTypeNormal,
Name: "普通礼物",
TabKey: "Gift",
Status: resourcedomain.StatusDisabled,
SortOrder: 11,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("update gift type config failed: %v", err)
}
if updated.Status != resourcedomain.StatusDisabled || updated.SortOrder != 11 {
t.Fatalf("updated gift type config mismatch: %+v", updated)
}
activeItems, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{ActiveOnly: true})
if err != nil {
t.Fatalf("list active gift type configs failed: %v", err)
}
if giftTypeCodesContain(activeItems, resourcedomain.GiftTypeNormal) {
t.Fatalf("disabled gift type must not be returned as active: %+v", activeItems)
}
}
// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验region_id=0 作为 GLOBAL 兜底区域。
func TestGiftConfigRegionFilter(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
globalResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_regional_global",
ResourceType: resourcedomain.TypeGift,
Name: "Regional Global",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create global gift resource failed: %v", err)
}
saudiResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_regional_saudi",
ResourceType: resourcedomain.TypeGift,
Name: "Regional Saudi",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create saudi gift resource failed: %v", err)
}
egyptResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_regional_egypt",
ResourceType: resourcedomain.TypeGift,
Name: "Regional Egypt",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create egypt gift resource failed: %v", err)
}
commands := []resourcedomain.GiftConfigCommand{
{
GiftID: "regional-global",
ResourceID: globalResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Regional Global",
PriceVersion: "v1",
CoinPrice: 1,
GiftPointAmount: 1,
HeatValue: 1,
OperatorUserID: 90001,
RegionIDs: []int64{0},
},
{
GiftID: "regional-saudi",
ResourceID: saudiResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Regional Saudi",
PriceVersion: "v1",
CoinPrice: 2,
GiftPointAmount: 2,
HeatValue: 2,
OperatorUserID: 90001,
RegionIDs: []int64{1001},
},
{
GiftID: "regional-egypt",
ResourceID: egyptResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Regional Egypt",
PriceVersion: "v1",
CoinPrice: 3,
GiftPointAmount: 3,
HeatValue: 3,
OperatorUserID: 90001,
RegionIDs: []int64{2002},
},
}
for _, command := range commands {
if _, err := svc.CreateGiftConfig(ctx, command); err != nil {
t.Fatalf("create gift config %s failed: %v", command.GiftID, err)
}
}
items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
Keyword: "regional-",
ActiveOnly: true,
FilterRegion: true,
RegionID: 1001,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("list region gifts failed: %v", err)
}
if total != 2 || !giftIDsContain(items, "regional-global") || !giftIDsContain(items, "regional-saudi") || giftIDsContain(items, "regional-egypt") {
t.Fatalf("region gift list mismatch total=%d items=%+v", total, items)
}
repository.SetBalance(51001, 100)
if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-regional-egypt-in-saudi",
RoomID: "room-region-1001",
SenderUserID: 51001,
TargetUserID: 51002,
GiftID: "regional-egypt",
GiftCount: 1,
RegionID: 1001,
}); !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("expected NOT_FOUND when sending gift outside configured region, got %v", err)
}
}
// TestGrantResourceGroupExpandsWalletAssetAndEntitlement 验证资源组赠送会原子展开钱包资产入账和非钱包权益。
func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_vip",
ResourceType: resourcedomain.TypeBadge,
Name: "VIP Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create badge resource failed: %v", err)
}
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
GroupCode: "starter_pack",
Name: "Starter Pack",
Status: resourcedomain.StatusActive,
Items: []resourcedomain.ResourceGroupItemInput{
{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1},
{ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2},
},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create resource group failed: %v", err)
}
command := resourcedomain.GrantResourceGroupCommand{
CommandID: "cmd-grant-starter-pack",
TargetUserID: 42001,
GroupID: group.GroupID,
Reason: "test grant",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
}
first, err := svc.GrantResourceGroup(ctx, command)
if err != nil {
t.Fatalf("GrantResourceGroup failed: %v", err)
}
second, err := svc.GrantResourceGroup(ctx, command)
if err != nil {
t.Fatalf("GrantResourceGroup retry failed: %v", err)
}
if first.GrantID == "" || first.GrantID != second.GrantID || len(first.Items) != 2 {
t.Fatalf("grant idempotency mismatch: first=%+v second=%+v", first, second)
}
balances, err := svc.GetBalances(ctx, 42001, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 2000 {
t.Fatalf("coin grant balance mismatch: %+v", balances)
}
if got := repository.CountRows("resource_grants", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent group grant should write one grant, got %d", got)
}
if got := repository.CountRows("resource_grant_items", "grant_id = ?", first.GrantID); got != 2 {
t.Fatalf("group grant should write two grant items, got %d", got)
}
if got := repository.CountRows("user_resource_entitlements", "user_id = ?", int64(42001)); got != 1 {
t.Fatalf("group grant should write one non-coin entitlement, got %d", got)
}
if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ?", int64(42001), ledger.AssetCoin); got != 1 {
t.Fatalf("group grant should write one coin wallet entry, got %d", got)
}
}
// TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(53001, 1000)
svc := walletservice.New(repository)
ctx := context.Background()
resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "shop_frame_gold",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "Gold Frame",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
PriceType: resourcedomain.PriceTypeCoin,
CoinPrice: 100,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create shop resource failed: %v", err)
}
shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
Items: []resourcedomain.ResourceShopItemInput{{
ResourceID: resource.ResourceID,
Status: resourcedomain.StatusActive,
DurationDays: resourcedomain.ShopDurationThreeDays,
SortOrder: 1,
}},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("upsert shop item failed: %v", err)
}
receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
CommandID: "cmd-shop-frame",
UserID: 53001,
ShopItemID: shopItems[0].ShopItemID,
})
if err != nil {
t.Fatalf("PurchaseResourceShopItem failed: %v", err)
}
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 300 || receipt.Balance.AvailableAmount != 700 {
t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt)
}
if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() {
t.Fatalf("resource shop entitlement mismatch: %+v", receipt.Resource)
}
again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
CommandID: "cmd-shop-frame",
UserID: 53001,
ShopItemID: shopItems[0].ShopItemID,
})
if err != nil {
t.Fatalf("PurchaseResourceShopItem retry failed: %v", err)
}
if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount {
t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again)
}
if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 {
t.Fatalf("resource shop order should be persisted once, got %d", got)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-frame", "resource_shop_purchase"); got != 1 {
t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-300)); got != 1 {
t.Fatalf("resource shop purchase should write one debit entry, got %d", got)
}
if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 {
t.Fatalf("resource shop purchase should write one resource grant, got %d", got)
}
if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(53001), resource.ResourceID); got != 1 {
t.Fatalf("resource shop purchase should grant one entitlement, got %d", got)
}
}
func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) {
repository := mysqltest.NewRepository(t)
activity := &fakeActivityBadgeClient{status: "consumed"}
svc := walletservice.New(repository, activity)
ctx := context.Background()
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_projection_tile",
ResourceType: resourcedomain.TypeBadge,
Name: "Projection Tile Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
MetadataJSON: `{"badge_form":"tile"}`,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create badge resource failed: %v", err)
}
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-badge-projection",
TargetUserID: 42002,
ResourceID: badgeResource.ResourceID,
Quantity: 1,
Reason: "system badge grant",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("GrantResource failed: %v", err)
}
records, err := repository.ClaimPendingWalletOutbox(ctx, "badge-mq-test", 20, time.Now().Add(time.Minute).UnixMilli())
if err != nil {
t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
}
processed := 0
for _, record := range records {
handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage failed: %v", err)
}
if handled {
processed++
}
}
if processed != 1 || len(activity.requests) != 1 {
t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests))
}
req := activity.requests[0]
if req.GetEventType() != "ResourceGranted" || req.GetSourceService() != "wallet-service" || req.GetMetricType() != "wallet_badge_grant" || req.GetUserId() != 42002 {
t.Fatalf("activity request mismatch: %+v", req)
}
var payload struct {
GrantID string `json:"grant_id"`
GrantSource string `json:"grant_source"`
Items []struct {
ResourceID int64 `json:"resource_id"`
ResourceType string `json:"resource_type"`
EntitlementID string `json:"entitlement_id"`
MetadataJSON string `json:"metadata_json"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(req.GetDimensionsJson()), &payload); err != nil {
t.Fatalf("decode badge projection payload failed: %v", err)
}
if payload.GrantID != grant.GrantID || payload.GrantSource != resourcedomain.GrantSourceAdmin || len(payload.Items) != 1 {
t.Fatalf("badge projection payload mismatch: %+v", payload)
}
if payload.Items[0].ResourceID != badgeResource.ResourceID || payload.Items[0].ResourceType != resourcedomain.TypeBadge || payload.Items[0].EntitlementID == "" {
t.Fatalf("badge projection item mismatch: %+v", payload.Items[0])
}
if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 {
t.Fatalf("badge projection should mark event done, got %d", got)
}
processed = 0
for _, record := range records {
handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err)
}
if handled {
processed++
}
}
if processed != 0 || len(activity.requests) != 1 {
t.Fatalf("badge projection must be idempotent: processed=%d requests=%d", processed, len(activity.requests))
}
}
// TestManagerCenterResourceGrantFiltersAndRechecksSwitch 锁定经理中心资源开关必须同时约束列表和写事务。
func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
enabled, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_manager_enabled",
ResourceType: resourcedomain.TypeBadge,
Name: "Manager Enabled Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create manager enabled resource failed: %v", err)
}
disabledForManager, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_manager_disabled",
ResourceType: resourcedomain.TypeBadge,
Name: "Manager Disabled Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: false,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create manager disabled resource failed: %v", err)
}
if _, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_disabled_status",
ResourceType: resourcedomain.TypeBadge,
Name: "Disabled Badge",
Status: resourcedomain.StatusDisabled,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
}); err != nil {
t.Fatalf("create disabled status resource failed: %v", err)
}
items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{
ResourceType: resourcedomain.TypeBadge,
ManagerGrantOnly: true,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListResources manager grant only failed: %v", err)
}
if total != 1 || len(items) != 1 || items[0].ResourceID != enabled.ResourceID {
t.Fatalf("manager grant resource list mismatch: total=%d items=%+v", total, items)
}
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-manager-grant-enabled",
TargetUserID: 43001,
ResourceID: enabled.ResourceID,
Quantity: 1,
Reason: "manager center",
OperatorUserID: 91001,
GrantSource: resourcedomain.GrantSourceManagerCenter,
})
if err != nil {
t.Fatalf("manager center grant enabled resource failed: %v", err)
}
if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 {
t.Fatalf("manager grant response mismatch: %+v", grant)
}
_, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-manager-grant-disabled",
TargetUserID: 43001,
ResourceID: disabledForManager.ResourceID,
Quantity: 1,
Reason: "manager center",
OperatorUserID: 91001,
GrantSource: resourcedomain.GrantSourceManagerCenter,
})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("expected PERMISSION_DENIED for manager disabled resource, got %v", err)
}
}
func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
command := resourcedomain.ResourceCommand{
ResourceCode: "duplicate_badge",
ResourceType: resourcedomain.TypeBadge,
Name: "Duplicate Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
}
if _, err := svc.CreateResource(ctx, command); err != nil {
t.Fatalf("first CreateResource failed: %v", err)
}
command.Name = "Duplicate Badge Again"
if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("duplicate resource_code should be conflict, got %v", err)
}
}
// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
_, err := svc.GrantResourceGroup(context.Background(), resourcedomain.GrantResourceGroupCommand{
CommandID: "cmd-manager-grant-group",
TargetUserID: 43002,
GroupID: 101,
Reason: "manager center",
OperatorUserID: 91001,
GrantSource: resourcedomain.GrantSourceManagerCenter,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for manager center group grant, got %v", err)
}
}
// TestActiveResourceGroupRejectsUngrantableResource 验证 active 资源组不能保存或启用当前不可发放的资源项。
func TestActiveResourceGroupRejectsUngrantableResource(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
disabledBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_disabled",
ResourceType: resourcedomain.TypeBadge,
Name: "Disabled Badge",
Status: resourcedomain.StatusDisabled,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create disabled badge failed: %v", err)
}
_, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
GroupCode: "active_badge_disabled",
Name: "Active Disabled Badge",
Status: resourcedomain.StatusActive,
Items: []resourcedomain.ResourceGroupItemInput{
{ResourceID: disabledBadge.ResourceID, Quantity: 1},
},
OperatorUserID: 90001,
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("expected CONFLICT for disabled group resource, got %v", err)
}
coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "coin_resource_forbidden_in_group",
ResourceType: resourcedomain.TypeCoin,
Name: "Coin Resource",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyWalletCredit,
WalletAssetType: ledger.AssetCoin,
WalletAssetAmount: 1000,
UsageScopes: []string{"grant"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create coin resource failed: %v", err)
}
_, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
GroupCode: "active_coin_resource",
Name: "Active Coin Resource",
Status: resourcedomain.StatusActive,
Items: []resourcedomain.ResourceGroupItemInput{
{ResourceID: coinResource.ResourceID, Quantity: 1},
},
OperatorUserID: 90001,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for coin resource group item, got %v", err)
}
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
GroupCode: "disabled_badge_staging",
Name: "Disabled Badge Staging",
Status: resourcedomain.StatusDisabled,
Items: []resourcedomain.ResourceGroupItemInput{
{ResourceID: disabledBadge.ResourceID, Quantity: 1},
},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("disabled resource group should be saveable before enable: %v", err)
}
_, err = svc.SetResourceGroupStatus(ctx, resourcedomain.StatusCommand{
ID: group.GroupID,
Status: resourcedomain.StatusActive,
OperatorUserID: 90001,
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("expected CONFLICT when enabling group with disabled resource, got %v", err)
}
}
// TestEquipUserResourceRequiresActiveEquipableEntitlement 验证用户只能佩戴自己有效的可佩戴权益。
func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_equippable",
ResourceType: resourcedomain.TypeBadge,
Name: "Equippable Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create badge resource failed: %v", err)
}
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-grant-equip-badge",
TargetUserID: 43001,
ResourceID: badgeResource.ResourceID,
Quantity: 1,
Reason: "test equip",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("grant badge resource failed: %v", err)
}
equipped, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
UserID: 43001,
ResourceID: badgeResource.ResourceID,
})
if err != nil {
t.Fatalf("equip badge resource failed: %v", err)
}
if equipped.EntitlementID == "" || equipped.EntitlementID != grant.Items[0].EntitlementID {
t.Fatalf("equipped entitlement mismatch: equipped=%+v grant=%+v", equipped, grant)
}
if !equipped.Equipped {
t.Fatalf("equip response should mark entitlement as equipped: %+v", equipped)
}
resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
UserID: 43001,
ResourceType: resourcedomain.TypeBadge,
ActiveOnly: true,
})
if err != nil {
t.Fatalf("list user resources failed: %v", err)
}
if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != equipped.EntitlementID {
t.Fatalf("resource list should expose equipped entitlement: resources=%+v equipped=%+v", resources, equipped)
}
repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
UserID: 43001,
ResourceID: badgeResource.ResourceID,
EntitlementID: equipped.EntitlementID,
})
if err != nil {
t.Fatalf("repeat equip badge resource failed: %v", err)
}
if repeated.EntitlementID != equipped.EntitlementID {
t.Fatalf("repeat equip should keep entitlement: repeated=%+v equipped=%+v", repeated, equipped)
}
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 1 {
t.Fatalf("equipment should keep one active badge, got %d", got)
}
equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
UserIDs: []int64{43001},
ResourceTypes: []string{resourcedomain.TypeBadge},
})
if err != nil {
t.Fatalf("batch get equipped resources failed: %v", err)
}
if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].EntitlementID != equipped.EntitlementID {
t.Fatalf("batch equipped resources mismatch: %+v", equippedResources)
}
unequipped, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
UserID: 43001,
ResourceType: resourcedomain.TypeBadge,
})
if err != nil {
t.Fatalf("unequip badge resource failed: %v", err)
}
if !unequipped.Unequipped || unequipped.ResourceType != resourcedomain.TypeBadge {
t.Fatalf("unequip result mismatch: %+v", unequipped)
}
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 0 {
t.Fatalf("equipment should be removed after unequip, got %d", got)
}
emptyUnequip, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
UserID: 43001,
ResourceType: resourcedomain.TypeBadge,
})
if err != nil {
t.Fatalf("repeat unequip badge resource failed: %v", err)
}
if emptyUnequip.Unequipped {
t.Fatalf("repeat unequip should be idempotent false: %+v", emptyUnequip)
}
vehicleResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "vehicle_expiring",
ResourceType: resourcedomain.TypeVehicle,
Name: "Expiring Vehicle",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"room_entry"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create vehicle resource failed: %v", err)
}
vehicleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-grant-expiring-vehicle",
TargetUserID: 43001,
ResourceID: vehicleResource.ResourceID,
Quantity: 1,
DurationMS: int64(time.Hour / time.Millisecond),
Reason: "test expired equipment",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("grant vehicle resource failed: %v", err)
}
if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
UserID: 43001,
ResourceID: vehicleResource.ResourceID,
}); err != nil {
t.Fatalf("equip vehicle resource failed: %v", err)
}
repository.ExpireEntitlement(vehicleGrant.Items[0].EntitlementID, time.Now().Add(-time.Second).UnixMilli())
expiredEquipped, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
UserIDs: []int64{43001},
ResourceTypes: []string{resourcedomain.TypeVehicle},
})
if err != nil {
t.Fatalf("batch get expired equipped resources failed: %v", err)
}
if len(expiredEquipped) != 1 || len(expiredEquipped[0].Resources) != 0 {
t.Fatalf("expired equipment should be filtered: %+v", expiredEquipped)
}
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeVehicle); got != 0 {
t.Fatalf("expired equipment should be pruned, got %d", got)
}
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_not_equipable",
ResourceType: resourcedomain.TypeGift,
Name: "Not Equipable Gift",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create gift resource failed: %v", err)
}
if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-grant-non-equipable-gift",
TargetUserID: 43001,
ResourceID: giftResource.ResourceID,
Quantity: 1,
Reason: "test non equip",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
}); err != nil {
t.Fatalf("grant gift resource failed: %v", err)
}
_, err = svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
UserID: 43001,
ResourceID: giftResource.ResourceID,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for non-equipable resource, got %v", err)
}
}
// TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(51001, 20000)
repository.SeedVIPRewardGroup(1, 71001, []mysqltest.VipRewardResourceSeed{
{
ResourceID: 7100101,
ResourceCode: "vip1_avatar_frame",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "VIP1 Avatar Frame",
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
},
})
svc := walletservice.New(repository)
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip1",
UserID: 51001,
Level: 1,
})
if err != nil {
t.Fatalf("PurchaseVip failed: %v", err)
}
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.CoinSpent != 1000 || receipt.CoinBalanceAfter != 19000 {
t.Fatalf("vip receipt mismatch: %+v", receipt)
}
if !receipt.Vip.Active || receipt.Vip.Level != 1 || receipt.Vip.ExpiresAtMS <= receipt.Vip.StartedAtMS {
t.Fatalf("vip membership mismatch: %+v", receipt.Vip)
}
if len(receipt.RewardItems) != 1 || receipt.RewardItems[0].ResourceType != resourcedomain.TypeAvatarFrame {
t.Fatalf("vip reward items mismatch: %+v", receipt.RewardItems)
}
if got := repository.CountRows("vip_purchase_orders", "user_id = ? AND target_level = ?", int64(51001), int32(1)); got != 1 {
t.Fatalf("vip order should be persisted once, got %d", got)
}
if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND status = 'active'", int64(51001)); got != 1 {
t.Fatalf("vip reward entitlement should be active, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51001), "VipActivated"); got != 1 {
t.Fatalf("vip purchase should enqueue one activation IM event, got %d", got)
}
again, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip1",
UserID: 51001,
Level: 1,
})
if err != nil {
t.Fatalf("PurchaseVip retry failed: %v", err)
}
if again.OrderID != receipt.OrderID || again.CoinBalanceAfter != receipt.CoinBalanceAfter {
t.Fatalf("vip idempotency mismatch: first=%+v again=%+v", receipt, again)
}
if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip-renew",
UserID: 51001,
Level: 1,
}); err != nil {
t.Fatalf("same level renewal should be allowed: %v", err)
}
upgrade, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip3",
UserID: 51001,
Level: 3,
})
if err != nil {
t.Fatalf("vip upgrade failed: %v", err)
}
if upgrade.Vip.Level != 3 {
t.Fatalf("vip upgrade level mismatch: %+v", upgrade.Vip)
}
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip-down-to-1",
UserID: 51001,
Level: 1,
})
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
t.Fatalf("expected VIP_DOWNGRADE_NOT_ALLOWED, got %v", err)
}
}
func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(51004, 20000)
resources := []mysqltest.VipRewardResourceSeed{
{
ResourceID: 7100401,
ResourceCode: "vip_renew_frame",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "VIP Renew Frame",
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
},
{
ResourceID: 7100402,
ResourceCode: "vip_renew_mic",
ResourceType: resourcedomain.TypeMicSeatAnimation,
Name: "VIP Renew Mic",
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
},
{
ResourceID: 7100403,
ResourceCode: "vip_renew_badge",
ResourceType: resourcedomain.TypeBadge,
Name: "VIP Renew Badge",
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
},
}
repository.SeedVIPRewardGroup(1, 71004, resources)
svc := walletservice.New(repository)
first, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip-renew-expiry-first",
UserID: 51004,
Level: 1,
})
if err != nil {
t.Fatalf("first VIP purchase failed: %v", err)
}
if len(first.RewardItems) != len(resources) {
t.Fatalf("first reward item count mismatch: %+v", first.RewardItems)
}
for _, resource := range resources {
expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
if expiresAt != first.Vip.ExpiresAtMS {
t.Fatalf("first entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, first.Vip.ExpiresAtMS)
}
}
renew, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip-renew-expiry-second",
UserID: 51004,
Level: 1,
})
if err != nil {
t.Fatalf("VIP renewal failed: %v", err)
}
if renew.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS+604800000 {
t.Fatalf("renewed VIP expiry mismatch: first=%d renewed=%d", first.Vip.ExpiresAtMS, renew.Vip.ExpiresAtMS)
}
if len(renew.RewardItems) != len(resources) {
t.Fatalf("renew reward item count mismatch: %+v", renew.RewardItems)
}
for _, item := range renew.RewardItems {
if item.ExpiresAtMS != renew.Vip.ExpiresAtMS {
t.Fatalf("renew reward response expiry mismatch for %d: got %d want %d", item.ResourceID, item.ExpiresAtMS, renew.Vip.ExpiresAtMS)
}
}
for _, resource := range resources {
expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
if expiresAt != renew.Vip.ExpiresAtMS {
t.Fatalf("renew entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, renew.Vip.ExpiresAtMS)
}
}
}
// TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。
func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
adminGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
CommandID: "cmd-admin-vip1",
TargetUserID: 51003,
Level: 1,
GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001,
Reason: "manual vip grant",
})
if err != nil {
t.Fatalf("GrantVip admin failed: %v", err)
}
if adminGrant.TransactionID == "" || !adminGrant.Vip.Active || adminGrant.Vip.Level != 1 || len(adminGrant.RewardItems) != 1 {
t.Fatalf("admin vip grant receipt mismatch: %+v", adminGrant)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-admin-vip1", "vip_grant"); got != 1 {
t.Fatalf("vip grant should write one wallet transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", adminGrant.TransactionID); got != 0 {
t.Fatalf("vip grant should not write coin entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 1 {
t.Fatalf("vip grant should enqueue one activation IM event, got %d", got)
}
again, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
CommandID: "cmd-admin-vip1",
TargetUserID: 51003,
Level: 1,
GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001,
Reason: "manual vip grant",
})
if err != nil {
t.Fatalf("GrantVip retry failed: %v", err)
}
if again.TransactionID != adminGrant.TransactionID {
t.Fatalf("vip grant idempotency mismatch: first=%+v again=%+v", adminGrant, again)
}
activityGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
CommandID: "cmd-activity-vip3",
TargetUserID: 51003,
Level: 3,
GrantSource: ledger.VipGrantSourceActivity,
Reason: "activity vip reward",
})
if err != nil {
t.Fatalf("GrantVip activity failed: %v", err)
}
if activityGrant.Vip.Level != 3 {
t.Fatalf("activity vip grant should upgrade to level 3: %+v", activityGrant)
}
if got := repository.CountRows("user_vip_history", "user_id = ? AND action = ?", int64(51003), ledger.VipGrantSourceActivity); got != 1 {
t.Fatalf("activity vip grant should write history action, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 2 {
t.Fatalf("both vip grants should enqueue activation IM events, got %d", got)
}
}
// TestVipLevelSixRequiresRechargeThreshold 验证 6-10 级 VIP 必须先达到累计充值门槛。
func TestVipLevelSixRequiresRechargeThreshold(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(51002, 50000)
svc := walletservice.New(repository)
currentLevels, err := svc.ListAdminVipLevels(context.Background())
if err != nil {
t.Fatalf("ListAdminVipLevels failed: %v", err)
}
if len(currentLevels) != 10 {
t.Fatalf("expected 10 vip levels, got %d", len(currentLevels))
}
rewardGroupID := currentLevels[0].RewardResourceGroupID
commands := make([]ledger.AdminVipLevelCommand, 0, len(currentLevels))
for _, level := range currentLevels {
command := ledger.AdminVipLevelCommand{
Level: level.Level,
Name: level.Name,
Status: level.Status,
PriceCoin: level.PriceCoin,
DurationMS: level.DurationMS,
RewardResourceGroupID: level.RewardResourceGroupID,
RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount,
SortOrder: level.SortOrder,
}
if command.Level == 6 {
command.Status = ledger.VipStatusActive
command.PriceCoin = 12000
command.RewardResourceGroupID = rewardGroupID
command.RequiredRechargeCoinAmount = 5000
}
commands = append(commands, command)
}
if _, err := svc.UpdateAdminVipLevels(context.Background(), commands, 9001); err != nil {
t.Fatalf("UpdateAdminVipLevels failed: %v", err)
}
_, packages, err := svc.ListVipPackages(context.Background(), 51002)
if err != nil {
t.Fatalf("ListVipPackages failed: %v", err)
}
level6 := vipLevelByNumber(packages, 6)
if level6 == nil || level6.CanPurchase || level6.PurchaseLockedReason != "recharge_required" || level6.RequiredRechargeCoinAmount != 5000 {
t.Fatalf("vip level 6 should be locked by recharge gate: %+v", level6)
}
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip6-locked",
UserID: 51002,
Level: 6,
})
if !xerr.IsCode(err, xerr.VIPRechargeRequired) {
t.Fatalf("expected VIP_RECHARGE_REQUIRED, got %v", err)
}
repository.SetRechargeStats(51002, 5000)
_, packages, err = svc.ListVipPackages(context.Background(), 51002)
if err != nil {
t.Fatalf("ListVipPackages after recharge failed: %v", err)
}
level6 = vipLevelByNumber(packages, 6)
if level6 == nil || !level6.CanPurchase || level6.UserRechargeCoinAmount != 5000 {
t.Fatalf("vip level 6 should be purchasable after recharge: %+v", level6)
}
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip6",
UserID: 51002,
Level: 6,
})
if err != nil {
t.Fatalf("PurchaseVip level 6 failed: %v", err)
}
if receipt.Vip.Level != 6 || receipt.CoinSpent != 12000 {
t.Fatalf("vip level 6 receipt mismatch: %+v", receipt)
}
}
func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel {
for index := range levels {
if levels[index].Level == target {
return &levels[index]
}
}
return nil
}
// TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后钱包概览不再返回钻石余额或兑换入口。
func TestWalletOverviewDisablesDiamondExchange(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(52001, 700)
svc := walletservice.New(repository)
overview, err := svc.GetWalletOverview(context.Background(), 52001)
if err != nil {
t.Fatalf("GetWalletOverview failed: %v", err)
}
if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled {
t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
}
if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, "DIAMOND") != 0 {
t.Fatalf("overview balances mismatch: %+v", overview.Balances)
}
rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001)
if err != nil {
t.Fatalf("GetDiamondExchangeConfig failed: %v", err)
}
if len(rules) != 0 {
t.Fatalf("diamond exchange rules must be empty after DIAMOND removal: %+v", rules)
}
}
// TestRechargeProductsAreConfiguredByPlatformRegionAndStatus 锁定内购配置由 wallet-service 持久化并按平台、区域和上架态投放。
func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
AmountMicro: 1500000,
CoinAmount: 1500,
ProductName: "1500 Coins",
Description: "coin pack",
Platform: ledger.RechargeProductPlatformAndroid,
RegionIDs: []int64{1002, 1001},
Enabled: true,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("CreateRechargeProduct failed: %v", err)
}
if product.ProductID <= 0 || product.ProductCode == "" || product.Channel != ledger.RechargeChannelGoogle {
t.Fatalf("created product identifiers mismatch: %+v", product)
}
if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 {
t.Fatalf("created product regions mismatch: %+v", product.RegionIDs)
}
products, channels, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid)
if err != nil {
t.Fatalf("ListRechargeProducts android failed: %v", err)
}
if len(products) != 1 || products[0].ProductName != "1500 Coins" || products[0].CoinAmount != 1500 || products[0].AmountMicro != 1500000 {
t.Fatalf("android recharge product mismatch: products=%+v", products)
}
if len(channels) != 1 || channels[0] != ledger.RechargeChannelGoogle {
t.Fatalf("android recharge channels mismatch: %+v", channels)
}
iosProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformIOS)
if err != nil {
t.Fatalf("ListRechargeProducts ios failed: %v", err)
}
if len(iosProducts) != 0 {
t.Fatalf("ios list must not include android product: %+v", iosProducts)
}
_, err = svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
ProductID: product.ProductID,
AmountMicro: 2500000,
CoinAmount: 2600,
ProductName: "2600 Coins",
Description: "updated pack",
Platform: ledger.RechargeProductPlatformAndroid,
RegionIDs: []int64{1002},
Enabled: false,
OperatorUserID: 90002,
})
if err != nil {
t.Fatalf("UpdateRechargeProduct failed: %v", err)
}
inactiveProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid)
if err != nil {
t.Fatalf("ListRechargeProducts inactive failed: %v", err)
}
if len(inactiveProducts) != 0 {
t.Fatalf("disabled product must not be exposed to app: %+v", inactiveProducts)
}
adminProducts, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
AppCode: "lalu",
Status: ledger.RechargeProductStatusDisabled,
RegionID: 1002,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListAdminRechargeProducts failed: %v", err)
}
if total != 1 || len(adminProducts) != 1 || adminProducts[0].Enabled || adminProducts[0].CoinAmount != 2600 {
t.Fatalf("admin disabled product mismatch: total=%d products=%+v", total, adminProducts)
}
if err := svc.DeleteRechargeProduct(context.Background(), "lalu", product.ProductID); err != nil {
t.Fatalf("DeleteRechargeProduct failed: %v", err)
}
remaining, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{AppCode: "lalu", Page: 1, PageSize: 10})
if err != nil {
t.Fatalf("ListAdminRechargeProducts after delete failed: %v", err)
}
if total != 0 || len(remaining) != 0 {
t.Fatalf("deleted product must disappear: total=%d products=%+v", total, remaining)
}
}
// TestConfirmGooglePaymentUsesProductNameAsGoogleProductID 锁定后台 product_name 作为 Google Play 商品 ID。
func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
const googleProductID = "coins_1500_google"
product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
AmountMicro: 1500000,
CoinAmount: 1500,
ProductName: googleProductID,
Description: "google coin pack",
Platform: ledger.RechargeProductPlatformAndroid,
RegionIDs: []int64{1001},
Enabled: true,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("CreateRechargeProduct failed: %v", err)
}
if product.ProductCode == googleProductID {
t.Fatalf("test requires internal product_code to differ from Google product ID: %+v", product)
}
svc.SetGooglePlayClient(&fakeGooglePlayClient{
purchase: ledger.GooglePlayPurchase{
ProductID: googleProductID,
OrderID: "GPA.1",
PurchaseState: ledger.GooglePurchaseStatePurchased,
ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
},
})
receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
AppCode: "lalu",
CommandID: "google-pay-product-name",
UserID: 53001,
RegionID: 1001,
ProductID: product.ProductID,
ProductCode: googleProductID,
PackageName: "com.org.laluparty",
PurchaseToken: "purchase-token-product-name",
OrderID: "GPA.1",
})
if err != nil {
t.Fatalf("ConfirmGooglePayment failed: %v", err)
}
if receipt.Status != ledger.PaymentStatusCredited || receipt.CoinAmount != 1500 || receipt.ProductCode != product.ProductCode {
t.Fatalf("google payment receipt mismatch: %+v", receipt)
}
if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ?", product.ProductID, product.ProductCode); got != 1 {
t.Fatalf("google payment should write one payment order with internal product_code, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
t.Fatalf("google payment should write one wallet entry, got %d", got)
}
}
func TestApplyGameCoinChangeDebitCreditAndIdempotency(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(88001, 500)
svc := walletservice.New(repository)
debit := ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-1",
UserID: 88001,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-1",
ProviderRoundID: "round-1",
OpType: ledger.GameOpDebit,
CoinAmount: 120,
RoomID: "room-game-1",
RequestHash: "hash-order-1",
}
first, err := svc.ApplyGameCoinChange(context.Background(), debit)
if err != nil {
t.Fatalf("game debit failed: %v", err)
}
if first.BalanceAfter != 380 || first.IdempotentReplay {
t.Fatalf("game debit receipt mismatch: %+v", first)
}
second, err := svc.ApplyGameCoinChange(context.Background(), debit)
if err != nil {
t.Fatalf("game debit replay failed: %v", err)
}
if second.TransactionID != first.TransactionID || !second.IdempotentReplay || second.BalanceAfter != 380 {
t.Fatalf("game debit replay mismatch: first=%+v second=%+v", first, second)
}
credit, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-2",
UserID: 88001,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-2",
OpType: ledger.GameOpCredit,
CoinAmount: 70,
RequestHash: "hash-order-2",
})
if err != nil {
t.Fatalf("game credit failed: %v", err)
}
if credit.BalanceAfter != 450 {
t.Fatalf("game credit balance mismatch: %+v", credit)
}
balances, err := svc.GetBalances(context.Background(), 88001, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 450 {
t.Fatalf("final balance mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_transactions", "biz_type IN ('game_debit','game_credit')"); got != 2 {
t.Fatalf("game coin changes should write two transactions, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "event_type = ?", "WalletBalanceChanged"); got < 2 {
t.Fatalf("game coin changes should publish balance outbox events, got %d", got)
}
}
func TestApplyGameCoinChangeRejectsHashConflictAndInsufficientBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(88002, 50)
svc := walletservice.New(repository)
command := ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-conflict",
UserID: 88002,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-conflict",
OpType: ledger.GameOpDebit,
CoinAmount: 20,
RequestHash: "hash-a",
}
if _, err := svc.ApplyGameCoinChange(context.Background(), command); err != nil {
t.Fatalf("first game debit failed: %v", err)
}
command.RequestHash = "hash-b"
if _, err := svc.ApplyGameCoinChange(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) {
t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
}
_, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-insufficient",
UserID: 88002,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-insufficient",
OpType: ledger.GameOpDebit,
CoinAmount: 1000,
RequestHash: "hash-insufficient",
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "game:demo:order-insufficient"); got != 0 {
t.Fatalf("insufficient game debit must not write transaction, got %d", got)
}
}
func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 {
for _, balance := range balances {
if balance.AssetType == assetType {
return balance.AvailableAmount
}
}
return -1
}
func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) {
t.Helper()
balances, err := svc.GetBalances(context.Background(), userID, []string{assetType})
if err != nil {
t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err)
}
if got := balanceAmount(balances, assetType); got != want {
t.Fatalf("balance user=%d asset=%s mismatch: got %d want %d", userID, assetType, got, want)
}
}
func giftWallItemByID(items []ledger.GiftWallItem, giftID string) (ledger.GiftWallItem, bool) {
for _, item := range items {
if item.GiftID == giftID {
return item, true
}
}
return ledger.GiftWallItem{}, false
}
func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
for _, item := range items {
if item.GiftID == giftID {
return true
}
}
return false
}
func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool {
for _, item := range items {
if item.TypeCode == typeCode {
return true
}
}
return false
}
type fakeActivityBadgeClient struct {
status string
err error
requests []*activityv1.ConsumeAchievementEventRequest
}
func (f *fakeActivityBadgeClient) ConsumeAchievementEvent(_ context.Context, req *activityv1.ConsumeAchievementEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error) {
f.requests = append(f.requests, req)
if f.err != nil {
return nil, f.err
}
status := f.status
if status == "" {
status = "consumed"
}
return &activityv1.ConsumeAchievementEventResponse{EventId: req.GetEventId(), Status: status}, nil
}
type fakeGooglePlayClient struct {
purchase ledger.GooglePlayPurchase
err error
consumed []string
}
func (f *fakeGooglePlayClient) GetProductPurchase(_ context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) {
if f.err != nil {
return ledger.GooglePlayPurchase{}, f.err
}
purchase := f.purchase
if purchase.PackageName == "" {
purchase.PackageName = packageName
}
return purchase, nil
}
func (f *fakeGooglePlayClient) ConsumeProduct(_ context.Context, _ string, productID string, _ string) error {
f.consumed = append(f.consumed, productID)
return nil
}