diff --git a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql index 34e69490..c825d46d 100644 --- a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql +++ b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql @@ -81,6 +81,18 @@ CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers ( KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物日付费去重表'; +CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + stat_day DATE NOT NULL COMMENT 'UTC 统计日', + country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家或区域维度 ID,0 表示未知或全局', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物流水金币', + payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物返奖金币', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, stat_day, country_id, pool_id), + KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物奖池国家日统计聚合表'; + CREATE TABLE IF NOT EXISTS stat_game_day_country ( app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index 97cdb79a..8c6f5f21 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -257,6 +257,7 @@ func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool, EventType: message.EventType, UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID), CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id")), + PoolID: mysqlstorage.String(payload, "pool_id"), Amount: mysqlstorage.Int64(payload, "amount"), OccurredAtMS: message.OccurredAtMS, }, true, nil diff --git a/services/statistics-service/internal/app/local_flow_test.go b/services/statistics-service/internal/app/local_flow_test.go index ef679489..b0d61c42 100644 --- a/services/statistics-service/internal/app/local_flow_test.go +++ b/services/statistics-service/internal/app/local_flow_test.go @@ -135,6 +135,13 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) { if overview.LuckyGiftTurnover != 1_000 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 700 || 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_000 || pool.PayoutCoin != 300 || pool.ProfitCoin != 700 { + 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) } diff --git a/services/statistics-service/internal/storage/mysql/query.go b/services/statistics-service/internal/storage/mysql/query.go index 59f81a44..e208a631 100644 --- a/services/statistics-service/internal/storage/mysql/query.go +++ b/services/statistics-service/internal/storage/mysql/query.go @@ -16,36 +16,37 @@ type OverviewQuery struct { } type Overview struct { - AppCode string `json:"app_code"` - StartMS int64 `json:"start_ms"` - EndMS int64 `json:"end_ms"` - CountryID int64 `json:"country_id"` - NewUsers int64 `json:"new_users"` - ActiveUsers int64 `json:"active_users"` - PaidUsers int64 `json:"paid_users"` - RechargeUSDMinor int64 `json:"recharge_usd_minor"` - NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"` - CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"` - MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"` - GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` - GiftCoinSpent int64 `json:"gift_coin_spent"` - LuckyGiftTurnover int64 `json:"lucky_gift_turnover"` - LuckyGiftPayout int64 `json:"lucky_gift_payout"` - LuckyGiftPayers int64 `json:"lucky_gift_payers"` - LuckyGiftProfit int64 `json:"lucky_gift_profit"` - LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"` - LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"` - GameTurnover int64 `json:"game_turnover"` - GamePayout int64 `json:"game_payout"` - GameRefund int64 `json:"game_refund"` - GamePlayers int64 `json:"game_players"` - GameProfit int64 `json:"game_profit"` - GameProfitRate float64 `json:"game_profit_rate"` - ARPUUSDMinor int64 `json:"arpu_usd_minor"` - ARPPUUSDMinor int64 `json:"arppu_usd_minor"` - UPValueUSDMinor int64 `json:"up_value_usd_minor"` - Retention Retention `json:"retention"` - GameRanking []GameRank `json:"game_ranking"` + AppCode string `json:"app_code"` + StartMS int64 `json:"start_ms"` + EndMS int64 `json:"end_ms"` + CountryID int64 `json:"country_id"` + NewUsers int64 `json:"new_users"` + ActiveUsers int64 `json:"active_users"` + PaidUsers int64 `json:"paid_users"` + RechargeUSDMinor int64 `json:"recharge_usd_minor"` + NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"` + CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"` + MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"` + GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` + GiftCoinSpent int64 `json:"gift_coin_spent"` + LuckyGiftTurnover int64 `json:"lucky_gift_turnover"` + LuckyGiftPayout int64 `json:"lucky_gift_payout"` + LuckyGiftPayers int64 `json:"lucky_gift_payers"` + LuckyGiftProfit int64 `json:"lucky_gift_profit"` + LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"` + LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"` + GameTurnover int64 `json:"game_turnover"` + GamePayout int64 `json:"game_payout"` + GameRefund int64 `json:"game_refund"` + GamePlayers int64 `json:"game_players"` + GameProfit int64 `json:"game_profit"` + GameProfitRate float64 `json:"game_profit_rate"` + ARPUUSDMinor int64 `json:"arpu_usd_minor"` + ARPPUUSDMinor int64 `json:"arppu_usd_minor"` + UPValueUSDMinor int64 `json:"up_value_usd_minor"` + Retention Retention `json:"retention"` + GameRanking []GameRank `json:"game_ranking"` + LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"` } type Retention struct { @@ -69,6 +70,15 @@ type GameRank struct { ProfitRate float64 `json:"profit_rate"` } +type LuckyGiftPoolStat struct { + PoolID string `json:"pool_id"` + TurnoverCoin int64 `json:"turnover_coin"` + PayoutCoin int64 `json:"payout_coin"` + ProfitCoin int64 `json:"profit_coin"` + PayoutRate float64 `json:"payout_rate"` + ProfitRate float64 `json:"profit_rate"` +} + func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) { app := appcode.Normalize(query.AppCode) if app == "" { @@ -115,6 +125,12 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov return Overview{}, err } overview.GameRanking = ranking + // Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。 + pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID) + if err != nil { + return Overview{}, err + } + overview.LuckyGiftPools = pools return overview, nil } @@ -174,6 +190,37 @@ func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay return out, rows.Err() } +func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64) ([]LuckyGiftPoolStat, error) { + args := []any{app, startDay, endDay} + filter := "" + if countryID > 0 { + filter = " AND country_id = ?" + args = append(args, countryID) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0) + FROM stat_lucky_gift_pool_day_country + WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` + GROUP BY pool_id + ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []LuckyGiftPoolStat{} + for rows.Next() { + var item LuckyGiftPoolStat + if err := rows.Scan(&item.PoolID, &item.TurnoverCoin, &item.PayoutCoin); err != nil { + return nil, err + } + item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin + item.PayoutRate = ratio(item.PayoutCoin, item.TurnoverCoin) + item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin) + out = append(out, item) + } + return out, rows.Err() +} + func normalizeRange(startMS, endMS int64) (int64, int64) { if startMS <= 0 { now := time.Now().UTC() diff --git a/services/statistics-service/internal/storage/mysql/repository.go b/services/statistics-service/internal/storage/mysql/repository.go index f7025333..6bb4231a 100644 --- a/services/statistics-service/internal/storage/mysql/repository.go +++ b/services/statistics-service/internal/storage/mysql/repository.go @@ -102,6 +102,13 @@ func (r *Repository) Migrate(ctx context.Context) error { PRIMARY KEY (app_code, stat_day, user_id), KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country ( + app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + pool_id VARCHAR(96) NOT NULL, turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, stat_day, country_id, pool_id), + KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_game_day_country ( app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL, @@ -171,6 +178,7 @@ type LuckyGiftRewardEvent struct { EventType string UserID int64 CountryID int64 + PoolID string Amount int64 OccurredAtMS int64 } @@ -268,7 +276,8 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e return err } luckyTurnover, luckyPayers := int64(0), int64(0) - if strings.TrimSpace(event.PoolID) != "" { + poolID := strings.TrimSpace(event.PoolID) + if poolID != "" { luckyTurnover = event.GiftValue affected, err := insertUnique(ctx, tx, ` INSERT IGNORE INTO stat_lucky_gift_day_payers (app_code, stat_day, country_id, user_id, first_paid_at_ms) @@ -278,6 +287,17 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e return err } luckyPayers = affected + // Pool 明细是 Databi 点击下钻的真实口径:每笔带 pool_id 的幸运礼物送礼先按 UTC 日、国家和奖池累加流水,返奖事件稍后用相同维度补齐 payout。 + if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, poolID, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_lucky_gift_pool_day_country + SET turnover_coin = turnover_coin + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_day = ? AND country_id = ? AND pool_id = ? + `, luckyTurnover, nowMS, appcode.Normalize(event.AppCode), day, countryID, poolID); err != nil { + return err + } } _, err := tx.ExecContext(ctx, ` UPDATE stat_app_day_country @@ -296,6 +316,20 @@ func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGift if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil { return err } + poolID := strings.TrimSpace(event.PoolID) + if poolID != "" { + // 钱包返奖事件带回 pool_id 后,按同一 UTC 日和国家写入奖池返奖;利润不落表,查询时用 turnover-payout 实时计算,避免双写漂移。 + if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, poolID, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_lucky_gift_pool_day_country + SET payout_coin = payout_coin + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_day = ? AND country_id = ? AND pool_id = ? + `, event.Amount, nowMS, appcode.Normalize(event.AppCode), day, countryID, poolID); err != nil { + return err + } + } _, err := tx.ExecContext(ctx, ` UPDATE stat_app_day_country SET lucky_gift_payout = lucky_gift_payout + ?, updated_at_ms = ? @@ -470,6 +504,14 @@ func ensureGameDay(ctx context.Context, tx *sql.Tx, app string, day string, coun return err } +func ensureLuckyGiftPoolDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, poolID string, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO stat_lucky_gift_pool_day_country (app_code, stat_day, country_id, pool_id, updated_at_ms) + VALUES (?, ?, ?, ?, ?) + `, appcode.Normalize(app), day, countryID, strings.TrimSpace(poolID), nowMS) + return err +} + func insertUnique(ctx context.Context, tx *sql.Tx, query string, args ...any) (int64, error) { result, err := tx.ExecContext(ctx, query, args...) if err != nil {