Update game adapter defaults and statistics overview
This commit is contained in:
parent
02ce8874c3
commit
44e38ab9bb
@ -32,10 +32,13 @@ type rechargeBillDTO struct {
|
||||
}
|
||||
|
||||
type rechargeBillUserDTO struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
UserID string `json:"userId"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
CountryName string `json:"countryName"`
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
}
|
||||
|
||||
type rechargeProductDTO struct {
|
||||
|
||||
@ -84,16 +84,18 @@ func (h *Handler) fillRechargeBillUsers(ctx context.Context, appCode string, ite
|
||||
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) {
|
||||
if h == nil || h.userDB == nil {
|
||||
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, `
|
||||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`,
|
||||
SELECT u.user_id, COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.avatar, ''),
|
||||
COALESCE(u.country, ''), COALESCE(c.country_name, ''), COALESCE(c.country_display_name, '')
|
||||
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)...)...,
|
||||
)
|
||||
if err != nil {
|
||||
@ -105,7 +107,15 @@ func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, use
|
||||
for rows.Next() {
|
||||
var profile rechargeBillUserDTO
|
||||
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
|
||||
}
|
||||
profile.UserID = strconv.FormatInt(userID, 10)
|
||||
|
||||
@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS game_platforms (
|
||||
platform_name VARCHAR(128) NOT NULL COMMENT '平台名称',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||
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_ip_whitelist JSON NULL COMMENT '回调IP白名单',
|
||||
adapter_config JSON NULL COMMENT '适配器配置',
|
||||
|
||||
@ -66,7 +66,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
platform_name VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
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_ip_whitelist JSON NULL,
|
||||
adapter_config JSON NULL,
|
||||
@ -251,7 +251,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
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,
|
||||
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,
|
||||
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), '[]'),
|
||||
COALESCE(CAST(p.adapter_config AS CHAR), '{}')
|
||||
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) {
|
||||
// 回调入口只知道 platform_code,必须按 app_code 隔离查询,防止多租户平台编码冲突。
|
||||
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), '[]'),
|
||||
COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
|
||||
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) {
|
||||
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), '[]'),
|
||||
COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
|
||||
FROM game_platforms WHERE app_code = ?`
|
||||
|
||||
@ -412,8 +412,6 @@ func normalizeStatus(status string) string {
|
||||
func normalizeAdapterType(adapterType string) string {
|
||||
// 允许的 adapter 必须在这里登记,后台传错值时直接拒绝,避免落库后回调走错协议。
|
||||
switch strings.ToLower(strings.TrimSpace(adapterType)) {
|
||||
case "", gamedomain.AdapterDemo:
|
||||
return gamedomain.AdapterDemo
|
||||
case gamedomain.AdapterYomiV4:
|
||||
return gamedomain.AdapterYomiV4
|
||||
case gamedomain.AdapterLeaderCCV1:
|
||||
@ -422,6 +420,8 @@ func normalizeAdapterType(adapterType string) string {
|
||||
return gamedomain.AdapterZeeOneV1
|
||||
case gamedomain.AdapterBaishunV1:
|
||||
return gamedomain.AdapterBaishunV1
|
||||
case gamedomain.AdapterVivaGamesV1:
|
||||
return gamedomain.AdapterVivaGamesV1
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -22,3 +22,35 @@ func TestPlatformToProtoExposesCallbackSecretForAdmin(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package mysql
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
@ -16,38 +17,51 @@ type OverviewQuery struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
Retention Retention `json:"retention"`
|
||||
GameRanking []GameRank `json:"game_ranking"`
|
||||
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
|
||||
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"`
|
||||
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"`
|
||||
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 {
|
||||
@ -87,48 +101,17 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
||||
}
|
||||
startDay := statDay(query.StartMS)
|
||||
endDay := statDay(query.EndMS - 1)
|
||||
args := []any{app, startDay, endDay}
|
||||
countryFilter := ""
|
||||
if query.CountryID > 0 {
|
||||
countryFilter = " AND country_id = ?"
|
||||
args = append(args, query.CountryID)
|
||||
previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
|
||||
overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID)
|
||||
if err != nil {
|
||||
return Overview{}, err
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
// 历史聚合行可能只写了真实 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
|
||||
previous.AppCode, previous.CountryID = app, query.CountryID
|
||||
// Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
|
||||
pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID)
|
||||
if err != nil {
|
||||
@ -142,10 +125,89 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
||||
overview.LuckyGiftPools = 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.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, 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) {
|
||||
@ -295,6 +357,25 @@ func normalizeRange(startMS, endMS int64) (int64, int64) {
|
||||
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
|
||||
@ -302,6 +383,19 @@ func div(numerator, denominator int64) int64 {
|
||||
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
|
||||
|
||||
@ -64,3 +64,46 @@ func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user