2616 lines
120 KiB
Go
2616 lines
120 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"`
|
||
RegisteredUsers int64 `json:"registered_users"`
|
||
Registrations int64 `json:"registrations"`
|
||
ActiveUsers int64 `json:"active_users"`
|
||
PaidUsers int64 `json:"paid_users"`
|
||
RechargeUsers int64 `json:"recharge_users"`
|
||
NewPaidUsers int64 `json:"new_paid_users"`
|
||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
|
||
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||
CoinTotal int64 `json:"coin_total"`
|
||
ConsumedCoin int64 `json:"consumed_coin"`
|
||
OutputCoin int64 `json:"output_coin"`
|
||
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
|
||
ConsumeOutputDelta int64 `json:"consume_output_delta"`
|
||
PlatformGrantCoin int64 `json:"platform_grant_coin"`
|
||
ManualGrantCoin int64 `json:"manual_grant_coin"`
|
||
SalaryUSDMinor int64 `json:"salary_usd_minor"`
|
||
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
|
||
MicOnlineMS int64 `json:"mic_online_ms"`
|
||
MicOnlineUsers int64 `json:"mic_online_users"`
|
||
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
|
||
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||
RealRoomRobotNormalGiftCoin int64 `json:"real_room_robot_normal_gift_coin"`
|
||
RealRoomRobotLuckyGiftCoin int64 `json:"real_room_robot_lucky_gift_coin"`
|
||
RealRoomRobotSuperGiftCoin int64 `json:"real_room_robot_super_gift_coin"`
|
||
RealRoomRobotTotalGiftCoin int64 `json:"real_room_robot_total_gift_coin"`
|
||
RealRoomRobotGiftRoomCount int64 `json:"real_room_robot_gift_room_count"`
|
||
RealRoomRobotAvgRoomGiftCoin int64 `json:"real_room_robot_avg_room_gift_coin"`
|
||
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
|
||
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
|
||
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
|
||
GameTurnover int64 `json:"game_turnover"`
|
||
GamePayout int64 `json:"game_payout"`
|
||
GameRefund int64 `json:"game_refund"`
|
||
GamePlayers int64 `json:"game_players"`
|
||
GameProfit int64 `json:"game_profit"`
|
||
GameProfitRate float64 `json:"game_profit_rate"`
|
||
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
|
||
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
|
||
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
|
||
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
|
||
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||
UPValueUSDMinor int64 `json:"up_value_usd_minor"`
|
||
PayerRate float64 `json:"payer_rate"`
|
||
PaidConversionRate float64 `json:"paid_conversion_rate"`
|
||
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
|
||
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
|
||
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
|
||
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
|
||
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
|
||
NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"`
|
||
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
|
||
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
|
||
PayerRateDeltaPP float64 `json:"payer_rate_delta_pp"`
|
||
PaidConversionRateDeltaPP float64 `json:"paid_conversion_rate_delta_pp"`
|
||
RechargeConversionRateDeltaPP float64 `json:"recharge_conversion_rate_delta_pp"`
|
||
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
|
||
CoinSellerTransferDeltaRate float64 `json:"coin_seller_transfer_coin_delta_rate"`
|
||
LuckyGiftTurnoverDeltaRate float64 `json:"lucky_gift_turnover_delta_rate"`
|
||
LuckyGiftPayoutDeltaRate float64 `json:"lucky_gift_payout_delta_rate"`
|
||
LuckyGiftProfitDeltaRate float64 `json:"lucky_gift_profit_delta_rate"`
|
||
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
|
||
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
|
||
Retention Retention `json:"retention"`
|
||
DailySeries []DailySeriesItem `json:"daily_series"`
|
||
DailyCountryBreakdown []DailyCountryStat `json:"daily_country_breakdown"`
|
||
GameRanking []GameRank `json:"game_ranking"`
|
||
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
|
||
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
|
||
ReportMetricSources []ReportMetricSource `json:"report_metric_sources"`
|
||
}
|
||
|
||
type DailySeriesItem struct {
|
||
StatDay string `json:"stat_day"`
|
||
Label string `json:"label"`
|
||
NewUsers int64 `json:"new_users"`
|
||
RegisteredUsers int64 `json:"registered_users"`
|
||
Registrations int64 `json:"registrations"`
|
||
ActiveUsers int64 `json:"active_users"`
|
||
PaidUsers int64 `json:"paid_users"`
|
||
NewPaidUsers int64 `json:"new_paid_users"`
|
||
RechargeUsers int64 `json:"recharge_users"`
|
||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
|
||
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||
CoinTotal int64 `json:"coin_total"`
|
||
ConsumedCoin int64 `json:"consumed_coin"`
|
||
OutputCoin int64 `json:"output_coin"`
|
||
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
|
||
ConsumeOutputDelta int64 `json:"consume_output_delta"`
|
||
PlatformGrantCoin int64 `json:"platform_grant_coin"`
|
||
ManualGrantCoin int64 `json:"manual_grant_coin"`
|
||
SalaryUSDMinor int64 `json:"salary_usd_minor"`
|
||
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
|
||
MicOnlineMS int64 `json:"mic_online_ms"`
|
||
MicOnlineUsers int64 `json:"mic_online_users"`
|
||
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
|
||
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||
GameTurnover int64 `json:"game_turnover"`
|
||
GameProfit int64 `json:"game_profit"`
|
||
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
|
||
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
|
||
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
|
||
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
|
||
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||
PayerRate float64 `json:"payer_rate"`
|
||
PaidConversionRate float64 `json:"paid_conversion_rate"`
|
||
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
|
||
D1RetentionUsers *int64 `json:"d1_retention_users"`
|
||
D1RetentionBaseUsers *int64 `json:"d1_retention_base_users"`
|
||
D1RetentionRate *float64 `json:"d1_retention_rate"`
|
||
D7RetentionUsers *int64 `json:"d7_retention_users"`
|
||
D7RetentionBaseUsers *int64 `json:"d7_retention_base_users"`
|
||
D7RetentionRate *float64 `json:"d7_retention_rate"`
|
||
D30RetentionUsers *int64 `json:"d30_retention_users"`
|
||
D30RetentionBaseUsers *int64 `json:"d30_retention_base_users"`
|
||
D30RetentionRate *float64 `json:"d30_retention_rate"`
|
||
}
|
||
|
||
type Retention struct {
|
||
RegisteredUsers int64 `json:"registered_users"`
|
||
Day1Users *int64 `json:"day1_users"`
|
||
Day1BaseUsers *int64 `json:"day1_base_users"`
|
||
Day1Rate *float64 `json:"day1_rate"`
|
||
Day7Users *int64 `json:"day7_users"`
|
||
Day7BaseUsers *int64 `json:"day7_base_users"`
|
||
Day7Rate *float64 `json:"day7_rate"`
|
||
Day30Users *int64 `json:"day30_users"`
|
||
Day30BaseUsers *int64 `json:"day30_base_users"`
|
||
Day30Rate *float64 `json:"day30_rate"`
|
||
}
|
||
|
||
type GameRank struct {
|
||
PlatformCode string `json:"platform_code"`
|
||
GameID string `json:"game_id"`
|
||
TurnoverCoin int64 `json:"turnover_coin"`
|
||
PayoutCoin int64 `json:"payout_coin"`
|
||
RefundCoin int64 `json:"refund_coin"`
|
||
PlayerCount int64 `json:"player_count"`
|
||
ProfitCoin int64 `json:"profit_coin"`
|
||
ProfitRate float64 `json:"profit_rate"`
|
||
}
|
||
|
||
type LuckyGiftPoolStat struct {
|
||
PoolID string `json:"pool_id"`
|
||
TurnoverCoin int64 `json:"turnover_coin"`
|
||
PayoutCoin int64 `json:"payout_coin"`
|
||
ProfitCoin int64 `json:"profit_coin"`
|
||
PayoutRate float64 `json:"payout_rate"`
|
||
ProfitRate float64 `json:"profit_rate"`
|
||
}
|
||
|
||
type superLuckyAggregate struct {
|
||
Turnover int64
|
||
Payout int64
|
||
}
|
||
|
||
type CountryBreakdown struct {
|
||
CountryID int64 `json:"country_id"`
|
||
RegionID int64 `json:"region_id"`
|
||
NewUsers int64 `json:"new_users"`
|
||
RegisteredUsers int64 `json:"registered_users"`
|
||
Registrations int64 `json:"registrations"`
|
||
ActiveUsers int64 `json:"active_users"`
|
||
PaidUsers int64 `json:"paid_users"`
|
||
RechargeUsers int64 `json:"recharge_users"`
|
||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
|
||
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||
CoinTotal int64 `json:"coin_total"`
|
||
ConsumedCoin int64 `json:"consumed_coin"`
|
||
OutputCoin int64 `json:"output_coin"`
|
||
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
|
||
ConsumeOutputDelta int64 `json:"consume_output_delta"`
|
||
PlatformGrantCoin int64 `json:"platform_grant_coin"`
|
||
ManualGrantCoin int64 `json:"manual_grant_coin"`
|
||
SalaryUSDMinor int64 `json:"salary_usd_minor"`
|
||
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
|
||
MicOnlineMS int64 `json:"mic_online_ms"`
|
||
MicOnlineUsers int64 `json:"mic_online_users"`
|
||
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
|
||
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
|
||
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
|
||
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
|
||
GameTurnover int64 `json:"game_turnover"`
|
||
GamePayout int64 `json:"game_payout"`
|
||
GameRefund int64 `json:"game_refund"`
|
||
GamePlayers int64 `json:"game_players"`
|
||
GameProfit int64 `json:"game_profit"`
|
||
GameProfitRate float64 `json:"game_profit_rate"`
|
||
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
|
||
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
|
||
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
|
||
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
|
||
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||
PayerRate float64 `json:"payer_rate"`
|
||
PaidConversionRate float64 `json:"paid_conversion_rate"`
|
||
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
|
||
D1RetentionUsers *int64 `json:"d1_retention_users"`
|
||
D1RetentionBaseUsers *int64 `json:"d1_retention_base_users"`
|
||
D1RetentionRate *float64 `json:"d1_retention_rate"`
|
||
D7RetentionUsers *int64 `json:"d7_retention_users"`
|
||
D7RetentionBaseUsers *int64 `json:"d7_retention_base_users"`
|
||
D7RetentionRate *float64 `json:"d7_retention_rate"`
|
||
D30RetentionUsers *int64 `json:"d30_retention_users"`
|
||
D30RetentionBaseUsers *int64 `json:"d30_retention_base_users"`
|
||
D30RetentionRate *float64 `json:"d30_retention_rate"`
|
||
}
|
||
|
||
type DailyCountryStat struct {
|
||
StatDay string `json:"stat_day"`
|
||
Label string `json:"label"`
|
||
CountryBreakdown
|
||
}
|
||
|
||
type ReportMetricSource struct {
|
||
Key string `json:"key"`
|
||
Label string `json:"label"`
|
||
Status string `json:"status"`
|
||
Source string `json:"source"`
|
||
Definition string `json:"definition"`
|
||
MissingReason string `json:"missing_reason,omitempty"`
|
||
}
|
||
|
||
type SelfGameOverviewQuery struct {
|
||
AppCode string
|
||
StatTZ string
|
||
StartMS int64
|
||
EndMS int64
|
||
GameID string
|
||
CountryID int64
|
||
RegionID int64
|
||
}
|
||
|
||
type SelfGameOverview struct {
|
||
AppCode string `json:"app_code"`
|
||
StatTZ string `json:"stat_tz"`
|
||
StartMS int64 `json:"start_ms"`
|
||
EndMS int64 `json:"end_ms"`
|
||
GameID string `json:"game_id"`
|
||
CountryID int64 `json:"country_id"`
|
||
RegionID int64 `json:"region_id"`
|
||
Events []SelfGameEventStat `json:"events"`
|
||
MatchTotals SelfGameMatchTotals `json:"match_totals"`
|
||
StakeBreakdown []SelfGameStakeStat `json:"stake_breakdown"`
|
||
PoolStats []SelfGamePoolStat `json:"pool_stats"`
|
||
ParticipantDistribution []SelfGameParticipantStat `json:"participant_distribution"`
|
||
UserRisk []SelfGameUserRiskStat `json:"user_risk"`
|
||
Retention SelfGameRetention `json:"retention"`
|
||
MetricDefinitions []MetricDefinition `json:"metric_definitions"`
|
||
Funnel []SelfGameFunnelStep `json:"funnel"`
|
||
Conversion SelfGameConversion `json:"conversion"`
|
||
}
|
||
|
||
type PlatformGrantCoinQuery struct {
|
||
AppCode string
|
||
StatTZ string
|
||
StartMS int64
|
||
EndMS int64
|
||
StatDay string
|
||
CountryID int64
|
||
RegionID int64
|
||
UserID int64
|
||
EventType string
|
||
Page int
|
||
PageSize int
|
||
}
|
||
|
||
type AppTrackingFunnelQuery struct {
|
||
AppCode string
|
||
StatTZ string
|
||
StartMS int64
|
||
EndMS int64
|
||
CountryID int64
|
||
RegionID int64
|
||
RegionIDs []int64
|
||
}
|
||
|
||
type AppTrackingFunnel struct {
|
||
AppCode string `json:"app_code"`
|
||
StatTZ string `json:"stat_tz"`
|
||
StartMS int64 `json:"start_ms"`
|
||
EndMS int64 `json:"end_ms"`
|
||
CountryID int64 `json:"country_id"`
|
||
RegionID int64 `json:"region_id"`
|
||
RegionIDs []int64 `json:"region_ids,omitempty"`
|
||
Steps []AppTrackingFunnelStep `json:"steps"`
|
||
Cohorts []AppTrackingFunnelCohort `json:"cohorts"`
|
||
Totals AppTrackingFunnelTotals `json:"totals"`
|
||
}
|
||
|
||
type AppTrackingFunnelStep struct {
|
||
Key string `json:"key"`
|
||
EventName string `json:"event_name"`
|
||
Label string `json:"label"`
|
||
EventCount int64 `json:"event_count"`
|
||
UserCount int64 `json:"user_count"`
|
||
DeviceCount int64 `json:"device_count"`
|
||
OverallConversionRate float64 `json:"overall_conversion_rate"`
|
||
PreviousConversionRate float64 `json:"previous_conversion_rate"`
|
||
DropoffUsers int64 `json:"dropoff_users"`
|
||
IsFailure bool `json:"is_failure,omitempty"`
|
||
}
|
||
|
||
type AppTrackingFunnelCohort struct {
|
||
Dimension string `json:"dimension"`
|
||
DimensionLabel string `json:"dimension_label"`
|
||
Value string `json:"value"`
|
||
Label string `json:"label"`
|
||
BaseUsers int64 `json:"base_users"`
|
||
D1RetentionUsers int64 `json:"d1_retention_users"`
|
||
D1RetentionRate float64 `json:"d1_retention_rate"`
|
||
Steps []AppTrackingCohortStepCount `json:"steps"`
|
||
}
|
||
|
||
type AppTrackingCohortStepCount struct {
|
||
EventName string `json:"event_name"`
|
||
Label string `json:"label"`
|
||
UserCount int64 `json:"user_count"`
|
||
}
|
||
|
||
type AppTrackingFunnelTotals struct {
|
||
LoginStartUsers int64 `json:"login_start_users"`
|
||
LoginSuccessUsers int64 `json:"login_success_users"`
|
||
ProfileCompleteUsers int64 `json:"profile_complete_users"`
|
||
RoomImpressionUsers int64 `json:"room_impression_users"`
|
||
RoomClickUsers int64 `json:"room_click_users"`
|
||
RoomJoinSuccessUsers int64 `json:"room_join_success_users"`
|
||
RoomJoinFailUsers int64 `json:"room_join_fail_users"`
|
||
Stay30sUsers int64 `json:"stay_30s_users"`
|
||
Stay3mUsers int64 `json:"stay_3m_users"`
|
||
Stay10mUsers int64 `json:"stay_10m_users"`
|
||
SendMessageUsers int64 `json:"send_message_users"`
|
||
FollowHostUsers int64 `json:"follow_host_users"`
|
||
AddFriendUsers int64 `json:"add_friend_users"`
|
||
CheckInUsers int64 `json:"check_in_users"`
|
||
PushPermissionUsers int64 `json:"push_permission_users"`
|
||
D1RetentionBaseUsers int64 `json:"d1_retention_base_users"`
|
||
D1RetentionUsers int64 `json:"d1_retention_users"`
|
||
D1RetentionRate float64 `json:"d1_retention_rate"`
|
||
}
|
||
|
||
type PlatformGrantCoinUser struct {
|
||
UserID int64 `json:"user_id"`
|
||
CountryID int64 `json:"country_id"`
|
||
RegionID int64 `json:"region_id"`
|
||
TotalCoin int64 `json:"total_coin"`
|
||
RecordCount int64 `json:"record_count"`
|
||
FirstGrantedAtMS int64 `json:"first_granted_at_ms"`
|
||
LastGrantedAtMS int64 `json:"last_granted_at_ms"`
|
||
}
|
||
|
||
type PlatformGrantCoinRecord struct {
|
||
EventID string `json:"event_id"`
|
||
EventType string `json:"event_type"`
|
||
UserID int64 `json:"user_id"`
|
||
CountryID int64 `json:"country_id"`
|
||
RegionID int64 `json:"region_id"`
|
||
Amount int64 `json:"amount"`
|
||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||
}
|
||
|
||
type PlatformGrantCoinUserPage struct {
|
||
Items []PlatformGrantCoinUser `json:"items"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"pageSize"`
|
||
Total int64 `json:"total"`
|
||
}
|
||
|
||
type PlatformGrantCoinRecordPage struct {
|
||
Items []PlatformGrantCoinRecord `json:"items"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"pageSize"`
|
||
Total int64 `json:"total"`
|
||
}
|
||
|
||
type SelfGameEventStat struct {
|
||
GameID string `json:"game_id"`
|
||
EventName string `json:"event_name"`
|
||
EventCount int64 `json:"event_count"`
|
||
UserCount int64 `json:"user_count"`
|
||
SuccessCount int64 `json:"success_count"`
|
||
ErrorCount int64 `json:"error_count"`
|
||
DurationMSSum int64 `json:"duration_ms_sum"`
|
||
AvgDurationMS int64 `json:"avg_duration_ms"`
|
||
}
|
||
|
||
type SelfGameMatchTotals struct {
|
||
CreatedMatches int64 `json:"created_matches"`
|
||
MatchedMatches int64 `json:"matched_matches"`
|
||
SettledMatches int64 `json:"settled_matches"`
|
||
CanceledMatches int64 `json:"canceled_matches"`
|
||
FailedMatches int64 `json:"failed_matches"`
|
||
HumanMatches int64 `json:"human_matches"`
|
||
RobotMatches int64 `json:"robot_matches"`
|
||
HumanParticipants int64 `json:"human_participants"`
|
||
RobotParticipants int64 `json:"robot_participants"`
|
||
WinnerCount int64 `json:"winner_count"`
|
||
LoserCount int64 `json:"loser_count"`
|
||
DrawCount int64 `json:"draw_count"`
|
||
UserStakeCoin int64 `json:"user_stake_coin"`
|
||
RobotStakeCoin int64 `json:"robot_stake_coin"`
|
||
PayoutCoin int64 `json:"payout_coin"`
|
||
RefundCoin int64 `json:"refund_coin"`
|
||
PlatformProfitCoin int64 `json:"platform_profit_coin"`
|
||
PoolDeltaCoin int64 `json:"pool_delta_coin"`
|
||
ForcedPlayerLoseMatches int64 `json:"forced_player_lose_matches"`
|
||
WaitMSSum int64 `json:"wait_ms_sum"`
|
||
DurationMSSum int64 `json:"duration_ms_sum"`
|
||
AvgWaitMS int64 `json:"avg_wait_ms"`
|
||
AvgDurationMS int64 `json:"avg_duration_ms"`
|
||
CancelRate float64 `json:"cancel_rate"`
|
||
FailRate float64 `json:"fail_rate"`
|
||
RobotMatchRate float64 `json:"robot_match_rate"`
|
||
PlatformProfitRate float64 `json:"platform_profit_rate"`
|
||
}
|
||
|
||
type SelfGameStakeStat struct {
|
||
GameID string `json:"game_id"`
|
||
StakeCoin int64 `json:"stake_coin"`
|
||
CreatedMatches int64 `json:"created_matches"`
|
||
MatchedMatches int64 `json:"matched_matches"`
|
||
SettledMatches int64 `json:"settled_matches"`
|
||
CanceledMatches int64 `json:"canceled_matches"`
|
||
FailedMatches int64 `json:"failed_matches"`
|
||
HumanParticipants int64 `json:"human_participants"`
|
||
RobotParticipants int64 `json:"robot_participants"`
|
||
UserStakeCoin int64 `json:"user_stake_coin"`
|
||
RobotStakeCoin int64 `json:"robot_stake_coin"`
|
||
PayoutCoin int64 `json:"payout_coin"`
|
||
RefundCoin int64 `json:"refund_coin"`
|
||
PlatformProfitCoin int64 `json:"platform_profit_coin"`
|
||
PoolDeltaCoin int64 `json:"pool_delta_coin"`
|
||
PlatformProfitRate float64 `json:"platform_profit_rate"`
|
||
}
|
||
|
||
type SelfGamePoolStat struct {
|
||
GameID string `json:"game_id"`
|
||
Direction string `json:"direction"`
|
||
Reason string `json:"reason"`
|
||
TransactionCount int64 `json:"transaction_count"`
|
||
InCoin int64 `json:"in_coin"`
|
||
OutCoin int64 `json:"out_coin"`
|
||
BalanceAfterCoin int64 `json:"balance_after_coin"`
|
||
}
|
||
|
||
type SelfGameParticipantStat struct {
|
||
GameID string `json:"game_id"`
|
||
StakeCoin int64 `json:"stake_coin"`
|
||
ParticipantType string `json:"participant_type"`
|
||
Result string `json:"result"`
|
||
RPSGesture string `json:"rps_gesture"`
|
||
DicePoint int64 `json:"dice_point"`
|
||
ParticipantCount int64 `json:"participant_count"`
|
||
PayoutCoin int64 `json:"payout_coin"`
|
||
NetWinCoin int64 `json:"net_win_coin"`
|
||
WinRate float64 `json:"win_rate"`
|
||
}
|
||
|
||
type SelfGameUserRiskStat struct {
|
||
GameID string `json:"game_id"`
|
||
UserID int64 `json:"user_id"`
|
||
MatchCount int64 `json:"match_count"`
|
||
WinCount int64 `json:"win_count"`
|
||
LoseCount int64 `json:"lose_count"`
|
||
DrawCount int64 `json:"draw_count"`
|
||
CancelCount int64 `json:"cancel_count"`
|
||
StakeCoin int64 `json:"stake_coin"`
|
||
PayoutCoin int64 `json:"payout_coin"`
|
||
NetWinCoin int64 `json:"net_win_coin"`
|
||
WinRate float64 `json:"win_rate"`
|
||
CancelRate float64 `json:"cancel_rate"`
|
||
}
|
||
|
||
type SelfGameRetention struct {
|
||
CohortUsers int64 `json:"cohort_users"`
|
||
Day1Users int64 `json:"day1_users"`
|
||
Day7Users int64 `json:"day7_users"`
|
||
Day1Rate float64 `json:"day1_rate"`
|
||
Day7Rate float64 `json:"day7_rate"`
|
||
}
|
||
|
||
type MetricDefinition struct {
|
||
Metric string `json:"metric"`
|
||
Definition string `json:"definition"`
|
||
}
|
||
|
||
type SelfGameFunnelStep struct {
|
||
EventName string `json:"event_name"`
|
||
EventCount int64 `json:"event_count"`
|
||
UserCount int64 `json:"user_count"`
|
||
}
|
||
|
||
type SelfGameConversion struct {
|
||
PageToMatchClickRate float64 `json:"page_to_match_click_rate"`
|
||
MatchClickToSuccessRate float64 `json:"match_click_to_success_rate"`
|
||
SuccessToSettlementRate float64 `json:"success_to_settlement_rate"`
|
||
SettlementToPlayAgainRate float64 `json:"settlement_to_play_again_rate"`
|
||
PageToSettlementRate float64 `json:"page_to_settlement_rate"`
|
||
}
|
||
|
||
func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) {
|
||
app := appcode.Normalize(query.AppCode)
|
||
if app == "" {
|
||
app = "lalu"
|
||
}
|
||
statTZ := normalizeStatTZ(query.StatTZ)
|
||
startDay := statDayIn(query.StartMS, statTZ)
|
||
endDay := statDayIn(query.EndMS-1, statTZ)
|
||
previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
|
||
overview, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
previous, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.AppCode, overview.StatTZ, overview.StartMS, overview.EndMS, overview.CountryID = app, statTZ, query.StartMS, query.EndMS, query.CountryID
|
||
previous.AppCode, previous.StatTZ, previous.CountryID = app, statTZ, query.CountryID
|
||
// 新增付费用户来自日充值用户去重表和注册表的交集;只读聚合小表,避免 Databi 页面回扫钱包或订单明细。
|
||
if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
||
return Overview{}, err
|
||
}
|
||
if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
|
||
return Overview{}, err
|
||
}
|
||
if robotGifts, err := r.queryRealRoomRobotGiftOverview(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
||
return Overview{}, err
|
||
} else {
|
||
overview.RealRoomRobotNormalGiftCoin = robotGifts.RealRoomRobotNormalGiftCoin
|
||
overview.RealRoomRobotLuckyGiftCoin = robotGifts.RealRoomRobotLuckyGiftCoin
|
||
overview.RealRoomRobotSuperGiftCoin = robotGifts.RealRoomRobotSuperGiftCoin
|
||
overview.RealRoomRobotTotalGiftCoin = robotGifts.RealRoomRobotTotalGiftCoin
|
||
overview.RealRoomRobotGiftRoomCount = robotGifts.RealRoomRobotGiftRoomCount
|
||
overview.RealRoomRobotAvgRoomGiftCoin = robotGifts.RealRoomRobotAvgRoomGiftCoin
|
||
}
|
||
// Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
|
||
pools, err := r.queryLuckyGiftPools(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.LuckyGiftPools = pools
|
||
overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(pools)
|
||
if statTZ == StatTZUTC {
|
||
// activity-service 的 lucky_draw_pool_day_stats 仍是 UTC 日表;只有 UTC 查询能拿它覆盖旧统计行,北京日必须使用本服务 stat_tz=Asia/Shanghai 的读模型。
|
||
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
||
return Overview{}, err
|
||
} else if ok {
|
||
oldTurnover, oldPayout := overview.LuckyGiftTurnover, overview.LuckyGiftPayout
|
||
overview.LuckyGiftPools = activityPools
|
||
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
||
overview.ConsumedCoin = replaceEmbeddedFlow(overview.ConsumedCoin, oldTurnover, overview.LuckyGiftTurnover)
|
||
overview.OutputCoin = replaceEmbeddedFlow(overview.OutputCoin, oldPayout, overview.LuckyGiftPayout)
|
||
overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(activityPools)
|
||
}
|
||
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
|
||
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
|
||
return Overview{}, err
|
||
} else if ok {
|
||
oldTurnover, oldPayout := previous.LuckyGiftTurnover, previous.LuckyGiftPayout
|
||
previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
||
previous.ConsumedCoin = replaceEmbeddedFlow(previous.ConsumedCoin, oldTurnover, previous.LuckyGiftTurnover)
|
||
previous.OutputCoin = replaceEmbeddedFlow(previous.OutputCoin, oldPayout, previous.LuckyGiftPayout)
|
||
}
|
||
}
|
||
applyOverviewDerivedMetrics(&overview)
|
||
applyOverviewDerivedMetrics(&previous)
|
||
overview.applyDeltaRates(previous)
|
||
retentionCube, err := r.queryRetentionCube(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.Retention = retentionCube.Total.toRetention()
|
||
ranking, err := r.queryGameRanking(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.GameRanking = ranking
|
||
if query.CountryID <= 0 {
|
||
breakdown, err := r.queryCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByCountry)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.CountryBreakdown = breakdown
|
||
dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByDayCountry)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.DailyCountryBreakdown = dailyCountryBreakdown
|
||
}
|
||
seriesStartDay, seriesEndDay := startDay, endDay
|
||
if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
|
||
seriesStartDay = statDayIn(query.SeriesStartMS, statTZ)
|
||
seriesEndDay = statDayIn(query.SeriesEndMS-1, statTZ)
|
||
}
|
||
seriesRetentionByDay := retentionCube.ByDay
|
||
if seriesStartDay != startDay || seriesEndDay != endDay {
|
||
seriesRetentionCube, err := r.queryRetentionCube(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
seriesRetentionByDay = seriesRetentionCube.ByDay
|
||
}
|
||
// daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。
|
||
dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID, seriesRetentionByDay)
|
||
if err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.DailySeries = dailySeries
|
||
overview.ReportMetricSources = reportMetricSources()
|
||
return overview, nil
|
||
}
|
||
|
||
func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOverviewQuery) (SelfGameOverview, error) {
|
||
startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
|
||
app := appcode.Normalize(query.AppCode)
|
||
if app == "" {
|
||
app = "lalu"
|
||
}
|
||
statTZ := normalizeStatTZ(query.StatTZ)
|
||
startDay := statDayIn(startMS, statTZ)
|
||
endDay := statDayIn(endMS-1, statTZ)
|
||
gameID := normalizeShortText(query.GameID)
|
||
overview := SelfGameOverview{
|
||
AppCode: app,
|
||
StatTZ: statTZ,
|
||
StartMS: startMS,
|
||
EndMS: endMS,
|
||
GameID: gameID,
|
||
CountryID: query.CountryID,
|
||
RegionID: query.RegionID,
|
||
}
|
||
events, err := r.querySelfGameEvents(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.Events = events
|
||
overview.Funnel, overview.Conversion = buildSelfGameFunnel(events)
|
||
totals, err := r.querySelfGameMatchTotals(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.MatchTotals = totals
|
||
stakes, err := r.querySelfGameStakeBreakdown(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.StakeBreakdown = stakes
|
||
pools, err := r.querySelfGamePools(ctx, app, statTZ, startDay, endDay, gameID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.PoolStats = pools
|
||
participants, err := r.querySelfGameParticipantDistribution(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.ParticipantDistribution = participants
|
||
risk, err := r.querySelfGameUserRisk(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.UserRisk = risk
|
||
retention, err := r.querySelfGameRetention(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
|
||
if err != nil {
|
||
return SelfGameOverview{}, err
|
||
}
|
||
overview.Retention = retention
|
||
overview.MetricDefinitions = selfGameMetricDefinitions()
|
||
return overview, nil
|
||
}
|
||
|
||
func (r *Repository) QueryAppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (AppTrackingFunnel, error) {
|
||
startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
|
||
app := appcode.Normalize(query.AppCode)
|
||
if app == "" {
|
||
app = "lalu"
|
||
}
|
||
statTZ := normalizeStatTZ(query.StatTZ)
|
||
out := AppTrackingFunnel{
|
||
AppCode: app,
|
||
StatTZ: statTZ,
|
||
StartMS: startMS,
|
||
EndMS: endMS,
|
||
CountryID: query.CountryID,
|
||
RegionID: query.RegionID,
|
||
RegionIDs: normalizeTrackingRegionIDs(query.RegionIDs),
|
||
Steps: []AppTrackingFunnelStep{},
|
||
Cohorts: []AppTrackingFunnelCohort{},
|
||
}
|
||
steps, err := r.queryAppTrackingFunnelSteps(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
|
||
if err != nil {
|
||
return AppTrackingFunnel{}, err
|
||
}
|
||
out.Steps = buildAppTrackingFunnelSteps(steps)
|
||
out.Totals = appTrackingTotals(out.Steps)
|
||
d1Base, d1Users, err := r.queryAppTrackingD1Total(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
|
||
if err != nil {
|
||
return AppTrackingFunnel{}, err
|
||
}
|
||
out.Totals.D1RetentionBaseUsers = d1Base
|
||
out.Totals.D1RetentionUsers = d1Users
|
||
out.Totals.D1RetentionRate = ratio(d1Users, d1Base)
|
||
cohorts, err := r.queryAppTrackingD1Cohorts(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
|
||
if err != nil {
|
||
return AppTrackingFunnel{}, err
|
||
}
|
||
out.Cohorts = cohorts
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Repository) QueryPlatformGrantCoinUsers(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinUserPage, error) {
|
||
app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query)
|
||
filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, 0, query.EventType)
|
||
out := PlatformGrantCoinUserPage{Items: []PlatformGrantCoinUser{}, Page: page, PageSize: pageSize}
|
||
// 明细下钻只读 statistics-service 自己维护的事件投影;钱包库是写账事实源,不能在报表查询时跨库扫账务流水。
|
||
countRow := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT 1
|
||
FROM stat_platform_grant_coin_events
|
||
WHERE `+filter+`
|
||
GROUP BY user_id, country_id, region_id
|
||
) grouped`, args...)
|
||
if err := countRow.Scan(&out.Total); err != nil {
|
||
return PlatformGrantCoinUserPage{}, err
|
||
}
|
||
rowsArgs := append(append([]any{}, args...), pageSize, offset)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT user_id, country_id, region_id,
|
||
COALESCE(SUM(amount),0),
|
||
COUNT(*),
|
||
COALESCE(MIN(occurred_at_ms),0),
|
||
COALESCE(MAX(occurred_at_ms),0)
|
||
FROM stat_platform_grant_coin_events
|
||
WHERE `+filter+`
|
||
GROUP BY user_id, country_id, region_id
|
||
ORDER BY COALESCE(SUM(amount),0) DESC, COALESCE(MAX(occurred_at_ms),0) DESC, user_id DESC
|
||
LIMIT ? OFFSET ?`, rowsArgs...)
|
||
if err != nil {
|
||
return PlatformGrantCoinUserPage{}, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var item PlatformGrantCoinUser
|
||
if err := rows.Scan(&item.UserID, &item.CountryID, &item.RegionID, &item.TotalCoin, &item.RecordCount, &item.FirstGrantedAtMS, &item.LastGrantedAtMS); err != nil {
|
||
return PlatformGrantCoinUserPage{}, err
|
||
}
|
||
out.Items = append(out.Items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return PlatformGrantCoinUserPage{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Repository) QueryPlatformGrantCoinRecords(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinRecordPage, error) {
|
||
app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query)
|
||
filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, query.UserID, query.EventType)
|
||
out := PlatformGrantCoinRecordPage{Items: []PlatformGrantCoinRecord{}, Page: page, PageSize: pageSize}
|
||
// 来源页按单个用户分页读取事件事实,排序使用事件发生时间,避免同一用户大量奖励一次性塞给前端。
|
||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM stat_platform_grant_coin_events WHERE `+filter, args...).Scan(&out.Total); err != nil {
|
||
return PlatformGrantCoinRecordPage{}, err
|
||
}
|
||
rowsArgs := append(append([]any{}, args...), pageSize, offset)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT event_id, event_type, user_id, country_id, region_id, amount, occurred_at_ms
|
||
FROM stat_platform_grant_coin_events
|
||
WHERE `+filter+`
|
||
ORDER BY occurred_at_ms DESC, event_id DESC
|
||
LIMIT ? OFFSET ?`, rowsArgs...)
|
||
if err != nil {
|
||
return PlatformGrantCoinRecordPage{}, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var item PlatformGrantCoinRecord
|
||
if err := rows.Scan(&item.EventID, &item.EventType, &item.UserID, &item.CountryID, &item.RegionID, &item.Amount, &item.OccurredAtMS); err != nil {
|
||
return PlatformGrantCoinRecordPage{}, err
|
||
}
|
||
out.Items = append(out.Items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return PlatformGrantCoinRecordPage{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
|
||
args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||
COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0),
|
||
COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0), COALESCE(SUM(salary_transfer_coin),0),
|
||
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||
COALESCE(SUM(gift_coin_spent),0),
|
||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||
COALESCE(SUM(game_players),0)
|
||
FROM stat_app_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
|
||
var overview Overview
|
||
if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.CoinSellerStockCoin, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.CoinTotal, &overview.ConsumedCoin, &overview.OutputCoin, &overview.PlatformGrantCoin, &overview.ManualGrantCoin, &overview.SalaryUSDMinor, &overview.SalaryTransferCoin, &overview.MicOnlineMS, &overview.MicOnlineUsers, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
|
||
return Overview{}, err
|
||
}
|
||
return overview, nil
|
||
}
|
||
|
||
func (r *Repository) queryNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (int64, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND p.country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
// p 表和 r 表都已经是统计小表,这里只按充值发生地过滤,保证新增付费用户口径和付费用户卡片一致。
|
||
filter += " AND p.region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(DISTINCT p.user_id)
|
||
FROM stat_recharge_day_payers p
|
||
JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id
|
||
WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ?
|
||
AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...)
|
||
var count int64
|
||
if err := row.Scan(&count); err != nil && err != sql.ErrNoRows {
|
||
return 0, err
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT COALESCE(SUM(normal_gift_coin),0),
|
||
COALESCE(SUM(lucky_gift_coin),0),
|
||
COALESCE(SUM(super_lucky_gift_coin),0),
|
||
COUNT(*)
|
||
FROM stat_real_room_robot_gift_day_rooms
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
|
||
var overview Overview
|
||
if err := row.Scan(&overview.RealRoomRobotNormalGiftCoin, &overview.RealRoomRobotLuckyGiftCoin, &overview.RealRoomRobotSuperGiftCoin, &overview.RealRoomRobotGiftRoomCount); err != nil {
|
||
return Overview{}, err
|
||
}
|
||
overview.RealRoomRobotTotalGiftCoin = overview.RealRoomRobotNormalGiftCoin + overview.RealRoomRobotLuckyGiftCoin + overview.RealRoomRobotSuperGiftCoin
|
||
overview.RealRoomRobotAvgRoomGiftCoin = div(overview.RealRoomRobotTotalGiftCoin, overview.RealRoomRobotGiftRoomCount)
|
||
return overview, nil
|
||
}
|
||
|
||
func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64, retentionByDay map[string]retentionCount) ([]DailySeriesItem, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'),
|
||
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
|
||
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||
COALESCE(SUM(gift_coin_spent),0),
|
||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0),
|
||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0)
|
||
FROM stat_app_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY stat_day
|
||
ORDER BY stat_day ASC`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []DailySeriesItem{}
|
||
for rows.Next() {
|
||
var item DailySeriesItem
|
||
var gamePayout, gameRefund int64
|
||
if err := rows.Scan(&item.StatDay, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.OutputCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.GameTurnover, &gamePayout, &gameRefund); err != nil {
|
||
return nil, err
|
||
}
|
||
item.Label = item.StatDay
|
||
item.GameProfit = item.GameTurnover - gamePayout - gameRefund
|
||
applyDailySeriesDerivedMetrics(&item)
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
newPaidUsersByDay, err := r.queryDailyNewPaidUsers(ctx, app, statTZ, startDay, endDay, countryID, regionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range out {
|
||
out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
|
||
}
|
||
for index := range out {
|
||
if retention, ok := retentionByDay[out[index].StatDay]; ok {
|
||
out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
|
||
out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
|
||
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
|
||
}
|
||
}
|
||
superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range out {
|
||
if super, ok := superByDay[out[index].StatDay]; ok {
|
||
out[index].SuperLuckyGiftTurnover = super.Turnover
|
||
out[index].SuperLuckyGiftPayout = super.Payout
|
||
applyDailySeriesDerivedMetrics(&out[index])
|
||
}
|
||
}
|
||
if normalizeStatTZ(statTZ) == StatTZUTC {
|
||
if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
|
||
return nil, err
|
||
} else if ok {
|
||
out = mergeActivityLuckyGiftDaily(out, activityDaily)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND p.country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND p.region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(p.stat_day, '%Y-%m-%d'), COUNT(DISTINCT p.user_id)
|
||
FROM stat_recharge_day_payers p
|
||
JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id
|
||
WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ?
|
||
AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY p.stat_day`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[string]int64{}
|
||
for rows.Next() {
|
||
var day string
|
||
var count int64
|
||
if err := rows.Scan(&day, &count); err != nil {
|
||
return nil, err
|
||
}
|
||
out[day] = count
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
type retentionCount struct {
|
||
RegisteredUsers int64
|
||
D1Users int64
|
||
D1Base int64
|
||
D7Users int64
|
||
D7Base int64
|
||
D30Users int64
|
||
D30Base int64
|
||
}
|
||
|
||
func (count *retentionCount) add(next retentionCount) {
|
||
count.RegisteredUsers += next.RegisteredUsers
|
||
count.D1Users += next.D1Users
|
||
count.D1Base += next.D1Base
|
||
count.D7Users += next.D7Users
|
||
count.D7Base += next.D7Base
|
||
count.D30Users += next.D30Users
|
||
count.D30Base += next.D30Base
|
||
}
|
||
|
||
func (count retentionCount) toRetention() Retention {
|
||
out := Retention{RegisteredUsers: count.RegisteredUsers}
|
||
out.Day1Users, out.Day1BaseUsers, out.Day1Rate = retentionPointers(count.D1Users, count.D1Base)
|
||
out.Day7Users, out.Day7BaseUsers, out.Day7Rate = retentionPointers(count.D7Users, count.D7Base)
|
||
out.Day30Users, out.Day30BaseUsers, out.Day30Rate = retentionPointers(count.D30Users, count.D30Base)
|
||
return out
|
||
}
|
||
|
||
type retentionCube struct {
|
||
Total retentionCount
|
||
ByDay map[string]retentionCount
|
||
ByCountry map[int64]retentionCount
|
||
ByDayCountry map[string]retentionCount
|
||
}
|
||
|
||
func newRetentionCube() retentionCube {
|
||
return retentionCube{
|
||
ByDay: map[string]retentionCount{},
|
||
ByCountry: map[int64]retentionCount{},
|
||
ByDayCountry: map[string]retentionCount{},
|
||
}
|
||
}
|
||
|
||
func (cube *retentionCube) add(day string, countryID int64, count retentionCount) {
|
||
cube.Total.add(count)
|
||
|
||
dayCount := cube.ByDay[day]
|
||
dayCount.add(count)
|
||
cube.ByDay[day] = dayCount
|
||
|
||
countryCount := cube.ByCountry[countryID]
|
||
countryCount.add(count)
|
||
cube.ByCountry[countryID] = countryCount
|
||
|
||
dayCountryKey := dailyCountryKey(day, countryID)
|
||
dayCountryCount := cube.ByDayCountry[dayCountryKey]
|
||
dayCountryCount.add(count)
|
||
cube.ByDayCountry[dayCountryKey] = dayCountryCount
|
||
}
|
||
|
||
func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (retentionCube, error) {
|
||
tz := normalizeStatTZ(statTZ)
|
||
today := statDayIn(time.Now().UnixMilli(), tz)
|
||
args := []any{
|
||
today, today,
|
||
today, today,
|
||
today, today,
|
||
app, tz, startDay, endDay,
|
||
}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND r.country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND r.region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(r.registered_day, '%Y-%m-%d'), r.country_id, COUNT(*),
|
||
COALESCE(SUM(CASE WHEN d1.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
|
||
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? THEN 1 ELSE 0 END),0),
|
||
COALESCE(SUM(CASE WHEN d7.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
|
||
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? THEN 1 ELSE 0 END),0),
|
||
COALESCE(SUM(CASE WHEN d30.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
|
||
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? THEN 1 ELSE 0 END),0)
|
||
FROM stat_user_registration r
|
||
LEFT JOIN stat_user_day_activity d1 ON DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? AND d1.app_code = r.app_code AND d1.stat_tz = r.stat_tz AND d1.stat_day = DATE_ADD(r.registered_day, INTERVAL 1 DAY) AND d1.user_id = r.user_id
|
||
LEFT JOIN stat_user_day_activity d7 ON DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? AND d7.app_code = r.app_code AND d7.stat_tz = r.stat_tz AND d7.stat_day = DATE_ADD(r.registered_day, INTERVAL 7 DAY) AND d7.user_id = r.user_id
|
||
LEFT JOIN stat_user_day_activity d30 ON DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? AND d30.app_code = r.app_code AND d30.stat_tz = r.stat_tz AND d30.stat_day = DATE_ADD(r.registered_day, INTERVAL 30 DAY) AND d30.user_id = r.user_id
|
||
WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY r.registered_day, r.country_id`, args...)
|
||
if err != nil {
|
||
return retentionCube{}, err
|
||
}
|
||
defer rows.Close()
|
||
out := newRetentionCube()
|
||
for rows.Next() {
|
||
var day string
|
||
var countryID int64
|
||
var count retentionCount
|
||
if err := rows.Scan(&day, &countryID, &count.RegisteredUsers, &count.D1Users, &count.D1Base, &count.D7Users, &count.D7Base, &count.D30Users, &count.D30Base); err != nil {
|
||
return retentionCube{}, err
|
||
}
|
||
out.add(day, countryID, count)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return retentionCube{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
|
||
if item == nil {
|
||
return
|
||
}
|
||
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
|
||
if channelRechargeUSDMinor > item.RechargeUSDMinor {
|
||
item.RechargeUSDMinor = channelRechargeUSDMinor
|
||
}
|
||
// Databi 同时展示“注册”和“新增”,当前统计服务存储列名是 new_users;这里输出两个历史别名,避免前端为同一口径写 App 专属兼容。
|
||
item.RegisteredUsers = item.NewUsers
|
||
item.Registrations = item.NewUsers
|
||
// 现有 Lalu 聚合表的 paid_users 来自充值用户去重表;充值用户先按同一事实输出,后续如果产品拆出其他付费行为,再新增独立事实列。
|
||
item.RechargeUsers = item.PaidUsers
|
||
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
||
item.ConsumeOutputDelta = item.ConsumedCoin - item.OutputCoin
|
||
item.ConsumeOutputRatio = ratio(item.OutputCoin, item.ConsumedCoin)
|
||
item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout
|
||
item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover)
|
||
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
||
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
||
item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
|
||
item.PayerRate = ratio(item.PaidUsers, item.ActiveUsers)
|
||
item.PaidConversionRate = item.PayerRate
|
||
item.RechargeConversionRate = ratio(item.RechargeUsers, item.ActiveUsers)
|
||
}
|
||
|
||
func applyOverviewDerivedMetrics(overview *Overview) {
|
||
if overview == nil {
|
||
return
|
||
}
|
||
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor;查询时用渠道合计兜底。币商进货是 USDT 充值,币商转普通金币仍只展示金币卡片。
|
||
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor + overview.CoinSellerRechargeUSDMinor
|
||
if channelRechargeUSDMinor > overview.RechargeUSDMinor {
|
||
overview.RechargeUSDMinor = channelRechargeUSDMinor
|
||
}
|
||
// 统计存储层保留最小事实列,HTTP 读模型补齐 Databi 历史字段名,保证 Lalu/Yumi/Aslan 三类 App 共用一套前端解析。
|
||
overview.RegisteredUsers = overview.NewUsers
|
||
overview.Registrations = overview.NewUsers
|
||
overview.RechargeUsers = overview.PaidUsers
|
||
overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund
|
||
overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover)
|
||
overview.ConsumeOutputDelta = overview.ConsumedCoin - overview.OutputCoin
|
||
overview.ConsumeOutputRatio = ratio(overview.OutputCoin, overview.ConsumedCoin)
|
||
overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
|
||
overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
|
||
overview.UPValueUSDMinor = overview.ARPPUUSDMinor
|
||
overview.AvgMicOnlineMS = div(overview.MicOnlineMS, overview.MicOnlineUsers)
|
||
overview.PayerRate = ratio(overview.PaidUsers, overview.ActiveUsers)
|
||
overview.PaidConversionRate = overview.PayerRate
|
||
overview.RechargeConversionRate = ratio(overview.RechargeUsers, overview.ActiveUsers)
|
||
overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
|
||
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
|
||
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
|
||
overview.SuperLuckyGiftProfit = overview.SuperLuckyGiftTurnover - overview.SuperLuckyGiftPayout
|
||
overview.SuperLuckyGiftProfitRate = ratio(overview.SuperLuckyGiftProfit, overview.SuperLuckyGiftTurnover)
|
||
}
|
||
|
||
func (overview *Overview) applyDeltaRates(previous Overview) {
|
||
if overview == nil {
|
||
return
|
||
}
|
||
overview.RechargeDeltaRate = deltaRate(overview.RechargeUSDMinor, previous.RechargeUSDMinor)
|
||
overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor)
|
||
overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers)
|
||
overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers)
|
||
overview.NewPaidUsersDeltaRate = deltaRate(overview.NewPaidUsers, previous.NewPaidUsers)
|
||
overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor)
|
||
overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor)
|
||
overview.PayerRateDeltaPP = percentagePointDelta(overview.PayerRate, previous.PayerRate)
|
||
overview.PaidConversionRateDeltaPP = percentagePointDelta(overview.PaidConversionRate, previous.PaidConversionRate)
|
||
overview.RechargeConversionRateDeltaPP = percentagePointDelta(overview.RechargeConversionRate, previous.RechargeConversionRate)
|
||
overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent)
|
||
overview.CoinSellerTransferDeltaRate = deltaRate(overview.CoinSellerTransferCoin, previous.CoinSellerTransferCoin)
|
||
overview.LuckyGiftTurnoverDeltaRate = deltaRate(overview.LuckyGiftTurnover, previous.LuckyGiftTurnover)
|
||
overview.LuckyGiftPayoutDeltaRate = deltaRate(overview.LuckyGiftPayout, previous.LuckyGiftPayout)
|
||
overview.LuckyGiftProfitDeltaRate = deltaRate(overview.LuckyGiftProfit, previous.LuckyGiftProfit)
|
||
overview.GameTurnoverDeltaRate = deltaRate(overview.GameTurnover, previous.GameTurnover)
|
||
overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
|
||
}
|
||
|
||
func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter = " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0),
|
||
COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0)
|
||
FROM stat_game_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY platform_code, game_id
|
||
ORDER BY COALESCE(SUM(turnover_coin),0) DESC
|
||
LIMIT 50`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []GameRank{}
|
||
for rows.Next() {
|
||
var item GameRank
|
||
if err := rows.Scan(&item.PlatformCode, &item.GameID, &item.TurnoverCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlayerCount); err != nil {
|
||
return nil, err
|
||
}
|
||
item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin - item.RefundCoin
|
||
item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64, retentionByCountry map[int64]retentionCount) ([]CountryBreakdown, error) {
|
||
args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if regionID > 0 {
|
||
filter = " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT country_id, COALESCE(MAX(region_id),0),
|
||
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||
COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0),
|
||
COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0), COALESCE(SUM(salary_transfer_coin),0),
|
||
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||
COALESCE(SUM(gift_coin_spent),0),
|
||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||
COALESCE(SUM(game_players),0)
|
||
FROM stat_app_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY country_id
|
||
ORDER BY COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC
|
||
LIMIT 500`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []CountryBreakdown{}
|
||
for rows.Next() {
|
||
var item CountryBreakdown
|
||
if err := rows.Scan(&item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.OutputCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
|
||
return nil, err
|
||
}
|
||
applyCountryBreakdownDerivedMetrics(&item)
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range out {
|
||
if retention, ok := retentionByCountry[out[index].CountryID]; ok {
|
||
out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
|
||
out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
|
||
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
|
||
}
|
||
}
|
||
superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range out {
|
||
if super, ok := superByCountry[out[index].CountryID]; ok {
|
||
out[index].SuperLuckyGiftTurnover = super.Turnover
|
||
out[index].SuperLuckyGiftPayout = super.Payout
|
||
applyCountryBreakdownDerivedMetrics(&out[index])
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64, retentionByDayCountry map[string]retentionCount) ([]DailyCountryStat, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if regionID > 0 {
|
||
filter = " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(MAX(region_id),0),
|
||
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
|
||
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||
COALESCE(SUM(gift_coin_spent),0),
|
||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||
COALESCE(SUM(game_players),0)
|
||
FROM stat_app_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY stat_day, country_id
|
||
ORDER BY stat_day ASC, COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC
|
||
LIMIT 5000`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []DailyCountryStat{}
|
||
for rows.Next() {
|
||
var item DailyCountryStat
|
||
if err := rows.Scan(&item.StatDay, &item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.OutputCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
|
||
return nil, err
|
||
}
|
||
item.Label = item.StatDay
|
||
applyCountryBreakdownDerivedMetrics(&item.CountryBreakdown)
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range out {
|
||
if retention, ok := retentionByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
|
||
out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
|
||
out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
|
||
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
|
||
}
|
||
}
|
||
superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range out {
|
||
if super, ok := superByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
|
||
out[index].SuperLuckyGiftTurnover = super.Turnover
|
||
out[index].SuperLuckyGiftPayout = super.Payout
|
||
applyCountryBreakdownDerivedMetrics(&out[index].CountryBreakdown)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
|
||
if item == nil {
|
||
return
|
||
}
|
||
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
|
||
if channelRechargeUSDMinor > item.RechargeUSDMinor {
|
||
item.RechargeUSDMinor = channelRechargeUSDMinor
|
||
}
|
||
item.RegisteredUsers = item.NewUsers
|
||
item.Registrations = item.NewUsers
|
||
item.RechargeUsers = item.PaidUsers
|
||
item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund
|
||
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
|
||
item.ConsumeOutputDelta = item.ConsumedCoin - item.OutputCoin
|
||
item.ConsumeOutputRatio = ratio(item.OutputCoin, item.ConsumedCoin)
|
||
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
||
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
||
item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
|
||
item.PayerRate = ratio(item.PaidUsers, item.ActiveUsers)
|
||
item.PaidConversionRate = item.PayerRate
|
||
item.RechargeConversionRate = ratio(item.RechargeUsers, item.ActiveUsers)
|
||
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
||
item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
|
||
item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
|
||
item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout
|
||
item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover)
|
||
}
|
||
|
||
func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter = " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||
FROM stat_lucky_gift_pool_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||
GROUP BY pool_id
|
||
ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []LuckyGiftPoolStat{}
|
||
for rows.Next() {
|
||
var item LuckyGiftPoolStat
|
||
if err := rows.Scan(&item.PoolID, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
|
||
return nil, err
|
||
}
|
||
item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
|
||
item.PayoutRate = ratio(item.PayoutCoin, item.TurnoverCoin)
|
||
item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) queryLuckyGiftPoolsFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, bool, error) {
|
||
if r == nil || r.activityDB == nil {
|
||
return nil, false, nil
|
||
}
|
||
args := []any{app, startDay, endDay}
|
||
filter := ""
|
||
if regionID > 0 {
|
||
filter = " AND visible_region_id = ?"
|
||
args = append(args, regionID)
|
||
} else if countryID > 0 {
|
||
filter = " AND visible_region_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
rows, err := r.activityDB.QueryContext(ctx, `
|
||
SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||
FROM lucky_draw_pool_day_stats
|
||
WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+`
|
||
GROUP BY pool_id
|
||
ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...)
|
||
if err != nil {
|
||
if isMissingTableError(err) {
|
||
return nil, false, nil
|
||
}
|
||
return nil, false, err
|
||
}
|
||
defer rows.Close()
|
||
out := []LuckyGiftPoolStat{}
|
||
for rows.Next() {
|
||
var item LuckyGiftPoolStat
|
||
if err := rows.Scan(&item.PoolID, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
|
||
return nil, false, err
|
||
}
|
||
item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
|
||
item.PayoutRate = ratio(item.PayoutCoin, item.TurnoverCoin)
|
||
item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, false, err
|
||
}
|
||
return out, len(out) > 0, nil
|
||
}
|
||
|
||
func (r *Repository) queryLuckyGiftDailyFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]LuckyGiftPoolStat, bool, error) {
|
||
if r == nil || r.activityDB == nil {
|
||
return nil, false, nil
|
||
}
|
||
args := []any{app, startDay, endDay}
|
||
filter := ""
|
||
if regionID > 0 {
|
||
filter = " AND visible_region_id = ?"
|
||
args = append(args, regionID)
|
||
} else if countryID > 0 {
|
||
filter = " AND visible_region_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
rows, err := r.activityDB.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||
FROM lucky_draw_pool_day_stats
|
||
WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+`
|
||
GROUP BY stat_day`, args...)
|
||
if err != nil {
|
||
if isMissingTableError(err) {
|
||
return nil, false, nil
|
||
}
|
||
return nil, false, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[string]LuckyGiftPoolStat{}
|
||
for rows.Next() {
|
||
var day string
|
||
var item LuckyGiftPoolStat
|
||
if err := rows.Scan(&day, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
|
||
return nil, false, err
|
||
}
|
||
item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
|
||
out[day] = item
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, false, err
|
||
}
|
||
return out, len(out) > 0, nil
|
||
}
|
||
|
||
func mergeActivityLuckyGiftDaily(items []DailySeriesItem, activityDaily map[string]LuckyGiftPoolStat) []DailySeriesItem {
|
||
byDay := make(map[string]int, len(items))
|
||
for index := range items {
|
||
byDay[items[index].StatDay] = index
|
||
}
|
||
for day, activity := range activityDaily {
|
||
index, exists := byDay[day]
|
||
if !exists {
|
||
items = append(items, DailySeriesItem{StatDay: day, Label: day})
|
||
index = len(items) - 1
|
||
byDay[day] = index
|
||
}
|
||
oldTurnover, oldPayout := items[index].LuckyGiftTurnover, items[index].LuckyGiftPayout
|
||
items[index].LuckyGiftTurnover = activity.TurnoverCoin
|
||
items[index].LuckyGiftPayout = activity.PayoutCoin
|
||
items[index].ConsumedCoin = replaceEmbeddedFlow(items[index].ConsumedCoin, oldTurnover, activity.TurnoverCoin)
|
||
items[index].OutputCoin = replaceEmbeddedFlow(items[index].OutputCoin, oldPayout, activity.PayoutCoin)
|
||
applyDailySeriesDerivedMetrics(&items[index])
|
||
}
|
||
sort.SliceStable(items, func(left, right int) bool {
|
||
return items[left].StatDay < items[right].StatDay
|
||
})
|
||
return items
|
||
}
|
||
|
||
func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
|
||
var turnover, payout int64
|
||
for _, pool := range pools {
|
||
turnover += pool.TurnoverCoin
|
||
payout += pool.PayoutCoin
|
||
}
|
||
return turnover, payout
|
||
}
|
||
|
||
func sumSuperLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
|
||
var turnover, payout int64
|
||
for _, pool := range pools {
|
||
if !isSuperLuckyPool(pool.PoolID) {
|
||
continue
|
||
}
|
||
turnover += pool.TurnoverCoin
|
||
payout += pool.PayoutCoin
|
||
}
|
||
return turnover, payout
|
||
}
|
||
|
||
func replaceEmbeddedFlow(total, oldValue, newValue int64) int64 {
|
||
if total >= oldValue {
|
||
return total - oldValue + newValue
|
||
}
|
||
return total + newValue
|
||
}
|
||
|
||
func (r *Repository) querySuperLuckyGiftDaily(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]superLuckyAggregate, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||
FROM stat_lucky_gift_pool_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
|
||
GROUP BY stat_day`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[string]superLuckyAggregate{}
|
||
for rows.Next() {
|
||
var day string
|
||
var item superLuckyAggregate
|
||
if err := rows.Scan(&day, &item.Turnover, &item.Payout); err != nil {
|
||
return nil, err
|
||
}
|
||
out[day] = item
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) querySuperLuckyGiftCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[int64]superLuckyAggregate, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if regionID > 0 {
|
||
filter = " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||
FROM stat_lucky_gift_pool_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
|
||
GROUP BY country_id`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[int64]superLuckyAggregate{}
|
||
for rows.Next() {
|
||
var countryID int64
|
||
var item superLuckyAggregate
|
||
if err := rows.Scan(&countryID, &item.Turnover, &item.Payout); err != nil {
|
||
return nil, err
|
||
}
|
||
out[countryID] = item
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) querySuperLuckyGiftDailyCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[string]superLuckyAggregate, error) {
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
filter := ""
|
||
if regionID > 0 {
|
||
filter = " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||
FROM stat_lucky_gift_pool_day_country
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
|
||
GROUP BY stat_day, country_id`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[string]superLuckyAggregate{}
|
||
for rows.Next() {
|
||
var day string
|
||
var countryID int64
|
||
var item superLuckyAggregate
|
||
if err := rows.Scan(&day, &countryID, &item.Turnover, &item.Payout); err != nil {
|
||
return nil, err
|
||
}
|
||
out[dailyCountryKey(day, countryID)] = item
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func dailyCountryKey(day string, countryID int64) string {
|
||
return day + ":" + strconv.FormatInt(countryID, 10)
|
||
}
|
||
|
||
func superLuckyPoolSQL() string {
|
||
return "LOWER(pool_id) LIKE '%super_lucky%'"
|
||
}
|
||
|
||
func isSuperLuckyPool(poolID string) bool {
|
||
return strings.Contains(strings.ToLower(strings.TrimSpace(poolID)), "super_lucky")
|
||
}
|
||
|
||
func reportMetricSources() []ReportMetricSource {
|
||
return []ReportMetricSource{
|
||
{Key: "new_users", Label: "注册", Status: "available", Source: "UserRegistered", Definition: "统计窗口内完成注册的用户数;registered_users 和 registrations 是同一口径别名。"},
|
||
{Key: "active_users", Label: "活跃用户", Status: "available", Source: "RoomUserJoined / GameOrderSettled", Definition: "统计服务按用户维度去重后的日活,进房和游戏结算都会沉淀为本地活跃事实。"},
|
||
{Key: "paid_users", Label: "付费用户", Status: "available", Source: "stat_recharge_day_payers", Definition: "统计窗口内发生充值的去重用户数;当前 Lalu 付费用户等同充值用户。"},
|
||
{Key: "recharge_users", Label: "充值用户", Status: "available", Source: "stat_recharge_day_payers", Definition: "统计窗口内发生充值的去重用户数;当前 Lalu 由 paid_users 派生。"},
|
||
{Key: "recharge_usd_minor", Label: "总充值", Status: "available", Source: "WalletRechargeRecorded", Definition: "所有充值渠道的美元最小单位总额,历史行缺总额时用可拆分渠道合计兜底。"},
|
||
{Key: "new_user_recharge_usd_minor", Label: "新用户充值", Status: "available", Source: "WalletRechargeRecorded + UserRegistered", Definition: "统计窗口内注册用户在同窗口产生的充值美元最小单位。"},
|
||
{Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Status: "available", Source: "WalletCoinSellerStockPurchased / WalletRechargeRecorded", Definition: "币商进货 USDT 和未识别为 Google/MifaPay/USDT-TRC20/币商转账的充值美元最小单位。"},
|
||
{Key: "coin_seller_stock_coin", Label: "币商充值金币", Status: "available", Source: "WalletCoinSellerStockPurchased coin_amount", Definition: "币商库存进货金币数量,扣减类库存调整按负数冲减。"},
|
||
{Key: "coin_seller_transfer_coin", Label: "币商出货金币", Status: "available", Source: "WalletRechargeRecorded recharge_type=coin_seller_transfer|coin_seller", Definition: "币商向用户转入普通金币数量。"},
|
||
{Key: "google_recharge_usd_minor", Label: "谷歌充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=google|google_play", Definition: "Google Play 充值美元最小单位。"},
|
||
{Key: "mifapay_recharge_usd_minor", Label: "三方充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=mifapay|v5pay", Definition: "当前三方充值列使用 MifaPay/V5Pay 美元最小单位。"},
|
||
{Key: "game_turnover", Label: "游戏流水/利润率", Status: "available", Source: "GameOrderSettled", Definition: "debit/bet 为流水,credit/payout 为返奖,refund/reverse 为退款,利润率=(流水-返奖-退款)/流水。"},
|
||
{Key: "lucky_gift_turnover", Label: "幸运礼物流水/利润率", Status: "available", Source: "RoomGiftSent + WalletLuckyGiftRewardCredited", Definition: "带 pool_id 的送礼金币为流水,钱包返奖为返奖,利润率=(流水-返奖)/流水。"},
|
||
{Key: "super_lucky_gift_turnover", Label: "超级幸运礼物流水/利润率", Status: "available", Source: "stat_lucky_gift_pool_day_country", Definition: "pool_id 包含 super_lucky 的奖池流水和返奖派生,利润率=(流水-返奖)/流水。"},
|
||
{Key: "gift_coin_spent", Label: "礼物流水", Status: "available", Source: "RoomGiftSent", Definition: "非机器人真实送礼 coin_spent,历史事件无 coin_spent 时回退 gift_value。"},
|
||
{Key: "coin_total", Label: "用户金币数", Status: "available", Source: "wallet_entries / WalletBalanceChanged", Definition: "普通用户 COIN 钱包 available+frozen 余额总和;多日总览只取结束日余额快照,日序列展示每日日终快照,当天按余额变动事件滚动更新。"},
|
||
{Key: "consumed_coin", Label: "消耗金币", Status: "available", Source: "GameOrderSettled / RoomGiftSent / WalletBalanceChanged", Definition: "游戏投入 + 普通礼物投入 + lucky 礼物投入 + 转盘投入 + 发红包;礼物和游戏分别以 room/game owner 事实为准,钱包只补转盘、红包和私聊礼物扣费。"},
|
||
{Key: "output_coin", Label: "产出金币", Status: "available", Source: "GameOrderSettled / WalletGiftIncomeCredited / WalletLuckyGiftRewardCredited / WalletWheelRewardCredited / WalletBalanceChanged", Definition: "游戏产出 + 收礼产出 + lucky 礼物产出 + 转盘金币奖励 + 红包领取和退回;转盘礼物价值需钱包奖励事件携带金币价值后才能统计。"},
|
||
{Key: "consume_output_ratio", Label: "消耗产出比", Status: "available", Source: "stat_app_day_country", Definition: "比例=产出金币/消耗金币,差额=消耗金币-产出金币。"},
|
||
{Key: "platform_grant_coin", Label: "平台发放金币", Status: "available", Source: "Wallet*RewardCredited / WalletBalanceChanged biz_type=resource_grant", Definition: "排行榜奖励、活动奖励、签到/任务奖励和火箭奖励中的金币发放;resource_grant 只统计可识别命令前缀的 COIN 入账,不包含人工发放、装扮价值、lucky 返奖和转盘产出。"},
|
||
{Key: "manual_grant_coin", Label: "人工发放金币", Status: "available", Source: "WalletBalanceChanged biz_type=manual_credit", Definition: "后台人工调账向普通 COIN 钱包正向发放的金币。"},
|
||
{Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "wallet_entries / WalletBalanceChanged", Definition: "主播和代理工资钱包 HOST_SALARY_USD+AGENCY_SALARY_USD 的 available+frozen 余额总和;多日总览只取结束日余额快照,日序列展示每日日终快照,不包含 residual、BD/Admin 工资。"},
|
||
{Key: "salary_transfer_coin", Label: "工资兑换金币", Status: "available", Source: "WalletBalanceChanged biz_type=salary_transfer_to_coin_seller / wallet_transactions", Definition: "用户工资钱包转给币商时,币商 COIN_SELLER_COIN 入账的金币数量;国家/区域按发起转账用户归属。"},
|
||
{Key: "avg_mic_online_ms", Label: "平均麦上时间", Status: "available", Source: "user_mic_daily_stats", Definition: "麦上总时长 / 当日麦上用户数,只统计 mic_online_ms > 0 的用户。"},
|
||
{Key: "mic_online_ms", Label: "麦上总时长", Status: "available", Source: "user_mic_daily_stats.mic_online_ms", Definition: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"},
|
||
{Key: "arpu_usd_minor", Label: "ARPU", Status: "available", Source: "stat_app_day_country", Definition: "总充值 / 活跃用户,返回美元最小单位。"},
|
||
{Key: "arppu_usd_minor", Label: "ARPPU", Status: "available", Source: "stat_app_day_country", Definition: "总充值 / 付费用户,返回美元最小单位。"},
|
||
{Key: "paid_conversion_rate", Label: "付费转化率", Status: "available", Source: "stat_app_day_country", Definition: "付费用户 / 活跃用户;当前 Lalu 付费用户等同充值用户。"},
|
||
{Key: "recharge_conversion_rate", Label: "充值转化率", Status: "available", Source: "stat_app_day_country", Definition: "充值用户 / 活跃用户;当前 Lalu 充值用户由 paid_users 派生。"},
|
||
}
|
||
}
|
||
|
||
func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
|
||
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT game_id, event_name, COALESCE(SUM(event_count),0), COALESCE(SUM(user_count),0),
|
||
COALESCE(SUM(success_count),0), COALESCE(SUM(error_count),0), COALESCE(SUM(duration_ms_sum),0)
|
||
FROM stat_self_game_h5_event_day
|
||
WHERE `+filter+`
|
||
GROUP BY game_id, event_name
|
||
ORDER BY game_id, event_name`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []SelfGameEventStat{}
|
||
for rows.Next() {
|
||
var item SelfGameEventStat
|
||
if err := rows.Scan(&item.GameID, &item.EventName, &item.EventCount, &item.UserCount, &item.SuccessCount, &item.ErrorCount, &item.DurationMSSum); err != nil {
|
||
return nil, err
|
||
}
|
||
item.AvgDurationMS = div(item.DurationMSSum, item.EventCount)
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) {
|
||
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0),
|
||
COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_matches),0),
|
||
COALESCE(SUM(robot_matches),0), COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0),
|
||
COALESCE(SUM(winner_count),0), COALESCE(SUM(loser_count),0), COALESCE(SUM(draw_count),0),
|
||
COALESCE(SUM(user_stake_coin),0), COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0),
|
||
COALESCE(SUM(refund_coin),0), COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0),
|
||
COALESCE(SUM(forced_player_lose_matches),0), COALESCE(SUM(wait_ms_sum),0), COALESCE(SUM(duration_ms_sum),0)
|
||
FROM stat_self_game_match_day
|
||
WHERE `+filter, args...)
|
||
var totals SelfGameMatchTotals
|
||
if err := row.Scan(&totals.CreatedMatches, &totals.MatchedMatches, &totals.SettledMatches, &totals.CanceledMatches, &totals.FailedMatches, &totals.HumanMatches, &totals.RobotMatches, &totals.HumanParticipants, &totals.RobotParticipants, &totals.WinnerCount, &totals.LoserCount, &totals.DrawCount, &totals.UserStakeCoin, &totals.RobotStakeCoin, &totals.PayoutCoin, &totals.RefundCoin, &totals.PlatformProfitCoin, &totals.PoolDeltaCoin, &totals.ForcedPlayerLoseMatches, &totals.WaitMSSum, &totals.DurationMSSum); err != nil {
|
||
return SelfGameMatchTotals{}, err
|
||
}
|
||
applySelfGameMatchDerived(&totals)
|
||
return totals, nil
|
||
}
|
||
|
||
func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) {
|
||
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT game_id, stake_coin, COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0),
|
||
COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0),
|
||
COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0), COALESCE(SUM(user_stake_coin),0),
|
||
COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0),
|
||
COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0)
|
||
FROM stat_self_game_match_day
|
||
WHERE `+filter+`
|
||
GROUP BY game_id, stake_coin
|
||
ORDER BY game_id, stake_coin`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []SelfGameStakeStat{}
|
||
for rows.Next() {
|
||
var item SelfGameStakeStat
|
||
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil {
|
||
return nil, err
|
||
}
|
||
// platform_profit_coin 是 game-service 在结算时按真实用户侧快照写入的 user_stake - payout - refund;
|
||
// 机器人下注只是奖池和对手侧流水,不能进入抽水率分母,否则人机局会把平台抽水率稀释一半。
|
||
item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin)
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) querySelfGamePools(ctx context.Context, app, statTZ, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) {
|
||
statTZ = normalizeStatTZ(statTZ)
|
||
filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
|
||
args := []any{app, statTZ, startDay, endDay}
|
||
if gameID != "" {
|
||
filter += " AND game_id = ?"
|
||
args = append(args, gameID)
|
||
}
|
||
latestArgs := append([]any{}, args...)
|
||
latestArgs = append(latestArgs, app, statTZ)
|
||
latestArgs = append(latestArgs, args...)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count,
|
||
agg.in_coin, agg.out_coin, COALESCE(latest.balance_after_coin,0)
|
||
FROM (
|
||
SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0) AS transaction_count,
|
||
COALESCE(SUM(in_coin),0) AS in_coin, COALESCE(SUM(out_coin),0) AS out_coin
|
||
FROM stat_self_game_pool_day
|
||
WHERE `+filter+`
|
||
GROUP BY game_id, direction, reason
|
||
) agg
|
||
LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.stat_tz = ? AND latest.game_id = agg.game_id
|
||
AND latest.direction = agg.direction AND latest.reason = agg.reason
|
||
AND latest.stat_day = (
|
||
SELECT p2.stat_day
|
||
FROM stat_self_game_pool_day p2
|
||
WHERE `+filter+` AND p2.game_id = agg.game_id AND p2.direction = agg.direction AND p2.reason = agg.reason
|
||
ORDER BY p2.stat_day DESC, p2.updated_at_ms DESC
|
||
LIMIT 1
|
||
)
|
||
ORDER BY agg.game_id, agg.direction, agg.reason`, latestArgs...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []SelfGamePoolStat{}
|
||
for rows.Next() {
|
||
var item SelfGamePoolStat
|
||
if err := rows.Scan(&item.GameID, &item.Direction, &item.Reason, &item.TransactionCount, &item.InCoin, &item.OutCoin, &item.BalanceAfterCoin); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) {
|
||
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT game_id, stake_coin, participant_type, result, rps_gesture, dice_point,
|
||
COALESCE(SUM(participant_count),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
|
||
FROM stat_self_game_participant_day
|
||
WHERE `+filter+`
|
||
GROUP BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point
|
||
ORDER BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
items := []SelfGameParticipantStat{}
|
||
totals := map[string]int64{}
|
||
for rows.Next() {
|
||
var item SelfGameParticipantStat
|
||
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil {
|
||
return nil, err
|
||
}
|
||
key := selfGameParticipantRateKey(item)
|
||
totals[key] += item.ParticipantCount
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range items {
|
||
key := selfGameParticipantRateKey(items[index])
|
||
if items[index].Result == "win" {
|
||
items[index].WinRate = ratio(items[index].ParticipantCount, totals[key])
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func selfGameParticipantRateKey(item SelfGameParticipantStat) string {
|
||
playKey := item.RPSGesture
|
||
if item.DicePoint > 0 {
|
||
playKey = strconv.FormatInt(item.DicePoint, 10)
|
||
}
|
||
// 玩法分布查询本身按 stake_coin 分桶返回,胜率分母也必须保留同一档位;
|
||
// 否则同一点数或同一手势在不同下注档位的结果会互相稀释,前端每一行的胜率就不是该行口径。
|
||
return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey
|
||
}
|
||
|
||
func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) {
|
||
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT game_id, user_id, COALESCE(SUM(match_count),0), COALESCE(SUM(win_count),0),
|
||
COALESCE(SUM(lose_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(cancel_count),0),
|
||
COALESCE(SUM(stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
|
||
FROM stat_self_game_user_day
|
||
WHERE `+filter+`
|
||
GROUP BY game_id, user_id
|
||
ORDER BY SUM(match_count) DESC, SUM(net_win_coin) DESC, SUM(cancel_count) DESC
|
||
LIMIT 100`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []SelfGameUserRiskStat{}
|
||
for rows.Next() {
|
||
var item SelfGameUserRiskStat
|
||
if err := rows.Scan(&item.GameID, &item.UserID, &item.MatchCount, &item.WinCount, &item.LoseCount, &item.DrawCount, &item.CancelCount, &item.StakeCoin, &item.PayoutCoin, &item.NetWinCoin); err != nil {
|
||
return nil, err
|
||
}
|
||
item.WinRate = ratio(item.WinCount, item.MatchCount)
|
||
item.CancelRate = ratio(item.CancelCount, item.MatchCount+item.CancelCount)
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) querySelfGameRetention(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) {
|
||
cohortFilter, cohortArgs := selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||
returnFilter, returnArgs := selfGameUserDayFilter(app, statTZ, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID)
|
||
args := make([]any, 0, len(cohortArgs)+len(returnArgs)*2)
|
||
args = append(args, cohortArgs...)
|
||
args = append(args, returnArgs...)
|
||
args = append(args, returnArgs...)
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(1),
|
||
COALESCE(SUM(CASE WHEN d1.user_id IS NULL THEN 0 ELSE 1 END), 0),
|
||
COALESCE(SUM(CASE WHEN d7.user_id IS NULL THEN 0 ELSE 1 END), 0)
|
||
FROM (
|
||
SELECT DISTINCT stat_day, user_id
|
||
FROM stat_self_game_user_day
|
||
WHERE `+cohortFilter+`
|
||
) cohort
|
||
LEFT JOIN (
|
||
SELECT DISTINCT stat_day, user_id
|
||
FROM stat_self_game_user_day
|
||
WHERE `+returnFilter+`
|
||
) d1 ON d1.user_id = cohort.user_id AND d1.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 1 DAY)
|
||
LEFT JOIN (
|
||
SELECT DISTINCT stat_day, user_id
|
||
FROM stat_self_game_user_day
|
||
WHERE `+returnFilter+`
|
||
) d7 ON d7.user_id = cohort.user_id AND d7.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 7 DAY)`, args...)
|
||
var retention SelfGameRetention
|
||
if err := row.Scan(&retention.CohortUsers, &retention.Day1Users, &retention.Day7Users); err != nil {
|
||
return SelfGameRetention{}, err
|
||
}
|
||
retention.Day1Rate = ratio(retention.Day1Users, retention.CohortUsers)
|
||
retention.Day7Rate = ratio(retention.Day7Users, retention.CohortUsers)
|
||
return retention, nil
|
||
}
|
||
|
||
func selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
|
||
filter := "app_code = ? AND stat_tz = ? AND match_count > 0"
|
||
args := []any{app, normalizeStatTZ(statTZ)}
|
||
if startDay != "" && endDay != "" {
|
||
filter += " AND stat_day BETWEEN ? AND ?"
|
||
args = append(args, startDay, endDay)
|
||
}
|
||
if gameID != "" {
|
||
filter += " AND game_id = ?"
|
||
args = append(args, gameID)
|
||
}
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
return filter, args
|
||
}
|
||
|
||
func selfGameFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
|
||
filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
if gameID != "" {
|
||
filter += " AND game_id = ?"
|
||
args = append(args, gameID)
|
||
}
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
return filter, args
|
||
}
|
||
|
||
func applySelfGameMatchDerived(totals *SelfGameMatchTotals) {
|
||
if totals == nil {
|
||
return
|
||
}
|
||
totals.AvgWaitMS = div(totals.WaitMSSum, totals.MatchedMatches+totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
|
||
totals.AvgDurationMS = div(totals.DurationMSSum, totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
|
||
totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches)
|
||
totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches)
|
||
totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches)
|
||
// platform_profit_coin 与真实用户下注同口径,分母只使用 user_stake_coin;
|
||
// robot_stake_coin 是机器人对手侧流水,展示总下注时可以合并,但不能用于真实用户侧抽水率。
|
||
totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin)
|
||
}
|
||
|
||
func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) {
|
||
order := []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"}
|
||
byEvent := map[string]SelfGameFunnelStep{}
|
||
for _, event := range events {
|
||
step := byEvent[event.EventName]
|
||
step.EventName = event.EventName
|
||
step.EventCount += event.EventCount
|
||
step.UserCount += event.UserCount
|
||
byEvent[event.EventName] = step
|
||
}
|
||
funnel := make([]SelfGameFunnelStep, 0, len(order))
|
||
for _, name := range order {
|
||
funnel = append(funnel, byEvent[name])
|
||
funnel[len(funnel)-1].EventName = name
|
||
}
|
||
return funnel, SelfGameConversion{
|
||
PageToMatchClickRate: ratio(byEvent["match_click"].UserCount, byEvent["page_open"].UserCount),
|
||
MatchClickToSuccessRate: ratio(byEvent["match_success"].UserCount, byEvent["match_click"].UserCount),
|
||
SuccessToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["match_success"].UserCount),
|
||
SettlementToPlayAgainRate: ratio(byEvent["play_again"].UserCount, byEvent["settlement_show"].UserCount),
|
||
PageToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["page_open"].UserCount),
|
||
}
|
||
}
|
||
|
||
type appTrackingStepDef struct {
|
||
Key string
|
||
EventName string
|
||
Label string
|
||
IsFailure bool
|
||
}
|
||
|
||
var appTrackingStepDefs = []appTrackingStepDef{
|
||
{Key: "login_start", EventName: "login_start", Label: "登录开始"},
|
||
{Key: "login_success", EventName: "login_success", Label: "登录成功"},
|
||
{Key: "profile_start", EventName: "profile_start", Label: "资料开始"},
|
||
{Key: "profile_complete", EventName: "profile_complete", Label: "资料完成"},
|
||
{Key: "home_room_impression", EventName: "home_room_impression", Label: "首页房间曝光"},
|
||
{Key: "home_room_click", EventName: "home_room_click", Label: "首页房间点击"},
|
||
{Key: "room_join_success", EventName: "room_join_success", Label: "进房成功"},
|
||
{Key: "room_join_fail", EventName: "room_join_fail", Label: "进房失败", IsFailure: true},
|
||
{Key: "stay_30s", EventName: "stay_30s", Label: "停留 30 秒"},
|
||
{Key: "stay_3m", EventName: "stay_3m", Label: "停留 3 分钟"},
|
||
{Key: "stay_10m", EventName: "stay_10m", Label: "停留 10 分钟"},
|
||
{Key: "mic_up", EventName: "mic_up", Label: "上麦"},
|
||
{Key: "send_message", EventName: "send_message", Label: "发消息"},
|
||
{Key: "follow_host", EventName: "follow_host", Label: "关注主播"},
|
||
{Key: "add_friend", EventName: "add_friend", Label: "加好友"},
|
||
{Key: "check_in", EventName: "check_in", Label: "签到"},
|
||
{Key: "push_permission", EventName: "push_permission", Label: "Push 授权"},
|
||
}
|
||
|
||
type appTrackingCohortDef struct {
|
||
Key string
|
||
Label string
|
||
}
|
||
|
||
var appTrackingCohortDefs = []appTrackingCohortDef{
|
||
{Key: "country", Label: "国家"},
|
||
{Key: "language", Label: "语言"},
|
||
{Key: "channel", Label: "渠道"},
|
||
{Key: "login_method", Label: "登录方式"},
|
||
{Key: "first_room_stay", Label: "首房停留时长"},
|
||
}
|
||
|
||
type appTrackingStepAggregate struct {
|
||
EventName string
|
||
EventCount int64
|
||
UserCount int64
|
||
DeviceCount int64
|
||
}
|
||
|
||
func (r *Repository) queryAppTrackingFunnelSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]appTrackingStepAggregate, error) {
|
||
filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
|
||
names := appTrackingEventNames()
|
||
placeholders := sqlPlaceholders(len(names))
|
||
for _, name := range names {
|
||
args = append(args, name)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT event_name, COUNT(*), COUNT(DISTINCT `+appTrackingIdentitySQL("")+`), COUNT(DISTINCT NULLIF(device_id, ''))
|
||
FROM app_tracking_events
|
||
WHERE `+filter+` AND event_name IN (`+placeholders+`)
|
||
GROUP BY event_name`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []appTrackingStepAggregate{}
|
||
for rows.Next() {
|
||
var item appTrackingStepAggregate
|
||
if err := rows.Scan(&item.EventName, &item.EventCount, &item.UserCount, &item.DeviceCount); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func buildAppTrackingFunnelSteps(aggregates []appTrackingStepAggregate) []AppTrackingFunnelStep {
|
||
byEvent := map[string]appTrackingStepAggregate{}
|
||
for _, item := range aggregates {
|
||
byEvent[item.EventName] = item
|
||
}
|
||
out := make([]AppTrackingFunnelStep, 0, len(appTrackingStepDefs))
|
||
var baseUsers int64
|
||
var previousUsers int64
|
||
for index, def := range appTrackingStepDefs {
|
||
aggregate := byEvent[def.EventName]
|
||
if index == 0 {
|
||
baseUsers = aggregate.UserCount
|
||
}
|
||
step := AppTrackingFunnelStep{
|
||
Key: def.Key,
|
||
EventName: def.EventName,
|
||
Label: def.Label,
|
||
EventCount: aggregate.EventCount,
|
||
UserCount: aggregate.UserCount,
|
||
DeviceCount: aggregate.DeviceCount,
|
||
OverallConversionRate: ratio(aggregate.UserCount, baseUsers),
|
||
PreviousConversionRate: ratio(aggregate.UserCount, previousUsers),
|
||
IsFailure: def.IsFailure,
|
||
}
|
||
if previousUsers > aggregate.UserCount {
|
||
step.DropoffUsers = previousUsers - aggregate.UserCount
|
||
}
|
||
out = append(out, step)
|
||
previousUsers = aggregate.UserCount
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appTrackingTotals(steps []AppTrackingFunnelStep) AppTrackingFunnelTotals {
|
||
byEvent := map[string]int64{}
|
||
for _, step := range steps {
|
||
byEvent[step.EventName] = step.UserCount
|
||
}
|
||
return AppTrackingFunnelTotals{
|
||
LoginStartUsers: byEvent["login_start"],
|
||
LoginSuccessUsers: byEvent["login_success"],
|
||
ProfileCompleteUsers: byEvent["profile_complete"],
|
||
RoomImpressionUsers: byEvent["home_room_impression"],
|
||
RoomClickUsers: byEvent["home_room_click"],
|
||
RoomJoinSuccessUsers: byEvent["room_join_success"],
|
||
RoomJoinFailUsers: byEvent["room_join_fail"],
|
||
Stay30sUsers: byEvent["stay_30s"],
|
||
Stay3mUsers: byEvent["stay_3m"],
|
||
Stay10mUsers: byEvent["stay_10m"],
|
||
SendMessageUsers: byEvent["send_message"],
|
||
FollowHostUsers: byEvent["follow_host"],
|
||
AddFriendUsers: byEvent["add_friend"],
|
||
CheckInUsers: byEvent["check_in"],
|
||
PushPermissionUsers: byEvent["push_permission"],
|
||
}
|
||
}
|
||
|
||
func (r *Repository) queryAppTrackingD1Total(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (int64, int64, error) {
|
||
filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
|
||
args = append(args, app)
|
||
row := r.db.QueryRowContext(ctx, `
|
||
WITH base AS (
|
||
SELECT DISTINCT stat_day, `+appTrackingIdentitySQL("")+` AS identity
|
||
FROM app_tracking_events
|
||
WHERE `+filter+` AND event_name = 'login_success'
|
||
)
|
||
SELECT COUNT(*),
|
||
COALESCE(SUM(CASE WHEN EXISTS (
|
||
SELECT 1 FROM app_tracking_events n
|
||
WHERE n.app_code = ? AND n.stat_day = DATE_ADD(base.stat_day, INTERVAL 1 DAY)
|
||
AND `+appTrackingIdentitySQL("n")+` = base.identity
|
||
LIMIT 1
|
||
) THEN 1 ELSE 0 END),0)
|
||
FROM base`, args...)
|
||
var baseUsers, retainedUsers int64
|
||
if err := row.Scan(&baseUsers, &retainedUsers); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
return baseUsers, retainedUsers, nil
|
||
}
|
||
|
||
func (r *Repository) queryAppTrackingD1Cohorts(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]AppTrackingFunnelCohort, error) {
|
||
out := []AppTrackingFunnelCohort{}
|
||
for _, def := range appTrackingCohortDefs {
|
||
rows, err := r.queryAppTrackingD1CohortDimension(ctx, app, startMS, endMS, countryID, regionID, regionIDs, def)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
stepCounts, err := r.queryAppTrackingCohortSteps(ctx, app, startMS, endMS, countryID, regionID, regionIDs, def)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range rows {
|
||
rows[index].Steps = buildAppTrackingCohortSteps(stepCounts[rows[index].Value])
|
||
}
|
||
out = append(out, rows...)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Repository) queryAppTrackingD1CohortDimension(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64, def appTrackingCohortDef) ([]AppTrackingFunnelCohort, error) {
|
||
filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
|
||
sourceSQL := appTrackingD1CohortSourceSQL(def.Key, filter)
|
||
args := appTrackingCohortSourceArgs(def.Key, filterArgs)
|
||
args = append(args, app)
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
WITH base AS (`+sourceSQL+`)
|
||
SELECT value, COUNT(*),
|
||
COALESCE(SUM(CASE WHEN EXISTS (
|
||
SELECT 1 FROM app_tracking_events n
|
||
WHERE n.app_code = ? AND n.stat_day = DATE_ADD(base.stat_day, INTERVAL 1 DAY)
|
||
AND `+appTrackingIdentitySQL("n")+` = base.identity
|
||
LIMIT 1
|
||
) THEN 1 ELSE 0 END),0)
|
||
FROM base
|
||
GROUP BY value
|
||
ORDER BY COUNT(*) DESC, value ASC
|
||
LIMIT 50`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []AppTrackingFunnelCohort{}
|
||
for rows.Next() {
|
||
var value sql.NullString
|
||
var baseUsers, retainedUsers int64
|
||
if err := rows.Scan(&value, &baseUsers, &retainedUsers); err != nil {
|
||
return nil, err
|
||
}
|
||
cohortValue := normalizeCohortValue(value.String)
|
||
out = append(out, AppTrackingFunnelCohort{
|
||
Dimension: def.Key,
|
||
DimensionLabel: def.Label,
|
||
Value: cohortValue,
|
||
Label: cohortValue,
|
||
BaseUsers: baseUsers,
|
||
D1RetentionUsers: retainedUsers,
|
||
D1RetentionRate: ratio(retainedUsers, baseUsers),
|
||
Steps: []AppTrackingCohortStepCount{},
|
||
})
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) queryAppTrackingCohortSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64, def appTrackingCohortDef) (map[string]map[string]int64, error) {
|
||
filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
|
||
cohortSQL := appTrackingStepCohortSourceSQL(def.Key, filter)
|
||
args := appTrackingCohortSourceArgs(def.Key, filterArgs)
|
||
eventFilter, eventArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
|
||
args = append(args, eventArgs...)
|
||
names := appTrackingEventNames()
|
||
placeholders := sqlPlaceholders(len(names))
|
||
for _, name := range names {
|
||
args = append(args, name)
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
WITH cohort AS (`+cohortSQL+`),
|
||
events AS (
|
||
SELECT DISTINCT `+appTrackingIdentitySQL("")+` AS identity, event_name
|
||
FROM app_tracking_events
|
||
WHERE `+eventFilter+` AND event_name IN (`+placeholders+`)
|
||
)
|
||
SELECT cohort.value, events.event_name, COUNT(DISTINCT cohort.identity)
|
||
FROM cohort
|
||
JOIN events ON events.identity = cohort.identity
|
||
GROUP BY cohort.value, events.event_name`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[string]map[string]int64{}
|
||
for rows.Next() {
|
||
var value sql.NullString
|
||
var eventName string
|
||
var userCount int64
|
||
if err := rows.Scan(&value, &eventName, &userCount); err != nil {
|
||
return nil, err
|
||
}
|
||
cohortValue := normalizeCohortValue(value.String)
|
||
if out[cohortValue] == nil {
|
||
out[cohortValue] = map[string]int64{}
|
||
}
|
||
out[cohortValue][eventName] = userCount
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func buildAppTrackingCohortSteps(counts map[string]int64) []AppTrackingCohortStepCount {
|
||
out := make([]AppTrackingCohortStepCount, 0, len(appTrackingStepDefs))
|
||
for _, def := range appTrackingStepDefs {
|
||
out = append(out, AppTrackingCohortStepCount{
|
||
EventName: def.EventName,
|
||
Label: def.Label,
|
||
UserCount: counts[def.EventName],
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appTrackingD1CohortSourceSQL(dimension string, filter string) string {
|
||
if dimension == "first_room_stay" {
|
||
return `
|
||
SELECT base.stat_day, base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value
|
||
FROM (
|
||
SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity
|
||
FROM app_tracking_events
|
||
WHERE ` + filter + ` AND event_name = 'login_success'
|
||
) base
|
||
LEFT JOIN (
|
||
SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity,
|
||
MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank
|
||
FROM app_tracking_events
|
||
WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m')
|
||
GROUP BY stat_day, identity
|
||
) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity`
|
||
}
|
||
return `
|
||
SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value
|
||
FROM app_tracking_events
|
||
WHERE ` + filter + ` AND event_name = 'login_success'`
|
||
}
|
||
|
||
func appTrackingStepCohortSourceSQL(dimension string, filter string) string {
|
||
if dimension == "first_room_stay" {
|
||
return `
|
||
SELECT DISTINCT base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value
|
||
FROM (
|
||
SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity
|
||
FROM app_tracking_events
|
||
WHERE ` + filter + ` AND event_name = 'login_success'
|
||
) base
|
||
LEFT JOIN (
|
||
SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity,
|
||
MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank
|
||
FROM app_tracking_events
|
||
WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m')
|
||
GROUP BY stat_day, identity
|
||
) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity`
|
||
}
|
||
return `
|
||
SELECT DISTINCT ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value
|
||
FROM app_tracking_events
|
||
WHERE ` + filter + ` AND event_name = 'login_success'`
|
||
}
|
||
|
||
func appTrackingCohortSourceArgs(dimension string, filterArgs []any) []any {
|
||
args := append([]any{}, filterArgs...)
|
||
if dimension == "first_room_stay" {
|
||
args = append(args, filterArgs...)
|
||
}
|
||
return args
|
||
}
|
||
|
||
func appTrackingStayBucketSQL(rankExpr string) string {
|
||
return "CASE COALESCE(" + rankExpr + ",0) WHEN 3 THEN '10m+' WHEN 2 THEN '3m-10m' WHEN 1 THEN '30s-3m' ELSE '<30s' END"
|
||
}
|
||
|
||
func appTrackingCohortValueSQL(dimension string, alias string) string {
|
||
prefix := ""
|
||
if alias != "" {
|
||
prefix = alias + "."
|
||
}
|
||
jsonValue := func(path string) string {
|
||
return "NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(" + prefix + "properties_json, '$." + path + "'))), '')"
|
||
}
|
||
switch dimension {
|
||
case "country":
|
||
return "COALESCE(" + jsonValue("country") + ", IF(" + prefix + "country_id > 0, CAST(" + prefix + "country_id AS CHAR), 'unknown'))"
|
||
case "language":
|
||
return "COALESCE(NULLIF(TRIM(" + prefix + "language), ''), " + jsonValue("language") + ", 'unknown')"
|
||
case "channel":
|
||
return "COALESCE(" + jsonValue("channel") + ", " + jsonValue("utm_source") + ", 'unknown')"
|
||
case "login_method":
|
||
return "COALESCE(" + jsonValue("login_method") + ", " + jsonValue("login_provider") + ", " + jsonValue("method") + ", " + jsonValue("provider") + ", 'unknown')"
|
||
default:
|
||
return "'unknown'"
|
||
}
|
||
}
|
||
|
||
func appTrackingFilter(app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (string, []any) {
|
||
filter := "app_code = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?"
|
||
args := []any{app, startMS, endMS}
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
return filter, args
|
||
}
|
||
regionIDs = normalizeTrackingRegionIDs(regionIDs)
|
||
if len(regionIDs) > 0 {
|
||
filter += " AND region_id IN (" + sqlPlaceholders(len(regionIDs)) + ")"
|
||
for _, id := range regionIDs {
|
||
args = append(args, id)
|
||
}
|
||
}
|
||
return filter, args
|
||
}
|
||
|
||
func normalizeTrackingRegionIDs(regionIDs []int64) []int64 {
|
||
out := make([]int64, 0, len(regionIDs))
|
||
seen := map[int64]struct{}{}
|
||
for _, id := range regionIDs {
|
||
if id <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||
return out
|
||
}
|
||
|
||
func appTrackingIdentitySQL(alias string) string {
|
||
prefix := ""
|
||
if alias != "" {
|
||
prefix = alias + "."
|
||
}
|
||
// identity_key is a generated column indexed by the statistics schema.
|
||
// Keeping identity derivation out of hot funnel SQL lets MySQL use window
|
||
// and retention indexes instead of rebuilding CONCAT keys for every row.
|
||
return prefix + "identity_key"
|
||
}
|
||
|
||
func appTrackingEventNames() []string {
|
||
out := make([]string, 0, len(appTrackingStepDefs))
|
||
for _, def := range appTrackingStepDefs {
|
||
out = append(out, def.EventName)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func sqlPlaceholders(count int) string {
|
||
if count <= 0 {
|
||
return "NULL"
|
||
}
|
||
parts := make([]string, count)
|
||
for index := range parts {
|
||
parts[index] = "?"
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
func normalizeCohortValue(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || strings.EqualFold(value, "null") {
|
||
return "unknown"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func normalizeRange(startMS, endMS int64) (int64, int64) {
|
||
if startMS <= 0 {
|
||
now := time.Now().UTC()
|
||
startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||
}
|
||
if endMS <= startMS {
|
||
endMS = startMS + 24*time.Hour.Milliseconds()
|
||
}
|
||
return startMS, endMS
|
||
}
|
||
|
||
func normalizePlatformGrantCoinQuery(query PlatformGrantCoinQuery) (string, string, string, string, int, int, int) {
|
||
app := appcode.Normalize(query.AppCode)
|
||
if app == "" {
|
||
app = "lalu"
|
||
}
|
||
statTZ := normalizeStatTZ(query.StatTZ)
|
||
startDay := strings.TrimSpace(query.StatDay)
|
||
endDay := startDay
|
||
if startDay == "" {
|
||
startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
|
||
startDay = statDayIn(startMS, statTZ)
|
||
endDay = statDayIn(endMS-1, statTZ)
|
||
}
|
||
page := query.Page
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
pageSize := query.PageSize
|
||
if pageSize < 1 {
|
||
pageSize = 20
|
||
}
|
||
if pageSize > 100 {
|
||
pageSize = 100
|
||
}
|
||
return app, statTZ, startDay, endDay, page, pageSize, (page - 1) * pageSize
|
||
}
|
||
|
||
func platformGrantCoinFilter(app string, statTZ string, startDay string, endDay string, countryID int64, regionID int64, userID int64, eventType string) (string, []any) {
|
||
filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
|
||
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||
if countryID > 0 {
|
||
filter += " AND country_id = ?"
|
||
args = append(args, countryID)
|
||
}
|
||
if regionID > 0 {
|
||
filter += " AND region_id = ?"
|
||
args = append(args, regionID)
|
||
}
|
||
if userID > 0 {
|
||
filter += " AND user_id = ?"
|
||
args = append(args, userID)
|
||
}
|
||
if normalizedEventType := strings.TrimSpace(eventType); normalizedEventType != "" && normalizedEventType != "all" {
|
||
filter += " AND event_type = ?"
|
||
args = append(args, normalizedEventType)
|
||
}
|
||
return filter, args
|
||
}
|
||
|
||
func previousStatDayRange(startDay, endDay string) (string, string) {
|
||
start, err := time.Parse("2006-01-02", startDay)
|
||
if err != nil {
|
||
start = time.Now().UTC()
|
||
}
|
||
end, err := time.Parse("2006-01-02", endDay)
|
||
if err != nil || end.Before(start) {
|
||
end = start
|
||
}
|
||
// Databi 的日期窗口按 UTC stat_day 聚合;上一周期采用同样天数,今日就是昨天,近 7 日就是前 7 日。
|
||
dayCount := int(end.Sub(start)/(24*time.Hour)) + 1
|
||
if dayCount < 1 {
|
||
dayCount = 1
|
||
}
|
||
previousEnd := start.AddDate(0, 0, -1)
|
||
previousStart := start.AddDate(0, 0, -dayCount)
|
||
return previousStart.Format("2006-01-02"), previousEnd.Format("2006-01-02")
|
||
}
|
||
|
||
func addStatDays(day string, offset int) string {
|
||
parsed, err := time.Parse("2006-01-02", day)
|
||
if err != nil {
|
||
return day
|
||
}
|
||
return parsed.AddDate(0, 0, offset).Format("2006-01-02")
|
||
}
|
||
|
||
func div(numerator, denominator int64) int64 {
|
||
if denominator <= 0 {
|
||
return 0
|
||
}
|
||
return numerator / denominator
|
||
}
|
||
|
||
func deltaRate(current, previous int64) float64 {
|
||
if previous == 0 {
|
||
if current == 0 {
|
||
return 0
|
||
}
|
||
if current > 0 {
|
||
return 1
|
||
}
|
||
return -1
|
||
}
|
||
return float64(current-previous) / math.Abs(float64(previous))
|
||
}
|
||
|
||
func percentagePointDelta(current, previous float64) float64 {
|
||
return (current - previous) * 100
|
||
}
|
||
|
||
func ratio(numerator, denominator int64) float64 {
|
||
if denominator <= 0 {
|
||
return 0
|
||
}
|
||
return float64(numerator) / float64(denominator)
|
||
}
|
||
|
||
func retentionPointers(users, base int64) (*int64, *int64, *float64) {
|
||
if base <= 0 {
|
||
return nil, nil, nil
|
||
}
|
||
usersValue := users
|
||
baseValue := base
|
||
rateValue := ratio(users, base)
|
||
return &usersValue, &baseValue, &rateValue
|
||
}
|
||
|
||
func selfGameMetricDefinitions() []MetricDefinition {
|
||
return []MetricDefinition{
|
||
{Metric: "game_pv", Definition: "H5 page_open event_count;同一用户多次打开会重复计数。"},
|
||
{Metric: "game_uv", Definition: "H5 page_open user_count;同一 UTC 日、同一 game_id、同一用户只计一次。"},
|
||
{Metric: "match_click_users", Definition: "H5 match_click user_count;用户点击匹配按钮后上报。"},
|
||
{Metric: "match_success_users", Definition: "H5 match_success user_count;匹配接口成功返回有效 match_id 后上报。"},
|
||
{Metric: "settlement_users", Definition: "H5 settlement_show user_count;结算页展示时上报。"},
|
||
{Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户;只统计 stat_self_game_user_day.match_count > 0。"},
|
||
{Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
|
||
{Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
|
||
{Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin - payout_coin - refund_coin;抽水率分母同样只用 user_stake_coin。"},
|
||
{Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合;in_coin/out_coin 是入池/出池金额,balance_after_coin 是查询窗口内同方向同原因的最新余额快照。"},
|
||
}
|
||
}
|