增加相关字段

This commit is contained in:
zhx 2026-06-04 19:28:56 +08:00
parent 82dfb9238f
commit 0fdaec9ef9
5 changed files with 140 additions and 31 deletions

View File

@ -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 '国家或区域维度 ID0 表示未知或全局',
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,

View File

@ -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

View File

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

View File

@ -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()

View File

@ -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 {