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"` RegisteredUsers int64 `json:"registered_users"` Registrations int64 `json:"registrations"` ActiveUsers int64 `json:"active_users"` PaidUsers int64 `json:"paid_users"` RechargeUsers int64 `json:"recharge_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"` OutputCoin int64 `json:"output_coin"` ConsumeOutputRatio float64 `json:"consume_output_ratio"` ConsumeOutputDelta int64 `json:"consume_output_delta"` 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"` PayerRate float64 `json:"payer_rate"` PaidConversionRate float64 `json:"paid_conversion_rate"` RechargeConversionRate float64 `json:"recharge_conversion_rate"` 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"` PayerRateDeltaPP float64 `json:"payer_rate_delta_pp"` PaidConversionRateDeltaPP float64 `json:"paid_conversion_rate_delta_pp"` RechargeConversionRateDeltaPP float64 `json:"recharge_conversion_rate_delta_pp"` 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"` RegisteredUsers int64 `json:"registered_users"` Registrations int64 `json:"registrations"` 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"` OutputCoin int64 `json:"output_coin"` ConsumeOutputRatio float64 `json:"consume_output_ratio"` ConsumeOutputDelta int64 `json:"consume_output_delta"` 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"` PayerRate float64 `json:"payer_rate"` PaidConversionRate float64 `json:"paid_conversion_rate"` RechargeConversionRate float64 `json:"recharge_conversion_rate"` D1RetentionUsers *int64 `json:"d1_retention_users"` D1RetentionBaseUsers *int64 `json:"d1_retention_base_users"` D1RetentionRate *float64 `json:"d1_retention_rate"` D7RetentionUsers *int64 `json:"d7_retention_users"` D7RetentionBaseUsers *int64 `json:"d7_retention_base_users"` D7RetentionRate *float64 `json:"d7_retention_rate"` D30RetentionUsers *int64 `json:"d30_retention_users"` D30RetentionBaseUsers *int64 `json:"d30_retention_base_users"` D30RetentionRate *float64 `json:"d30_retention_rate"` } type Retention struct { RegisteredUsers int64 `json:"registered_users"` Day1Users *int64 `json:"day1_users"` Day1BaseUsers *int64 `json:"day1_base_users"` Day1Rate *float64 `json:"day1_rate"` Day7Users *int64 `json:"day7_users"` Day7BaseUsers *int64 `json:"day7_base_users"` Day7Rate *float64 `json:"day7_rate"` Day30Users *int64 `json:"day30_users"` Day30BaseUsers *int64 `json:"day30_base_users"` 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"` RegisteredUsers int64 `json:"registered_users"` Registrations int64 `json:"registrations"` ActiveUsers int64 `json:"active_users"` PaidUsers int64 `json:"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"` OutputCoin int64 `json:"output_coin"` ConsumeOutputRatio float64 `json:"consume_output_ratio"` ConsumeOutputDelta int64 `json:"consume_output_delta"` 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"` PayerRate float64 `json:"payer_rate"` PaidConversionRate float64 `json:"paid_conversion_rate"` RechargeConversionRate float64 `json:"recharge_conversion_rate"` D1RetentionUsers *int64 `json:"d1_retention_users"` D1RetentionBaseUsers *int64 `json:"d1_retention_base_users"` D1RetentionRate *float64 `json:"d1_retention_rate"` D7RetentionUsers *int64 `json:"d7_retention_users"` D7RetentionBaseUsers *int64 `json:"d7_retention_base_users"` D7RetentionRate *float64 `json:"d7_retention_rate"` D30RetentionUsers *int64 `json:"d30_retention_users"` D30RetentionBaseUsers *int64 `json:"d30_retention_base_users"` D30RetentionRate *float64 `json:"d30_retention_rate"` } 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 PlatformGrantCoinQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 StatDay string CountryID int64 RegionID int64 UserID int64 EventType string Page int PageSize int } type AppTrackingFunnelQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 CountryID int64 RegionID int64 RegionIDs []int64 } type AppTrackingFunnel 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"` RegionID int64 `json:"region_id"` RegionIDs []int64 `json:"region_ids,omitempty"` Steps []AppTrackingFunnelStep `json:"steps"` Cohorts []AppTrackingFunnelCohort `json:"cohorts"` Totals AppTrackingFunnelTotals `json:"totals"` } type AppTrackingFunnelStep struct { Key string `json:"key"` EventName string `json:"event_name"` Label string `json:"label"` EventCount int64 `json:"event_count"` UserCount int64 `json:"user_count"` DeviceCount int64 `json:"device_count"` OverallConversionRate float64 `json:"overall_conversion_rate"` PreviousConversionRate float64 `json:"previous_conversion_rate"` DropoffUsers int64 `json:"dropoff_users"` IsFailure bool `json:"is_failure,omitempty"` } type AppTrackingFunnelCohort struct { Dimension string `json:"dimension"` DimensionLabel string `json:"dimension_label"` Value string `json:"value"` Label string `json:"label"` BaseUsers int64 `json:"base_users"` D1RetentionUsers int64 `json:"d1_retention_users"` D1RetentionRate float64 `json:"d1_retention_rate"` Steps []AppTrackingCohortStepCount `json:"steps"` } type AppTrackingCohortStepCount struct { EventName string `json:"event_name"` Label string `json:"label"` UserCount int64 `json:"user_count"` } type AppTrackingFunnelTotals struct { LoginStartUsers int64 `json:"login_start_users"` LoginSuccessUsers int64 `json:"login_success_users"` ProfileCompleteUsers int64 `json:"profile_complete_users"` RoomImpressionUsers int64 `json:"room_impression_users"` RoomClickUsers int64 `json:"room_click_users"` RoomJoinSuccessUsers int64 `json:"room_join_success_users"` RoomJoinFailUsers int64 `json:"room_join_fail_users"` Stay30sUsers int64 `json:"stay_30s_users"` Stay3mUsers int64 `json:"stay_3m_users"` Stay10mUsers int64 `json:"stay_10m_users"` SendMessageUsers int64 `json:"send_message_users"` FollowHostUsers int64 `json:"follow_host_users"` AddFriendUsers int64 `json:"add_friend_users"` CheckInUsers int64 `json:"check_in_users"` PushPermissionUsers int64 `json:"push_permission_users"` D1RetentionBaseUsers int64 `json:"d1_retention_base_users"` D1RetentionUsers int64 `json:"d1_retention_users"` D1RetentionRate float64 `json:"d1_retention_rate"` } type PlatformGrantCoinUser struct { UserID int64 `json:"user_id"` CountryID int64 `json:"country_id"` RegionID int64 `json:"region_id"` TotalCoin int64 `json:"total_coin"` RecordCount int64 `json:"record_count"` FirstGrantedAtMS int64 `json:"first_granted_at_ms"` LastGrantedAtMS int64 `json:"last_granted_at_ms"` } type PlatformGrantCoinRecord struct { EventID string `json:"event_id"` EventType string `json:"event_type"` UserID int64 `json:"user_id"` CountryID int64 `json:"country_id"` RegionID int64 `json:"region_id"` Amount int64 `json:"amount"` OccurredAtMS int64 `json:"occurred_at_ms"` } type PlatformGrantCoinUserPage struct { Items []PlatformGrantCoinUser `json:"items"` Page int `json:"page"` PageSize int `json:"pageSize"` Total int64 `json:"total"` } type PlatformGrantCoinRecordPage struct { Items []PlatformGrantCoinRecord `json:"items"` Page int `json:"page"` PageSize int `json:"pageSize"` Total int64 `json:"total"` } 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 { oldTurnover, oldPayout := overview.LuckyGiftTurnover, overview.LuckyGiftPayout overview.LuckyGiftPools = activityPools overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools) overview.ConsumedCoin = replaceEmbeddedFlow(overview.ConsumedCoin, oldTurnover, overview.LuckyGiftTurnover) overview.OutputCoin = replaceEmbeddedFlow(overview.OutputCoin, oldPayout, overview.LuckyGiftPayout) 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 { oldTurnover, oldPayout := previous.LuckyGiftTurnover, previous.LuckyGiftPayout previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools) previous.ConsumedCoin = replaceEmbeddedFlow(previous.ConsumedCoin, oldTurnover, previous.LuckyGiftTurnover) previous.OutputCoin = replaceEmbeddedFlow(previous.OutputCoin, oldPayout, previous.LuckyGiftPayout) } } applyOverviewDerivedMetrics(&overview) applyOverviewDerivedMetrics(&previous) overview.applyDeltaRates(previous) retentionCube, err := r.queryRetentionCube(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.Retention = retentionCube.Total.toRetention() 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, retentionCube.ByCountry) if err != nil { return Overview{}, err } overview.CountryBreakdown = breakdown dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByDayCountry) 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) } seriesRetentionByDay := retentionCube.ByDay if seriesStartDay != startDay || seriesEndDay != endDay { seriesRetentionCube, err := r.queryRetentionCube(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } seriesRetentionByDay = seriesRetentionCube.ByDay } // daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。 dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID, seriesRetentionByDay) 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) QueryAppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (AppTrackingFunnel, error) { startMS, endMS := normalizeRange(query.StartMS, query.EndMS) app := appcode.Normalize(query.AppCode) if app == "" { app = "lalu" } statTZ := normalizeStatTZ(query.StatTZ) out := AppTrackingFunnel{ AppCode: app, StatTZ: statTZ, StartMS: startMS, EndMS: endMS, CountryID: query.CountryID, RegionID: query.RegionID, RegionIDs: normalizeTrackingRegionIDs(query.RegionIDs), Steps: []AppTrackingFunnelStep{}, Cohorts: []AppTrackingFunnelCohort{}, } steps, err := r.queryAppTrackingFunnelSteps(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs) if err != nil { return AppTrackingFunnel{}, err } out.Steps = buildAppTrackingFunnelSteps(steps) out.Totals = appTrackingTotals(out.Steps) d1Base, d1Users, err := r.queryAppTrackingD1Total(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs) if err != nil { return AppTrackingFunnel{}, err } out.Totals.D1RetentionBaseUsers = d1Base out.Totals.D1RetentionUsers = d1Users out.Totals.D1RetentionRate = ratio(d1Users, d1Base) cohorts, err := r.queryAppTrackingD1Cohorts(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs) if err != nil { return AppTrackingFunnel{}, err } out.Cohorts = cohorts return out, nil } func (r *Repository) QueryPlatformGrantCoinUsers(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinUserPage, error) { app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query) filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, 0, query.EventType) out := PlatformGrantCoinUserPage{Items: []PlatformGrantCoinUser{}, Page: page, PageSize: pageSize} // 明细下钻只读 statistics-service 自己维护的事件投影;钱包库是写账事实源,不能在报表查询时跨库扫账务流水。 countRow := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM ( SELECT 1 FROM stat_platform_grant_coin_events WHERE `+filter+` GROUP BY user_id, country_id, region_id ) grouped`, args...) if err := countRow.Scan(&out.Total); err != nil { return PlatformGrantCoinUserPage{}, err } rowsArgs := append(append([]any{}, args...), pageSize, offset) rows, err := r.db.QueryContext(ctx, ` SELECT user_id, country_id, region_id, COALESCE(SUM(amount),0), COUNT(*), COALESCE(MIN(occurred_at_ms),0), COALESCE(MAX(occurred_at_ms),0) FROM stat_platform_grant_coin_events WHERE `+filter+` GROUP BY user_id, country_id, region_id ORDER BY COALESCE(SUM(amount),0) DESC, COALESCE(MAX(occurred_at_ms),0) DESC, user_id DESC LIMIT ? OFFSET ?`, rowsArgs...) if err != nil { return PlatformGrantCoinUserPage{}, err } defer rows.Close() for rows.Next() { var item PlatformGrantCoinUser if err := rows.Scan(&item.UserID, &item.CountryID, &item.RegionID, &item.TotalCoin, &item.RecordCount, &item.FirstGrantedAtMS, &item.LastGrantedAtMS); err != nil { return PlatformGrantCoinUserPage{}, err } out.Items = append(out.Items, item) } if err := rows.Err(); err != nil { return PlatformGrantCoinUserPage{}, err } return out, nil } func (r *Repository) QueryPlatformGrantCoinRecords(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinRecordPage, error) { app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query) filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, query.UserID, query.EventType) out := PlatformGrantCoinRecordPage{Items: []PlatformGrantCoinRecord{}, Page: page, PageSize: pageSize} // 来源页按单个用户分页读取事件事实,排序使用事件发生时间,避免同一用户大量奖励一次性塞给前端。 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM stat_platform_grant_coin_events WHERE `+filter, args...).Scan(&out.Total); err != nil { return PlatformGrantCoinRecordPage{}, err } rowsArgs := append(append([]any{}, args...), pageSize, offset) rows, err := r.db.QueryContext(ctx, ` SELECT event_id, event_type, user_id, country_id, region_id, amount, occurred_at_ms FROM stat_platform_grant_coin_events WHERE `+filter+` ORDER BY occurred_at_ms DESC, event_id DESC LIMIT ? OFFSET ?`, rowsArgs...) if err != nil { return PlatformGrantCoinRecordPage{}, err } defer rows.Close() for rows.Next() { var item PlatformGrantCoinRecord if err := rows.Scan(&item.EventID, &item.EventType, &item.UserID, &item.CountryID, &item.RegionID, &item.Amount, &item.OccurredAtMS); err != nil { return PlatformGrantCoinRecordPage{}, err } out.Items = append(out.Items, item) } if err := rows.Err(); err != nil { return PlatformGrantCoinRecordPage{}, err } return out, nil } func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { args := []any{endDay, endDay, 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(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0), COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),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.OutputCoin, &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, retentionByDay map[string]retentionCount) ([]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(output_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.OutputCoin, &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] } for index := range out { if retention, ok := retentionByDay[out[index].StatDay]; ok { out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base) out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base) out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base) } } 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() } type retentionCount struct { RegisteredUsers int64 D1Users int64 D1Base int64 D7Users int64 D7Base int64 D30Users int64 D30Base int64 } func (count *retentionCount) add(next retentionCount) { count.RegisteredUsers += next.RegisteredUsers count.D1Users += next.D1Users count.D1Base += next.D1Base count.D7Users += next.D7Users count.D7Base += next.D7Base count.D30Users += next.D30Users count.D30Base += next.D30Base } func (count retentionCount) toRetention() Retention { out := Retention{RegisteredUsers: count.RegisteredUsers} out.Day1Users, out.Day1BaseUsers, out.Day1Rate = retentionPointers(count.D1Users, count.D1Base) out.Day7Users, out.Day7BaseUsers, out.Day7Rate = retentionPointers(count.D7Users, count.D7Base) out.Day30Users, out.Day30BaseUsers, out.Day30Rate = retentionPointers(count.D30Users, count.D30Base) return out } type retentionCube struct { Total retentionCount ByDay map[string]retentionCount ByCountry map[int64]retentionCount ByDayCountry map[string]retentionCount } func newRetentionCube() retentionCube { return retentionCube{ ByDay: map[string]retentionCount{}, ByCountry: map[int64]retentionCount{}, ByDayCountry: map[string]retentionCount{}, } } func (cube *retentionCube) add(day string, countryID int64, count retentionCount) { cube.Total.add(count) dayCount := cube.ByDay[day] dayCount.add(count) cube.ByDay[day] = dayCount countryCount := cube.ByCountry[countryID] countryCount.add(count) cube.ByCountry[countryID] = countryCount dayCountryKey := dailyCountryKey(day, countryID) dayCountryCount := cube.ByDayCountry[dayCountryKey] dayCountryCount.add(count) cube.ByDayCountry[dayCountryKey] = dayCountryCount } func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (retentionCube, error) { tz := normalizeStatTZ(statTZ) today := statDayIn(time.Now().UnixMilli(), tz) args := []any{ today, today, today, today, today, today, app, tz, startDay, endDay, } filter := "" if countryID > 0 { filter += " AND r.country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND r.region_id = ?" args = append(args, regionID) } rows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(r.registered_day, '%Y-%m-%d'), r.country_id, COUNT(*), COALESCE(SUM(CASE WHEN d1.user_id IS NOT NULL THEN 1 ELSE 0 END),0), COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? THEN 1 ELSE 0 END),0), COALESCE(SUM(CASE WHEN d7.user_id IS NOT NULL THEN 1 ELSE 0 END),0), COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? THEN 1 ELSE 0 END),0), COALESCE(SUM(CASE WHEN d30.user_id IS NOT NULL THEN 1 ELSE 0 END),0), COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? THEN 1 ELSE 0 END),0) FROM stat_user_registration r LEFT JOIN stat_user_day_activity d1 ON DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? AND d1.app_code = r.app_code AND d1.stat_tz = r.stat_tz AND d1.stat_day = DATE_ADD(r.registered_day, INTERVAL 1 DAY) AND d1.user_id = r.user_id LEFT JOIN stat_user_day_activity d7 ON DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? AND d7.app_code = r.app_code AND d7.stat_tz = r.stat_tz AND d7.stat_day = DATE_ADD(r.registered_day, INTERVAL 7 DAY) AND d7.user_id = r.user_id LEFT JOIN stat_user_day_activity d30 ON DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? AND d30.app_code = r.app_code AND d30.stat_tz = r.stat_tz AND d30.stat_day = DATE_ADD(r.registered_day, INTERVAL 30 DAY) AND d30.user_id = r.user_id WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+` GROUP BY r.registered_day, r.country_id`, args...) if err != nil { return retentionCube{}, err } defer rows.Close() out := newRetentionCube() for rows.Next() { var day string var countryID int64 var count retentionCount if err := rows.Scan(&day, &countryID, &count.RegisteredUsers, &count.D1Users, &count.D1Base, &count.D7Users, &count.D7Base, &count.D30Users, &count.D30Base); err != nil { return retentionCube{}, err } out.add(day, countryID, count) } if err := rows.Err(); err != nil { return retentionCube{}, err } return out, nil } func applyDailySeriesDerivedMetrics(item *DailySeriesItem) { if item == nil { return } channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor if channelRechargeUSDMinor > item.RechargeUSDMinor { item.RechargeUSDMinor = channelRechargeUSDMinor } // Databi 同时展示“注册”和“新增”,当前统计服务存储列名是 new_users;这里输出两个历史别名,避免前端为同一口径写 App 专属兼容。 item.RegisteredUsers = item.NewUsers item.Registrations = item.NewUsers // 现有 Lalu 聚合表的 paid_users 来自充值用户去重表;充值用户先按同一事实输出,后续如果产品拆出其他付费行为,再新增独立事实列。 item.RechargeUsers = item.PaidUsers item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout item.ConsumeOutputDelta = item.ConsumedCoin - item.OutputCoin item.ConsumeOutputRatio = ratio(item.OutputCoin, item.ConsumedCoin) 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) item.PayerRate = ratio(item.PaidUsers, item.ActiveUsers) item.PaidConversionRate = item.PayerRate item.RechargeConversionRate = ratio(item.RechargeUsers, item.ActiveUsers) } 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 } // 统计存储层保留最小事实列,HTTP 读模型补齐 Databi 历史字段名,保证 Lalu/Yumi/Aslan 三类 App 共用一套前端解析。 overview.RegisteredUsers = overview.NewUsers overview.Registrations = overview.NewUsers overview.RechargeUsers = overview.PaidUsers overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover) overview.ConsumeOutputDelta = overview.ConsumedCoin - overview.OutputCoin overview.ConsumeOutputRatio = ratio(overview.OutputCoin, overview.ConsumedCoin) 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.PayerRate = ratio(overview.PaidUsers, overview.ActiveUsers) overview.PaidConversionRate = overview.PayerRate overview.RechargeConversionRate = ratio(overview.RechargeUsers, overview.ActiveUsers) 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.PayerRateDeltaPP = percentagePointDelta(overview.PayerRate, previous.PayerRate) overview.PaidConversionRateDeltaPP = percentagePointDelta(overview.PaidConversionRate, previous.PaidConversionRate) overview.RechargeConversionRateDeltaPP = percentagePointDelta(overview.RechargeConversionRate, previous.RechargeConversionRate) 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) 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, retentionByCountry map[int64]retentionCount) ([]CountryBreakdown, error) { args := []any{endDay, endDay, 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(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0), COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),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.OutputCoin, &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 } for index := range out { if retention, ok := retentionByCountry[out[index].CountryID]; ok { out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base) out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base) out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base) } } 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, retentionByDayCountry map[string]retentionCount) ([]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(output_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.OutputCoin, &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 } for index := range out { if retention, ok := retentionByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok { out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base) out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base) out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base) } } 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.RegisteredUsers = item.NewUsers item.Registrations = item.NewUsers item.RechargeUsers = item.PaidUsers item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover) item.ConsumeOutputDelta = item.ConsumedCoin - item.OutputCoin item.ConsumeOutputRatio = ratio(item.OutputCoin, item.ConsumedCoin) item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers) item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers) item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers) item.PayerRate = ratio(item.PaidUsers, item.ActiveUsers) item.PaidConversionRate = item.PayerRate item.RechargeConversionRate = ratio(item.RechargeUsers, item.ActiveUsers) 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 } oldTurnover, oldPayout := items[index].LuckyGiftTurnover, items[index].LuckyGiftPayout items[index].LuckyGiftTurnover = activity.TurnoverCoin items[index].LuckyGiftPayout = activity.PayoutCoin items[index].ConsumedCoin = replaceEmbeddedFlow(items[index].ConsumedCoin, oldTurnover, activity.TurnoverCoin) items[index].OutputCoin = replaceEmbeddedFlow(items[index].OutputCoin, oldPayout, 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 replaceEmbeddedFlow(total, oldValue, newValue int64) int64 { if total >= oldValue { return total - oldValue + newValue } return total + newValue } 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: "new_users", Label: "注册", Status: "available", Source: "UserRegistered", Definition: "统计窗口内完成注册的用户数;registered_users 和 registrations 是同一口径别名。"}, {Key: "active_users", Label: "活跃用户", Status: "available", Source: "RoomUserJoined / GameOrderSettled", Definition: "统计服务按用户维度去重后的日活,进房和游戏结算都会沉淀为本地活跃事实。"}, {Key: "paid_users", Label: "付费用户", Status: "available", Source: "stat_recharge_day_payers", Definition: "统计窗口内发生充值的去重用户数;当前 Lalu 付费用户等同充值用户。"}, {Key: "recharge_users", Label: "充值用户", Status: "available", Source: "stat_recharge_day_payers", Definition: "统计窗口内发生充值的去重用户数;当前 Lalu 由 paid_users 派生。"}, {Key: "recharge_usd_minor", Label: "总充值", Status: "available", Source: "WalletRechargeRecorded", Definition: "所有充值渠道的美元最小单位总额,历史行缺总额时用可拆分渠道合计兜底。"}, {Key: "new_user_recharge_usd_minor", Label: "新用户充值", Status: "available", Source: "WalletRechargeRecorded + UserRegistered", Definition: "统计窗口内注册用户在同窗口产生的充值美元最小单位。"}, {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_entries / WalletBalanceChanged", Definition: "普通用户 COIN 钱包 available+frozen 余额总和;多日总览只取结束日余额快照,日序列展示每日日终快照,当天按余额变动事件滚动更新。"}, {Key: "consumed_coin", Label: "消耗金币", Status: "available", Source: "GameOrderSettled / RoomGiftSent / WalletBalanceChanged", Definition: "游戏投入 + 普通礼物投入 + lucky 礼物投入 + 转盘投入 + 发红包;礼物和游戏分别以 room/game owner 事实为准,钱包只补转盘、红包和私聊礼物扣费。"}, {Key: "output_coin", Label: "产出金币", Status: "available", Source: "GameOrderSettled / WalletGiftIncomeCredited / WalletLuckyGiftRewardCredited / WalletWheelRewardCredited / WalletBalanceChanged", Definition: "游戏产出 + 收礼产出 + lucky 礼物产出 + 转盘金币奖励 + 红包领取和退回;转盘礼物价值需钱包奖励事件携带金币价值后才能统计。"}, {Key: "consume_output_ratio", Label: "消耗产出比", Status: "available", Source: "stat_app_day_country", Definition: "比例=产出金币/消耗金币,差额=消耗金币-产出金币。"}, {Key: "platform_grant_coin", Label: "平台发放金币", Status: "available", Source: "Wallet*RewardCredited / WalletBalanceChanged biz_type=resource_grant", Definition: "排行榜奖励、活动奖励、签到/任务奖励和火箭奖励中的金币发放;resource_grant 只统计可识别命令前缀的 COIN 入账,不包含人工发放、装扮价值、lucky 返奖和转盘产出。"}, {Key: "manual_grant_coin", Label: "人工发放金币", Status: "available", Source: "WalletBalanceChanged biz_type=manual_credit", Definition: "后台人工调账向普通 COIN 钱包正向发放的金币。"}, {Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "wallet_entries / WalletBalanceChanged", Definition: "主播和代理工资钱包 HOST_SALARY_USD+AGENCY_SALARY_USD 的 available+frozen 余额总和;多日总览只取结束日余额快照,日序列展示每日日终快照,不包含 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: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"}, {Key: "arpu_usd_minor", Label: "ARPU", Status: "available", Source: "stat_app_day_country", Definition: "总充值 / 活跃用户,返回美元最小单位。"}, {Key: "arppu_usd_minor", Label: "ARPPU", Status: "available", Source: "stat_app_day_country", Definition: "总充值 / 付费用户,返回美元最小单位。"}, {Key: "paid_conversion_rate", Label: "付费转化率", Status: "available", Source: "stat_app_day_country", Definition: "付费用户 / 活跃用户;当前 Lalu 付费用户等同充值用户。"}, {Key: "recharge_conversion_rate", Label: "充值转化率", Status: "available", Source: "stat_app_day_country", Definition: "充值用户 / 活跃用户;当前 Lalu 充值用户由 paid_users 派生。"}, } } 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), } } type appTrackingStepDef struct { Key string EventName string Label string IsFailure bool } var appTrackingStepDefs = []appTrackingStepDef{ {Key: "login_start", EventName: "login_start", Label: "登录开始"}, {Key: "login_success", EventName: "login_success", Label: "登录成功"}, {Key: "profile_start", EventName: "profile_start", Label: "资料开始"}, {Key: "profile_complete", EventName: "profile_complete", Label: "资料完成"}, {Key: "home_room_impression", EventName: "home_room_impression", Label: "首页房间曝光"}, {Key: "home_room_click", EventName: "home_room_click", Label: "首页房间点击"}, {Key: "room_join_success", EventName: "room_join_success", Label: "进房成功"}, {Key: "room_join_fail", EventName: "room_join_fail", Label: "进房失败", IsFailure: true}, {Key: "stay_30s", EventName: "stay_30s", Label: "停留 30 秒"}, {Key: "stay_3m", EventName: "stay_3m", Label: "停留 3 分钟"}, {Key: "stay_10m", EventName: "stay_10m", Label: "停留 10 分钟"}, {Key: "mic_up", EventName: "mic_up", Label: "上麦"}, {Key: "send_message", EventName: "send_message", Label: "发消息"}, {Key: "follow_host", EventName: "follow_host", Label: "关注主播"}, {Key: "add_friend", EventName: "add_friend", Label: "加好友"}, {Key: "check_in", EventName: "check_in", Label: "签到"}, {Key: "push_permission", EventName: "push_permission", Label: "Push 授权"}, } type appTrackingCohortDef struct { Key string Label string } var appTrackingCohortDefs = []appTrackingCohortDef{ {Key: "country", Label: "国家"}, {Key: "language", Label: "语言"}, {Key: "channel", Label: "渠道"}, {Key: "login_method", Label: "登录方式"}, {Key: "first_room_stay", Label: "首房停留时长"}, } type appTrackingStepAggregate struct { EventName string EventCount int64 UserCount int64 DeviceCount int64 } func (r *Repository) queryAppTrackingFunnelSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]appTrackingStepAggregate, error) { filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs) names := appTrackingEventNames() placeholders := sqlPlaceholders(len(names)) for _, name := range names { args = append(args, name) } rows, err := r.db.QueryContext(ctx, ` SELECT event_name, COUNT(*), COUNT(DISTINCT `+appTrackingIdentitySQL("")+`), COUNT(DISTINCT NULLIF(device_id, '')) FROM app_tracking_events WHERE `+filter+` AND event_name IN (`+placeholders+`) GROUP BY event_name`, args...) if err != nil { return nil, err } defer rows.Close() out := []appTrackingStepAggregate{} for rows.Next() { var item appTrackingStepAggregate if err := rows.Scan(&item.EventName, &item.EventCount, &item.UserCount, &item.DeviceCount); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func buildAppTrackingFunnelSteps(aggregates []appTrackingStepAggregate) []AppTrackingFunnelStep { byEvent := map[string]appTrackingStepAggregate{} for _, item := range aggregates { byEvent[item.EventName] = item } out := make([]AppTrackingFunnelStep, 0, len(appTrackingStepDefs)) var baseUsers int64 var previousUsers int64 for index, def := range appTrackingStepDefs { aggregate := byEvent[def.EventName] if index == 0 { baseUsers = aggregate.UserCount } step := AppTrackingFunnelStep{ Key: def.Key, EventName: def.EventName, Label: def.Label, EventCount: aggregate.EventCount, UserCount: aggregate.UserCount, DeviceCount: aggregate.DeviceCount, OverallConversionRate: ratio(aggregate.UserCount, baseUsers), PreviousConversionRate: ratio(aggregate.UserCount, previousUsers), IsFailure: def.IsFailure, } if previousUsers > aggregate.UserCount { step.DropoffUsers = previousUsers - aggregate.UserCount } out = append(out, step) previousUsers = aggregate.UserCount } return out } func appTrackingTotals(steps []AppTrackingFunnelStep) AppTrackingFunnelTotals { byEvent := map[string]int64{} for _, step := range steps { byEvent[step.EventName] = step.UserCount } return AppTrackingFunnelTotals{ LoginStartUsers: byEvent["login_start"], LoginSuccessUsers: byEvent["login_success"], ProfileCompleteUsers: byEvent["profile_complete"], RoomImpressionUsers: byEvent["home_room_impression"], RoomClickUsers: byEvent["home_room_click"], RoomJoinSuccessUsers: byEvent["room_join_success"], RoomJoinFailUsers: byEvent["room_join_fail"], Stay30sUsers: byEvent["stay_30s"], Stay3mUsers: byEvent["stay_3m"], Stay10mUsers: byEvent["stay_10m"], SendMessageUsers: byEvent["send_message"], FollowHostUsers: byEvent["follow_host"], AddFriendUsers: byEvent["add_friend"], CheckInUsers: byEvent["check_in"], PushPermissionUsers: byEvent["push_permission"], } } func (r *Repository) queryAppTrackingD1Total(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (int64, int64, error) { filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs) args = append(args, app) row := r.db.QueryRowContext(ctx, ` WITH base AS ( SELECT DISTINCT stat_day, `+appTrackingIdentitySQL("")+` AS identity FROM app_tracking_events WHERE `+filter+` AND event_name = 'login_success' ) SELECT COUNT(*), COALESCE(SUM(CASE WHEN EXISTS ( SELECT 1 FROM app_tracking_events n WHERE n.app_code = ? AND n.stat_day = DATE_ADD(base.stat_day, INTERVAL 1 DAY) AND `+appTrackingIdentitySQL("n")+` = base.identity LIMIT 1 ) THEN 1 ELSE 0 END),0) FROM base`, args...) var baseUsers, retainedUsers int64 if err := row.Scan(&baseUsers, &retainedUsers); err != nil { return 0, 0, err } return baseUsers, retainedUsers, nil } func (r *Repository) queryAppTrackingD1Cohorts(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]AppTrackingFunnelCohort, error) { out := []AppTrackingFunnelCohort{} for _, def := range appTrackingCohortDefs { rows, err := r.queryAppTrackingD1CohortDimension(ctx, app, startMS, endMS, countryID, regionID, regionIDs, def) if err != nil { return nil, err } stepCounts, err := r.queryAppTrackingCohortSteps(ctx, app, startMS, endMS, countryID, regionID, regionIDs, def) if err != nil { return nil, err } for index := range rows { rows[index].Steps = buildAppTrackingCohortSteps(stepCounts[rows[index].Value]) } out = append(out, rows...) } return out, nil } func (r *Repository) queryAppTrackingD1CohortDimension(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64, def appTrackingCohortDef) ([]AppTrackingFunnelCohort, error) { filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs) sourceSQL := appTrackingD1CohortSourceSQL(def.Key, filter) args := appTrackingCohortSourceArgs(def.Key, filterArgs) args = append(args, app) rows, err := r.db.QueryContext(ctx, ` WITH base AS (`+sourceSQL+`) SELECT value, COUNT(*), COALESCE(SUM(CASE WHEN EXISTS ( SELECT 1 FROM app_tracking_events n WHERE n.app_code = ? AND n.stat_day = DATE_ADD(base.stat_day, INTERVAL 1 DAY) AND `+appTrackingIdentitySQL("n")+` = base.identity LIMIT 1 ) THEN 1 ELSE 0 END),0) FROM base GROUP BY value ORDER BY COUNT(*) DESC, value ASC LIMIT 50`, args...) if err != nil { return nil, err } defer rows.Close() out := []AppTrackingFunnelCohort{} for rows.Next() { var value sql.NullString var baseUsers, retainedUsers int64 if err := rows.Scan(&value, &baseUsers, &retainedUsers); err != nil { return nil, err } cohortValue := normalizeCohortValue(value.String) out = append(out, AppTrackingFunnelCohort{ Dimension: def.Key, DimensionLabel: def.Label, Value: cohortValue, Label: cohortValue, BaseUsers: baseUsers, D1RetentionUsers: retainedUsers, D1RetentionRate: ratio(retainedUsers, baseUsers), Steps: []AppTrackingCohortStepCount{}, }) } return out, rows.Err() } func (r *Repository) queryAppTrackingCohortSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64, def appTrackingCohortDef) (map[string]map[string]int64, error) { filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs) cohortSQL := appTrackingStepCohortSourceSQL(def.Key, filter) args := appTrackingCohortSourceArgs(def.Key, filterArgs) eventFilter, eventArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs) args = append(args, eventArgs...) names := appTrackingEventNames() placeholders := sqlPlaceholders(len(names)) for _, name := range names { args = append(args, name) } rows, err := r.db.QueryContext(ctx, ` WITH cohort AS (`+cohortSQL+`), events AS ( SELECT DISTINCT `+appTrackingIdentitySQL("")+` AS identity, event_name FROM app_tracking_events WHERE `+eventFilter+` AND event_name IN (`+placeholders+`) ) SELECT cohort.value, events.event_name, COUNT(DISTINCT cohort.identity) FROM cohort JOIN events ON events.identity = cohort.identity GROUP BY cohort.value, events.event_name`, args...) if err != nil { return nil, err } defer rows.Close() out := map[string]map[string]int64{} for rows.Next() { var value sql.NullString var eventName string var userCount int64 if err := rows.Scan(&value, &eventName, &userCount); err != nil { return nil, err } cohortValue := normalizeCohortValue(value.String) if out[cohortValue] == nil { out[cohortValue] = map[string]int64{} } out[cohortValue][eventName] = userCount } return out, rows.Err() } func buildAppTrackingCohortSteps(counts map[string]int64) []AppTrackingCohortStepCount { out := make([]AppTrackingCohortStepCount, 0, len(appTrackingStepDefs)) for _, def := range appTrackingStepDefs { out = append(out, AppTrackingCohortStepCount{ EventName: def.EventName, Label: def.Label, UserCount: counts[def.EventName], }) } return out } func appTrackingD1CohortSourceSQL(dimension string, filter string) string { if dimension == "first_room_stay" { return ` SELECT base.stat_day, base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value FROM ( SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity FROM app_tracking_events WHERE ` + filter + ` AND event_name = 'login_success' ) base LEFT JOIN ( SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity, MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank FROM app_tracking_events WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m') GROUP BY stat_day, identity ) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity` } return ` SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value FROM app_tracking_events WHERE ` + filter + ` AND event_name = 'login_success'` } func appTrackingStepCohortSourceSQL(dimension string, filter string) string { if dimension == "first_room_stay" { return ` SELECT DISTINCT base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value FROM ( SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity FROM app_tracking_events WHERE ` + filter + ` AND event_name = 'login_success' ) base LEFT JOIN ( SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity, MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank FROM app_tracking_events WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m') GROUP BY stat_day, identity ) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity` } return ` SELECT DISTINCT ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value FROM app_tracking_events WHERE ` + filter + ` AND event_name = 'login_success'` } func appTrackingCohortSourceArgs(dimension string, filterArgs []any) []any { args := append([]any{}, filterArgs...) if dimension == "first_room_stay" { args = append(args, filterArgs...) } return args } func appTrackingStayBucketSQL(rankExpr string) string { return "CASE COALESCE(" + rankExpr + ",0) WHEN 3 THEN '10m+' WHEN 2 THEN '3m-10m' WHEN 1 THEN '30s-3m' ELSE '<30s' END" } func appTrackingCohortValueSQL(dimension string, alias string) string { prefix := "" if alias != "" { prefix = alias + "." } jsonValue := func(path string) string { return "NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(" + prefix + "properties_json, '$." + path + "'))), '')" } switch dimension { case "country": return "COALESCE(" + jsonValue("country") + ", IF(" + prefix + "country_id > 0, CAST(" + prefix + "country_id AS CHAR), 'unknown'))" case "language": return "COALESCE(NULLIF(TRIM(" + prefix + "language), ''), " + jsonValue("language") + ", 'unknown')" case "channel": return "COALESCE(" + jsonValue("channel") + ", " + jsonValue("utm_source") + ", 'unknown')" case "login_method": return "COALESCE(" + jsonValue("login_method") + ", " + jsonValue("login_provider") + ", " + jsonValue("method") + ", " + jsonValue("provider") + ", 'unknown')" default: return "'unknown'" } } func appTrackingFilter(app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (string, []any) { filter := "app_code = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?" args := []any{app, startMS, endMS} 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 } regionIDs = normalizeTrackingRegionIDs(regionIDs) if len(regionIDs) > 0 { filter += " AND region_id IN (" + sqlPlaceholders(len(regionIDs)) + ")" for _, id := range regionIDs { args = append(args, id) } } return filter, args } func normalizeTrackingRegionIDs(regionIDs []int64) []int64 { out := make([]int64, 0, len(regionIDs)) seen := map[int64]struct{}{} for _, id := range regionIDs { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func appTrackingIdentitySQL(alias string) string { prefix := "" if alias != "" { prefix = alias + "." } return "CASE WHEN " + prefix + "user_id > 0 THEN CONCAT('u:', " + prefix + "user_id) ELSE CONCAT('d:', " + prefix + "device_id) END" } func appTrackingEventNames() []string { out := make([]string, 0, len(appTrackingStepDefs)) for _, def := range appTrackingStepDefs { out = append(out, def.EventName) } return out } func sqlPlaceholders(count int) string { if count <= 0 { return "NULL" } parts := make([]string, count) for index := range parts { parts[index] = "?" } return strings.Join(parts, ",") } func normalizeCohortValue(value string) string { value = strings.TrimSpace(value) if value == "" || strings.EqualFold(value, "null") { return "unknown" } return value } 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 normalizePlatformGrantCoinQuery(query PlatformGrantCoinQuery) (string, string, string, string, int, int, int) { app := appcode.Normalize(query.AppCode) if app == "" { app = "lalu" } statTZ := normalizeStatTZ(query.StatTZ) startDay := strings.TrimSpace(query.StatDay) endDay := startDay if startDay == "" { startMS, endMS := normalizeRange(query.StartMS, query.EndMS) startDay = statDayIn(startMS, statTZ) endDay = statDayIn(endMS-1, statTZ) } page := query.Page if page < 1 { page = 1 } pageSize := query.PageSize if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } return app, statTZ, startDay, endDay, page, pageSize, (page - 1) * pageSize } func platformGrantCoinFilter(app string, statTZ string, startDay string, endDay string, countryID int64, regionID int64, userID int64, eventType string) (string, []any) { filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?" args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} if countryID > 0 { filter += " AND country_id = ?" args = append(args, countryID) } if regionID > 0 { filter += " AND region_id = ?" args = append(args, regionID) } if userID > 0 { filter += " AND user_id = ?" args = append(args, userID) } if normalizedEventType := strings.TrimSpace(eventType); normalizedEventType != "" && normalizedEventType != "all" { filter += " AND event_type = ?" args = append(args, normalizedEventType) } return filter, args } 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 percentagePointDelta(current, previous float64) float64 { return (current - previous) * 100 } func ratio(numerator, denominator int64) float64 { if denominator <= 0 { return 0 } return float64(numerator) / float64(denominator) } func retentionPointers(users, base int64) (*int64, *int64, *float64) { if base <= 0 { return nil, nil, nil } usersValue := users baseValue := base rateValue := ratio(users, base) return &usersValue, &baseValue, &rateValue } 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 是查询窗口内同方向同原因的最新余额快照。"}, } }