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