package mysql import ( "context" "database/sql" "math" "sort" "strconv" "strings" "time" "hyapp/pkg/appcode" ) type OverviewQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 SeriesStartMS int64 SeriesEndMS int64 CountryID int64 RegionID int64 } type Overview struct { AppCode string `json:"app_code"` StatTZ string `json:"stat_tz"` 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"` CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"` MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"` GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` CoinTotal int64 `json:"coin_total"` ConsumedCoin int64 `json:"consumed_coin"` PlatformGrantCoin int64 `json:"platform_grant_coin"` ManualGrantCoin int64 `json:"manual_grant_coin"` SalaryUSDMinor int64 `json:"salary_usd_minor"` SalaryTransferCoin int64 `json:"salary_transfer_coin"` MicOnlineMS int64 `json:"mic_online_ms"` MicOnlineUsers int64 `json:"mic_online_users"` AvgMicOnlineMS int64 `json:"avg_mic_online_ms"` GiftCoinSpent int64 `json:"gift_coin_spent"` RealRoomRobotNormalGiftCoin int64 `json:"real_room_robot_normal_gift_coin"` RealRoomRobotLuckyGiftCoin int64 `json:"real_room_robot_lucky_gift_coin"` RealRoomRobotSuperGiftCoin int64 `json:"real_room_robot_super_gift_coin"` RealRoomRobotTotalGiftCoin int64 `json:"real_room_robot_total_gift_coin"` RealRoomRobotGiftRoomCount int64 `json:"real_room_robot_gift_room_count"` RealRoomRobotAvgRoomGiftCoin int64 `json:"real_room_robot_avg_room_gift_coin"` 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"` SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"` SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"` SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"` SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_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"` DailyCountryBreakdown []DailyCountryStat `json:"daily_country_breakdown"` GameRanking []GameRank `json:"game_ranking"` LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"` CountryBreakdown []CountryBreakdown `json:"country_breakdown"` ReportMetricSources []ReportMetricSource `json:"report_metric_sources"` } 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"` CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"` MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"` GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` CoinTotal int64 `json:"coin_total"` ConsumedCoin int64 `json:"consumed_coin"` PlatformGrantCoin int64 `json:"platform_grant_coin"` ManualGrantCoin int64 `json:"manual_grant_coin"` SalaryUSDMinor int64 `json:"salary_usd_minor"` SalaryTransferCoin int64 `json:"salary_transfer_coin"` MicOnlineMS int64 `json:"mic_online_ms"` MicOnlineUsers int64 `json:"mic_online_users"` AvgMicOnlineMS int64 `json:"avg_mic_online_ms"` 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"` SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"` SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"` SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"` SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"` 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 superLuckyAggregate struct { Turnover int64 Payout int64 } 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"` CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"` MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"` GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` CoinTotal int64 `json:"coin_total"` ConsumedCoin int64 `json:"consumed_coin"` PlatformGrantCoin int64 `json:"platform_grant_coin"` ManualGrantCoin int64 `json:"manual_grant_coin"` SalaryUSDMinor int64 `json:"salary_usd_minor"` SalaryTransferCoin int64 `json:"salary_transfer_coin"` MicOnlineMS int64 `json:"mic_online_ms"` MicOnlineUsers int64 `json:"mic_online_users"` AvgMicOnlineMS int64 `json:"avg_mic_online_ms"` 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"` SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"` SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"` SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"` SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"` ARPUUSDMinor int64 `json:"arpu_usd_minor"` ARPPUUSDMinor int64 `json:"arppu_usd_minor"` } type DailyCountryStat struct { StatDay string `json:"stat_day"` Label string `json:"label"` CountryBreakdown } type ReportMetricSource struct { Key string `json:"key"` Label string `json:"label"` Status string `json:"status"` Source string `json:"source"` Definition string `json:"definition"` MissingReason string `json:"missing_reason,omitempty"` } type SelfGameOverviewQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 GameID string CountryID int64 RegionID int64 } type SelfGameOverview struct { AppCode string `json:"app_code"` StatTZ string `json:"stat_tz"` StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` GameID string `json:"game_id"` CountryID int64 `json:"country_id"` RegionID int64 `json:"region_id"` Events []SelfGameEventStat `json:"events"` MatchTotals SelfGameMatchTotals `json:"match_totals"` StakeBreakdown []SelfGameStakeStat `json:"stake_breakdown"` PoolStats []SelfGamePoolStat `json:"pool_stats"` ParticipantDistribution []SelfGameParticipantStat `json:"participant_distribution"` UserRisk []SelfGameUserRiskStat `json:"user_risk"` Retention SelfGameRetention `json:"retention"` MetricDefinitions []MetricDefinition `json:"metric_definitions"` Funnel []SelfGameFunnelStep `json:"funnel"` Conversion SelfGameConversion `json:"conversion"` } type SelfGameEventStat struct { GameID string `json:"game_id"` EventName string `json:"event_name"` EventCount int64 `json:"event_count"` UserCount int64 `json:"user_count"` SuccessCount int64 `json:"success_count"` ErrorCount int64 `json:"error_count"` DurationMSSum int64 `json:"duration_ms_sum"` AvgDurationMS int64 `json:"avg_duration_ms"` } type SelfGameMatchTotals struct { CreatedMatches int64 `json:"created_matches"` MatchedMatches int64 `json:"matched_matches"` SettledMatches int64 `json:"settled_matches"` CanceledMatches int64 `json:"canceled_matches"` FailedMatches int64 `json:"failed_matches"` HumanMatches int64 `json:"human_matches"` RobotMatches int64 `json:"robot_matches"` HumanParticipants int64 `json:"human_participants"` RobotParticipants int64 `json:"robot_participants"` WinnerCount int64 `json:"winner_count"` LoserCount int64 `json:"loser_count"` DrawCount int64 `json:"draw_count"` UserStakeCoin int64 `json:"user_stake_coin"` RobotStakeCoin int64 `json:"robot_stake_coin"` PayoutCoin int64 `json:"payout_coin"` RefundCoin int64 `json:"refund_coin"` PlatformProfitCoin int64 `json:"platform_profit_coin"` PoolDeltaCoin int64 `json:"pool_delta_coin"` ForcedPlayerLoseMatches int64 `json:"forced_player_lose_matches"` WaitMSSum int64 `json:"wait_ms_sum"` DurationMSSum int64 `json:"duration_ms_sum"` AvgWaitMS int64 `json:"avg_wait_ms"` AvgDurationMS int64 `json:"avg_duration_ms"` CancelRate float64 `json:"cancel_rate"` FailRate float64 `json:"fail_rate"` RobotMatchRate float64 `json:"robot_match_rate"` PlatformProfitRate float64 `json:"platform_profit_rate"` } type SelfGameStakeStat struct { GameID string `json:"game_id"` StakeCoin int64 `json:"stake_coin"` CreatedMatches int64 `json:"created_matches"` MatchedMatches int64 `json:"matched_matches"` SettledMatches int64 `json:"settled_matches"` CanceledMatches int64 `json:"canceled_matches"` FailedMatches int64 `json:"failed_matches"` HumanParticipants int64 `json:"human_participants"` RobotParticipants int64 `json:"robot_participants"` UserStakeCoin int64 `json:"user_stake_coin"` RobotStakeCoin int64 `json:"robot_stake_coin"` PayoutCoin int64 `json:"payout_coin"` RefundCoin int64 `json:"refund_coin"` PlatformProfitCoin int64 `json:"platform_profit_coin"` PoolDeltaCoin int64 `json:"pool_delta_coin"` PlatformProfitRate float64 `json:"platform_profit_rate"` } type SelfGamePoolStat struct { GameID string `json:"game_id"` Direction string `json:"direction"` Reason string `json:"reason"` TransactionCount int64 `json:"transaction_count"` InCoin int64 `json:"in_coin"` OutCoin int64 `json:"out_coin"` BalanceAfterCoin int64 `json:"balance_after_coin"` } type SelfGameParticipantStat struct { GameID string `json:"game_id"` StakeCoin int64 `json:"stake_coin"` ParticipantType string `json:"participant_type"` Result string `json:"result"` RPSGesture string `json:"rps_gesture"` DicePoint int64 `json:"dice_point"` ParticipantCount int64 `json:"participant_count"` PayoutCoin int64 `json:"payout_coin"` NetWinCoin int64 `json:"net_win_coin"` WinRate float64 `json:"win_rate"` } type SelfGameUserRiskStat struct { GameID string `json:"game_id"` UserID int64 `json:"user_id"` MatchCount int64 `json:"match_count"` WinCount int64 `json:"win_count"` LoseCount int64 `json:"lose_count"` DrawCount int64 `json:"draw_count"` CancelCount int64 `json:"cancel_count"` StakeCoin int64 `json:"stake_coin"` PayoutCoin int64 `json:"payout_coin"` NetWinCoin int64 `json:"net_win_coin"` WinRate float64 `json:"win_rate"` CancelRate float64 `json:"cancel_rate"` } type SelfGameRetention struct { CohortUsers int64 `json:"cohort_users"` Day1Users int64 `json:"day1_users"` Day7Users int64 `json:"day7_users"` Day1Rate float64 `json:"day1_rate"` Day7Rate float64 `json:"day7_rate"` } type MetricDefinition struct { Metric string `json:"metric"` Definition string `json:"definition"` } type SelfGameFunnelStep struct { EventName string `json:"event_name"` EventCount int64 `json:"event_count"` UserCount int64 `json:"user_count"` } type SelfGameConversion struct { PageToMatchClickRate float64 `json:"page_to_match_click_rate"` MatchClickToSuccessRate float64 `json:"match_click_to_success_rate"` SuccessToSettlementRate float64 `json:"success_to_settlement_rate"` SettlementToPlayAgainRate float64 `json:"settlement_to_play_again_rate"` PageToSettlementRate float64 `json:"page_to_settlement_rate"` } func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) { app := appcode.Normalize(query.AppCode) if app == "" { app = "lalu" } statTZ := normalizeStatTZ(query.StatTZ) startDay := statDayIn(query.StartMS, statTZ) endDay := statDayIn(query.EndMS-1, statTZ) previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay) overview, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } previous, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.AppCode, overview.StatTZ, overview.StartMS, overview.EndMS, overview.CountryID = app, statTZ, query.StartMS, query.EndMS, query.CountryID previous.AppCode, previous.StatTZ, previous.CountryID = app, statTZ, query.CountryID // 新增付费用户来自日充值用户去重表和注册表的交集;只读聚合小表,避免 Databi 页面回扫钱包或订单明细。 if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } if robotGifts, err := r.queryRealRoomRobotGiftOverview(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } else { overview.RealRoomRobotNormalGiftCoin = robotGifts.RealRoomRobotNormalGiftCoin overview.RealRoomRobotLuckyGiftCoin = robotGifts.RealRoomRobotLuckyGiftCoin overview.RealRoomRobotSuperGiftCoin = robotGifts.RealRoomRobotSuperGiftCoin overview.RealRoomRobotTotalGiftCoin = robotGifts.RealRoomRobotTotalGiftCoin overview.RealRoomRobotGiftRoomCount = robotGifts.RealRoomRobotGiftRoomCount overview.RealRoomRobotAvgRoomGiftCoin = robotGifts.RealRoomRobotAvgRoomGiftCoin } // Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。 pools, err := r.queryLuckyGiftPools(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.LuckyGiftPools = pools overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(pools) if statTZ == StatTZUTC { // activity-service 的 lucky_draw_pool_day_stats 仍是 UTC 日表;只有 UTC 查询能拿它覆盖旧统计行,北京日必须使用本服务 stat_tz=Asia/Shanghai 的读模型。 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) overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(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, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.Retention = retention ranking, err := r.queryGameRanking(ctx, app, statTZ, 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, statTZ, startDay, endDay, query.RegionID) if err != nil { return Overview{}, err } overview.CountryBreakdown = breakdown dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID) if err != nil { return Overview{}, err } overview.DailyCountryBreakdown = dailyCountryBreakdown } seriesStartDay, seriesEndDay := startDay, endDay if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS { seriesStartDay = statDayIn(query.SeriesStartMS, statTZ) seriesEndDay = statDayIn(query.SeriesEndMS-1, statTZ) } // daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。 dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.DailySeries = dailySeries overview.ReportMetricSources = reportMetricSources() return overview, nil } func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOverviewQuery) (SelfGameOverview, error) { startMS, endMS := normalizeRange(query.StartMS, query.EndMS) app := appcode.Normalize(query.AppCode) if app == "" { app = "lalu" } statTZ := normalizeStatTZ(query.StatTZ) startDay := statDayIn(startMS, statTZ) endDay := statDayIn(endMS-1, statTZ) gameID := normalizeShortText(query.GameID) overview := SelfGameOverview{ AppCode: app, StatTZ: statTZ, StartMS: startMS, EndMS: endMS, GameID: gameID, CountryID: query.CountryID, RegionID: query.RegionID, } events, err := r.querySelfGameEvents(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.Events = events overview.Funnel, overview.Conversion = buildSelfGameFunnel(events) totals, err := r.querySelfGameMatchTotals(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.MatchTotals = totals stakes, err := r.querySelfGameStakeBreakdown(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.StakeBreakdown = stakes pools, err := r.querySelfGamePools(ctx, app, statTZ, startDay, endDay, gameID) if err != nil { return SelfGameOverview{}, err } overview.PoolStats = pools participants, err := r.querySelfGameParticipantDistribution(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.ParticipantDistribution = participants risk, err := r.querySelfGameUserRisk(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.UserRisk = risk retention, err := r.querySelfGameRetention(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.Retention = retention overview.MetricDefinitions = selfGameMetricDefinitions() return overview, nil } func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { args := []any{app, normalizeStatTZ(statTZ), 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(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0), COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0), COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),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_tz = ? 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.CoinSellerStockCoin, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.CoinTotal, &overview.ConsumedCoin, &overview.PlatformGrantCoin, &overview.ManualGrantCoin, &overview.SalaryUSDMinor, &overview.SalaryTransferCoin, &overview.MicOnlineMS, &overview.MicOnlineUsers, &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, statTZ, startDay, endDay string, countryID int64, regionID int64) (int64, error) { args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), 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.stat_tz = p.stat_tz AND r.user_id = p.user_id WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ? AND r.stat_tz = ? 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) queryRealRoomRobotGiftOverview(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { args := []any{app, normalizeStatTZ(statTZ), 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(normal_gift_coin),0), COALESCE(SUM(lucky_gift_coin),0), COALESCE(SUM(super_lucky_gift_coin),0), COUNT(*) FROM stat_real_room_robot_gift_day_rooms WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...) var overview Overview if err := row.Scan(&overview.RealRoomRobotNormalGiftCoin, &overview.RealRoomRobotLuckyGiftCoin, &overview.RealRoomRobotSuperGiftCoin, &overview.RealRoomRobotGiftRoomCount); err != nil { return Overview{}, err } overview.RealRoomRobotTotalGiftCoin = overview.RealRoomRobotNormalGiftCoin + overview.RealRoomRobotLuckyGiftCoin + overview.RealRoomRobotSuperGiftCoin overview.RealRoomRobotAvgRoomGiftCoin = div(overview.RealRoomRobotTotalGiftCoin, overview.RealRoomRobotGiftRoomCount) return overview, nil } func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) { args := []any{app, normalizeStatTZ(statTZ), 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(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0), COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0), COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),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_tz = ? 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.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &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, statTZ, startDay, endDay, countryID, regionID) if err != nil { return nil, err } for index := range out { out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay] } superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID) if err != nil { return nil, err } for index := range out { if super, ok := superByDay[out[index].StatDay]; ok { out[index].SuperLuckyGiftTurnover = super.Turnover out[index].SuperLuckyGiftPayout = super.Payout applyDailySeriesDerivedMetrics(&out[index]) } } if normalizeStatTZ(statTZ) == StatTZUTC { 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, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) { args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), 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.stat_tz = p.stat_tz AND r.user_id = p.user_id WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ? AND r.stat_tz = ? 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.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover) item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers) item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers) item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers) } 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.AvgMicOnlineMS = div(overview.MicOnlineMS, overview.MicOnlineUsers) overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover) overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover) overview.SuperLuckyGiftProfit = overview.SuperLuckyGiftTurnover - overview.SuperLuckyGiftPayout overview.SuperLuckyGiftProfitRate = ratio(overview.SuperLuckyGiftProfit, overview.SuperLuckyGiftTurnover) } 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, statTZ, startDay, endDay string, countryID int64, regionID int64) (Retention, error) { filter := "" args := []any{app, normalizeStatTZ(statTZ), 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.stat_tz=r.stat_tz 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.stat_tz=r.stat_tz 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.stat_tz=r.stat_tz 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.stat_tz = ? 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, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) { args := []any{app, normalizeStatTZ(statTZ), 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_tz = ? 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, statTZ, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) { args := []any{app, normalizeStatTZ(statTZ), 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(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0), COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0), COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),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_tz = ? 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.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &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) } if err := rows.Err(); err != nil { return nil, err } superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID) if err != nil { return nil, err } for index := range out { if super, ok := superByCountry[out[index].CountryID]; ok { out[index].SuperLuckyGiftTurnover = super.Turnover out[index].SuperLuckyGiftPayout = super.Payout applyCountryBreakdownDerivedMetrics(&out[index]) } } return out, nil } func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]DailyCountryStat, error) { args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" 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'), 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(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0), COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0), COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0), COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),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_tz = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY stat_day, country_id ORDER BY stat_day ASC, COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC LIMIT 5000`, args...) if err != nil { return nil, err } defer rows.Close() out := []DailyCountryStat{} for rows.Next() { var item DailyCountryStat if err := rows.Scan(&item.StatDay, &item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil { return nil, err } item.Label = item.StatDay applyCountryBreakdownDerivedMetrics(&item.CountryBreakdown) out = append(out, item) } if err := rows.Err(); err != nil { return nil, err } superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID) if err != nil { return nil, err } for index := range out { if super, ok := superByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok { out[index].SuperLuckyGiftTurnover = super.Turnover out[index].SuperLuckyGiftPayout = super.Payout applyCountryBreakdownDerivedMetrics(&out[index].CountryBreakdown) } } return out, nil } 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.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers) item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover) item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover) item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover) } func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) { args := []any{app, normalizeStatTZ(statTZ), 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_tz = ? 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 sumSuperLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) { var turnover, payout int64 for _, pool := range pools { if !isSuperLuckyPool(pool.PoolID) { continue } turnover += pool.TurnoverCoin payout += pool.PayoutCoin } return turnover, payout } func (r *Repository) querySuperLuckyGiftDaily(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]superLuckyAggregate, error) { args := []any{app, normalizeStatTZ(statTZ), 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(turnover_coin),0), COALESCE(SUM(payout_coin),0) FROM stat_lucky_gift_pool_day_country WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+` GROUP BY stat_day`, args...) if err != nil { return nil, err } defer rows.Close() out := map[string]superLuckyAggregate{} for rows.Next() { var day string var item superLuckyAggregate if err := rows.Scan(&day, &item.Turnover, &item.Payout); err != nil { return nil, err } out[day] = item } return out, rows.Err() } func (r *Repository) querySuperLuckyGiftCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[int64]superLuckyAggregate, error) { args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if regionID > 0 { filter = " AND region_id = ?" args = append(args, regionID) } rows, err := r.db.QueryContext(ctx, ` SELECT country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0) FROM stat_lucky_gift_pool_day_country WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+` GROUP BY country_id`, args...) if err != nil { return nil, err } defer rows.Close() out := map[int64]superLuckyAggregate{} for rows.Next() { var countryID int64 var item superLuckyAggregate if err := rows.Scan(&countryID, &item.Turnover, &item.Payout); err != nil { return nil, err } out[countryID] = item } return out, rows.Err() } func (r *Repository) querySuperLuckyGiftDailyCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[string]superLuckyAggregate, error) { args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" 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'), country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0) FROM stat_lucky_gift_pool_day_country WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+` GROUP BY stat_day, country_id`, args...) if err != nil { return nil, err } defer rows.Close() out := map[string]superLuckyAggregate{} for rows.Next() { var day string var countryID int64 var item superLuckyAggregate if err := rows.Scan(&day, &countryID, &item.Turnover, &item.Payout); err != nil { return nil, err } out[dailyCountryKey(day, countryID)] = item } return out, rows.Err() } func dailyCountryKey(day string, countryID int64) string { return day + ":" + strconv.FormatInt(countryID, 10) } func superLuckyPoolSQL() string { return "LOWER(pool_id) LIKE '%super_lucky%'" } func isSuperLuckyPool(poolID string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(poolID)), "super_lucky") } func reportMetricSources() []ReportMetricSource { return []ReportMetricSource{ {Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Status: "available", Source: "WalletCoinSellerStockPurchased / WalletRechargeRecorded", Definition: "币商进货 USDT 和未识别为 Google/MifaPay/USDT-TRC20/币商转账的充值美元最小单位。"}, {Key: "coin_seller_stock_coin", Label: "币商充值金币", Status: "available", Source: "WalletCoinSellerStockPurchased coin_amount", Definition: "币商库存进货金币数量,扣减类库存调整按负数冲减。"}, {Key: "coin_seller_transfer_coin", Label: "币商出货金币", Status: "available", Source: "WalletRechargeRecorded recharge_type=coin_seller_transfer|coin_seller", Definition: "币商向用户转入普通金币数量。"}, {Key: "google_recharge_usd_minor", Label: "谷歌充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=google|google_play", Definition: "Google Play 充值美元最小单位。"}, {Key: "mifapay_recharge_usd_minor", Label: "三方充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=mifapay|v5pay", Definition: "当前三方充值列使用 MifaPay/V5Pay 美元最小单位。"}, {Key: "game_turnover", Label: "游戏流水/利润率", Status: "available", Source: "GameOrderSettled", Definition: "debit/bet 为流水,credit/payout 为返奖,refund/reverse 为退款,利润率=(流水-返奖-退款)/流水。"}, {Key: "lucky_gift_turnover", Label: "幸运礼物流水/利润率", Status: "available", Source: "RoomGiftSent + WalletLuckyGiftRewardCredited", Definition: "带 pool_id 的送礼金币为流水,钱包返奖为返奖,利润率=(流水-返奖)/流水。"}, {Key: "super_lucky_gift_turnover", Label: "超级幸运礼物流水/利润率", Status: "available", Source: "stat_lucky_gift_pool_day_country", Definition: "pool_id 包含 super_lucky 的奖池流水和返奖派生,利润率=(流水-返奖)/流水。"}, {Key: "gift_coin_spent", Label: "礼物流水", Status: "available", Source: "RoomGiftSent", Definition: "非机器人真实送礼 coin_spent,历史事件无 coin_spent 时回退 gift_value。"}, {Key: "coin_total", Label: "金币数量(总和)", Status: "available", Source: "wallet_accounts / wallet_entries", Definition: "普通用户 COIN 钱包日终 available+frozen 余额总和;当天实时行按 WalletBalanceChanged 增量滚动,回填固化日终余额。"}, {Key: "consumed_coin", Label: "消耗金币", Status: "available", Source: "WalletBalanceChanged biz_type allowlist", Definition: "仅统计消费类普通 COIN 扣减:送礼、VIP、资源商城、红包创建、游戏下注、转盘抽奖和 CP 分手费。"}, {Key: "platform_grant_coin", Label: "平台发放金币", Status: "available", Source: "Wallet*RewardCredited", Definition: "系统奖励类 COIN 正向发放,包括任务、幸运礼物返奖、转盘、房间流水、邀请活动和代理开通奖励。"}, {Key: "manual_grant_coin", Label: "人工发放金币", Status: "available", Source: "WalletBalanceChanged biz_type=manual_credit", Definition: "后台人工调账向普通 COIN 钱包正向发放的金币。"}, {Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "host_salary_settlement_records / WalletBalanceChanged", Definition: "成功结算的主播工资和代理工资 USD minor 合计,不包含 residual、BD/Admin 工资。"}, {Key: "salary_transfer_coin", Label: "工资兑换金币", Status: "available", Source: "WalletBalanceChanged biz_type=salary_transfer_to_coin_seller / wallet_transactions", Definition: "用户工资钱包转给币商时,币商 COIN_SELLER_COIN 入账的金币数量;国家/区域按发起转账用户归属。"}, {Key: "avg_mic_online_ms", Label: "平均麦上时间", Status: "available", Source: "user_mic_daily_stats", Definition: "麦上总时长 / 当日麦上用户数,只统计 mic_online_ms > 0 的用户。"}, {Key: "mic_online_ms", Label: "麦上总时长", Status: "available", Source: "user_mic_daily_stats.mic_online_ms", Definition: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"}, } } func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) { filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, event_name, COALESCE(SUM(event_count),0), COALESCE(SUM(user_count),0), COALESCE(SUM(success_count),0), COALESCE(SUM(error_count),0), COALESCE(SUM(duration_ms_sum),0) FROM stat_self_game_h5_event_day WHERE `+filter+` GROUP BY game_id, event_name ORDER BY game_id, event_name`, args...) if err != nil { return nil, err } defer rows.Close() out := []SelfGameEventStat{} for rows.Next() { var item SelfGameEventStat if err := rows.Scan(&item.GameID, &item.EventName, &item.EventCount, &item.UserCount, &item.SuccessCount, &item.ErrorCount, &item.DurationMSSum); err != nil { return nil, err } item.AvgDurationMS = div(item.DurationMSSum, item.EventCount) out = append(out, item) } return out, rows.Err() } func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) { filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) row := r.db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_matches),0), COALESCE(SUM(robot_matches),0), COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0), COALESCE(SUM(winner_count),0), COALESCE(SUM(loser_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(user_stake_coin),0), COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0), COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0), COALESCE(SUM(forced_player_lose_matches),0), COALESCE(SUM(wait_ms_sum),0), COALESCE(SUM(duration_ms_sum),0) FROM stat_self_game_match_day WHERE `+filter, args...) var totals SelfGameMatchTotals if err := row.Scan(&totals.CreatedMatches, &totals.MatchedMatches, &totals.SettledMatches, &totals.CanceledMatches, &totals.FailedMatches, &totals.HumanMatches, &totals.RobotMatches, &totals.HumanParticipants, &totals.RobotParticipants, &totals.WinnerCount, &totals.LoserCount, &totals.DrawCount, &totals.UserStakeCoin, &totals.RobotStakeCoin, &totals.PayoutCoin, &totals.RefundCoin, &totals.PlatformProfitCoin, &totals.PoolDeltaCoin, &totals.ForcedPlayerLoseMatches, &totals.WaitMSSum, &totals.DurationMSSum); err != nil { return SelfGameMatchTotals{}, err } applySelfGameMatchDerived(&totals) return totals, nil } func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) { filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, stake_coin, COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0), COALESCE(SUM(user_stake_coin),0), COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0), COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0) FROM stat_self_game_match_day WHERE `+filter+` GROUP BY game_id, stake_coin ORDER BY game_id, stake_coin`, args...) if err != nil { return nil, err } defer rows.Close() out := []SelfGameStakeStat{} for rows.Next() { var item SelfGameStakeStat if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil { return nil, err } // platform_profit_coin 是 game-service 在结算时按真实用户侧快照写入的 user_stake - payout - refund; // 机器人下注只是奖池和对手侧流水,不能进入抽水率分母,否则人机局会把平台抽水率稀释一半。 item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin) out = append(out, item) } return out, rows.Err() } func (r *Repository) querySelfGamePools(ctx context.Context, app, statTZ, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) { statTZ = normalizeStatTZ(statTZ) filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?" args := []any{app, statTZ, startDay, endDay} if gameID != "" { filter += " AND game_id = ?" args = append(args, gameID) } latestArgs := append([]any{}, args...) latestArgs = append(latestArgs, app, statTZ) latestArgs = append(latestArgs, args...) rows, err := r.db.QueryContext(ctx, ` SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count, agg.in_coin, agg.out_coin, COALESCE(latest.balance_after_coin,0) FROM ( SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0) AS transaction_count, COALESCE(SUM(in_coin),0) AS in_coin, COALESCE(SUM(out_coin),0) AS out_coin FROM stat_self_game_pool_day WHERE `+filter+` GROUP BY game_id, direction, reason ) agg LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.stat_tz = ? AND latest.game_id = agg.game_id AND latest.direction = agg.direction AND latest.reason = agg.reason AND latest.stat_day = ( SELECT p2.stat_day FROM stat_self_game_pool_day p2 WHERE `+filter+` AND p2.game_id = agg.game_id AND p2.direction = agg.direction AND p2.reason = agg.reason ORDER BY p2.stat_day DESC, p2.updated_at_ms DESC LIMIT 1 ) ORDER BY agg.game_id, agg.direction, agg.reason`, latestArgs...) if err != nil { return nil, err } defer rows.Close() out := []SelfGamePoolStat{} for rows.Next() { var item SelfGamePoolStat if err := rows.Scan(&item.GameID, &item.Direction, &item.Reason, &item.TransactionCount, &item.InCoin, &item.OutCoin, &item.BalanceAfterCoin); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) { filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, stake_coin, participant_type, result, rps_gesture, dice_point, COALESCE(SUM(participant_count),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0) FROM stat_self_game_participant_day WHERE `+filter+` GROUP BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point ORDER BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point`, args...) if err != nil { return nil, err } defer rows.Close() items := []SelfGameParticipantStat{} totals := map[string]int64{} for rows.Next() { var item SelfGameParticipantStat if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil { return nil, err } key := selfGameParticipantRateKey(item) totals[key] += item.ParticipantCount items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } for index := range items { key := selfGameParticipantRateKey(items[index]) if items[index].Result == "win" { items[index].WinRate = ratio(items[index].ParticipantCount, totals[key]) } } return items, nil } func selfGameParticipantRateKey(item SelfGameParticipantStat) string { playKey := item.RPSGesture if item.DicePoint > 0 { playKey = strconv.FormatInt(item.DicePoint, 10) } // 玩法分布查询本身按 stake_coin 分桶返回,胜率分母也必须保留同一档位; // 否则同一点数或同一手势在不同下注档位的结果会互相稀释,前端每一行的胜率就不是该行口径。 return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey } func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) { filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, user_id, COALESCE(SUM(match_count),0), COALESCE(SUM(win_count),0), COALESCE(SUM(lose_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(cancel_count),0), COALESCE(SUM(stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0) FROM stat_self_game_user_day WHERE `+filter+` GROUP BY game_id, user_id ORDER BY SUM(match_count) DESC, SUM(net_win_coin) DESC, SUM(cancel_count) DESC LIMIT 100`, args...) if err != nil { return nil, err } defer rows.Close() out := []SelfGameUserRiskStat{} for rows.Next() { var item SelfGameUserRiskStat if err := rows.Scan(&item.GameID, &item.UserID, &item.MatchCount, &item.WinCount, &item.LoseCount, &item.DrawCount, &item.CancelCount, &item.StakeCoin, &item.PayoutCoin, &item.NetWinCoin); err != nil { return nil, err } item.WinRate = ratio(item.WinCount, item.MatchCount) item.CancelRate = ratio(item.CancelCount, item.MatchCount+item.CancelCount) out = append(out, item) } return out, rows.Err() } func (r *Repository) querySelfGameRetention(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) { cohortFilter, cohortArgs := selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) returnFilter, returnArgs := selfGameUserDayFilter(app, statTZ, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID) args := make([]any, 0, len(cohortArgs)+len(returnArgs)*2) args = append(args, cohortArgs...) args = append(args, returnArgs...) args = append(args, returnArgs...) row := r.db.QueryRowContext(ctx, ` SELECT COUNT(1), COALESCE(SUM(CASE WHEN d1.user_id IS NULL THEN 0 ELSE 1 END), 0), COALESCE(SUM(CASE WHEN d7.user_id IS NULL THEN 0 ELSE 1 END), 0) FROM ( SELECT DISTINCT stat_day, user_id FROM stat_self_game_user_day WHERE `+cohortFilter+` ) cohort LEFT JOIN ( SELECT DISTINCT stat_day, user_id FROM stat_self_game_user_day WHERE `+returnFilter+` ) d1 ON d1.user_id = cohort.user_id AND d1.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 1 DAY) LEFT JOIN ( SELECT DISTINCT stat_day, user_id FROM stat_self_game_user_day WHERE `+returnFilter+` ) d7 ON d7.user_id = cohort.user_id AND d7.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 7 DAY)`, args...) var retention SelfGameRetention if err := row.Scan(&retention.CohortUsers, &retention.Day1Users, &retention.Day7Users); err != nil { return SelfGameRetention{}, err } retention.Day1Rate = ratio(retention.Day1Users, retention.CohortUsers) retention.Day7Rate = ratio(retention.Day7Users, retention.CohortUsers) return retention, nil } func selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) { filter := "app_code = ? AND stat_tz = ? AND match_count > 0" args := []any{app, normalizeStatTZ(statTZ)} if startDay != "" && endDay != "" { filter += " AND stat_day BETWEEN ? AND ?" args = append(args, startDay, endDay) } if gameID != "" { filter += " AND game_id = ?" args = append(args, gameID) } if countryID > 0 { filter += " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } return filter, args } func selfGameFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) { filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?" args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} if gameID != "" { filter += " AND game_id = ?" args = append(args, gameID) } if countryID > 0 { filter += " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } return filter, args } func applySelfGameMatchDerived(totals *SelfGameMatchTotals) { if totals == nil { return } totals.AvgWaitMS = div(totals.WaitMSSum, totals.MatchedMatches+totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches) totals.AvgDurationMS = div(totals.DurationMSSum, totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches) totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches) totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches) totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches) // platform_profit_coin 与真实用户下注同口径,分母只使用 user_stake_coin; // robot_stake_coin 是机器人对手侧流水,展示总下注时可以合并,但不能用于真实用户侧抽水率。 totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin) } func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) { order := []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"} byEvent := map[string]SelfGameFunnelStep{} for _, event := range events { step := byEvent[event.EventName] step.EventName = event.EventName step.EventCount += event.EventCount step.UserCount += event.UserCount byEvent[event.EventName] = step } funnel := make([]SelfGameFunnelStep, 0, len(order)) for _, name := range order { funnel = append(funnel, byEvent[name]) funnel[len(funnel)-1].EventName = name } return funnel, SelfGameConversion{ PageToMatchClickRate: ratio(byEvent["match_click"].UserCount, byEvent["page_open"].UserCount), MatchClickToSuccessRate: ratio(byEvent["match_success"].UserCount, byEvent["match_click"].UserCount), SuccessToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["match_success"].UserCount), SettlementToPlayAgainRate: ratio(byEvent["play_again"].UserCount, byEvent["settlement_show"].UserCount), PageToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["page_open"].UserCount), } } 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 addStatDays(day string, offset int) string { parsed, err := time.Parse("2006-01-02", day) if err != nil { return day } return parsed.AddDate(0, 0, offset).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) } func selfGameMetricDefinitions() []MetricDefinition { return []MetricDefinition{ {Metric: "game_pv", Definition: "H5 page_open event_count;同一用户多次打开会重复计数。"}, {Metric: "game_uv", Definition: "H5 page_open user_count;同一 UTC 日、同一 game_id、同一用户只计一次。"}, {Metric: "match_click_users", Definition: "H5 match_click user_count;用户点击匹配按钮后上报。"}, {Metric: "match_success_users", Definition: "H5 match_success user_count;匹配接口成功返回有效 match_id 后上报。"}, {Metric: "settlement_users", Definition: "H5 settlement_show user_count;结算页展示时上报。"}, {Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户;只统计 stat_self_game_user_day.match_count > 0。"}, {Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"}, {Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"}, {Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin - payout_coin - refund_coin;抽水率分母同样只用 user_stake_coin。"}, {Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合;in_coin/out_coin 是入池/出池金额,balance_after_coin 是查询窗口内同方向同原因的最新余额快照。"}, } }