2026-06-11 19:37:21 +08:00

471 lines
16 KiB
Go

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"
)
func TestLocalStatisticsDataScreenFlow(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)
}
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)
}
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 != 400 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 550 || 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 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)
}
}
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 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)
}