2026-06-30 14:58:48 +08:00

1867 lines
88 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"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
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"`
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"`
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
CoinSellerTransferDeltaRate float64 `json:"coin_seller_transfer_coin_delta_rate"`
LuckyGiftTurnoverDeltaRate float64 `json:"lucky_gift_turnover_delta_rate"`
LuckyGiftPayoutDeltaRate float64 `json:"lucky_gift_payout_delta_rate"`
LuckyGiftProfitDeltaRate float64 `json:"lucky_gift_profit_delta_rate"`
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
Retention Retention `json:"retention"`
DailySeries []DailySeriesItem `json:"daily_series"`
DailyCountryBreakdown []DailyCountryStat `json:"daily_country_breakdown"`
GameRanking []GameRank `json:"game_ranking"`
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
ReportMetricSources []ReportMetricSource `json:"report_metric_sources"`
}
type DailySeriesItem struct {
StatDay string `json:"stat_day"`
Label string `json:"label"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
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"`
}
type Retention struct {
RegisteredUsers int64 `json:"registered_users"`
Day1Users int64 `json:"day1_users"`
Day7Users int64 `json:"day7_users"`
Day30Users int64 `json:"day30_users"`
Day1Rate float64 `json:"day1_rate"`
Day7Rate float64 `json:"day7_rate"`
Day30Rate float64 `json:"day30_rate"`
}
type GameRank struct {
PlatformCode string `json:"platform_code"`
GameID string `json:"game_id"`
TurnoverCoin int64 `json:"turnover_coin"`
PayoutCoin int64 `json:"payout_coin"`
RefundCoin int64 `json:"refund_coin"`
PlayerCount int64 `json:"player_count"`
ProfitCoin int64 `json:"profit_coin"`
ProfitRate float64 `json:"profit_rate"`
}
type LuckyGiftPoolStat struct {
PoolID string `json:"pool_id"`
TurnoverCoin int64 `json:"turnover_coin"`
PayoutCoin int64 `json:"payout_coin"`
ProfitCoin int64 `json:"profit_coin"`
PayoutRate float64 `json:"payout_rate"`
ProfitRate float64 `json:"profit_rate"`
}
type superLuckyAggregate struct {
Turnover int64
Payout int64
}
type CountryBreakdown struct {
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
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"`
}
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
Page int
PageSize int
}
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)
retention, err := r.queryRetention(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.Retention = retention
ranking, err := r.queryGameRanking(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.GameRanking = ranking
if query.CountryID <= 0 {
breakdown, err := r.queryCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.CountryBreakdown = breakdown
dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.DailyCountryBreakdown = dailyCountryBreakdown
}
seriesStartDay, seriesEndDay := startDay, endDay
if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
seriesStartDay = statDayIn(query.SeriesStartMS, statTZ)
seriesEndDay = statDayIn(query.SeriesEndMS-1, statTZ)
}
// daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。
dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.DailySeries = dailySeries
overview.ReportMetricSources = reportMetricSources()
return overview, nil
}
func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOverviewQuery) (SelfGameOverview, error) {
startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
app := appcode.Normalize(query.AppCode)
if app == "" {
app = "lalu"
}
statTZ := normalizeStatTZ(query.StatTZ)
startDay := statDayIn(startMS, statTZ)
endDay := statDayIn(endMS-1, statTZ)
gameID := normalizeShortText(query.GameID)
overview := SelfGameOverview{
AppCode: app,
StatTZ: statTZ,
StartMS: startMS,
EndMS: endMS,
GameID: gameID,
CountryID: query.CountryID,
RegionID: query.RegionID,
}
events, err := r.querySelfGameEvents(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.Events = events
overview.Funnel, overview.Conversion = buildSelfGameFunnel(events)
totals, err := r.querySelfGameMatchTotals(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.MatchTotals = totals
stakes, err := r.querySelfGameStakeBreakdown(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.StakeBreakdown = stakes
pools, err := r.querySelfGamePools(ctx, app, statTZ, startDay, endDay, gameID)
if err != nil {
return SelfGameOverview{}, err
}
overview.PoolStats = pools
participants, err := r.querySelfGameParticipantDistribution(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.ParticipantDistribution = participants
risk, err := r.querySelfGameUserRisk(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.UserRisk = risk
retention, err := r.querySelfGameRetention(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.Retention = retention
overview.MetricDefinitions = selfGameMetricDefinitions()
return overview, nil
}
func (r *Repository) 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)
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)
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) ([]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]
}
superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID)
if err != nil {
return nil, err
}
for index := range out {
if super, ok := superByDay[out[index].StatDay]; ok {
out[index].SuperLuckyGiftTurnover = super.Turnover
out[index].SuperLuckyGiftPayout = super.Payout
applyDailySeriesDerivedMetrics(&out[index])
}
}
if normalizeStatTZ(statTZ) == StatTZUTC {
if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
return nil, err
} else if ok {
out = mergeActivityLuckyGiftDaily(out, activityDaily)
}
}
return out, nil
}
func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) {
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if countryID > 0 {
filter += " AND p.country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND p.region_id = ?"
args = append(args, regionID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(p.stat_day, '%Y-%m-%d'), COUNT(DISTINCT p.user_id)
FROM stat_recharge_day_payers p
JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id
WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ?
AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+`
GROUP BY p.stat_day`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var day string
var count int64
if err := rows.Scan(&day, &count); err != nil {
return nil, err
}
out[day] = count
}
return out, rows.Err()
}
func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
if item == nil {
return
}
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
if channelRechargeUSDMinor > item.RechargeUSDMinor {
item.RechargeUSDMinor = channelRechargeUSDMinor
}
item.RechargeUsers = item.PaidUsers
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
item.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)
}
func applyOverviewDerivedMetrics(overview *Overview) {
if overview == nil {
return
}
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor查询时用渠道合计兜底。币商进货是 USDT 充值,币商转普通金币仍只展示金币卡片。
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor + overview.CoinSellerRechargeUSDMinor
if channelRechargeUSDMinor > overview.RechargeUSDMinor {
overview.RechargeUSDMinor = channelRechargeUSDMinor
}
overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund
overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover)
overview.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.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
overview.SuperLuckyGiftProfit = overview.SuperLuckyGiftTurnover - overview.SuperLuckyGiftPayout
overview.SuperLuckyGiftProfitRate = ratio(overview.SuperLuckyGiftProfit, overview.SuperLuckyGiftTurnover)
}
func (overview *Overview) applyDeltaRates(previous Overview) {
if overview == nil {
return
}
overview.RechargeDeltaRate = deltaRate(overview.RechargeUSDMinor, previous.RechargeUSDMinor)
overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor)
overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers)
overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers)
overview.NewPaidUsersDeltaRate = deltaRate(overview.NewPaidUsers, previous.NewPaidUsers)
overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor)
overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor)
overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent)
overview.CoinSellerTransferDeltaRate = deltaRate(overview.CoinSellerTransferCoin, previous.CoinSellerTransferCoin)
overview.LuckyGiftTurnoverDeltaRate = deltaRate(overview.LuckyGiftTurnover, previous.LuckyGiftTurnover)
overview.LuckyGiftPayoutDeltaRate = deltaRate(overview.LuckyGiftPayout, previous.LuckyGiftPayout)
overview.LuckyGiftProfitDeltaRate = deltaRate(overview.LuckyGiftProfit, previous.LuckyGiftProfit)
overview.GameTurnoverDeltaRate = deltaRate(overview.GameTurnover, previous.GameTurnover)
overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
}
func (r *Repository) queryRetention(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Retention, error) {
filter := ""
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
if countryID > 0 {
filter = " AND r.country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND r.region_id = ?"
args = append(args, regionID)
}
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(*),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0)
FROM stat_user_registration r
WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...)
var out Retention
if err := row.Scan(&out.RegisteredUsers, &out.Day1Users, &out.Day7Users, &out.Day30Users); err != nil && err != sql.ErrNoRows {
return Retention{}, err
}
out.Day1Rate = ratio(out.Day1Users, out.RegisteredUsers)
out.Day7Rate = ratio(out.Day7Users, out.RegisteredUsers)
out.Day30Rate = ratio(out.Day30Users, out.RegisteredUsers)
return out, nil
}
func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if countryID > 0 {
filter = " AND country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND region_id = ?"
args = append(args, regionID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0),
COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0)
FROM stat_game_day_country
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
GROUP BY platform_code, game_id
ORDER BY COALESCE(SUM(turnover_coin),0) DESC
LIMIT 50`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []GameRank{}
for rows.Next() {
var item GameRank
if err := rows.Scan(&item.PlatformCode, &item.GameID, &item.TurnoverCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlayerCount); err != nil {
return nil, err
}
item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin - item.RefundCoin
item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) {
args := []any{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
}
superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID)
if err != nil {
return nil, err
}
for index := range out {
if super, ok := superByCountry[out[index].CountryID]; ok {
out[index].SuperLuckyGiftTurnover = super.Turnover
out[index].SuperLuckyGiftPayout = super.Payout
applyCountryBreakdownDerivedMetrics(&out[index])
}
}
return out, nil
}
func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]DailyCountryStat, error) {
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if regionID > 0 {
filter = " AND region_id = ?"
args = append(args, regionID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(MAX(region_id),0),
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(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
}
superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID)
if err != nil {
return nil, err
}
for index := range out {
if super, ok := superByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
out[index].SuperLuckyGiftTurnover = super.Turnover
out[index].SuperLuckyGiftPayout = super.Payout
applyCountryBreakdownDerivedMetrics(&out[index].CountryBreakdown)
}
}
return out, nil
}
func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
if item == nil {
return
}
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
if channelRechargeUSDMinor > item.RechargeUSDMinor {
item.RechargeUSDMinor = channelRechargeUSDMinor
}
item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
item.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.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: "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: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"},
}
}
func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, event_name, COALESCE(SUM(event_count),0), COALESCE(SUM(user_count),0),
COALESCE(SUM(success_count),0), COALESCE(SUM(error_count),0), COALESCE(SUM(duration_ms_sum),0)
FROM stat_self_game_h5_event_day
WHERE `+filter+`
GROUP BY game_id, event_name
ORDER BY game_id, event_name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGameEventStat{}
for rows.Next() {
var item SelfGameEventStat
if err := rows.Scan(&item.GameID, &item.EventName, &item.EventCount, &item.UserCount, &item.SuccessCount, &item.ErrorCount, &item.DurationMSSum); err != nil {
return nil, err
}
item.AvgDurationMS = div(item.DurationMSSum, item.EventCount)
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) {
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
row := r.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0),
COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_matches),0),
COALESCE(SUM(robot_matches),0), COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0),
COALESCE(SUM(winner_count),0), COALESCE(SUM(loser_count),0), COALESCE(SUM(draw_count),0),
COALESCE(SUM(user_stake_coin),0), COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0),
COALESCE(SUM(refund_coin),0), COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0),
COALESCE(SUM(forced_player_lose_matches),0), COALESCE(SUM(wait_ms_sum),0), COALESCE(SUM(duration_ms_sum),0)
FROM stat_self_game_match_day
WHERE `+filter, args...)
var totals SelfGameMatchTotals
if err := row.Scan(&totals.CreatedMatches, &totals.MatchedMatches, &totals.SettledMatches, &totals.CanceledMatches, &totals.FailedMatches, &totals.HumanMatches, &totals.RobotMatches, &totals.HumanParticipants, &totals.RobotParticipants, &totals.WinnerCount, &totals.LoserCount, &totals.DrawCount, &totals.UserStakeCoin, &totals.RobotStakeCoin, &totals.PayoutCoin, &totals.RefundCoin, &totals.PlatformProfitCoin, &totals.PoolDeltaCoin, &totals.ForcedPlayerLoseMatches, &totals.WaitMSSum, &totals.DurationMSSum); err != nil {
return SelfGameMatchTotals{}, err
}
applySelfGameMatchDerived(&totals)
return totals, nil
}
func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) {
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, stake_coin, COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0),
COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0),
COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0), COALESCE(SUM(user_stake_coin),0),
COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0),
COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0)
FROM stat_self_game_match_day
WHERE `+filter+`
GROUP BY game_id, stake_coin
ORDER BY game_id, stake_coin`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGameStakeStat{}
for rows.Next() {
var item SelfGameStakeStat
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil {
return nil, err
}
// platform_profit_coin 是 game-service 在结算时按真实用户侧快照写入的 user_stake - payout - refund
// 机器人下注只是奖池和对手侧流水,不能进入抽水率分母,否则人机局会把平台抽水率稀释一半。
item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin)
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGamePools(ctx context.Context, app, statTZ, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) {
statTZ = normalizeStatTZ(statTZ)
filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
args := []any{app, statTZ, startDay, endDay}
if gameID != "" {
filter += " AND game_id = ?"
args = append(args, gameID)
}
latestArgs := append([]any{}, args...)
latestArgs = append(latestArgs, app, statTZ)
latestArgs = append(latestArgs, args...)
rows, err := r.db.QueryContext(ctx, `
SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count,
agg.in_coin, agg.out_coin, COALESCE(latest.balance_after_coin,0)
FROM (
SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0) AS transaction_count,
COALESCE(SUM(in_coin),0) AS in_coin, COALESCE(SUM(out_coin),0) AS out_coin
FROM stat_self_game_pool_day
WHERE `+filter+`
GROUP BY game_id, direction, reason
) agg
LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.stat_tz = ? AND latest.game_id = agg.game_id
AND latest.direction = agg.direction AND latest.reason = agg.reason
AND latest.stat_day = (
SELECT p2.stat_day
FROM stat_self_game_pool_day p2
WHERE `+filter+` AND p2.game_id = agg.game_id AND p2.direction = agg.direction AND p2.reason = agg.reason
ORDER BY p2.stat_day DESC, p2.updated_at_ms DESC
LIMIT 1
)
ORDER BY agg.game_id, agg.direction, agg.reason`, latestArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGamePoolStat{}
for rows.Next() {
var item SelfGamePoolStat
if err := rows.Scan(&item.GameID, &item.Direction, &item.Reason, &item.TransactionCount, &item.InCoin, &item.OutCoin, &item.BalanceAfterCoin); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) {
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, stake_coin, participant_type, result, rps_gesture, dice_point,
COALESCE(SUM(participant_count),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
FROM stat_self_game_participant_day
WHERE `+filter+`
GROUP BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point
ORDER BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SelfGameParticipantStat{}
totals := map[string]int64{}
for rows.Next() {
var item SelfGameParticipantStat
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil {
return nil, err
}
key := selfGameParticipantRateKey(item)
totals[key] += item.ParticipantCount
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
for index := range items {
key := selfGameParticipantRateKey(items[index])
if items[index].Result == "win" {
items[index].WinRate = ratio(items[index].ParticipantCount, totals[key])
}
}
return items, nil
}
func selfGameParticipantRateKey(item SelfGameParticipantStat) string {
playKey := item.RPSGesture
if item.DicePoint > 0 {
playKey = strconv.FormatInt(item.DicePoint, 10)
}
// 玩法分布查询本身按 stake_coin 分桶返回,胜率分母也必须保留同一档位;
// 否则同一点数或同一手势在不同下注档位的结果会互相稀释,前端每一行的胜率就不是该行口径。
return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey
}
func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) {
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, user_id, COALESCE(SUM(match_count),0), COALESCE(SUM(win_count),0),
COALESCE(SUM(lose_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(cancel_count),0),
COALESCE(SUM(stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
FROM stat_self_game_user_day
WHERE `+filter+`
GROUP BY game_id, user_id
ORDER BY SUM(match_count) DESC, SUM(net_win_coin) DESC, SUM(cancel_count) DESC
LIMIT 100`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGameUserRiskStat{}
for rows.Next() {
var item SelfGameUserRiskStat
if err := rows.Scan(&item.GameID, &item.UserID, &item.MatchCount, &item.WinCount, &item.LoseCount, &item.DrawCount, &item.CancelCount, &item.StakeCoin, &item.PayoutCoin, &item.NetWinCoin); err != nil {
return nil, err
}
item.WinRate = ratio(item.WinCount, item.MatchCount)
item.CancelRate = ratio(item.CancelCount, item.MatchCount+item.CancelCount)
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGameRetention(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) {
cohortFilter, cohortArgs := selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
returnFilter, returnArgs := selfGameUserDayFilter(app, statTZ, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID)
args := make([]any, 0, len(cohortArgs)+len(returnArgs)*2)
args = append(args, cohortArgs...)
args = append(args, returnArgs...)
args = append(args, returnArgs...)
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(1),
COALESCE(SUM(CASE WHEN d1.user_id IS NULL THEN 0 ELSE 1 END), 0),
COALESCE(SUM(CASE WHEN d7.user_id IS NULL THEN 0 ELSE 1 END), 0)
FROM (
SELECT DISTINCT stat_day, user_id
FROM stat_self_game_user_day
WHERE `+cohortFilter+`
) cohort
LEFT JOIN (
SELECT DISTINCT stat_day, user_id
FROM stat_self_game_user_day
WHERE `+returnFilter+`
) d1 ON d1.user_id = cohort.user_id AND d1.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 1 DAY)
LEFT JOIN (
SELECT DISTINCT stat_day, user_id
FROM stat_self_game_user_day
WHERE `+returnFilter+`
) d7 ON d7.user_id = cohort.user_id AND d7.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 7 DAY)`, args...)
var retention SelfGameRetention
if err := row.Scan(&retention.CohortUsers, &retention.Day1Users, &retention.Day7Users); err != nil {
return SelfGameRetention{}, err
}
retention.Day1Rate = ratio(retention.Day1Users, retention.CohortUsers)
retention.Day7Rate = ratio(retention.Day7Users, retention.CohortUsers)
return retention, nil
}
func selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
filter := "app_code = ? AND stat_tz = ? AND match_count > 0"
args := []any{app, normalizeStatTZ(statTZ)}
if startDay != "" && endDay != "" {
filter += " AND stat_day BETWEEN ? AND ?"
args = append(args, startDay, endDay)
}
if gameID != "" {
filter += " AND game_id = ?"
args = append(args, gameID)
}
if countryID > 0 {
filter += " AND country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND region_id = ?"
args = append(args, regionID)
}
return filter, args
}
func selfGameFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
if gameID != "" {
filter += " AND game_id = ?"
args = append(args, gameID)
}
if countryID > 0 {
filter += " AND country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND region_id = ?"
args = append(args, regionID)
}
return filter, args
}
func applySelfGameMatchDerived(totals *SelfGameMatchTotals) {
if totals == nil {
return
}
totals.AvgWaitMS = div(totals.WaitMSSum, totals.MatchedMatches+totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
totals.AvgDurationMS = div(totals.DurationMSSum, totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches)
totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches)
totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches)
// platform_profit_coin 与真实用户下注同口径,分母只使用 user_stake_coin
// robot_stake_coin 是机器人对手侧流水,展示总下注时可以合并,但不能用于真实用户侧抽水率。
totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin)
}
func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) {
order := []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"}
byEvent := map[string]SelfGameFunnelStep{}
for _, event := range events {
step := byEvent[event.EventName]
step.EventName = event.EventName
step.EventCount += event.EventCount
step.UserCount += event.UserCount
byEvent[event.EventName] = step
}
funnel := make([]SelfGameFunnelStep, 0, len(order))
for _, name := range order {
funnel = append(funnel, byEvent[name])
funnel[len(funnel)-1].EventName = name
}
return funnel, SelfGameConversion{
PageToMatchClickRate: ratio(byEvent["match_click"].UserCount, byEvent["page_open"].UserCount),
MatchClickToSuccessRate: ratio(byEvent["match_success"].UserCount, byEvent["match_click"].UserCount),
SuccessToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["match_success"].UserCount),
SettlementToPlayAgainRate: ratio(byEvent["play_again"].UserCount, byEvent["settlement_show"].UserCount),
PageToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["page_open"].UserCount),
}
}
func normalizeRange(startMS, endMS int64) (int64, int64) {
if startMS <= 0 {
now := time.Now().UTC()
startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}
if endMS <= startMS {
endMS = startMS + 24*time.Hour.Milliseconds()
}
return startMS, endMS
}
func 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) (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)
}
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 ratio(numerator, denominator int64) float64 {
if denominator <= 0 {
return 0
}
return float64(numerator) / float64(denominator)
}
func selfGameMetricDefinitions() []MetricDefinition {
return []MetricDefinition{
{Metric: "game_pv", Definition: "H5 page_open event_count同一用户多次打开会重复计数。"},
{Metric: "game_uv", Definition: "H5 page_open user_count同一 UTC 日、同一 game_id、同一用户只计一次。"},
{Metric: "match_click_users", Definition: "H5 match_click user_count用户点击匹配按钮后上报。"},
{Metric: "match_success_users", Definition: "H5 match_success user_count匹配接口成功返回有效 match_id 后上报。"},
{Metric: "settlement_users", Definition: "H5 settlement_show user_count结算页展示时上报。"},
{Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户;只统计 stat_self_game_user_day.match_count > 0。"},
{Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
{Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
{Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin - payout_coin - refund_coin抽水率分母同样只用 user_stake_coin。"},
{Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合in_coin/out_coin 是入池/出池金额balance_after_coin 是查询窗口内同方向同原因的最新余额快照。"},
}
}