diff --git a/server/admin/internal/modules/payment/dto.go b/server/admin/internal/modules/payment/dto.go index feb29e78..dd2a210f 100644 --- a/server/admin/internal/modules/payment/dto.go +++ b/server/admin/internal/modules/payment/dto.go @@ -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 { diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index f09bbf92..40c61514 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -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) diff --git a/services/game-service/deploy/mysql/initdb/001_game_service.sql b/services/game-service/deploy/mysql/initdb/001_game_service.sql index 39976f52..7a1c4595 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -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 '适配器配置', diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index b62cddba..5a5092fa 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -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 = ?` diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 60fa90db..93098cfb 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -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 "" } diff --git a/services/game-service/internal/transport/grpc/server_test.go b/services/game-service/internal/transport/grpc/server_test.go index 770bffdf..0d4ca170 100644 --- a/services/game-service/internal/transport/grpc/server_test.go +++ b/services/game-service/internal/transport/grpc/server_test.go @@ -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) + } + } +} diff --git a/services/statistics-service/internal/storage/mysql/query.go b/services/statistics-service/internal/storage/mysql/query.go index 999e080d..4c4fb228 100644 --- a/services/statistics-service/internal/storage/mysql/query.go +++ b/services/statistics-service/internal/storage/mysql/query.go @@ -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 diff --git a/services/statistics-service/internal/storage/mysql/query_test.go b/services/statistics-service/internal/storage/mysql/query_test.go index b0dab2a9..c84e4e7f 100644 --- a/services/statistics-service/internal/storage/mysql/query_test.go +++ b/services/statistics-service/internal/storage/mysql/query_test.go @@ -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) + } +}