1867 lines
88 KiB
Go
1867 lines
88 KiB
Go
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 是查询窗口内同方向同原因的最新余额快照。"},
|
||
}
|
||
}
|