Update game adapter defaults and statistics overview

This commit is contained in:
zhx 2026-06-07 14:50:19 +08:00
parent 02ce8874c3
commit 44e38ab9bb
8 changed files with 273 additions and 91 deletions

View File

@ -32,10 +32,13 @@ type rechargeBillDTO struct {
} }
type rechargeBillUserDTO struct { type rechargeBillUserDTO struct {
UserID string `json:"userId"` UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"` DisplayUserID string `json:"displayUserId"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
CountryDisplayName string `json:"countryDisplayName"`
} }
type rechargeProductDTO struct { type rechargeProductDTO struct {

View File

@ -84,16 +84,18 @@ func (h *Handler) fillRechargeBillUsers(ctx context.Context, appCode string, ite
return nil return nil
} }
// loadRechargeBillUsers 按 app_code 和用户 ID 批量读取展示资料,返回 map 便于用户列和币商列复用同一次查询结果 // loadRechargeBillUsers 按 app_code 和用户 ID 批量读取展示资料;国家只服务后台账单展示,账单金额和区域事实仍以 wallet-service 为准
func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]rechargeBillUserDTO, error) { func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]rechargeBillUserDTO, error) {
if h == nil || h.userDB == nil { if h == nil || h.userDB == nil {
return nil, errors.New("user mysql is not configured") return nil, errors.New("user mysql is not configured")
} }
// 本查询只读取 users 表里的展示字段,按照 app_code 和 user_id 精确命中;区域和账单筛选仍以 wallet-service 返回为准。 // 本查询只读取 users/countries 表里的展示字段,按照 app_code 和 user_id 精确命中;区域和账单筛选仍以 wallet-service 返回为准。
rows, err := h.userDB.QueryContext(ctx, ` rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') SELECT u.user_id, COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.avatar, ''),
FROM users COALESCE(u.country, ''), COALESCE(c.country_name, ''), COALESCE(c.country_display_name, '')
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, FROM users u
LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
WHERE u.app_code = ? AND u.user_id IN (`+placeholders(len(userIDs))+`)`,
append([]any{appCode}, int64Args(userIDs)...)..., append([]any{appCode}, int64Args(userIDs)...)...,
) )
if err != nil { if err != nil {
@ -105,7 +107,15 @@ func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, use
for rows.Next() { for rows.Next() {
var profile rechargeBillUserDTO var profile rechargeBillUserDTO
var userID int64 var userID int64
if err := rows.Scan(&userID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil { if err := rows.Scan(
&userID,
&profile.DisplayUserID,
&profile.Username,
&profile.Avatar,
&profile.CountryCode,
&profile.CountryName,
&profile.CountryDisplayName,
); err != nil {
return nil, err return nil, err
} }
profile.UserID = strconv.FormatInt(userID, 10) profile.UserID = strconv.FormatInt(userID, 10)

View File

@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS game_platforms (
platform_name VARCHAR(128) NOT NULL COMMENT '平台名称', platform_name VARCHAR(128) NOT NULL COMMENT '平台名称',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
api_base_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'API 基准 URL', api_base_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'API 基准 URL',
adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' COMMENT '适配器类型', adapter_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '适配器类型',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '回调密钥密文', callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '回调密钥密文',
callback_ip_whitelist JSON NULL COMMENT '回调IP白名单', callback_ip_whitelist JSON NULL COMMENT '回调IP白名单',
adapter_config JSON NULL COMMENT '适配器配置', adapter_config JSON NULL COMMENT '适配器配置',

View File

@ -66,7 +66,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
platform_name VARCHAR(128) NOT NULL, platform_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
api_base_url VARCHAR(512) NOT NULL DEFAULT '', api_base_url VARCHAR(512) NOT NULL DEFAULT '',
adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo', adapter_type VARCHAR(64) NOT NULL DEFAULT '',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '', callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
callback_ip_whitelist JSON NULL, callback_ip_whitelist JSON NULL,
adapter_config JSON NULL, adapter_config JSON NULL,
@ -251,7 +251,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
return err return err
} }
} }
if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' AFTER api_base_url"); err != nil { if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT '' AFTER api_base_url"); err != nil {
return err return err
} }
if err := r.ensureColumn(ctx, "game_catalog", "safe_height", "safe_height INT NOT NULL DEFAULT 0 AFTER orientation"); err != nil { if err := r.ensureColumn(ctx, "game_catalog", "safe_height", "safe_height INT NOT NULL DEFAULT 0 AFTER orientation"); err != nil {
@ -410,7 +410,7 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game
`SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category, `SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category,
c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status,
c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms, c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms,
p.platform_name, p.status, p.api_base_url, COALESCE(p.adapter_type, 'demo'), p.platform_name, p.status, p.api_base_url, COALESCE(p.adapter_type, ''),
p.callback_secret_ciphertext, COALESCE(CAST(p.callback_ip_whitelist AS CHAR), '[]'), p.callback_secret_ciphertext, COALESCE(CAST(p.callback_ip_whitelist AS CHAR), '[]'),
COALESCE(CAST(p.adapter_config AS CHAR), '{}') COALESCE(CAST(p.adapter_config AS CHAR), '{}')
FROM game_catalog c FROM game_catalog c
@ -435,7 +435,7 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game
func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) { func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) {
// 回调入口只知道 platform_code必须按 app_code 隔离查询,防止多租户平台编码冲突。 // 回调入口只知道 platform_code必须按 app_code 隔离查询,防止多租户平台编码冲突。
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
`SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'), `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, ''),
callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'), callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
FROM game_platforms FROM game_platforms
@ -1088,7 +1088,7 @@ func (r *Repository) MarkLevelEventFailed(ctx context.Context, eventID string, f
} }
func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) { func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
query := `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'), query := `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, ''),
callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'), callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
FROM game_platforms WHERE app_code = ?` FROM game_platforms WHERE app_code = ?`

View File

@ -412,8 +412,6 @@ func normalizeStatus(status string) string {
func normalizeAdapterType(adapterType string) string { func normalizeAdapterType(adapterType string) string {
// 允许的 adapter 必须在这里登记,后台传错值时直接拒绝,避免落库后回调走错协议。 // 允许的 adapter 必须在这里登记,后台传错值时直接拒绝,避免落库后回调走错协议。
switch strings.ToLower(strings.TrimSpace(adapterType)) { switch strings.ToLower(strings.TrimSpace(adapterType)) {
case "", gamedomain.AdapterDemo:
return gamedomain.AdapterDemo
case gamedomain.AdapterYomiV4: case gamedomain.AdapterYomiV4:
return gamedomain.AdapterYomiV4 return gamedomain.AdapterYomiV4
case gamedomain.AdapterLeaderCCV1: case gamedomain.AdapterLeaderCCV1:
@ -422,6 +420,8 @@ func normalizeAdapterType(adapterType string) string {
return gamedomain.AdapterZeeOneV1 return gamedomain.AdapterZeeOneV1
case gamedomain.AdapterBaishunV1: case gamedomain.AdapterBaishunV1:
return gamedomain.AdapterBaishunV1 return gamedomain.AdapterBaishunV1
case gamedomain.AdapterVivaGamesV1:
return gamedomain.AdapterVivaGamesV1
default: default:
return "" return ""
} }

View File

@ -22,3 +22,35 @@ func TestPlatformToProtoExposesCallbackSecretForAdmin(t *testing.T) {
t.Fatal("callback_secret_set = false, want true") t.Fatal("callback_secret_set = false, want true")
} }
} }
func TestNormalizePlatformAcceptsVivaGamesAdapter(t *testing.T) {
// 平台创建入口会先规范化 adapter_type新增厂商必须在这里放行否则后台配置页会被 gRPC 直接拒绝。
got, err := normalizePlatform(gamedomain.Platform{
PlatformCode: "vivagames",
PlatformName: "VIVAGAMES",
Status: gamedomain.StatusActive,
AdapterType: gamedomain.AdapterVivaGamesV1,
AdapterConfigJSON: "{}",
})
if err != nil {
t.Fatalf("normalizePlatform() error = %v", err)
}
if got.AdapterType != gamedomain.AdapterVivaGamesV1 {
t.Fatalf("adapter_type = %q, want %q", got.AdapterType, gamedomain.AdapterVivaGamesV1)
}
}
func TestNormalizePlatformRejectsDemoAdapter(t *testing.T) {
for _, adapterType := range []string{"", gamedomain.AdapterDemo} {
_, err := normalizePlatform(gamedomain.Platform{
PlatformCode: "demo",
PlatformName: "Demo",
Status: gamedomain.StatusActive,
AdapterType: adapterType,
AdapterConfigJSON: "{}",
})
if err == nil {
t.Fatalf("normalizePlatform(adapterType=%q) expected error", adapterType)
}
}
}

View File

@ -3,6 +3,7 @@ package mysql
import ( import (
"context" "context"
"database/sql" "database/sql"
"math"
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -16,38 +17,51 @@ type OverviewQuery struct {
} }
type Overview struct { type Overview struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
StartMS int64 `json:"start_ms"` StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"` EndMS int64 `json:"end_ms"`
CountryID int64 `json:"country_id"` CountryID int64 `json:"country_id"`
NewUsers int64 `json:"new_users"` NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"` ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"` PaidUsers int64 `json:"paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"` RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"` NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"` CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"` MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"` CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
GiftCoinSpent int64 `json:"gift_coin_spent"` GiftCoinSpent int64 `json:"gift_coin_spent"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"` LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"` LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftPayers int64 `json:"lucky_gift_payers"` LuckyGiftPayers int64 `json:"lucky_gift_payers"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"` LuckyGiftProfit int64 `json:"lucky_gift_profit"`
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"` LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"` LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
GameTurnover int64 `json:"game_turnover"` GameTurnover int64 `json:"game_turnover"`
GamePayout int64 `json:"game_payout"` GamePayout int64 `json:"game_payout"`
GameRefund int64 `json:"game_refund"` GameRefund int64 `json:"game_refund"`
GamePlayers int64 `json:"game_players"` GamePlayers int64 `json:"game_players"`
GameProfit int64 `json:"game_profit"` GameProfit int64 `json:"game_profit"`
GameProfitRate float64 `json:"game_profit_rate"` GameProfitRate float64 `json:"game_profit_rate"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"` ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"` ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
UPValueUSDMinor int64 `json:"up_value_usd_minor"` UPValueUSDMinor int64 `json:"up_value_usd_minor"`
Retention Retention `json:"retention"` RechargeDeltaRate float64 `json:"recharge_delta_rate"`
GameRanking []GameRank `json:"game_ranking"` NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"` ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
PaidUsersDeltaRate float64 `json:"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"`
GameRanking []GameRank `json:"game_ranking"`
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
} }
type Retention struct { type Retention struct {
@ -87,48 +101,17 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
} }
startDay := statDay(query.StartMS) startDay := statDay(query.StartMS)
endDay := statDay(query.EndMS - 1) endDay := statDay(query.EndMS - 1)
args := []any{app, startDay, endDay} previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
countryFilter := "" overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID)
if query.CountryID > 0 { if err != nil {
countryFilter = " AND country_id = ?" return Overview{}, err
args = append(args, query.CountryID) }
previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID)
if err != nil {
return Overview{}, err
} }
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 ?`+countryFilter, args...)
var overview Overview
overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID
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 { previous.AppCode, previous.CountryID = app, query.CountryID
return Overview{}, err
}
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor查询时用渠道合计兜底但币商转普通金币只展示金币卡片不进入用户 USDT 充值。
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor
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
retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID)
if err != nil {
return Overview{}, err
}
overview.Retention = retention
ranking, err := r.queryGameRanking(ctx, app, startDay, endDay, query.CountryID)
if err != nil {
return Overview{}, err
}
overview.GameRanking = ranking
// Pool 明细和顶部汇总来自同一个统计窗口Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。 // Pool 明细和顶部汇总来自同一个统计窗口Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID) pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID)
if err != nil { if err != nil {
@ -142,10 +125,89 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
overview.LuckyGiftPools = activityPools overview.LuckyGiftPools = activityPools
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools) overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
} }
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID); 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)
if err != nil {
return Overview{}, err
}
overview.Retention = retention
ranking, err := r.queryGameRanking(ctx, app, startDay, endDay, query.CountryID)
if err != nil {
return Overview{}, err
}
overview.GameRanking = ranking
return overview, nil
}
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64) (Overview, error) {
args := []any{app, startDay, endDay}
countryFilter := ""
if countryID > 0 {
countryFilter = " AND country_id = ?"
args = append(args, countryID)
}
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 ?`+countryFilter, 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 applyOverviewDerivedMetrics(overview *Overview) {
if overview == nil {
return
}
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor查询时用渠道合计兜底但币商转普通金币只展示金币卡片不进入用户 USDT 充值。
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor
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.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover) overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover) overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
return overview, nil }
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.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) (Retention, error) { func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64) (Retention, error) {
@ -295,6 +357,25 @@ func normalizeRange(startMS, endMS int64) (int64, int64) {
return startMS, endMS 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 { func div(numerator, denominator int64) int64 {
if denominator <= 0 { if denominator <= 0 {
return 0 return 0
@ -302,6 +383,19 @@ func div(numerator, denominator int64) int64 {
return numerator / denominator 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 { func ratio(numerator, denominator int64) float64 {
if denominator <= 0 { if denominator <= 0 {
return 0 return 0

View File

@ -64,3 +64,46 @@ func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
t.Fatalf("super_lucky pool mismatch: %+v", overview.LuckyGiftPools[0]) t.Fatalf("super_lucky pool mismatch: %+v", overview.LuckyGiftPools[0])
} }
} }
func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_delta_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, active_users, paid_users, recharge_usd_minor, new_user_recharge_usd_minor,
mifapay_recharge_usd_minor, gift_coin_spent, coin_seller_transfer_coin, lucky_gift_turnover, lucky_gift_payout,
game_turnover, game_payout, game_refund, updated_at_ms
) VALUES
('lalu', '2026-06-05', 210, 10, 2, 100, 50, 100, 300, 80, 100, 50, 500, 100, 50, 1),
('lalu', '2026-06-06', 210, 20, 5, 200, 100, 200, 600, 200, 400, 100, 1000, 400, 100, 1)`); err != nil {
t.Fatalf("seed delta statistics: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.RechargeDeltaRate != 1 || overview.NewUserRechargeDeltaRate != 1 || overview.ActiveUsersDeltaRate != 1 {
t.Fatalf("top delta rates mismatch: %+v", overview)
}
if overview.PaidUsersDeltaRate != 1.5 || overview.ARPUDeltaRate != 0 || overview.ARPPUDeltaRate != -0.2 {
t.Fatalf("user delta rates mismatch: %+v", overview)
}
if overview.GiftCoinSpentDeltaRate != 1 || overview.CoinSellerTransferDeltaRate != 1.5 || overview.LuckyGiftTurnoverDeltaRate != 3 || overview.LuckyGiftPayoutDeltaRate != 1 || overview.LuckyGiftProfitDeltaRate != 5 {
t.Fatalf("coin delta rates mismatch: %+v", overview)
}
if overview.GameTurnoverDeltaRate != 1 || overview.GameProfitDeltaRate < 0.428 || overview.GameProfitDeltaRate > 0.429 {
t.Fatalf("game delta rates mismatch: %+v", overview)
}
}