2616 lines
120 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 + "."
}
// identity_key is a generated column indexed by the statistics schema.
// Keeping identity derivation out of hot funnel SQL lets MySQL use window
// and retention indexes instead of rebuilding CONCAT keys for every row.
return prefix + "identity_key"
}
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 是查询窗口内同方向同原因的最新余额快照。"},
}
}