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 {
|
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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 '适配器配置',
|
||||||
|
|||||||
@ -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 = ?`
|
||||||
|
|||||||
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user