760 lines
32 KiB
Go
760 lines
32 KiB
Go
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)
|
||
}
|