2026-07-04 22:02:28 +08:00

857 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dashboard
import (
"context"
"database/sql"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/config"
)
const externalPeriodMetricSelect = `
COALESCE(SUM(t.country_new_user), 0),
COALESCE(SUM(t.daily_active_user), 0),
COALESCE(SUM(t.daily_recharge_user), 0),
CAST(COALESCE(SUM(t.new_user_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.official_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.mifapay_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.google_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.dealer_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.user_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.new_dealer_user_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.salary_exchange), 0) AS CHAR),
CAST(COALESCE(SUM(t.salary_transfer), 0) AS CHAR),
CAST(COALESCE(SUM(t.gift_consume), 0) AS CHAR),
CAST(COALESCE(SUM(t.lucky_gift_total_flow), 0) AS CHAR),
COALESCE(SUM(t.lucky_gift_user), 0),
CAST(COALESCE(SUM(t.lucky_gift_payout), 0) AS CHAR),
CAST(COALESCE(SUM(t.lucky_gift_anchor_share), 0) AS CHAR),
CAST(COALESCE(SUM(t.game_total_flow), 0) AS CHAR),
COALESCE(SUM(t.game_user), 0),
CAST(COALESCE(SUM(t.game_payout), 0) AS CHAR),
COALESCE(SUM(t.d1_retention_user), 0),
COALESCE(SUM(t.d1_retention_base_user), 0),
COALESCE(SUM(t.d7_retention_user), 0),
COALESCE(SUM(t.d7_retention_base_user), 0),
COALESCE(SUM(t.d30_retention_user), 0),
COALESCE(SUM(t.d30_retention_base_user), 0),
MAX(t.refreshed_at)`
type MySQLExternalDashboardSource struct {
db *sql.DB
appCode string
appName string
sysOrigin string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
}
type externalDashboardMetric struct {
NewUsers int64
ActiveUsers int64
PaidUsers int64
RechargeUsers int64
NewUserRechargeUSDMinor int64
OfficialRechargeUSDMinor int64
MifaPayRechargeUSDMinor int64
GoogleRechargeUSDMinor int64
CoinSellerRechargeUSDMinor int64
UserRechargeUSDMinor int64
NewDealerRechargeUSDMinor int64
SalaryExchangeUSDMinor int64
SalaryTransferUSDMinor int64
SalaryUSDMinor *int64
GiftCoinSpent int64
LuckyGiftTurnover int64
LuckyGiftPayers int64
LuckyGiftPayout int64
LuckyGiftAnchorShare int64
GameTurnover int64
GamePlayers int64
GamePayout int64
D1RetentionUsers int64
D1RetentionBaseUsers int64
D7RetentionUsers int64
D7RetentionBaseUsers int64
D30RetentionUsers int64
D30RetentionBaseUsers int64
RechargeUSDMinor int64
ARPUUSDMinor int64
ARPPUUSDMinor int64
PayerRate float64
PaidConversionRate float64
RechargeConversionRate float64
GameProfit int64
GameProfitRate float64
LuckyGiftProfit int64
LuckyGiftPayoutRate float64
LuckyGiftProfitRate float64
D1RetentionRate float64
D7RetentionRate float64
D30RetentionRate float64
CoinTotal *int64
// 以下指针字段只有 likei Mongo 源能给出事实cdc MySQL 源保持 nil前端展示 "--"。
CoinSellerTransferCoin *int64
ConsumedCoin *int64
OutputCoin *int64
RefreshedAt sql.NullTime
}
type externalMetricScanner interface {
Scan(dest ...any) error
}
// NewMySQLExternalDashboardSource 把外接 App 的大屏查询绑定到 dashboard-cdc-worker 产出的只读聚合库;
// 返回 nil 代表配置未启用,调用方可以统一把 nil 过滤掉。
func NewMySQLExternalDashboardSource(db *sql.DB, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) ExternalDashboardSource {
if db == nil || !sourceConfig.Enabled {
return nil
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
statTimezone := strings.TrimSpace(sourceConfig.StatTimezone)
if statTimezone == "" {
statTimezone = "Asia/Riyadh"
}
timeout := sourceConfig.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
if appCode == "" || sysOrigin == "" {
return nil
}
return &MySQLExternalDashboardSource{
db: db,
appCode: appCode,
appName: strings.TrimSpace(sourceConfig.AppName),
sysOrigin: sysOrigin,
statTimezone: statTimezone,
requestTimeout: timeout,
regionResolvers: compactExternalDashboardRegionResolvers(regionResolvers),
}
}
func (s *MySQLExternalDashboardSource) AppCode() string {
if s == nil {
return ""
}
return s.appCode
}
func (s *MySQLExternalDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("external dashboard source is not configured")
}
countryFilter, err := s.countryFilter(ctx, query)
if err != nil {
return nil, err
}
requestTimezone := strings.TrimSpace(query.StatTZ)
if requestTimezone == "" {
requestTimezone = s.statTimezone
}
requestLocation, err := time.LoadLocation(requestTimezone)
if err != nil {
return nil, fmt.Errorf("load external dashboard request timezone %s: %w", requestTimezone, err)
}
storageTimezone := s.statTimezone
if _, err := time.LoadLocation(storageTimezone); err != nil {
return nil, fmt.Errorf("load external dashboard storage timezone %s: %w", storageTimezone, err)
}
// 前端的 stat_tz 表示筛选日期的解释方式dashboard-cdc-worker 表里的 stat_timezone 是外部项目固定存储口径,两者不能混用。
startDate, endDate, startMS, endMS := externalDashboardDateRange(query, requestLocation)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
total, err := s.loadMetricSummary(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
previous, err := s.loadMetricSummary(queryCtx, storageTimezone, previousStart, startDate, countryFilter)
if err != nil {
return nil, err
}
dailySeries, err := s.loadDailySeries(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
countryBreakdown, err := s.loadCountryBreakdown(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
dailyCountryBreakdown, err := s.loadDailyCountryBreakdown(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
response := total.toMap()
response["app_code"] = s.appCode
response["app_name"] = firstExternalDashboardValue(s.appName, s.appCode)
response["sys_origin"] = s.sysOrigin
response["stat_tz"] = storageTimezone
response["request_stat_tz"] = requestTimezone
response["start_ms"] = startMS
response["end_ms"] = endMS
response["daily_series"] = dailySeries
response["country_breakdown"] = countryBreakdown
response["daily_country_breakdown"] = dailyCountryBreakdown
response["retention"] = total.retentionMap()
response["report_metric_sources"] = externalDashboardMetricSources()
response["updated_at_ms"] = total.updatedAtMS()
applyExternalDashboardDeltas(response, total, previous)
return response, nil
}
type externalDashboardCountryFilter struct {
Applied bool
RegionID int64
Codes []string
}
func (s *MySQLExternalDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) {
if query.CountryID > 0 {
return externalDashboardCountryFilter{}, fmt.Errorf("external dashboard source %s does not support numeric country_id filter", s.appCode)
}
if query.RegionID <= 0 {
return externalDashboardCountryFilter{}, nil
}
for _, resolver := range s.regionResolvers {
codes, handled, err := resolver.CountryCodesForRegion(ctx, s.appCode, query.RegionID)
if err != nil {
return externalDashboardCountryFilter{}, err
}
if !handled {
continue
}
return externalDashboardCountryFilter{
Applied: true,
RegionID: query.RegionID,
Codes: normalizeExternalCountryCodes(codes),
}, nil
}
// dashboard-cdc-worker 没有 hyapp 的数字 region_id只能通过 legacy 区域目录映射到 country_code 后过滤;缺映射时直接失败,避免返回未筛选全量数据。
return externalDashboardCountryFilter{}, fmt.Errorf("external dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}
func (s *MySQLExternalDashboardSource) loadMetricSummary(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (externalDashboardMetric, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
row := s.db.QueryRowContext(ctx, `
SELECT `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL, args...)
metric, err := scanExternalDashboardMetric(row)
if err != nil {
return externalDashboardMetric{}, fmt.Errorf("query external dashboard summary for %s: %w", s.appCode, err)
}
return metric, nil
}
func (s *MySQLExternalDashboardSource) loadDailySeries(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) ([]map[string]any, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
rows, err := s.db.QueryContext(ctx, `
SELECT t.period_key, `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.period_key
ORDER BY t.period_key`, args...)
if err != nil {
return nil, fmt.Errorf("query external dashboard daily series for %s: %w", s.appCode, err)
}
defer rows.Close()
series := []map[string]any{}
for rows.Next() {
var statDay string
metric, err := scanExternalDashboardMetricWithPrefix(rows, &statDay)
if err != nil {
return nil, fmt.Errorf("scan external dashboard daily series for %s: %w", s.appCode, err)
}
row := metric.toMap()
row["label"] = statDay
row["stat_day"] = statDay
series = append(series, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external dashboard daily series for %s: %w", s.appCode, err)
}
return series, nil
}
func (s *MySQLExternalDashboardSource) loadCountryBreakdown(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) ([]map[string]any, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
rows, err := s.db.QueryContext(ctx, `
SELECT t.country_code, MAX(t.country_name), `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.country_code
ORDER BY SUM(t.official_recharge + t.mifapay_recharge + t.dealer_recharge) DESC, t.country_code`, args...)
if err != nil {
return nil, fmt.Errorf("query external dashboard country breakdown for %s: %w", s.appCode, err)
}
defer rows.Close()
countries := []map[string]any{}
for rows.Next() {
var countryCode string
var countryName sql.NullString
metric, err := scanExternalDashboardMetricWithPrefix(rows, &countryCode, &countryName)
if err != nil {
return nil, fmt.Errorf("scan external dashboard country breakdown for %s: %w", s.appCode, err)
}
row := metric.toMap()
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = firstExternalDashboardValue(countryName.String, countryCode)
row["country"] = firstExternalDashboardValue(countryName.String, countryCode)
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
countries = append(countries, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external dashboard country breakdown for %s: %w", s.appCode, err)
}
return countries, nil
}
func (s *MySQLExternalDashboardSource) loadDailyCountryBreakdown(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) ([]map[string]any, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
rows, err := s.db.QueryContext(ctx, `
SELECT t.period_key, t.country_code, MAX(t.country_name), `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.period_key, t.country_code
ORDER BY t.period_key, t.country_code`, args...)
if err != nil {
return nil, fmt.Errorf("query external dashboard daily country breakdown for %s: %w", s.appCode, err)
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var statDay string
var countryCode string
var countryName sql.NullString
metric, err := scanExternalDashboardMetricWithPrefix(rows, &statDay, &countryCode, &countryName)
if err != nil {
return nil, fmt.Errorf("scan external dashboard daily country breakdown for %s: %w", s.appCode, err)
}
row := metric.toMap()
row["label"] = statDay
row["stat_day"] = statDay
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = firstExternalDashboardValue(countryName.String, countryCode)
row["country"] = firstExternalDashboardValue(countryName.String, countryCode)
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external dashboard daily country breakdown for %s: %w", s.appCode, err)
}
return out, nil
}
func (s *MySQLExternalDashboardSource) periodMetricWhere(statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (string, []any) {
whereSQL := `t.period_type = 'DAY'
AND t.stat_timezone = ?
AND t.sys_origin = ?
AND t.period_start_date >= ?
AND t.period_start_date < ?`
args := []any{statTimezone, s.sysOrigin, dashboardSQLDate(startDate), dashboardSQLDate(endDate)}
if !countryFilter.Applied {
return whereSQL, args
}
if len(countryFilter.Codes) == 0 {
return whereSQL + `
AND 1 = 0`, args
}
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
placeholders = append(placeholders, "?")
args = append(args, code)
}
return whereSQL + `
AND t.country_code IN (` + strings.Join(placeholders, ",") + `)`, args
}
func scanExternalDashboardMetric(scanner externalMetricScanner) (externalDashboardMetric, error) {
metric := externalDashboardMetric{}
if err := scanner.Scan(externalDashboardScanDest(&metric)...); err != nil {
return externalDashboardMetric{}, err
}
metric.finalize()
return metric, nil
}
func scanExternalDashboardMetricWithPrefix(scanner externalMetricScanner, prefix ...any) (externalDashboardMetric, error) {
metric := externalDashboardMetric{}
dest := append(prefix, externalDashboardScanDest(&metric)...)
if err := scanner.Scan(dest...); err != nil {
return externalDashboardMetric{}, err
}
metric.finalize()
return metric, nil
}
func externalDashboardScanDest(metric *externalDashboardMetric) []any {
var newUserRecharge string
var officialRecharge string
var mifaPayRecharge string
var googleRecharge string
var dealerRecharge string
var userRecharge string
var newDealerRecharge string
var salaryExchange string
var salaryTransfer string
var giftConsume string
var luckyGiftTurnover string
var luckyGiftPayout string
var luckyGiftAnchorShare string
var gameTurnover string
var gamePayout string
return []any{
&metric.NewUsers,
&metric.ActiveUsers,
&metric.PaidUsers,
decimalMinorScanner(func(value int64) { metric.NewUserRechargeUSDMinor = value }, &newUserRecharge),
decimalMinorScanner(func(value int64) { metric.OfficialRechargeUSDMinor = value }, &officialRecharge),
decimalMinorScanner(func(value int64) { metric.MifaPayRechargeUSDMinor = value }, &mifaPayRecharge),
decimalMinorScanner(func(value int64) { metric.GoogleRechargeUSDMinor = value }, &googleRecharge),
decimalMinorScanner(func(value int64) { metric.CoinSellerRechargeUSDMinor = value }, &dealerRecharge),
decimalMinorScanner(func(value int64) { metric.UserRechargeUSDMinor = value }, &userRecharge),
decimalMinorScanner(func(value int64) { metric.NewDealerRechargeUSDMinor = value }, &newDealerRecharge),
decimalMinorScanner(func(value int64) { metric.SalaryExchangeUSDMinor = value }, &salaryExchange),
decimalMinorScanner(func(value int64) { metric.SalaryTransferUSDMinor = value }, &salaryTransfer),
decimalWholeScanner(func(value int64) { metric.GiftCoinSpent = value }, &giftConsume),
decimalWholeScanner(func(value int64) { metric.LuckyGiftTurnover = value }, &luckyGiftTurnover),
&metric.LuckyGiftPayers,
decimalWholeScanner(func(value int64) { metric.LuckyGiftPayout = value }, &luckyGiftPayout),
decimalWholeScanner(func(value int64) { metric.LuckyGiftAnchorShare = value }, &luckyGiftAnchorShare),
decimalWholeScanner(func(value int64) { metric.GameTurnover = value }, &gameTurnover),
&metric.GamePlayers,
decimalWholeScanner(func(value int64) { metric.GamePayout = value }, &gamePayout),
&metric.D1RetentionUsers,
&metric.D1RetentionBaseUsers,
&metric.D7RetentionUsers,
&metric.D7RetentionBaseUsers,
&metric.D30RetentionUsers,
&metric.D30RetentionBaseUsers,
&metric.RefreshedAt,
}
}
func (m *externalDashboardMetric) finalize() {
// worker 同时沉淀了官方、MifaPay、Google、用户充值和币商充值这里取能覆盖拆分口径的最大无重复总额避免 Google/MifaPay 被重复相加。
m.UserRechargeUSDMinor = maxInt64(m.UserRechargeUSDMinor, m.GoogleRechargeUSDMinor+m.MifaPayRechargeUSDMinor)
m.RechargeUSDMinor = maxInt64(
m.OfficialRechargeUSDMinor+m.MifaPayRechargeUSDMinor+m.CoinSellerRechargeUSDMinor,
maxInt64(m.UserRechargeUSDMinor+m.CoinSellerRechargeUSDMinor, m.GoogleRechargeUSDMinor+m.MifaPayRechargeUSDMinor+m.CoinSellerRechargeUSDMinor),
)
// dashboard-cdc-worker 的 daily_recharge_user 是充值去重用户;为兼容 Databi 旧字段名,同时输出 paid_users/recharge_users 两个同口径字段。
m.RechargeUsers = m.PaidUsers
m.ARPUUSDMinor = divideInt64(m.RechargeUSDMinor, m.ActiveUsers)
m.ARPPUUSDMinor = divideInt64(m.RechargeUSDMinor, m.PaidUsers)
m.PayerRate = ratioFloat64(m.PaidUsers, m.ActiveUsers)
m.PaidConversionRate = m.PayerRate
m.RechargeConversionRate = ratioFloat64(m.RechargeUsers, m.ActiveUsers)
m.GameProfit = m.GameTurnover - m.GamePayout
m.GameProfitRate = ratioFloat64(m.GameProfit, m.GameTurnover)
m.LuckyGiftProfit = m.LuckyGiftTurnover - m.LuckyGiftPayout
m.LuckyGiftPayoutRate = ratioFloat64(m.LuckyGiftPayout, m.LuckyGiftTurnover)
m.LuckyGiftProfitRate = ratioFloat64(m.LuckyGiftProfit, m.LuckyGiftTurnover)
m.D1RetentionRate = ratioFloat64(m.D1RetentionUsers, m.D1RetentionBaseUsers)
m.D7RetentionRate = ratioFloat64(m.D7RetentionUsers, m.D7RetentionBaseUsers)
m.D30RetentionRate = ratioFloat64(m.D30RetentionUsers, m.D30RetentionBaseUsers)
}
func (m externalDashboardMetric) toMap() map[string]any {
return map[string]any{
"active_users": m.ActiveUsers,
"arpu_usd_minor": m.ARPUUSDMinor,
"arppu_usd_minor": m.ARPPUUSDMinor,
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
"coin_seller_stock_coin": nil,
"coin_seller_transfer_coin": nullableInt64(m.CoinSellerTransferCoin),
"coin_total": nullableInt64(m.CoinTotal),
"consumed_coin": nullableInt64(m.ConsumedCoin),
"consume_output_ratio": consumeOutputRatioValue(m.ConsumedCoin, m.OutputCoin),
"daily_active_user": m.ActiveUsers,
"d1_retention_base_users": retentionMetricValue(m.D1RetentionBaseUsers, m.D1RetentionBaseUsers),
"d1_retention_rate": retentionRateValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"d1_retention_users": retentionMetricValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"d7_retention_base_users": retentionMetricValue(m.D7RetentionBaseUsers, m.D7RetentionBaseUsers),
"d7_retention_rate": retentionRateValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"d7_retention_users": retentionMetricValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"d30_retention_base_users": retentionMetricValue(m.D30RetentionBaseUsers, m.D30RetentionBaseUsers),
"d30_retention_rate": retentionRateValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"d30_retention_users": retentionMetricValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"game_payout": m.GamePayout,
"game_players": m.GamePlayers,
"game_profit": m.GameProfit,
"game_profit_rate": m.GameProfitRate,
"game_turnover": m.GameTurnover,
"gift_coin_spent": m.GiftCoinSpent,
"consume_output_delta": consumeOutputDeltaValue(m.ConsumedCoin, m.OutputCoin),
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
"lucky_gift_payers": m.LuckyGiftPayers,
"lucky_gift_payout": m.LuckyGiftPayout,
"lucky_gift_payout_rate": m.LuckyGiftPayoutRate,
"lucky_gift_profit": m.LuckyGiftProfit,
"lucky_gift_profit_rate": m.LuckyGiftProfitRate,
"lucky_gift_turnover": m.LuckyGiftTurnover,
"mifapay_recharge_usd_minor": m.MifaPayRechargeUSDMinor,
"mic_online_ms": nil,
"avg_mic_online_ms": nil,
"manual_grant_coin": nil,
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
"new_users": m.NewUsers,
"output_coin": nullableInt64(m.OutputCoin),
"paid_users": m.PaidUsers,
"paid_conversion_rate": m.PaidConversionRate,
"payer_rate": m.PayerRate,
"platform_grant_coin": nil,
"recharge_usd_minor": m.RechargeUSDMinor,
"recharge_users": m.RechargeUsers,
"recharge_conversion_rate": m.RechargeConversionRate,
"registered_users": m.NewUsers,
"registrations": m.NewUsers,
"room_lucky_gift_turnover": m.LuckyGiftTurnover,
"salary_exchange_usd_minor": m.SalaryExchangeUSDMinor,
"salary_transfer_coin": nil,
"salary_transfer_usd_minor": m.SalaryTransferUSDMinor,
"salary_usd_minor": nullableInt64(m.SalaryUSDMinor),
"super_lucky_gift_payout": nil,
"super_lucky_gift_profit": nil,
"super_lucky_gift_profit_rate": nil,
"super_lucky_gift_turnover": nil,
"total_recharge_usd_minor": m.RechargeUSDMinor,
"user_recharge_usd_minor": m.UserRechargeUSDMinor,
}
}
func (m externalDashboardMetric) retentionMap() map[string]any {
return map[string]any{
"day1_base_users": retentionMetricValue(m.D1RetentionBaseUsers, m.D1RetentionBaseUsers),
"day1_rate": retentionRateValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"day1_users": retentionMetricValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"day7_base_users": retentionMetricValue(m.D7RetentionBaseUsers, m.D7RetentionBaseUsers),
"day7_rate": retentionRateValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"day7_users": retentionMetricValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"day30_base_users": retentionMetricValue(m.D30RetentionBaseUsers, m.D30RetentionBaseUsers),
"day30_rate": retentionRateValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"day30_users": retentionMetricValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"registered_users": m.NewUsers,
}
}
func (m externalDashboardMetric) updatedAtMS() int64 {
if m.RefreshedAt.Valid {
return m.RefreshedAt.Time.UTC().UnixMilli()
}
return time.Now().UTC().UnixMilli()
}
func applyExternalDashboardDeltas(out map[string]any, current externalDashboardMetric, previous externalDashboardMetric) {
out["active_users_delta_rate"] = deltaRate(current.ActiveUsers, previous.ActiveUsers)
out["arpu_delta_rate"] = deltaRate(current.ARPUUSDMinor, previous.ARPUUSDMinor)
out["arppu_delta_rate"] = deltaRate(current.ARPPUUSDMinor, previous.ARPPUUSDMinor)
out["game_payout_delta_rate"] = deltaRate(current.GamePayout, previous.GamePayout)
out["game_profit_delta_rate"] = deltaRate(current.GameProfit, previous.GameProfit)
out["game_turnover_delta_rate"] = deltaRate(current.GameTurnover, previous.GameTurnover)
out["gift_coin_spent_delta_rate"] = deltaRate(current.GiftCoinSpent, previous.GiftCoinSpent)
out["lucky_gift_payout_delta_rate"] = deltaRate(current.LuckyGiftPayout, previous.LuckyGiftPayout)
out["lucky_gift_profit_delta_rate"] = deltaRate(current.LuckyGiftProfit, previous.LuckyGiftProfit)
out["lucky_gift_turnover_delta_rate"] = deltaRate(current.LuckyGiftTurnover, previous.LuckyGiftTurnover)
out["new_users_delta_rate"] = deltaRate(current.NewUsers, previous.NewUsers)
out["paid_users_delta_rate"] = deltaRate(current.PaidUsers, previous.PaidUsers)
out["payer_rate_delta_pp"] = percentagePointDelta(current.PayerRate, previous.PayerRate)
out["paid_conversion_rate_delta_pp"] = percentagePointDelta(current.PaidConversionRate, previous.PaidConversionRate)
out["recharge_delta_rate"] = deltaRate(current.RechargeUSDMinor, previous.RechargeUSDMinor)
out["recharge_conversion_rate_delta_pp"] = percentagePointDelta(current.RechargeConversionRate, previous.RechargeConversionRate)
out["user_recharge_delta_rate"] = deltaRate(current.UserRechargeUSDMinor, previous.UserRechargeUSDMinor)
}
func externalDashboardMetricSources() []map[string]any {
return []map[string]any{
{"field": "new_users", "available": true, "source": "country_dashboard_period_metric.country_new_user"},
{"field": "active_users", "available": true, "source": "country_dashboard_period_metric.daily_active_user"},
{"field": "paid_users", "available": true, "source": "country_dashboard_period_metric.daily_recharge_user"},
{"field": "recharge_users", "available": true, "source": "country_dashboard_period_metric.daily_recharge_user"},
{"field": "recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric official/mifapay/google/dealer/user recharge"},
{"field": "new_user_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.new_user_recharge"},
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.dealer_recharge"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有币商库存进货金币字段"},
{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有币商出货金币字段"},
{"field": "google_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.google_recharge"},
{"field": "mifapay_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.mifapay_recharge"},
{"field": "game_turnover", "available": true, "source": "country_dashboard_period_metric.game_total_flow/game_payout"},
{"field": "lucky_gift_turnover", "available": true, "source": "country_dashboard_period_metric.lucky_gift_total_flow/lucky_gift_payout"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "dashboard-cdc-worker 没有超级幸运礼物独立奖池字段"},
{"field": "gift_coin_spent", "available": true, "source": "country_dashboard_period_metric.gift_consume"},
{"field": "coin_total", "available": false, "missing_reason": "dashboard-cdc-worker 没有金币余额快照"},
{"field": "consumed_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有消耗金币总口径,不能把局部流水硬拼成总数"},
{"field": "output_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有产出金币总口径"},
{"field": "consume_output_ratio", "available": false, "missing_reason": "缺少消耗金币和产出金币总口径"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有平台发放金币字段"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有人工发放金币字段"},
{"field": "salary_usd_minor", "available": false, "missing_reason": "dashboard-cdc-worker 只有 salary_exchange/salary_transfer 流水,没有存量工资余额快照"},
{"field": "salary_transfer_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有工资兑换金币字段"},
{"field": "avg_mic_online_ms", "available": false, "missing_reason": "dashboard-cdc-worker 没有麦上时长字段"},
{"field": "mic_online_ms", "available": false, "missing_reason": "dashboard-cdc-worker 没有麦上时长字段"},
{"field": "arpu_usd_minor", "available": true, "source": "recharge_usd_minor / daily_active_user"},
{"field": "arppu_usd_minor", "available": true, "source": "recharge_usd_minor / daily_recharge_user"},
{"field": "paid_conversion_rate", "available": true, "source": "daily_recharge_user / daily_active_user"},
{"field": "recharge_conversion_rate", "available": true, "source": "daily_recharge_user / daily_active_user"},
{"field": "retention.day1_rate", "available": true, "source": "country_dashboard_period_metric.d1_retention_user/base_user"},
{"field": "retention.day7_rate", "available": true, "source": "country_dashboard_period_metric.d7_retention_user/base_user"},
{"field": "retention.day30_rate", "available": true, "source": "country_dashboard_period_metric.d30_retention_user/base_user"},
}
}
func externalDashboardDateRange(query StatisticsQuery, location *time.Location) (time.Time, time.Time, int64, int64) {
now := time.Now().In(location)
startMS := query.StartMS
if startMS <= 0 {
startMS = localMidnight(now, location).UnixMilli()
}
endMS := query.EndMS
if endMS <= startMS {
endMS = time.UnixMilli(startMS).In(location).AddDate(0, 0, 1).UnixMilli()
}
startDate := localMidnight(time.UnixMilli(startMS).In(location), location)
endMoment := time.UnixMilli(endMS - 1).In(location)
endDate := localMidnight(endMoment, location).AddDate(0, 0, 1)
return startDate, endDate, startDate.UnixMilli(), endDate.UnixMilli()
}
func localMidnight(t time.Time, location *time.Location) time.Time {
local := t.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}
func dashboardSQLDate(t time.Time) string {
return t.Format("2006-01-02")
}
func countCalendarDays(start time.Time, end time.Time) int {
days := 0
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
days++
}
if days <= 0 {
return 1
}
return days
}
type externalDecimalScanner struct {
raw *string
assign func(int64)
whole bool
}
func decimalMinorScanner(assign func(int64), raw *string) sql.Scanner {
return externalDecimalScanner{raw: raw, assign: assign}
}
func decimalWholeScanner(assign func(int64), raw *string) sql.Scanner {
return externalDecimalScanner{raw: raw, assign: assign, whole: true}
}
func (s externalDecimalScanner) Scan(value any) error {
var raw string
switch typed := value.(type) {
case nil:
raw = "0"
case []byte:
raw = string(typed)
case string:
raw = typed
default:
raw = fmt.Sprint(typed)
}
raw = strings.TrimSpace(raw)
if raw == "" || raw == "<nil>" {
raw = "0"
}
if s.raw != nil {
*s.raw = raw
}
minor, err := decimalTextToMinor(raw)
if err != nil {
return err
}
if s.whole {
minor = roundMinorToWhole(minor)
}
s.assign(minor)
return nil
}
func decimalTextToMinor(raw string) (int64, error) {
text := strings.TrimSpace(raw)
if text == "" {
return 0, nil
}
sign := int64(1)
if strings.HasPrefix(text, "-") {
sign = -1
text = strings.TrimPrefix(text, "-")
} else {
text = strings.TrimPrefix(text, "+")
}
wholeText, fractionText, _ := strings.Cut(text, ".")
if wholeText == "" {
wholeText = "0"
}
for len(fractionText) < 2 {
fractionText += "0"
}
if len(fractionText) > 2 {
fractionText = fractionText[:2]
}
whole, err := strconv.ParseInt(wholeText, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse decimal whole %q: %w", raw, err)
}
fraction, err := strconv.ParseInt(fractionText, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse decimal fraction %q: %w", raw, err)
}
return sign * (whole*100 + fraction), nil
}
func roundMinorToWhole(minor int64) int64 {
if minor < 0 {
return -roundMinorToWhole(-minor)
}
return (minor + 50) / 100
}
func divideInt64(numerator int64, denominator int64) int64 {
if denominator == 0 {
return 0
}
return int64(math.Round(float64(numerator) / float64(denominator)))
}
func ratioFloat64(numerator int64, denominator int64) float64 {
if denominator == 0 {
return 0
}
return float64(numerator) / float64(denominator)
}
func retentionMetricValue(value int64, base int64) any {
if base <= 0 {
return nil
}
return value
}
func retentionRateValue(users int64, base int64) any {
if base <= 0 {
return nil
}
return ratioFloat64(users, base)
}
func deltaRate(current int64, previous int64) float64 {
if previous == 0 {
if current == 0 {
return 0
}
return 1
}
return float64(current-previous) / float64(previous)
}
func percentagePointDelta(current float64, previous float64) float64 {
return (current - previous) * 100
}
func maxInt64(left int64, right int64) int64 {
if left > right {
return left
}
return right
}
func int64Ptr(value int64) *int64 {
out := value
return &out
}
func nullableInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func consumeOutputRatioValue(consumed *int64, output *int64) any {
if consumed == nil || output == nil {
return nil
}
return ratioFloat64(*consumed, *output)
}
func consumeOutputDeltaValue(consumed *int64, output *int64) any {
if consumed == nil || output == nil {
return nil
}
return *consumed - *output
}
func firstExternalDashboardValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func compactExternalDashboardRegionResolvers(resolvers []ExternalDashboardRegionResolver) []ExternalDashboardRegionResolver {
out := make([]ExternalDashboardRegionResolver, 0, len(resolvers))
for _, resolver := range resolvers {
if resolver != nil {
out = append(out, resolver)
}
}
return out
}
func normalizeExternalCountryCodes(codes []string) []string {
out := make([]string, 0, len(codes))
seen := map[string]struct{}{}
for _, rawCode := range codes {
code := strings.ToUpper(strings.TrimSpace(rawCode))
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
sort.Strings(out)
return out
}