hyapp-server/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go
2026-07-03 18:49:44 +08:00

1294 lines
45 KiB
Go
Raw 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"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const (
aslanMongoInAppPurchaseCollection = "in_app_purchase_details"
aslanMongoUserProfileCollection = "user_run_profile"
aslanMongoUserActiveCollection = "user_daily_active_log"
aslanMongoTeamMemberCollection = "team_member"
aslanMongoBankBalanceCollection = "user_bank_balance"
aslanMongoGiftWaterCollection = "gift_give_running_water"
aslanMongoGoldWaterCollection = "user_gold_running_water_v2"
aslanMongoChunkSize = 800
// likei GoldOrigin 枚举以 code 形式落在 user_gold_running_water_v2.origin
// 历史上部分 code 带数字后缀(如 HOT_GAME_200所以统一用前缀匹配。
aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD"
aslanMongoSellerAgentOriginPrefix = "SELLER_AGENT"
)
type AslanMongoDashboardSource struct {
db *mongo.Database
appCode string
appName string
sysOrigin string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
}
// NewAslanMongoDashboardSource 直接读取 likei 平台Aslan/Yumi 同构Mongo 里的已存在事实集合。
// 这里只做查询聚合和字段映射,不把数据复制到 hyapp statistics-service也不回退旧 console HTTP 接口。
func NewAslanMongoDashboardSource(database *mongo.Database, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
if database == nil || !sourceConfig.Enabled {
return nil, nil
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
if appCode == "" || sysOrigin == "" {
return nil, nil
}
statTimezone := strings.TrimSpace(sourceConfig.StatTimezone)
if statTimezone == "" {
statTimezone = "Asia/Shanghai"
}
if _, err := time.LoadLocation(statTimezone); err != nil {
return nil, fmt.Errorf("load aslan mongo storage timezone %s: %w", statTimezone, err)
}
timeout := sourceConfig.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return &AslanMongoDashboardSource{
db: database,
appCode: appCode,
appName: strings.TrimSpace(sourceConfig.AppName),
sysOrigin: sysOrigin,
statTimezone: statTimezone,
requestTimeout: timeout,
regionResolvers: compactExternalDashboardRegionResolvers(regionResolvers),
}, nil
}
func (s *AslanMongoDashboardSource) AppCode() string {
if s == nil {
return ""
}
return s.appCode
}
func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("aslan mongo 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 aslan mongo request timezone %s: %w", requestTimezone, err)
}
startDate, endDate, startMS, endMS := externalDashboardDateRange(query, requestLocation)
current, err := s.loadRange(ctx, startDate, endDate, countryFilter, true)
if err != nil {
return nil, err
}
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
// 环比只消费 applyExternalDashboardDeltas 里的流水字段,留存链路查询昂贵且不参与环比,跳过。
previous, err := s.loadRange(ctx, previousStart, startDate, countryFilter, false)
if err != nil {
return nil, err
}
response := current.Total.toMap()
response["app_code"] = s.appCode
response["app_name"] = firstExternalDashboardValue(s.appName, s.appCode)
response["sys_origin"] = s.sysOrigin
response["stat_tz"] = s.statTimezone
response["request_stat_tz"] = requestTimezone
response["start_ms"] = startMS
response["end_ms"] = endMS
response["daily_series"] = current.DailySeries
response["country_breakdown"] = current.CountryBreakdown
response["daily_country_breakdown"] = current.DailyCountryBreakdown
response["retention"] = current.Total.retentionMap()
response["report_metric_sources"] = aslanMongoDashboardMetricSources()
response["updated_at_ms"] = time.Now().UTC().UnixMilli()
applyExternalDashboardDeltas(response, current.Total, previous.Total)
response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor))
applyAslanMongoUnavailableFields(response)
return response, nil
}
func (s *AslanMongoDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) {
if query.CountryID > 0 {
return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo 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
}
// likei Mongo 集合只有国家码,没有 hyapp 的数字 region_id缺 legacy 区域映射时失败,避免误把全量数据当成某个区域返回。
return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}
// legacyMongoMetric 是 likei Mongo 源按 天×国家 汇总出的事实指标;
// 消耗/产出金币是全 App 口径(按用户拆国家需要全量账变反查用户,代价过高),只挂在日行和合计上。
type legacyMongoMetric struct {
NewUsers int64
ActiveUsers int64
PaidUsers int64
RechargeUSDMinor int64
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
NewUserRechargeUSDMinor int64
GiftCoinSpent int64
LuckyGiftTurnover int64
LuckyGiftPayout int64
LuckyGiftPayers int64
CoinSellerTransferCoin int64
SalaryUSDMinor int64
D1Users int64
D1Base int64
D7Users int64
D7Base int64
D30Users int64
D30Base int64
}
func (m *legacyMongoMetric) add(other *legacyMongoMetric) {
if other == nil {
return
}
m.NewUsers += other.NewUsers
m.ActiveUsers += other.ActiveUsers
m.PaidUsers += other.PaidUsers
m.RechargeUSDMinor += other.RechargeUSDMinor
m.GoogleUSDMinor += other.GoogleUSDMinor
m.ThirdPartyUSDMinor += other.ThirdPartyUSDMinor
m.NewUserRechargeUSDMinor += other.NewUserRechargeUSDMinor
m.GiftCoinSpent += other.GiftCoinSpent
m.LuckyGiftTurnover += other.LuckyGiftTurnover
m.LuckyGiftPayout += other.LuckyGiftPayout
m.LuckyGiftPayers += other.LuckyGiftPayers
m.CoinSellerTransferCoin += other.CoinSellerTransferCoin
m.SalaryUSDMinor += other.SalaryUSDMinor
m.D1Users += other.D1Users
m.D1Base += other.D1Base
m.D7Users += other.D7Users
m.D7Base += other.D7Base
m.D30Users += other.D30Users
m.D30Base += other.D30Base
}
type legacyMongoCoinFlow struct {
ConsumedCoin int64
OutputCoin int64
}
func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric {
metric := externalDashboardMetric{
NewUsers: m.NewUsers,
ActiveUsers: m.ActiveUsers,
PaidUsers: m.PaidUsers,
RechargeUSDMinor: m.RechargeUSDMinor,
UserRechargeUSDMinor: m.RechargeUSDMinor,
GoogleRechargeUSDMinor: m.GoogleUSDMinor,
MifaPayRechargeUSDMinor: m.ThirdPartyUSDMinor,
NewUserRechargeUSDMinor: m.NewUserRechargeUSDMinor,
GiftCoinSpent: m.GiftCoinSpent,
LuckyGiftTurnover: m.LuckyGiftTurnover,
LuckyGiftPayout: m.LuckyGiftPayout,
LuckyGiftPayers: m.LuckyGiftPayers,
CoinSellerTransferCoin: int64Ptr(m.CoinSellerTransferCoin),
SalaryUSDMinor: int64Ptr(m.SalaryUSDMinor),
D1RetentionUsers: m.D1Users,
D1RetentionBaseUsers: m.D1Base,
D7RetentionUsers: m.D7Users,
D7RetentionBaseUsers: m.D7Base,
D30RetentionUsers: m.D30Users,
D30RetentionBaseUsers: m.D30Base,
}
if coinFlow != nil {
metric.ConsumedCoin = int64Ptr(coinFlow.ConsumedCoin)
metric.OutputCoin = int64Ptr(coinFlow.OutputCoin)
}
metric.finalize()
// finalize 会用 official/google/mifapay 组合重推总充值likei 口径 total 就是 amountUsd 求和,避免二次推导。
metric.RechargeUSDMinor = m.RechargeUSDMinor
metric.UserRechargeUSDMinor = m.RechargeUSDMinor
return metric
}
type legacyMongoGrid struct {
days []string
metrics map[string]map[string]*legacyMongoMetric
coinFlows map[string]*legacyMongoCoinFlow
}
func newLegacyMongoGrid(startDate time.Time, endDate time.Time) *legacyMongoGrid {
grid := &legacyMongoGrid{
metrics: map[string]map[string]*legacyMongoMetric{},
coinFlows: map[string]*legacyMongoCoinFlow{},
}
for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
grid.days = append(grid.days, dashboardSQLDate(day))
}
return grid
}
func (g *legacyMongoGrid) at(day string, countryCode string) *legacyMongoMetric {
byCountry := g.metrics[day]
if byCountry == nil {
byCountry = map[string]*legacyMongoMetric{}
g.metrics[day] = byCountry
}
metric := byCountry[countryCode]
if metric == nil {
metric = &legacyMongoMetric{}
byCountry[countryCode] = metric
}
return metric
}
func (g *legacyMongoGrid) hasDay(day string) bool {
for _, item := range g.days {
if item == day {
return true
}
}
return false
}
func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, includeRetention bool) (aslanRangeOverview, error) {
grid := newLegacyMongoGrid(startDate, endDate)
cohorts, err := s.loadRegistrationCohorts(ctx, startDate, endDate, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
for day, byCountry := range cohorts {
for countryCode, userIDs := range byCountry {
grid.at(day, countryCode).NewUsers = int64(len(userIDs))
}
}
if err := s.loadActiveCounts(ctx, startDate, endDate, countryFilter, grid); err != nil {
return aslanRangeOverview{}, err
}
if err := s.loadRechargeStats(ctx, startDate, endDate, countryFilter, cohorts, grid); err != nil {
return aslanRangeOverview{}, err
}
if err := s.loadGiftStats(ctx, startDate, endDate, countryFilter, grid); err != nil {
return aslanRangeOverview{}, err
}
if err := s.loadGoldOriginStats(ctx, startDate, endDate, countryFilter, grid); err != nil {
return aslanRangeOverview{}, err
}
if err := s.loadCoinFlows(ctx, startDate, endDate, grid); err != nil {
return aslanRangeOverview{}, err
}
if includeRetention {
if err := s.applyRetention(ctx, cohorts, grid); err != nil {
return aslanRangeOverview{}, err
}
}
salarySnapshot, err := s.loadHostSalaryBalanceByCountry(ctx, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
// user_bank_balance 是余额快照而不是日报表;这里按当前快照填充每一天,区间汇总只保留一份快照,避免把余额按天累加。
for _, day := range grid.days {
for countryCode, balance := range salarySnapshot {
grid.at(day, countryCode).SalaryUSDMinor = balance
}
}
return s.assembleRange(grid, salarySnapshot, countryFilter), nil
}
func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, salarySnapshot map[string]int64, countryFilter externalDashboardCountryFilter) aslanRangeOverview {
dailySeries := []map[string]any{}
dailyCountries := []map[string]any{}
totalFlow := legacyMongoMetric{}
totalCoinFlow := legacyMongoCoinFlow{}
countryTotals := map[string]*legacyMongoMetric{}
for _, day := range grid.days {
byCountry := grid.metrics[day]
dayFlow := legacyMongoMetric{}
countryCodes := make([]string, 0, len(byCountry))
for countryCode := range byCountry {
countryCodes = append(countryCodes, countryCode)
}
sort.Strings(countryCodes)
for _, countryCode := range countryCodes {
metric := byCountry[countryCode]
dayFlow.add(metric)
countryTotal := countryTotals[countryCode]
if countryTotal == nil {
countryTotal = &legacyMongoMetric{}
countryTotals[countryCode] = countryTotal
}
salary := metric.SalaryUSDMinor
countryTotal.add(metric)
countryTotal.SalaryUSDMinor = salary
row := metric.toExternal(nil).toMap()
row["label"] = day
row["stat_day"] = day
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = countryCode
row["country"] = countryCode
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
applyAslanMongoUnavailableFields(row)
dailyCountries = append(dailyCountries, row)
}
coinFlow := grid.coinFlows[day]
if coinFlow != nil {
totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin
totalCoinFlow.OutputCoin += coinFlow.OutputCoin
}
// 工资余额是快照:日行取当日各国快照之和,区间合计不能跨天累加。
daySalary := dayFlow.SalaryUSDMinor
totalFlow.add(&dayFlow)
totalFlow.SalaryUSDMinor = daySalary
dayRow := dayFlow.toExternal(coinFlow).toMap()
dayRow["label"] = day
dayRow["stat_day"] = day
applyAslanMongoUnavailableFields(dayRow)
dailySeries = append(dailySeries, dayRow)
}
countryRows := make([]map[string]any, 0, len(countryTotals))
countryCodes := make([]string, 0, len(countryTotals))
for countryCode := range countryTotals {
countryCodes = append(countryCodes, countryCode)
}
sort.Strings(countryCodes)
for _, countryCode := range countryCodes {
row := countryTotals[countryCode].toExternal(nil).toMap()
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = countryCode
row["country"] = countryCode
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
applyAslanMongoUnavailableFields(row)
countryRows = append(countryRows, row)
}
sort.Slice(countryRows, func(i, j int) bool {
left, _ := countryRows[i]["recharge_usd_minor"].(int64)
right, _ := countryRows[j]["recharge_usd_minor"].(int64)
if left == right {
return fmt.Sprint(countryRows[i]["country_code"]) < fmt.Sprint(countryRows[j]["country_code"])
}
return left > right
})
return aslanRangeOverview{
Total: totalFlow.toExternal(&totalCoinFlow),
DailySeries: dailySeries,
CountryBreakdown: countryRows,
DailyCountryBreakdown: dailyCountries,
}
}
// loadRegistrationCohorts 返回 天→国家→注册用户ID同时用于新增数、新用户充值和留存 cohort。
func (s *AslanMongoDashboardSource) loadRegistrationCohorts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
match := bson.M{
"originSys": s.sysOrigin,
"del": bson.M{"$ne": true},
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"countryCode": bson.M{"$exists": true, "$ne": ""},
"accountStatus": bson.M{"$ne": "DELETED"},
}
aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "country", Value: "$countryCode"},
}},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$_id"}}},
}}},
}
out := map[string]map[string][]int64{}
err := s.aggregateEach(ctx, aslanMongoUserProfileCollection, pipeline, func(row bson.M) error {
day, countryCode := aslanMongoDayCountry(row["_id"])
if day == "" || countryCode == "" {
return nil
}
userIDs := aslanMongoInt64Values(row["users"])
if len(userIDs) == 0 {
return nil
}
byCountry := out[day]
if byCountry == nil {
byCountry = map[string][]int64{}
out[day] = byCountry
}
byCountry[countryCode] = userIDs
return nil
})
if err != nil {
return nil, fmt.Errorf("aggregate registration cohorts: %w", err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadActiveCounts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
// user_daily_active_log 的 activeDate 是 yyyy-MM-dd 字符串likei 固定按其统计时区落盘ISO 日期字符串可直接做范围比较。
match := bson.M{
"sysOrigin": s.sysOrigin,
"activeDate": bson.M{"$gte": dashboardSQLDate(startDate), "$lt": dashboardSQLDate(endDate)},
"registerCountryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(match, "registerCountryCode", countryFilter)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: "$activeDate"},
{Key: "country", Value: "$registerCountryCode"},
}},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$userId"}}},
}}},
}
err := s.aggregateEach(ctx, aslanMongoUserActiveCollection, pipeline, func(row bson.M) error {
day, countryCode := aslanMongoDayCountry(row["_id"])
if day == "" || countryCode == "" || !grid.hasDay(day) {
return nil
}
grid.at(day, countryCode).ActiveUsers = aslanMongoDistinctValueCount(row["users"])
return nil
})
if err != nil {
return fmt.Errorf("aggregate active users: %w", err)
}
return nil
}
// loadRechargeStats 一次聚合出 天×国家×渠道 的充值金额与充值用户,再按当日注册 cohort 拆出新用户充值。
func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
match := bson.M{
"sysOrigin": s.sysOrigin,
"status": "SUCCESS",
"trialPeriod": bson.M{"$ne": true},
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"countryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
// Aslan 原 Top 充值统计按 acceptUserId 聚合createUser 只在 acceptUserId 缺失的旧单据上兜底。
userExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "country", Value: "$countryCode"},
{Key: "factory", Value: bson.D{{Key: "$toUpper", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$factory.factoryCode", ""}}}}}},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: userExpression}}},
}}},
}
type dayCountryUsers struct {
day string
country string
}
distinctUsers := map[dayCountryUsers]map[string]struct{}{}
err := s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, pipeline, func(row bson.M) error {
key, ok := row["_id"].(bson.M)
if !ok {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
countryCode := aslanMongoCountryCode(key["country"])
factory := strings.TrimSpace(fmt.Sprint(key["factory"]))
if day == "" || countryCode == "" || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse recharge amount for %s/%s: %w", day, countryCode, err)
}
metric := grid.at(day, countryCode)
metric.RechargeUSDMinor += amount
if factory == "GOOGLE" {
metric.GoogleUSDMinor += amount
} else {
metric.ThirdPartyUSDMinor += amount
}
usersKey := dayCountryUsers{day: day, country: countryCode}
userSet := distinctUsers[usersKey]
if userSet == nil {
userSet = map[string]struct{}{}
distinctUsers[usersKey] = userSet
}
for _, value := range aslanMongoInt64Values(row["users"]) {
userSet[strconv.FormatInt(value, 10)] = struct{}{}
}
return nil
})
if err != nil {
return fmt.Errorf("aggregate recharge stats: %w", err)
}
for key, userSet := range distinctUsers {
grid.at(key.day, key.country).PaidUsers = int64(len(userSet))
}
// 新用户充值:按 天×用户 聚合当日充值,再与当日注册 cohort 求交集cohort 自带国家归属。
perUserPipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "user", Value: userExpression},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
}}},
}
perUser := map[string]map[int64]int64{}
err = s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, perUserPipeline, func(row bson.M) error {
key, ok := row["_id"].(bson.M)
if !ok {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
userID := aslanMongoWholeInt64(key["user"])
if day == "" || userID <= 0 || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse per-user recharge amount for %s: %w", day, err)
}
byUser := perUser[day]
if byUser == nil {
byUser = map[int64]int64{}
perUser[day] = byUser
}
byUser[userID] += amount
return nil
})
if err != nil {
return fmt.Errorf("aggregate per-user recharge: %w", err)
}
for day, byCountry := range cohorts {
byUser := perUser[day]
if len(byUser) == 0 {
continue
}
for countryCode, userIDs := range byCountry {
total := int64(0)
for _, userID := range userIDs {
total += byUser[userID]
}
if total != 0 {
grid.at(day, countryCode).NewUserRechargeUSDMinor = total
}
}
}
return nil
}
// loadGiftStats 按 天×赠送人 聚合礼物价值(金币),再经用户资料把流水拆到国家;
// luckyGift=true 的部分同时计入幸运礼物流水。
func (s *AslanMongoDashboardSource) loadGiftStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
match := bson.M{
"sysOrigin": s.sysOrigin,
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"refunded": bson.M{"$ne": true},
}
giftValueExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$giftValue.actualAmount", bson.D{{Key: "$ifNull", Value: bson.A{"$giftValue.giftValue", 0}}}}}}
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "user", Value: "$userId"},
}},
{Key: "total", Value: bson.D{{Key: "$sum", Value: giftValueExpression}}},
{Key: "lucky", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$luckyGift", true}}},
giftValueExpression,
0,
}}}}}},
}}},
}
type giftEntry struct {
day string
userID int64
total int64
lucky int64
}
entries := []giftEntry{}
err := s.aggregateEach(ctx, aslanMongoGiftWaterCollection, pipeline, func(row bson.M) error {
key, ok := row["_id"].(bson.M)
if !ok {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
userID := aslanMongoWholeInt64(key["user"])
if day == "" || userID <= 0 || !grid.hasDay(day) {
return nil
}
total, err := aslanMongoDecimalMinor(row["total"])
if err != nil {
return fmt.Errorf("parse gift total for %s: %w", day, err)
}
lucky, err := aslanMongoDecimalMinor(row["lucky"])
if err != nil {
return fmt.Errorf("parse lucky gift total for %s: %w", day, err)
}
// 礼物价值以金币计amount 十进制只是 Decimal128 载体,这里换回整数金币。
entries = append(entries, giftEntry{day: day, userID: userID, total: roundMinorToWhole(total), lucky: roundMinorToWhole(lucky)})
return nil
})
if err != nil {
return fmt.Errorf("aggregate gift stats: %w", err)
}
if len(entries) == 0 {
return nil
}
userIDs := make([]int64, 0, len(entries))
seen := map[int64]struct{}{}
for _, entry := range entries {
if _, ok := seen[entry.userID]; ok {
continue
}
seen[entry.userID] = struct{}{}
userIDs = append(userIDs, entry.userID)
}
countryByUser, err := s.loadUserCountries(ctx, userIDs, countryFilter)
if err != nil {
return err
}
for _, entry := range entries {
countryCode := countryByUser[entry.userID]
if countryCode == "" {
continue
}
metric := grid.at(entry.day, countryCode)
metric.GiftCoinSpent += entry.total
metric.LuckyGiftTurnover += entry.lucky
if entry.lucky > 0 {
metric.LuckyGiftPayers++
}
}
return nil
}
// loadGoldOriginStats 聚合金币流水里两类来源幸运礼物返奖LUCKY_GIFT_GOLD_REWARD*与币商出货SELLER_AGENT*)。
func (s *AslanMongoDashboardSource) loadGoldOriginStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
assigners := []struct {
originPrefix string
assign func(metric *legacyMongoMetric, amount int64)
}{
{aslanMongoLuckyGiftPayoutOriginPrefix, func(metric *legacyMongoMetric, amount int64) { metric.LuckyGiftPayout += amount }},
{aslanMongoSellerAgentOriginPrefix, func(metric *legacyMongoMetric, amount int64) { metric.CoinSellerTransferCoin += amount }},
}
for _, item := range assigners {
match := bson.M{
"sysOrigin": s.sysOrigin,
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"type": 0,
"origin": bson.M{"$regex": "^" + item.originPrefix},
}
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "user", Value: "$userId"},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$quantity"}}},
}}},
}
type goldEntry struct {
day string
userID int64
amount int64
}
entries := []goldEntry{}
err := s.aggregateEach(ctx, aslanMongoGoldWaterCollection, pipeline, func(row bson.M) error {
key, ok := row["_id"].(bson.M)
if !ok {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
userID := aslanMongoWholeInt64(key["user"])
if day == "" || userID <= 0 || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse gold origin %s amount for %s: %w", item.originPrefix, day, err)
}
entries = append(entries, goldEntry{day: day, userID: userID, amount: roundMinorToWhole(amount)})
return nil
})
if err != nil {
return fmt.Errorf("aggregate gold origin %s: %w", item.originPrefix, err)
}
if len(entries) == 0 {
continue
}
userIDs := make([]int64, 0, len(entries))
seen := map[int64]struct{}{}
for _, entry := range entries {
if _, ok := seen[entry.userID]; ok {
continue
}
seen[entry.userID] = struct{}{}
userIDs = append(userIDs, entry.userID)
}
countryByUser, err := s.loadUserCountries(ctx, userIDs, countryFilter)
if err != nil {
return err
}
for _, entry := range entries {
countryCode := countryByUser[entry.userID]
if countryCode == "" {
continue
}
item.assign(grid.at(entry.day, countryCode), entry.amount)
}
}
return nil
}
// loadCoinFlows 汇总每日全 App 消耗/产出金币type=1 是消耗、type=0 是收入(产出)。
// 区域筛选时无法低成本把全量账变拆到国家,保持整段 nil 交由上层展示 "--"。
func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate time.Time, endDate time.Time, grid *legacyMongoGrid) error {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"sysOrigin": s.sysOrigin,
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
}}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "type", Value: "$type"},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$quantity"}}},
}}},
}
err := s.aggregateEach(ctx, aslanMongoGoldWaterCollection, pipeline, func(row bson.M) error {
key, ok := row["_id"].(bson.M)
if !ok {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
if day == "" || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse coin flow amount for %s: %w", day, err)
}
flow := grid.coinFlows[day]
if flow == nil {
flow = &legacyMongoCoinFlow{}
grid.coinFlows[day] = flow
}
if aslanMongoWholeInt64(key["type"]) == 1 {
flow.ConsumedCoin += roundMinorToWhole(amount)
} else {
flow.OutputCoin += roundMinorToWhole(amount)
}
return nil
})
if err != nil {
return fmt.Errorf("aggregate coin flows: %w", err)
}
return nil
}
// applyRetention 用注册 cohort × user_daily_active_log 推 D1/D7/D30目标观察日未到时基数保持 0
// 上层比例展示为 0%,与 cdc 源口径一致。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
location, err := time.LoadLocation(s.statTimezone)
if err != nil {
return err
}
today := dashboardSQLDate(time.Now().In(location))
offsets := []struct {
days int
assign func(metric *legacyMongoMetric, users int64, base int64)
}{
{1, func(metric *legacyMongoMetric, users, base int64) { metric.D1Users, metric.D1Base = users, base }},
{7, func(metric *legacyMongoMetric, users, base int64) { metric.D7Users, metric.D7Base = users, base }},
{30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }},
}
for day, byCountry := range cohorts {
if !grid.hasDay(day) {
continue
}
dayTime, err := time.ParseInLocation("2006-01-02", day, location)
if err != nil {
continue
}
allIDs := []int64{}
for _, userIDs := range byCountry {
allIDs = append(allIDs, userIDs...)
}
if len(allIDs) == 0 {
continue
}
for _, offset := range offsets {
target := dashboardSQLDate(dayTime.AddDate(0, 0, offset.days))
if target > today {
continue
}
activeSet, err := s.loadActiveUserSet(ctx, target, allIDs)
if err != nil {
return err
}
for countryCode, userIDs := range byCountry {
active := int64(0)
for _, userID := range userIDs {
if _, ok := activeSet[userID]; ok {
active++
}
}
offset.assign(grid.at(day, countryCode), active, int64(len(userIDs)))
}
}
}
return nil
}
func (s *AslanMongoDashboardSource) loadActiveUserSet(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
out := map[int64]struct{}{}
for _, chunk := range aslanMongoInt64Chunks(userIDs, aslanMongoChunkSize) {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
cursor, err := s.db.Collection(aslanMongoUserActiveCollection).Find(queryCtx, bson.M{
"sysOrigin": s.sysOrigin,
"activeDate": activeDate,
"userId": bson.M{"$in": chunk},
}, options.Find().SetProjection(bson.M{"userId": 1}))
if err != nil {
cancel()
return nil, fmt.Errorf("query retention actives for %s: %w", activeDate, err)
}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(queryCtx)
cancel()
return nil, fmt.Errorf("decode retention active row for %s: %w", activeDate, err)
}
if userID := aslanMongoWholeInt64(row["userId"]); userID > 0 {
out[userID] = struct{}{}
}
}
err = cursor.Err()
_ = cursor.Close(queryCtx)
cancel()
if err != nil {
return nil, fmt.Errorf("iterate retention actives for %s: %w", activeDate, err)
}
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadHostSalaryBalanceByCountry(ctx context.Context, countryFilter externalDashboardCountryFilter) (map[string]int64, error) {
memberIDs, err := s.loadTeamMemberIDs(ctx)
if err != nil {
return nil, err
}
if len(memberIDs) == 0 {
return map[string]int64{}, nil
}
countryByUser, err := s.loadUserCountries(ctx, memberIDs, countryFilter)
if err != nil {
return nil, err
}
if len(countryByUser) == 0 {
return map[string]int64{}, nil
}
out := map[string]int64{}
for _, chunk := range aslanMongoInt64Chunks(memberIDs, aslanMongoChunkSize) {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
cursor, err := s.db.Collection(aslanMongoBankBalanceCollection).Find(queryCtx, bson.M{
"sysOrigin": s.sysOrigin,
"_id": bson.M{"$in": chunk},
}, options.Find().SetProjection(bson.M{"_id": 1, "balance": 1}))
if err != nil {
cancel()
return nil, fmt.Errorf("query user_bank_balance: %w", err)
}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(queryCtx)
cancel()
return nil, fmt.Errorf("decode user_bank_balance: %w", err)
}
userID := aslanMongoWholeInt64(row["_id"])
countryCode := countryByUser[userID]
if countryCode == "" {
continue
}
// user_bank_balance.balance 是 PennyAmount 的 long 值admin 响应也用 USD minor所以不用再做美元小数换算。
out[countryCode] += aslanMongoWholeInt64(row["balance"])
}
err = cursor.Err()
_ = cursor.Close(queryCtx)
cancel()
if err != nil {
return nil, fmt.Errorf("iterate user_bank_balance: %w", err)
}
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadTeamMemberIDs(ctx context.Context) ([]int64, error) {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
cursor, err := s.db.Collection(aslanMongoTeamMemberCollection).Find(queryCtx, bson.M{
"sysOrigin": s.sysOrigin,
"role": bson.M{"$in": bson.A{"MEMBER", "ADMIN"}},
}, options.Find().SetProjection(bson.M{"memberId": 1}))
if err != nil {
return nil, fmt.Errorf("query team_member: %w", err)
}
defer cursor.Close(queryCtx)
seen := map[int64]struct{}{}
out := []int64{}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return nil, fmt.Errorf("decode team_member: %w", err)
}
userID := aslanMongoWholeInt64(row["memberId"])
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
out = append(out, userID)
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate team_member: %w", err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadUserCountries(ctx context.Context, userIDs []int64, countryFilter externalDashboardCountryFilter) (map[int64]string, error) {
out := map[int64]string{}
for _, chunk := range aslanMongoInt64Chunks(userIDs, aslanMongoChunkSize) {
filter := bson.M{
"_id": bson.M{"$in": chunk},
"originSys": s.sysOrigin,
"del": bson.M{"$ne": true},
"countryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(filter, "countryCode", countryFilter)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
cursor, err := s.db.Collection(aslanMongoUserProfileCollection).Find(queryCtx, filter, options.Find().SetProjection(bson.M{"_id": 1, "countryCode": 1}))
if err != nil {
cancel()
return nil, fmt.Errorf("query user_run_profile countries: %w", err)
}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(queryCtx)
cancel()
return nil, fmt.Errorf("decode user_run_profile country: %w", err)
}
userID := aslanMongoWholeInt64(row["_id"])
countryCode := aslanMongoCountryCode(row["countryCode"])
if userID > 0 && countryCode != "" {
out[userID] = countryCode
}
}
err = cursor.Err()
_ = cursor.Close(queryCtx)
cancel()
if err != nil {
return nil, fmt.Errorf("iterate user_run_profile countries: %w", err)
}
}
return out, nil
}
func (s *AslanMongoDashboardSource) aggregateEach(ctx context.Context, collectionName string, pipeline mongo.Pipeline, handle func(bson.M) error) error {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
cursor, err := s.db.Collection(collectionName).Aggregate(queryCtx, pipeline, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return err
}
defer cursor.Close(queryCtx)
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return err
}
if err := handle(row); err != nil {
return err
}
}
return cursor.Err()
}
func (s *AslanMongoDashboardSource) dayExpression(field string) bson.D {
return bson.D{{Key: "$dateToString", Value: bson.D{
{Key: "format", Value: "%Y-%m-%d"},
{Key: "date", Value: field},
{Key: "timezone", Value: s.statTimezone},
}}}
}
func aslanMongoDayCountry(value any) (string, string) {
key, ok := value.(bson.M)
if !ok {
return "", ""
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
if day == "" || day == "<nil>" {
return "", ""
}
return day, aslanMongoCountryCode(key["country"])
}
func aslanMongoInt64Values(value any) []int64 {
values, ok := value.(bson.A)
if !ok {
if raw, ok := value.([]any); ok {
values = raw
}
}
out := make([]int64, 0, len(values))
seen := map[int64]struct{}{}
for _, item := range values {
parsed := aslanMongoWholeInt64(item)
if parsed <= 0 {
continue
}
if _, ok := seen[parsed]; ok {
continue
}
seen[parsed] = struct{}{}
out = append(out, parsed)
}
return out
}
func aslanMongoApplyCountryFilter(match bson.M, field string, countryFilter externalDashboardCountryFilter) {
if !countryFilter.Applied {
return
}
if len(countryFilter.Codes) == 0 {
match[field] = bson.M{"$in": bson.A{"__NO_COUNTRY__"}}
return
}
codes := bson.A{}
for _, code := range countryFilter.Codes {
if normalized := aslanMongoCountryCode(code); normalized != "" {
codes = append(codes, normalized)
}
}
if len(codes) == 0 {
codes = bson.A{"__NO_COUNTRY__"}
}
match[field] = bson.M{"$in": codes}
}
func aslanMongoCountryCode(value any) string {
code := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value)))
if code == "" || code == "<NIL>" || code == "NULL" {
return ""
}
return code
}
func aslanMongoDistinctValueCount(value any) int64 {
values, ok := value.(bson.A)
if !ok {
if raw, ok := value.([]any); ok {
values = raw
}
}
seen := map[string]struct{}{}
for _, item := range values {
key := strings.TrimSpace(fmt.Sprint(item))
if key == "" || key == "<nil>" || key == "0" {
continue
}
seen[key] = struct{}{}
}
return int64(len(seen))
}
func aslanMongoDecimalMinor(value any) (int64, error) {
switch typed := value.(type) {
case nil:
return 0, nil
case bson.Decimal128:
return decimalTextToMinor(typed.String())
case string:
return decimalTextToMinor(typed)
case int32:
return int64(typed) * 100, nil
case int64:
return typed * 100, nil
case int:
return int64(typed) * 100, nil
case float32:
return decimalTextToMinor(strconv.FormatFloat(float64(typed), 'f', -1, 32))
case float64:
return decimalTextToMinor(strconv.FormatFloat(typed, 'f', -1, 64))
default:
return decimalTextToMinor(fmt.Sprint(value))
}
}
func aslanMongoWholeInt64(value any) int64 {
switch typed := value.(type) {
case nil:
return 0
case int64:
return typed
case int32:
return int64(typed)
case int:
return int64(typed)
case float64:
return int64(typed)
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
case bson.Decimal128:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed.String()), 10, 64)
return parsed
default:
parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
return parsed
}
}
func aslanMongoInt64Chunks(values []int64, size int) [][]int64 {
if size <= 0 {
size = aslanMongoChunkSize
}
out := [][]int64{}
for start := 0; start < len(values); start += size {
end := start + size
if end > len(values) {
end = len(values)
}
out = append(out, values[start:end])
}
return out
}
// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径;
// 游戏流水的 GoldOrigin code 枚举过杂(大量带后缀的游戏 code 与奖励类混在一起),未确认前不硬凑。
var aslanMongoUnavailableMetricFields = []string{
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"new_dealer_recharge_usd_minor",
"game_turnover",
"game_payout",
"game_players",
"game_profit",
"game_profit_rate",
"game_payout_delta_rate",
"game_turnover_delta_rate",
"game_profit_delta_rate",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"super_lucky_gift_profit",
"super_lucky_gift_profit_rate",
"coin_total",
"coin_total_delta_rate",
"platform_grant_coin",
"manual_grant_coin",
"salary_exchange_usd_minor",
"salary_transfer_usd_minor",
"salary_transfer_coin",
"mic_online_ms",
"avg_mic_online_ms",
}
func applyAslanMongoUnavailableFields(row map[string]any) {
for _, field := range aslanMongoUnavailableMetricFields {
row[field] = nil
}
}
func aslanMongoDashboardMetricSources() []map[string]any {
return []map[string]any{
{"field": "new_users", "available": true, "source": "likei Mongo user_run_profile createTime/countryCode"},
{"field": "active_users", "available": true, "source": "likei Mongo user_daily_active_log activeDate/registerCountryCode"},
{"field": "paid_users", "available": true, "source": "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId"},
{"field": "recharge_users", "available": true, "source": "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId"},
{"field": "recharge_usd_minor", "available": true, "source": "likei Mongo in_app_purchase_details amountUsd"},
{"field": "google_recharge_usd_minor", "available": true, "source": "likei Mongo in_app_purchase_details factory.factoryCode=GOOGLE"},
{"field": "mifapay_recharge_usd_minor", "available": true, "source": "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE全部三方渠道合计"},
{"field": "new_user_recharge_usd_minor", "available": true, "source": "likei Mongo 当日注册 cohort × in_app_purchase_details 当日充值"},
{"field": "gift_coin_spent", "available": true, "source": "likei Mongo gift_give_running_water giftValue.actualAmount含幸运礼物"},
{"field": "lucky_gift_turnover", "available": true, "source": "likei Mongo gift_give_running_water luckyGift=true"},
{"field": "lucky_gift_payout", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=LUCKY_GIFT_GOLD_REWARD*"},
{"field": "coin_seller_transfer_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=SELLER_AGENT*"},
{"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "likei Mongo 未确认币商充值美元口径"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "likei Mongo 未确认币商进货金币口径"},
{"field": "consumed_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 type=1仅全 App 口径,不拆国家)"},
{"field": "output_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 type=0仅全 App 口径,不拆国家)"},
{"field": "consume_output_ratio", "available": true, "source": "consumed_coin / output_coin仅全 App 口径)"},
{"field": "game_turnover", "available": false, "missing_reason": "likei GoldOrigin 游戏 code 枚举过多且与奖励类混排,未确认口径前不聚合"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"},
{"field": "coin_total", "available": false, "missing_reason": "likei Mongo 没有可按国家低成本汇总的金币余额快照"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认平台发放金币来源枚举"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认人工发放金币来源枚举"},
{"field": "salary_usd_minor", "available": true, "source": "likei Mongo team_member + user_bank_balance + user_run_profile 当前快照"},
{"field": "mic_online_ms", "available": false, "missing_reason": "likei Mongo 未确认麦上时长集合"},
{"field": "arpu_usd_minor", "available": true, "source": "recharge_usd_minor / active_users"},
{"field": "arppu_usd_minor", "available": true, "source": "recharge_usd_minor / paid_users"},
{"field": "paid_conversion_rate", "available": true, "source": "paid_users / active_users"},
{"field": "recharge_conversion_rate", "available": true, "source": "recharge_users / active_users"},
{"field": "retention.day1_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+1"},
{"field": "retention.day7_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+7"},
{"field": "retention.day30_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+30"},
}
}