760 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package mysql
import (
"context"
"database/sql"
"math"
"sort"
"time"
"hyapp/pkg/appcode"
)
type OverviewQuery struct {
AppCode string
StartMS int64
EndMS int64
SeriesStartMS int64
SeriesEndMS int64
CountryID int64
RegionID int64
}
type Overview struct {
AppCode string `json:"app_code"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
CountryID int64 `json:"country_id"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
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"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
UPValueUSDMinor int64 `json:"up_value_usd_minor"`
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"`
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
CoinSellerTransferDeltaRate float64 `json:"coin_seller_transfer_coin_delta_rate"`
LuckyGiftTurnoverDeltaRate float64 `json:"lucky_gift_turnover_delta_rate"`
LuckyGiftPayoutDeltaRate float64 `json:"lucky_gift_payout_delta_rate"`
LuckyGiftProfitDeltaRate float64 `json:"lucky_gift_profit_delta_rate"`
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
Retention Retention `json:"retention"`
DailySeries []DailySeriesItem `json:"daily_series"`
GameRanking []GameRank `json:"game_ranking"`
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
}
type DailySeriesItem struct {
StatDay string `json:"stat_day"`
Label string `json:"label"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
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"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
}
type Retention struct {
RegisteredUsers int64 `json:"registered_users"`
Day1Users int64 `json:"day1_users"`
Day7Users int64 `json:"day7_users"`
Day30Users int64 `json:"day30_users"`
Day1Rate float64 `json:"day1_rate"`
Day7Rate float64 `json:"day7_rate"`
Day30Rate float64 `json:"day30_rate"`
}
type GameRank struct {
PlatformCode string `json:"platform_code"`
GameID string `json:"game_id"`
TurnoverCoin int64 `json:"turnover_coin"`
PayoutCoin int64 `json:"payout_coin"`
RefundCoin int64 `json:"refund_coin"`
PlayerCount int64 `json:"player_count"`
ProfitCoin int64 `json:"profit_coin"`
ProfitRate float64 `json:"profit_rate"`
}
type LuckyGiftPoolStat struct {
PoolID string `json:"pool_id"`
TurnoverCoin int64 `json:"turnover_coin"`
PayoutCoin int64 `json:"payout_coin"`
ProfitCoin int64 `json:"profit_coin"`
PayoutRate float64 `json:"payout_rate"`
ProfitRate float64 `json:"profit_rate"`
}
type CountryBreakdown struct {
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
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"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
}
func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) {
app := appcode.Normalize(query.AppCode)
if app == "" {
app = "lalu"
}
startDay := statDay(query.StartMS)
endDay := statDay(query.EndMS - 1)
previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID
previous.AppCode, previous.CountryID = app, query.CountryID
// 新增付费用户来自日充值用户去重表和注册表的交集;只读聚合小表,避免 Databi 页面回扫钱包或订单明细。
if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
return Overview{}, err
}
if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
return Overview{}, err
}
// Pool 明细和顶部汇总来自同一个统计窗口Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.LuckyGiftPools = pools
// 幸运礼物流水和返奖以 activity-service 的抽奖事实增量日汇总为准;这里仅读小表,避免 Databi 页面扫描 lucky_draw_records。
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
return Overview{}, err
} else if ok {
overview.LuckyGiftPools = activityPools
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
}
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
return Overview{}, err
} else if ok {
previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
}
applyOverviewDerivedMetrics(&overview)
applyOverviewDerivedMetrics(&previous)
overview.applyDeltaRates(previous)
retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.Retention = retention
ranking, err := r.queryGameRanking(ctx, app, 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, startDay, endDay, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.CountryBreakdown = breakdown
}
seriesStartDay, seriesEndDay := startDay, endDay
if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
seriesStartDay = statDay(query.SeriesStartMS)
seriesEndDay = statDay(query.SeriesEndMS - 1)
}
// daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。
dailySeries, err := r.queryDailySeries(ctx, app, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.DailySeries = dailySeries
return overview, nil
}
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
args := []any{app, 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(mifapay_recharge_usd_minor),0),
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),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_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.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &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, startDay, endDay string, countryID int64, regionID int64) (int64, error) {
args := []any{app, startDay, endDay, 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.user_id = p.user_id
WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ?
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) queryDailySeries(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) {
args := []any{app, 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(mifapay_recharge_usd_minor),0),
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),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_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.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &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, startDay, endDay, countryID, regionID)
if err != nil {
return nil, err
}
for index := range out {
out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
}
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, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) {
args := []any{app, startDay, endDay, 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.user_id = p.user_id
WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ?
AND r.registered_day BETWEEN ? AND ?`+filter+`
GROUP BY p.stat_day`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var day string
var count int64
if err := rows.Scan(&day, &count); err != nil {
return nil, err
}
out[day] = count
}
return out, rows.Err()
}
func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
if item == nil {
return
}
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
if channelRechargeUSDMinor > item.RechargeUSDMinor {
item.RechargeUSDMinor = channelRechargeUSDMinor
}
item.RechargeUsers = item.PaidUsers
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
}
func applyOverviewDerivedMetrics(overview *Overview) {
if overview == nil {
return
}
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor查询时用渠道合计兜底。币商进货是 USDT 充值,币商转普通金币仍只展示金币卡片。
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor + overview.CoinSellerRechargeUSDMinor
if channelRechargeUSDMinor > overview.RechargeUSDMinor {
overview.RechargeUSDMinor = channelRechargeUSDMinor
}
overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund
overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover)
overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
overview.UPValueUSDMinor = overview.ARPPUUSDMinor
overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
}
func (overview *Overview) applyDeltaRates(previous Overview) {
if overview == nil {
return
}
overview.RechargeDeltaRate = deltaRate(overview.RechargeUSDMinor, previous.RechargeUSDMinor)
overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor)
overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers)
overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers)
overview.NewPaidUsersDeltaRate = deltaRate(overview.NewPaidUsers, previous.NewPaidUsers)
overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor)
overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor)
overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent)
overview.CoinSellerTransferDeltaRate = deltaRate(overview.CoinSellerTransferCoin, previous.CoinSellerTransferCoin)
overview.LuckyGiftTurnoverDeltaRate = deltaRate(overview.LuckyGiftTurnover, previous.LuckyGiftTurnover)
overview.LuckyGiftPayoutDeltaRate = deltaRate(overview.LuckyGiftPayout, previous.LuckyGiftPayout)
overview.LuckyGiftProfitDeltaRate = deltaRate(overview.LuckyGiftProfit, previous.LuckyGiftProfit)
overview.GameTurnoverDeltaRate = deltaRate(overview.GameTurnover, previous.GameTurnover)
overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
}
func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Retention, error) {
filter := ""
args := []any{app, startDay, endDay}
if countryID > 0 {
filter = " AND r.country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND r.region_id = ?"
args = append(args, regionID)
}
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(*),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0)
FROM stat_user_registration r
WHERE r.app_code = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...)
var out Retention
if err := row.Scan(&out.RegisteredUsers, &out.Day1Users, &out.Day7Users, &out.Day30Users); err != nil && err != sql.ErrNoRows {
return Retention{}, err
}
out.Day1Rate = ratio(out.Day1Users, out.RegisteredUsers)
out.Day7Rate = ratio(out.Day7Users, out.RegisteredUsers)
out.Day30Rate = ratio(out.Day30Users, out.RegisteredUsers)
return out, nil
}
func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
args := []any{app, 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_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, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) {
args := []any{app, 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(mifapay_recharge_usd_minor),0),
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),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_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.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &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)
}
return out, rows.Err()
}
func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
if item == nil {
return
}
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
if channelRechargeUSDMinor > item.RechargeUSDMinor {
item.RechargeUSDMinor = channelRechargeUSDMinor
}
item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
}
func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
args := []any{app, 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_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
}
items[index].LuckyGiftTurnover = activity.TurnoverCoin
items[index].LuckyGiftPayout = 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 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 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 div(numerator, denominator int64) int64 {
if denominator <= 0 {
return 0
}
return numerator / denominator
}
func deltaRate(current, previous int64) float64 {
if previous == 0 {
if current == 0 {
return 0
}
if current > 0 {
return 1
}
return -1
}
return float64(current-previous) / math.Abs(float64(previous))
}
func ratio(numerator, denominator int64) float64 {
if denominator <= 0 {
return 0
}
return float64(numerator) / float64(denominator)
}