package app import ( "context" "encoding/json" "runtime" "testing" "time" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/appcode" "hyapp/pkg/gamemq" "hyapp/pkg/roommq" "hyapp/pkg/walletmq" mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql" ) // 这个用例只覆盖 statistics-service 对 owner outbox payload 的解析和聚合边界; // 本地真实业务链路用 scripts/databi-real-flow-check.sh 从各 owner service 的 outbox 重放验证。 func TestConsumeOutboxMessagesProjectsDataScreenMetrics(t *testing.T) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) schema := mysqlschema.New(t, mysqlschema.Config{ InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"), DatabasePrefix: "hy_stats_flow", }) repo, err := mysqlstorage.Open(ctx, schema.DSN) if err != nil { t.Fatalf("open statistics repository failed: %v", err) } t.Cleanup(func() { _ = repo.Close() }) occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli() dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli() const ( appCode = "lalu" countryID = int64(86) regionID = int64(210) userID = int64(42) ) googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{ AppCode: appCode, EventID: "WalletRechargeRecorded:google:1", EventType: "WalletRechargeRecorded", TransactionID: "wallet_tx_google_1", CommandID: "google_order_1", UserID: userID, PayloadJSON: mustJSON(t, map[string]any{ "amount_micro": int64(1_500_000), "country_id": countryID, "region_id": regionID, "channel": "google", "recharge_sequence": int64(1), }), OccurredAtMS: occurredAt, }) recharge, ok, err := rechargeEvent(googleRecharge) if err != nil || !ok { t.Fatalf("decode google recharge failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeRecharge(ctx, recharge); err != nil { t.Fatalf("consume google recharge failed: %v", err) } coinSellerStock := walletMessage(t, walletmq.WalletOutboxMessage{ AppCode: appCode, EventID: "WalletCoinSellerStockPurchased:1", EventType: "WalletCoinSellerStockPurchased", TransactionID: "wallet_tx_coin_seller_stock_1", CommandID: "coin_seller_stock_1", UserID: userID + 10, PayloadJSON: mustJSON(t, map[string]any{ "seller_user_id": userID + 10, "country_id": countryID, "region_id": regionID, "coin_amount": int64(400_000), "paid_amount_micro": int64(4_000_000), "counts_as_seller_recharge": true, "stock_type": "usdt_purchase", }), OccurredAtMS: occurredAt, }) stock, ok, err := coinSellerStockEvent(coinSellerStock) if err != nil || !ok { t.Fatalf("decode coin seller stock failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeCoinSellerStock(ctx, stock); err != nil { t.Fatalf("consume coin seller stock failed: %v", err) } coinSellerStockDeduction := walletMessage(t, walletmq.WalletOutboxMessage{ AppCode: appCode, EventID: "WalletCoinSellerStockPurchased:deduction:1", EventType: "WalletCoinSellerStockPurchased", TransactionID: "wallet_tx_coin_seller_stock_deduction_1", CommandID: "coin_seller_stock_deduction_1", UserID: userID + 10, PayloadJSON: mustJSON(t, map[string]any{ "seller_user_id": userID + 10, "country_id": countryID, "region_id": regionID, "coin_amount": int64(-50_000), "paid_amount_micro": int64(-500_000), "counts_as_seller_recharge": true, "stock_type": "usdt_deduction", }), OccurredAtMS: occurredAt, }) stockDeduction, ok, err := coinSellerStockEvent(coinSellerStockDeduction) if err != nil || !ok { t.Fatalf("decode coin seller stock deduction failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeCoinSellerStock(ctx, stockDeduction); err != nil { t.Fatalf("consume coin seller stock deduction failed: %v", err) } coinSellerRecharge := walletMessage(t, walletmq.WalletOutboxMessage{ AppCode: appCode, EventID: "WalletRechargeRecorded:coin_seller:1", EventType: "WalletRechargeRecorded", TransactionID: "wallet_tx_coin_seller_1", CommandID: "coin_seller_transfer_1", UserID: userID + 1, PayloadJSON: mustJSON(t, map[string]any{ "amount": int64(160_000), "recharge_type": "coin_seller_transfer", "target_country_id": countryID, "target_region_id": regionID, "recharge_sequence": int64(1), }), OccurredAtMS: occurredAt, }) coinSeller, ok, err := rechargeEvent(coinSellerRecharge) if err != nil || !ok { t.Fatalf("decode coin seller recharge failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeRecharge(ctx, coinSeller); err != nil { t.Fatalf("consume coin seller recharge failed: %v", err) } roomGiftBody := roomGiftMessage(t, appCode, "room_gift_1", "room_1", occurredAt, &roomeventsv1.RoomGiftSent{ SenderUserId: userID, TargetUserId: 99, GiftId: "rose", GiftCount: 10, GiftValue: 1_000, CoinSpent: 1_250, VisibleRegionId: regionID, CommandId: "gift_command_1", PoolId: "lucky_100", }) gift, ok, err := roomGiftEvent(roomGiftBody) if err != nil || !ok { t.Fatalf("decode room gift failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeRoomGift(ctx, gift); err != nil { t.Fatalf("consume room gift failed: %v", err) } robotGiftBody := roomGiftMessage(t, appCode, "room_gift_robot_1", "room_robot_real", occurredAt, &roomeventsv1.RoomGiftSent{ SenderUserId: 88001, TargetUserId: 88002, GiftId: "robot-super", GiftCount: 1, GiftValue: 700, CoinSpent: 900, VisibleRegionId: regionID, CommandId: "gift_command_robot_1", PoolId: "human_robot_super_lucky_display", GiftTypeCode: "super_lucky", IsRobotGift: true, RealRoomRobotGift: true, SyntheticLuckyGift: true, }) robotGift, ok, err := roomGiftEvent(robotGiftBody) if err != nil || !ok { t.Fatalf("decode real room robot gift failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeRoomGift(ctx, robotGift); err != nil { t.Fatalf("consume real room robot gift failed: %v", err) } luckyReward := walletMessage(t, walletmq.WalletOutboxMessage{ AppCode: appCode, EventID: "WalletLuckyGiftRewardCredited:1", EventType: "WalletLuckyGiftRewardCredited", TransactionID: "wallet_tx_lucky_1", CommandID: "lucky_reward:lucky_draw_1", UserID: userID, PayloadJSON: mustJSON(t, map[string]any{ "user_id": userID, "draw_id": "lucky_draw_1", "room_id": "room_1", "pool_id": "lucky_100", "amount": int64(300), "country_id": countryID, "visible_region_id": regionID, }), OccurredAtMS: occurredAt, }) reward, ok, err := luckyGiftRewardEvent(luckyReward) if err != nil || !ok { t.Fatalf("decode lucky reward failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeLuckyGiftReward(ctx, reward); err != nil { t.Fatalf("consume lucky reward failed: %v", err) } for _, message := range []gamemq.GameOutboxMessage{ gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, countryID, regionID, occurredAt), gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, countryID, regionID, occurredAt), } { body, err := gamemq.EncodeGameOutboxMessage(message) if err != nil { t.Fatalf("encode game message failed: %v", err) } event, ok, err := gameOrderEvent(body) if err != nil || !ok { t.Fatalf("decode game order failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeGameOrder(ctx, event); err != nil { t.Fatalf("consume game order failed: %v", err) } } overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{ AppCode: appCode, StartMS: dayStart, EndMS: dayStart + int64(24*time.Hour/time.Millisecond), RegionID: regionID, }) if err != nil { t.Fatalf("query overview failed: %v", err) } if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 350 || overview.CoinSellerStockCoin != 350_000 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 500 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 { t.Fatalf("google recharge metrics mismatch: %+v", overview) } if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 { t.Fatalf("lucky gift metrics mismatch: %+v", overview) } if overview.RealRoomRobotSuperGiftCoin != 900 || overview.RealRoomRobotTotalGiftCoin != 900 || overview.RealRoomRobotGiftRoomCount != 1 || overview.RealRoomRobotAvgRoomGiftCoin != 900 { t.Fatalf("real room robot gift metrics mismatch: %+v", overview) } if len(overview.LuckyGiftPools) != 1 { t.Fatalf("lucky gift pool breakdown missing: %+v", overview.LuckyGiftPools) } pool := overview.LuckyGiftPools[0] if pool.PoolID != "lucky_100" || pool.TurnoverCoin != 1_250 || pool.PayoutCoin != 300 || pool.ProfitCoin != 950 { t.Fatalf("lucky gift pool metrics mismatch: %+v", pool) } if overview.GameTurnover != 700 || overview.GamePayout != 200 || overview.GameProfit != 500 || overview.GamePlayers != 1 { t.Fatalf("game metrics mismatch: %+v", overview) } if overview.ConsumedCoin != 1_950 || overview.OutputCoin != 500 || overview.ConsumeOutputDelta != 1_450 { t.Fatalf("consume/output metrics mismatch: %+v", overview) } } func TestConsumeWalletOutboxBodyProjectsBalanceChangedReportMetrics(t *testing.T) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) schema := mysqlschema.New(t, mysqlschema.Config{ InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"), DatabasePrefix: "hy_stats_wallet_balance_flow", }) repo, err := mysqlstorage.Open(ctx, schema.DSN) if err != nil { t.Fatalf("open statistics repository failed: %v", err) } t.Cleanup(func() { _ = repo.Close() }) occurredAt := time.Date(2026, 6, 22, 8, 0, 0, 0, time.UTC).UnixMilli() for _, message := range []walletmq.WalletOutboxMessage{ { AppCode: "lalu", EventID: "WalletBalanceChanged:coin:topup", EventType: "WalletBalanceChanged", TransactionID: "wallet_tx_topup", CommandID: "topup", UserID: 88001, AssetType: "COIN", PayloadJSON: mustJSON(t, map[string]any{ "user_id": int64(88001), "asset_type": "COIN", "biz_type": "google_play_recharge", "available_delta": int64(100), "country_id": int64(15), "region_id": int64(10), "created_at_ms": occurredAt, }), OccurredAtMS: occurredAt, }, { AppCode: "lalu", EventID: "WalletBalanceChanged:coin:gift", EventType: "WalletBalanceChanged", TransactionID: "wallet_tx_gift", CommandID: "gift", UserID: 88001, AssetType: "COIN", PayloadJSON: mustJSON(t, map[string]any{ "user_id": int64(88001), "asset_type": "COIN", "biz_type": "red_packet_create", "available_delta": int64(-88), "country_id": int64(15), "region_id": int64(10), "created_at_ms": occurredAt, }), OccurredAtMS: occurredAt, }, { AppCode: "lalu", EventID: "WalletBalanceChanged:coin:gift-income-balance", EventType: "WalletBalanceChanged", TransactionID: "wallet_tx_gift_income", CommandID: "gift-income-balance", UserID: 88002, AssetType: "COIN", PayloadJSON: mustJSON(t, map[string]any{ "user_id": int64(88002), "asset_type": "COIN", "biz_type": "gift_debit", "available_delta": int64(25), "country_id": int64(15), "region_id": int64(10), "created_at_ms": occurredAt, }), OccurredAtMS: occurredAt, }, { AppCode: "lalu", EventID: "WalletGiftIncomeCredited:coin:gift-income", EventType: "WalletGiftIncomeCredited", TransactionID: "wallet_tx_gift_income", CommandID: "gift-income", UserID: 88002, AssetType: "COIN", AvailableDelta: 25, PayloadJSON: mustJSON(t, map[string]any{ "user_id": int64(88002), "amount": int64(25), "country_id": int64(15), "region_id": int64(10), "created_at_ms": occurredAt, }), OccurredAtMS: occurredAt, }, { AppCode: "lalu", EventID: "WalletBalanceChanged:salary:coin-seller", EventType: "WalletBalanceChanged", TransactionID: "wallet_tx_salary_seller", CommandID: "salary-seller", UserID: 99001, AssetType: "COIN_SELLER_COIN", PayloadJSON: mustJSON(t, map[string]any{ "user_id": int64(99001), "asset_type": "COIN_SELLER_COIN", "biz_type": "salary_transfer_to_coin_seller", "available_delta": int64(660), "country_id": int64(15), "region_id": int64(10), "metadata": map[string]any{ "source_user_id": int64(88001), "region_id": int64(10), }, "created_at_ms": occurredAt, }), OccurredAtMS: occurredAt, }, } { if err := ConsumeWalletOutboxBody(ctx, repo, walletMessage(t, message)); err != nil { t.Fatalf("consume wallet balance message %s failed: %v", message.EventID, err) } } start := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC).UnixMilli() overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{ AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 15, }) if err != nil { t.Fatalf("query overview failed: %v", err) } if overview.CoinTotal != 37 || overview.ConsumedCoin != 88 || overview.OutputCoin != 25 || overview.SalaryTransferCoin != 660 { t.Fatalf("wallet balance report metrics mismatch: %+v", overview) } } func TestRoomJoinActiveEventSkipsRobotJoin(t *testing.T) { occurredAt := time.Date(2026, 6, 25, 8, 0, 0, 0, time.UTC).UnixMilli() active, ok, err := roomJoinActiveEvent(roomJoinMessage(t, "lalu", "room_join_real_1", "room_1", occurredAt, &roomeventsv1.RoomUserJoined{ UserId: 42, Role: "audience", CountryId: 86, RegionId: 210, })) if err != nil || !ok { t.Fatalf("real room join should produce active event: ok=%v err=%v", ok, err) } if active.UserID != 42 || active.CountryID != 86 || active.RegionID != 210 || active.EventType != "RoomUserJoined" { t.Fatalf("real room join active payload mismatch: %+v", active) } active, ok, err = roomJoinActiveEvent(roomJoinMessage(t, "lalu", "room_join_robot_1", "room_1", occurredAt, &roomeventsv1.RoomUserJoined{ UserId: 88001, Role: "audience", CountryId: 86, RegionId: 210, IsRobot: true, })) if err != nil { t.Fatalf("robot room join decode failed: %v", err) } if ok { t.Fatalf("robot room join must not produce active event: %+v", active) } } func TestLocalSelfGameStatisticsFlow(t *testing.T) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) schema := mysqlschema.New(t, mysqlschema.Config{ InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"), DatabasePrefix: "hy_self_game_stats", }) repo, err := mysqlstorage.Open(ctx, schema.DSN) if err != nil { t.Fatalf("open statistics repository failed: %v", err) } t.Cleanup(func() { _ = repo.Close() }) occurredAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC).UnixMilli() dayStart := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC).UnixMilli() const ( appCode = "lalu" regionID = int64(210) userID = int64(42) ) appCtx := appcode.WithContext(ctx, appCode) for _, eventName := range []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"} { if err := repo.ConsumeSelfGameH5Event(appCtx, mysqlstorage.SelfGameH5Event{ AppCode: appCode, EventID: "h5:" + eventName, EventName: eventName, GameID: "dice", UserID: userID, RegionID: regionID, H5Version: "dice-h5-test", EntrySource: "room", DurationMS: 20, Success: true, OccurredAtMS: occurredAt, }); err != nil { t.Fatalf("consume h5 event %s failed: %v", eventName, err) } } body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{ AppCode: appCode, EventID: "SelfGameMatchSettled:dice_match_1", EventType: gamemq.EventTypeSelfGameMatchSettled, OrderID: "", UserID: userID, PlatformCode: "self", GameID: "dice", PayloadJSON: mustJSON(t, map[string]any{ "match_id": "dice_match_1", "game_id": "dice", "platform_code": "self", "region_id": regionID, "stake_coin": int64(500), "status": "settled", "result": "win", "match_mode": "robot", "forced_result": "", "user_participants": int64(1), "robot_participants": int64(1), "winner_count": int64(1), "loser_count": int64(1), "draw_count": int64(0), "user_stake_coin": int64(500), "robot_stake_coin": int64(500), "payout_coin": int64(900), "refund_coin": int64(0), "platform_profit_coin": int64(100), "pool_delta_coin": int64(50), "wait_ms": int64(1200), "duration_ms": int64(4800), "participants": []map[string]any{ { "user_id": userID, "participant_type": "user", "stake_coin": int64(500), "dice_points": []int64{6}, "result": "win", "payout_coin": int64(900), "net_win_coin": int64(400), }, { "user_id": int64(0), "participant_type": "robot", "stake_coin": int64(500), "dice_points": []int64{2}, "result": "lose", "payout_coin": int64(0), "net_win_coin": int64(-500), }, }, }), OccurredAtMS: occurredAt, }) if err != nil { t.Fatalf("encode self game match failed: %v", err) } match, ok, err := selfGameMatchEvent(body) if err != nil || !ok { t.Fatalf("decode self game match failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeSelfGameMatch(appCtx, match); err != nil { t.Fatalf("consume self game match failed: %v", err) } poolBody, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{ AppCode: appCode, EventID: "SelfGamePoolAdjusted:pool_adjust_1", EventType: gamemq.EventTypeSelfGamePoolAdjusted, UserID: userID, PlatformCode: "self", GameID: "dice", PayloadJSON: mustJSON(t, map[string]any{ "adjustment_id": "pool_adjust_1", "game_id": "dice", "match_id": "dice_match_1", "user_id": userID, "direction": "in", "amount_coin": int64(50), "reason": "match_pool_in", "balance_after": int64(1050), }), OccurredAtMS: occurredAt, }) if err != nil { t.Fatalf("encode self game pool failed: %v", err) } pool, ok, err := selfGamePoolAdjustmentEvent(poolBody) if err != nil || !ok { t.Fatalf("decode self game pool failed: ok=%v err=%v", ok, err) } if err := repo.ConsumeSelfGamePoolAdjustment(appCtx, pool); err != nil { t.Fatalf("consume self game pool failed: %v", err) } for _, dayOffset := range []int{1, 7} { if err := repo.ConsumeSelfGameMatch(appCtx, mysqlstorage.SelfGameMatchEvent{ AppCode: appCode, EventID: "SelfGameMatchSettled:dice_retention_" + string(rune('0'+dayOffset)), EventType: gamemq.EventTypeSelfGameMatchSettled, MatchID: "dice_retention", GameID: "dice", RegionID: regionID, StakeCoin: 500, UserParticipants: 1, WinnerCount: 1, UserStakeCoin: 500, PayoutCoin: 900, Participants: []mysqlstorage.SelfGameMatchParticipantEvent{{ UserID: userID, ParticipantType: "user", StakeCoin: 500, DicePoints: []int64{5}, Result: "win", PayoutCoin: 900, NetWinCoin: 400, }}, OccurredAtMS: time.UnixMilli(occurredAt).AddDate(0, 0, dayOffset).UnixMilli(), }); err != nil { t.Fatalf("consume retention self game match day+%d failed: %v", dayOffset, err) } } overview, err := repo.QuerySelfGameOverview(ctx, mysqlstorage.SelfGameOverviewQuery{ AppCode: appCode, StartMS: dayStart, EndMS: dayStart + int64(24*time.Hour/time.Millisecond), GameID: "dice", RegionID: regionID, }) if err != nil { t.Fatalf("query self game overview failed: %v", err) } if overview.Conversion.PageToMatchClickRate != 1 || overview.Conversion.MatchClickToSuccessRate != 1 || overview.Conversion.SuccessToSettlementRate != 1 || overview.Conversion.SettlementToPlayAgainRate != 1 { t.Fatalf("self game funnel mismatch: %+v", overview.Conversion) } if overview.MatchTotals.SettledMatches != 1 || overview.MatchTotals.RobotMatches != 1 || overview.MatchTotals.UserStakeCoin != 500 || overview.MatchTotals.PayoutCoin != 900 || overview.MatchTotals.PlatformProfitCoin != 100 || overview.MatchTotals.PoolDeltaCoin != 50 { t.Fatalf("self game match totals mismatch: %+v", overview.MatchTotals) } if len(overview.StakeBreakdown) != 1 || overview.StakeBreakdown[0].StakeCoin != 500 || overview.StakeBreakdown[0].PlatformProfitCoin != 100 { t.Fatalf("self game stake breakdown mismatch: %+v", overview.StakeBreakdown) } if len(overview.PoolStats) != 1 || overview.PoolStats[0].InCoin != 50 || overview.PoolStats[0].BalanceAfterCoin != 1050 { t.Fatalf("self game pool stats mismatch: %+v", overview.PoolStats) } if len(overview.UserRisk) != 1 || overview.UserRisk[0].UserID != userID || overview.UserRisk[0].WinCount != 1 || overview.UserRisk[0].NetWinCoin != 400 { t.Fatalf("self game user risk mismatch: %+v", overview.UserRisk) } if overview.Retention.CohortUsers != 1 || overview.Retention.Day1Users != 1 || overview.Retention.Day7Users != 1 || overview.Retention.Day1Rate != 1 || overview.Retention.Day7Rate != 1 { t.Fatalf("self game retention mismatch: %+v", overview.Retention) } if len(overview.MetricDefinitions) == 0 { t.Fatalf("self game metric definitions missing") } } func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte { t.Helper() body, err := walletmq.EncodeWalletOutboxMessage(message) if err != nil { t.Fatalf("encode wallet message failed: %v", err) } return body } func roomGiftMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, gift *roomeventsv1.RoomGiftSent) []byte { t.Helper() giftBody, err := proto.Marshal(gift) if err != nil { t.Fatalf("marshal gift failed: %v", err) } body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{ EventId: eventID, RoomId: roomID, EventType: "RoomGiftSent", RoomVersion: 1, OccurredAtMs: occurredAt, Body: giftBody, AppCode: appCode, }) if err != nil { t.Fatalf("encode room message failed: %v", err) } return body } func roomJoinMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, joined *roomeventsv1.RoomUserJoined) []byte { t.Helper() joinedBody, err := proto.Marshal(joined) if err != nil { t.Fatalf("marshal room join failed: %v", err) } body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{ EventId: eventID, RoomId: roomID, EventType: "RoomUserJoined", RoomVersion: 1, OccurredAtMs: occurredAt, Body: joinedBody, AppCode: appCode, }) if err != nil { t.Fatalf("encode room join message failed: %v", err) } return body } func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, countryID int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage { return gamemq.GameOutboxMessage{ AppCode: appCode, EventID: "GameOrderSettled:" + orderID, EventType: "GameOrderSettled", OrderID: orderID, UserID: userID, PlatformCode: "yomi", GameID: "fish_001", OpType: opType, CoinAmount: amount, PayloadJSON: mustJSONNoT(map[string]any{ "order_id": orderID, "platform_code": "yomi", "game_id": "fish_001", "user_id": userID, "room_id": "room_1", "visible_region_id": regionID, "country_id": countryID, "region_id": regionID, "op_type": opType, "coin_amount": amount, }), OccurredAtMS: occurredAt, } } func mustJSON(t *testing.T, value map[string]any) string { t.Helper() body, err := json.Marshal(value) if err != nil { t.Fatalf("marshal json failed: %v", err) } return string(body) } func mustJSONNoT(value map[string]any) string { body, err := json.Marshal(value) if err != nil { panic(err) } return string(body) }