2026-07-12 00:47:20 +08:00

892 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package app
import (
"context"
"encoding/json"
"runtime"
"strings"
"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"
)
func TestStatisticsWalletTagExpressionMatchesModeledEvents(t *testing.T) {
eventTypes := statisticsWalletEventTypes()
want := map[string]struct{}{
walletmq.EventTypeWalletRechargeRecorded: {},
walletmq.EventTypeWalletCoinSellerStockPurchased: {},
walletmq.EventTypeWalletLuckyGiftRewardCredited: {},
walletmq.EventTypeWalletTaskRewardCredited: {},
walletmq.EventTypeWalletWheelRewardCredited: {},
walletmq.EventTypeWalletRoomTurnoverRewardCredited: {},
walletmq.EventTypeWalletInviteActivityRewardCredited: {},
walletmq.EventTypeWalletAgencyOpeningRewardCredited: {},
walletmq.EventTypeWalletGiftIncomeCredited: {},
walletmq.EventTypeWalletBalanceChanged: {},
}
if len(eventTypes) != len(want) {
t.Fatalf("statistics wallet event count = %d, want %d: %v", len(eventTypes), len(want), eventTypes)
}
for _, eventType := range eventTypes {
if _, ok := want[eventType]; !ok {
t.Fatalf("statistics selector contains unmodeled event %q", eventType)
}
delete(want, eventType)
}
if len(want) != 0 {
t.Fatalf("statistics selector misses modeled events: %v", want)
}
expression, err := walletmq.LegacyCompatibleTagExpression(eventTypes...)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
if !strings.Contains(expression, walletmq.TagWalletOutboxEvent) {
t.Fatalf("compatibility expression misses legacy tag: %q", expression)
}
if strings.Contains(expression, walletmq.EventTypeWalletGiftDebited) {
t.Fatalf("statistics selector must not receive high-volume gift debit facts: %q", expression)
}
}
func TestStatisticsRoomTagExpressionOnlyIncludesModeledEvents(t *testing.T) {
expression, err := roommq.LegacyCompatibleTagExpression(statisticsRoomEventTypes()...)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
for _, required := range []string{roommq.TagRoomOutboxEvent, roommq.EventTypeRoomGiftSent, roommq.EventTypeRoomUserJoined} {
if !strings.Contains(expression, required) {
t.Fatalf("statistics room selector %q misses %q", expression, required)
}
}
if strings.Contains(expression, "RoomHeatChanged") {
t.Fatalf("statistics room selector must not receive heat events: %q", expression)
}
}
// 这个用例只覆盖 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 TestGiftConsumptionUsesRoomFactAsCanonicalOwner(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_gift_canonical_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, 7, 11, 2, 0, 0, 0, time.UTC).UnixMilli()
walletBody := walletMessage(t, walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: "WalletBalanceChanged:gift:canonical-1",
EventType: walletmq.EventTypeWalletBalanceChanged,
TransactionID: "wallet_tx_gift_canonical_1",
CommandID: "gift-canonical-1",
UserID: 88001,
AssetType: "COIN",
AvailableDelta: -120,
PayloadJSON: mustJSON(t, map[string]any{
"user_id": int64(88001),
"asset_type": "COIN",
"biz_type": "gift_debit",
"available_delta": int64(-120),
"country_id": int64(15),
"region_id": int64(10),
"created_at_ms": occurredAt,
}),
OccurredAtMS: occurredAt,
})
if err := ConsumeWalletOutboxBody(ctx, repo, walletBody); err != nil {
t.Fatalf("consume wallet gift balance fact failed: %v", err)
}
if err := ConsumeRoomOutboxBody(ctx, repo, roomGiftMessage(t, "lalu", "RoomGiftSent:canonical-1", "room-1", occurredAt, &roomeventsv1.RoomGiftSent{
SenderUserId: 88001,
TargetUserId: 88002,
GiftId: "gift-1",
GiftCount: 12,
GiftValue: 120,
CoinSpent: 120,
CountryId: 15,
VisibleRegionId: 10,
})); err != nil {
t.Fatalf("consume canonical room gift fact failed: %v", err)
}
// 多目标/微批钱包路径使用 gift_debit它和 direct_gift_debit 一样只能更新
// coin_total消费额仍由对应 target 的 RoomGiftSent 事实负责。
batchWalletBody := walletMessage(t, walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: "WalletBalanceChanged:gift:canonical-2",
EventType: walletmq.EventTypeWalletBalanceChanged,
TransactionID: "wallet_tx_gift_canonical_2",
CommandID: "gift-canonical-2",
UserID: 88001,
AssetType: "COIN",
AvailableDelta: -80,
PayloadJSON: mustJSON(t, map[string]any{
"user_id": int64(88001),
"asset_type": "COIN",
"biz_type": "gift_debit",
"available_delta": int64(-80),
"country_id": int64(15),
"region_id": int64(10),
"created_at_ms": occurredAt,
}),
OccurredAtMS: occurredAt,
})
if err := ConsumeWalletOutboxBody(ctx, repo, batchWalletBody); err != nil {
t.Fatalf("consume batch wallet gift balance fact failed: %v", err)
}
if err := ConsumeRoomOutboxBody(ctx, repo, roomGiftMessage(t, "lalu", "RoomGiftSent:canonical-2", "room-1", occurredAt, &roomeventsv1.RoomGiftSent{
SenderUserId: 88001,
TargetUserId: 88003,
GiftId: "gift-2",
GiftCount: 8,
GiftValue: 80,
CoinSpent: 80,
CountryId: 15,
VisibleRegionId: 10,
})); err != nil {
t.Fatalf("consume canonical batch room gift fact failed: %v", err)
}
// 私聊礼物没有 RoomGiftSentdirect_gift_debit 必须由钱包事件补计,否则消费总额永久漏失。
directWalletBody := walletMessage(t, walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: "WalletBalanceChanged:direct-gift:canonical-3",
EventType: walletmq.EventTypeWalletBalanceChanged,
TransactionID: "wallet_tx_direct_gift_canonical_3",
CommandID: "direct-gift-canonical-3",
UserID: 88001,
AssetType: "COIN",
AvailableDelta: -55,
PayloadJSON: mustJSON(t, map[string]any{
"user_id": int64(88001),
"asset_type": "COIN",
"biz_type": "direct_gift_debit",
"available_delta": int64(-55),
"country_id": int64(15),
"region_id": int64(10),
"created_at_ms": occurredAt,
}),
OccurredAtMS: occurredAt,
})
if err := ConsumeWalletOutboxBody(ctx, repo, directWalletBody); err != nil {
t.Fatalf("consume direct gift wallet fact failed: %v", err)
}
dayStart := time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
AppCode: "lalu",
StartMS: dayStart,
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
CountryID: 15,
})
if err != nil {
t.Fatalf("query canonical gift overview failed: %v", err)
}
if overview.CoinTotal != -255 || overview.GiftCoinSpent != 200 || overview.ConsumedCoin != 255 {
t.Fatalf("room gifts must use RoomGiftSent once while direct gifts use wallet once: %+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)
}