package mysql import ( "context" "database/sql" "math" "sort" "time" "hyapp/pkg/appcode" ) type OverviewQuery struct { AppCode string StartMS int64 EndMS int64 SeriesStartMS int64 SeriesEndMS int64 CountryID int64 RegionID int64 } 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"` NewPaidUsers int64 `json:"new_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"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` 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"` RechargeDeltaRate float64 `json:"recharge_delta_rate"` NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"` ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"` PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"` NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"` ARPUDeltaRate float64 `json:"arpu_delta_rate"` ARPPUDeltaRate float64 `json:"arppu_delta_rate"` GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"` CoinSellerTransferDeltaRate float64 `json:"coin_seller_transfer_coin_delta_rate"` LuckyGiftTurnoverDeltaRate float64 `json:"lucky_gift_turnover_delta_rate"` LuckyGiftPayoutDeltaRate float64 `json:"lucky_gift_payout_delta_rate"` LuckyGiftProfitDeltaRate float64 `json:"lucky_gift_profit_delta_rate"` GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"` GameProfitDeltaRate float64 `json:"game_profit_delta_rate"` Retention Retention `json:"retention"` DailySeries []DailySeriesItem `json:"daily_series"` GameRanking []GameRank `json:"game_ranking"` LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"` CountryBreakdown []CountryBreakdown `json:"country_breakdown"` } type DailySeriesItem struct { StatDay string `json:"stat_day"` Label string `json:"label"` NewUsers int64 `json:"new_users"` ActiveUsers int64 `json:"active_users"` PaidUsers int64 `json:"paid_users"` NewPaidUsers int64 `json:"new_paid_users"` RechargeUsers int64 `json:"recharge_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"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` GiftCoinSpent int64 `json:"gift_coin_spent"` LuckyGiftTurnover int64 `json:"lucky_gift_turnover"` LuckyGiftPayout int64 `json:"lucky_gift_payout"` LuckyGiftProfit int64 `json:"lucky_gift_profit"` GameTurnover int64 `json:"game_turnover"` GameProfit int64 `json:"game_profit"` ARPUUSDMinor int64 `json:"arpu_usd_minor"` ARPPUUSDMinor int64 `json:"arppu_usd_minor"` } type Retention struct { RegisteredUsers int64 `json:"registered_users"` Day1Users int64 `json:"day1_users"` Day7Users int64 `json:"day7_users"` Day30Users int64 `json:"day30_users"` Day1Rate float64 `json:"day1_rate"` Day7Rate float64 `json:"day7_rate"` Day30Rate float64 `json:"day30_rate"` } type GameRank struct { PlatformCode string `json:"platform_code"` GameID string `json:"game_id"` TurnoverCoin int64 `json:"turnover_coin"` PayoutCoin int64 `json:"payout_coin"` RefundCoin int64 `json:"refund_coin"` PlayerCount int64 `json:"player_count"` ProfitCoin int64 `json:"profit_coin"` 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"` } type CountryBreakdown struct { CountryID int64 `json:"country_id"` RegionID int64 `json:"region_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"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` 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"` } func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) { app := appcode.Normalize(query.AppCode) if app == "" { app = "lalu" } startDay := statDay(query.StartMS) endDay := statDay(query.EndMS - 1) previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay) overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID previous.AppCode, previous.CountryID = app, query.CountryID // 新增付费用户来自日充值用户去重表和注册表的交集;只读聚合小表,避免 Databi 页面回扫钱包或订单明细。 if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } // Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。 pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.LuckyGiftPools = pools // 幸运礼物流水和返奖以 activity-service 的抽奖事实增量日汇总为准;这里仅读小表,避免 Databi 页面扫描 lucky_draw_records。 if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } else if ok { overview.LuckyGiftPools = activityPools overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools) } // 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。 if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } else if ok { previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools) } applyOverviewDerivedMetrics(&overview) applyOverviewDerivedMetrics(&previous) overview.applyDeltaRates(previous) retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.Retention = retention ranking, err := r.queryGameRanking(ctx, app, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.GameRanking = ranking if query.CountryID <= 0 { breakdown, err := r.queryCountryBreakdown(ctx, app, startDay, endDay, query.RegionID) if err != nil { return Overview{}, err } overview.CountryBreakdown = breakdown } seriesStartDay, seriesEndDay := startDay, endDay if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS { seriesStartDay = statDay(query.SeriesStartMS) seriesEndDay = statDay(query.SeriesEndMS - 1) } // daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。 dailySeries, err := r.queryDailySeries(ctx, app, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.DailySeries = dailySeries return overview, nil } func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { args := []any{app, startDay, endDay} filter := "" if countryID > 0 { filter += " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } row := r.db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0), COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0), COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(gift_coin_spent),0), COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0), COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0), COALESCE(SUM(game_players),0) FROM stat_app_day_country WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter, args...) var overview Overview if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil { return Overview{}, err } return overview, nil } func (r *Repository) queryNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (int64, error) { args := []any{app, startDay, endDay, startDay, endDay} filter := "" if countryID > 0 { filter += " AND p.country_id = ?" args = append(args, countryID) } if regionID > 0 { // p 表和 r 表都已经是统计小表,这里只按充值发生地过滤,保证新增付费用户口径和付费用户卡片一致。 filter += " AND p.region_id = ?" args = append(args, regionID) } row := r.db.QueryRowContext(ctx, ` SELECT COUNT(DISTINCT p.user_id) FROM stat_recharge_day_payers p JOIN stat_user_registration r ON r.app_code = p.app_code AND r.user_id = p.user_id WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ? AND r.registered_day BETWEEN ? AND ?`+filter, args...) var count int64 if err := row.Scan(&count); err != nil && err != sql.ErrNoRows { return 0, err } return count, nil } func (r *Repository) queryDailySeries(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) { args := []any{app, startDay, endDay} filter := "" if countryID > 0 { filter += " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } rows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0), COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0), COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(gift_coin_spent),0), COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0) FROM stat_app_day_country WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY stat_day ORDER BY stat_day ASC`, args...) if err != nil { return nil, err } defer rows.Close() out := []DailySeriesItem{} for rows.Next() { var item DailySeriesItem var gamePayout, gameRefund int64 if err := rows.Scan(&item.StatDay, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.GameTurnover, &gamePayout, &gameRefund); err != nil { return nil, err } item.Label = item.StatDay item.GameProfit = item.GameTurnover - gamePayout - gameRefund applyDailySeriesDerivedMetrics(&item) out = append(out, item) } if err := rows.Err(); err != nil { return nil, err } newPaidUsersByDay, err := r.queryDailyNewPaidUsers(ctx, app, startDay, endDay, countryID, regionID) if err != nil { return nil, err } for index := range out { out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay] } if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil { return nil, err } else if ok { out = mergeActivityLuckyGiftDaily(out, activityDaily) } return out, nil } func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) { args := []any{app, startDay, endDay, startDay, endDay} filter := "" if countryID > 0 { filter += " AND p.country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND p.region_id = ?" args = append(args, regionID) } rows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(p.stat_day, '%Y-%m-%d'), COUNT(DISTINCT p.user_id) FROM stat_recharge_day_payers p JOIN stat_user_registration r ON r.app_code = p.app_code AND r.user_id = p.user_id WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ? AND r.registered_day BETWEEN ? AND ?`+filter+` GROUP BY p.stat_day`, args...) if err != nil { return nil, err } defer rows.Close() out := map[string]int64{} for rows.Next() { var day string var count int64 if err := rows.Scan(&day, &count); err != nil { return nil, err } out[day] = count } return out, rows.Err() } func applyDailySeriesDerivedMetrics(item *DailySeriesItem) { if item == nil { return } channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor if channelRechargeUSDMinor > item.RechargeUSDMinor { item.RechargeUSDMinor = channelRechargeUSDMinor } item.RechargeUsers = item.PaidUsers item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers) item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers) } func applyOverviewDerivedMetrics(overview *Overview) { if overview == nil { return } // 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor;查询时用渠道合计兜底。币商进货是 USDT 充值,币商转普通金币仍只展示金币卡片。 channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor + overview.CoinSellerRechargeUSDMinor if channelRechargeUSDMinor > overview.RechargeUSDMinor { overview.RechargeUSDMinor = channelRechargeUSDMinor } overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover) overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers) overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers) overview.UPValueUSDMinor = overview.ARPPUUSDMinor overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover) overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover) } func (overview *Overview) applyDeltaRates(previous Overview) { if overview == nil { return } overview.RechargeDeltaRate = deltaRate(overview.RechargeUSDMinor, previous.RechargeUSDMinor) overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor) overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers) overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers) overview.NewPaidUsersDeltaRate = deltaRate(overview.NewPaidUsers, previous.NewPaidUsers) overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor) overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor) overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent) overview.CoinSellerTransferDeltaRate = deltaRate(overview.CoinSellerTransferCoin, previous.CoinSellerTransferCoin) overview.LuckyGiftTurnoverDeltaRate = deltaRate(overview.LuckyGiftTurnover, previous.LuckyGiftTurnover) overview.LuckyGiftPayoutDeltaRate = deltaRate(overview.LuckyGiftPayout, previous.LuckyGiftPayout) overview.LuckyGiftProfitDeltaRate = deltaRate(overview.LuckyGiftProfit, previous.LuckyGiftProfit) overview.GameTurnoverDeltaRate = deltaRate(overview.GameTurnover, previous.GameTurnover) overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit) } func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Retention, error) { filter := "" args := []any{app, startDay, endDay} if countryID > 0 { filter = " AND r.country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND r.region_id = ?" args = append(args, regionID) } row := r.db.QueryRowContext(ctx, ` SELECT COUNT(*), COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0), COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0), COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0) FROM stat_user_registration r WHERE r.app_code = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...) var out Retention if err := row.Scan(&out.RegisteredUsers, &out.Day1Users, &out.Day7Users, &out.Day30Users); err != nil && err != sql.ErrNoRows { return Retention{}, err } out.Day1Rate = ratio(out.Day1Users, out.RegisteredUsers) out.Day7Rate = ratio(out.Day7Users, out.RegisteredUsers) out.Day30Rate = ratio(out.Day30Users, out.RegisteredUsers) return out, nil } func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) { args := []any{app, startDay, endDay} filter := "" if countryID > 0 { filter = " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } rows, err := r.db.QueryContext(ctx, ` SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0) FROM stat_game_day_country WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY platform_code, game_id ORDER BY COALESCE(SUM(turnover_coin),0) DESC LIMIT 50`, args...) if err != nil { return nil, err } defer rows.Close() out := []GameRank{} for rows.Next() { var item GameRank if err := rows.Scan(&item.PlatformCode, &item.GameID, &item.TurnoverCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlayerCount); err != nil { return nil, err } item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin - item.RefundCoin item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin) out = append(out, item) } return out, rows.Err() } func (r *Repository) queryCountryBreakdown(ctx context.Context, app, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) { args := []any{app, startDay, endDay} filter := "" if regionID > 0 { filter = " AND region_id = ?" args = append(args, regionID) } rows, err := r.db.QueryContext(ctx, ` SELECT country_id, COALESCE(MAX(region_id),0), COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0), COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0), COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(gift_coin_spent),0), COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0), COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0), COALESCE(SUM(game_players),0) FROM stat_app_day_country WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY country_id ORDER BY COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC LIMIT 500`, args...) if err != nil { return nil, err } defer rows.Close() out := []CountryBreakdown{} for rows.Next() { var item CountryBreakdown if err := rows.Scan(&item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil { return nil, err } applyCountryBreakdownDerivedMetrics(&item) out = append(out, item) } return out, rows.Err() } func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) { if item == nil { return } channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor if channelRechargeUSDMinor > item.RechargeUSDMinor { item.RechargeUSDMinor = channelRechargeUSDMinor } item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover) item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers) item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers) item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover) item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover) } func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) { args := []any{app, startDay, endDay} filter := "" if countryID > 0 { filter = " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } 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 (r *Repository) queryLuckyGiftPoolsFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, bool, error) { if r == nil || r.activityDB == nil { return nil, false, nil } args := []any{app, startDay, endDay} filter := "" if regionID > 0 { filter = " AND visible_region_id = ?" args = append(args, regionID) } else if countryID > 0 { filter = " AND visible_region_id = ?" args = append(args, countryID) } rows, err := r.activityDB.QueryContext(ctx, ` SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0) FROM lucky_draw_pool_day_stats WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+` GROUP BY pool_id ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...) if err != nil { if isMissingTableError(err) { return nil, false, nil } return nil, false, 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, false, 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) } if err := rows.Err(); err != nil { return nil, false, err } return out, len(out) > 0, nil } func (r *Repository) queryLuckyGiftDailyFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]LuckyGiftPoolStat, bool, error) { if r == nil || r.activityDB == nil { return nil, false, nil } args := []any{app, startDay, endDay} filter := "" if regionID > 0 { filter = " AND visible_region_id = ?" args = append(args, regionID) } else if countryID > 0 { filter = " AND visible_region_id = ?" args = append(args, countryID) } rows, err := r.activityDB.QueryContext(ctx, ` SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0) FROM lucky_draw_pool_day_stats WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+` GROUP BY stat_day`, args...) if err != nil { if isMissingTableError(err) { return nil, false, nil } return nil, false, err } defer rows.Close() out := map[string]LuckyGiftPoolStat{} for rows.Next() { var day string var item LuckyGiftPoolStat if err := rows.Scan(&day, &item.TurnoverCoin, &item.PayoutCoin); err != nil { return nil, false, err } item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin out[day] = item } if err := rows.Err(); err != nil { return nil, false, err } return out, len(out) > 0, nil } func mergeActivityLuckyGiftDaily(items []DailySeriesItem, activityDaily map[string]LuckyGiftPoolStat) []DailySeriesItem { byDay := make(map[string]int, len(items)) for index := range items { byDay[items[index].StatDay] = index } for day, activity := range activityDaily { index, exists := byDay[day] if !exists { items = append(items, DailySeriesItem{StatDay: day, Label: day}) index = len(items) - 1 byDay[day] = index } items[index].LuckyGiftTurnover = activity.TurnoverCoin items[index].LuckyGiftPayout = activity.PayoutCoin applyDailySeriesDerivedMetrics(&items[index]) } sort.SliceStable(items, func(left, right int) bool { return items[left].StatDay < items[right].StatDay }) return items } func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) { var turnover, payout int64 for _, pool := range pools { turnover += pool.TurnoverCoin payout += pool.PayoutCoin } return turnover, payout } func normalizeRange(startMS, endMS int64) (int64, int64) { if startMS <= 0 { now := time.Now().UTC() startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() } if endMS <= startMS { endMS = startMS + 24*time.Hour.Milliseconds() } return startMS, endMS } func previousStatDayRange(startDay, endDay string) (string, string) { start, err := time.Parse("2006-01-02", startDay) if err != nil { start = time.Now().UTC() } end, err := time.Parse("2006-01-02", endDay) if err != nil || end.Before(start) { end = start } // Databi 的日期窗口按 UTC stat_day 聚合;上一周期采用同样天数,今日就是昨天,近 7 日就是前 7 日。 dayCount := int(end.Sub(start)/(24*time.Hour)) + 1 if dayCount < 1 { dayCount = 1 } previousEnd := start.AddDate(0, 0, -1) previousStart := start.AddDate(0, 0, -dayCount) return previousStart.Format("2006-01-02"), previousEnd.Format("2006-01-02") } func div(numerator, denominator int64) int64 { if denominator <= 0 { return 0 } return numerator / denominator } func deltaRate(current, previous int64) float64 { if previous == 0 { if current == 0 { return 0 } if current > 0 { return 1 } return -1 } return float64(current-previous) / math.Abs(float64(previous)) } func ratio(numerator, denominator int64) float64 { if denominator <= 0 { return 0 } return float64(numerator) / float64(denominator) }