hyapp-server/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go
2026-07-02 19:23:36 +08:00

691 lines
25 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"
aslanMongoChunkSize = 800
)
type AslanMongoDashboardSource struct {
db *mongo.Database
appCode string
appName string
sysOrigin string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
}
type aslanMongoRechargeMetric struct {
AmountUSDMinor int64
Users int64
}
// NewAslanMongoDashboardSource 直接读取 Aslan 外接项目 Mongo 里的已存在事实集合。
// 这里只做查询聚合和字段映射,不把 Aslan 数据复制到 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)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
current, err := s.loadRange(queryCtx, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
previous, err := s.loadRange(queryCtx, previousStart, startDate, countryFilter)
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"] = aslanRetentionMap(current.Total)
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
}
// Aslan Mongo 集合只有国家码,没有 hyapp 的数字 region_id缺 legacy 区域映射时失败,避免误把全量数据当成某个区域返回。
return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}
func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (aslanRangeOverview, error) {
totalFlow := aslanDashboardMetric{}
totalSnapshot := aslanDashboardMetric{}
dailySeries := []map[string]any{}
dailyCountries := []map[string]any{}
countryAggregates := map[string]*aslanCountryAccumulator{}
// user_bank_balance 是余额快照而不是日报表;这里按当前快照填充每一天,区间汇总只保留最后一天快照,避免把余额按天累加。
salarySnapshot, err := s.loadHostSalaryBalanceByCountry(ctx, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
nextDay := day.AddDate(0, 0, 1)
registers, err := s.loadRegisteredUsersByCountry(ctx, day, nextDay, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
activeUsers, err := s.loadActiveUsersByCountry(ctx, day, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
recharges, err := s.loadRechargeByCountry(ctx, day, nextDay, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
dayFlow := aslanDashboardMetric{}
daySnapshot := aslanDashboardMetric{}
dayCountryAggregates := map[string]*aslanCountryAccumulator{}
for _, countryCode := range aslanMongoCountryKeys(registers, activeUsers, recharges, salarySnapshot) {
metric := aslanDashboardMetric{
NewUsers: registers[countryCode],
ActiveUsers: activeUsers[countryCode],
PaidUsers: recharges[countryCode].Users,
RechargeUSDMinor: recharges[countryCode].AmountUSDMinor,
SalaryUSDMinor: salarySnapshot[countryCode],
}
dayFlow.addFlow(metric)
daySnapshot.addSnapshot(metric)
totalFlow.addFlow(metric)
country := dayCountryAggregates[countryCode]
if country == nil {
country = &aslanCountryAccumulator{CountryCode: countryCode, CountryName: countryCode, RegionID: countryFilter.RegionID}
dayCountryAggregates[countryCode] = country
}
country.Flow.addFlow(metric)
country.Snapshot.addSnapshot(metric)
}
totalSnapshot = daySnapshot
dayMetric := aslanCombinedMetric(dayFlow, daySnapshot)
dayRow := dayMetric.toMap()
dayLabel := dashboardSQLDate(day)
dayRow["label"] = dayLabel
dayRow["stat_day"] = dayLabel
applyAslanMongoUnavailableFields(dayRow)
dailySeries = append(dailySeries, dayRow)
mergeAslanCountryDay(countryAggregates, dayCountryAggregates)
for _, row := range aslanDailyCountryRows(dayLabel, dayCountryAggregates) {
applyAslanMongoUnavailableFields(row)
dailyCountries = append(dailyCountries, row)
}
}
countryRows := aslanCountryRows(countryAggregates)
for _, row := range countryRows {
applyAslanMongoUnavailableFields(row)
}
return aslanRangeOverview{
Total: aslanCombinedMetric(totalFlow, totalSnapshot),
DailySeries: dailySeries,
CountryBreakdown: countryRows,
DailyCountryBreakdown: dailyCountries,
}, nil
}
func (s *AslanMongoDashboardSource) loadRegisteredUsersByCountry(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (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)
out, err := s.aggregateUniqueUsersByCountry(ctx, aslanMongoUserProfileCollection, match, "countryCode", "_id")
if err != nil {
return nil, fmt.Errorf("query aslan mongo registered users for %s: %w", dashboardSQLDate(startDate), err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadActiveUsersByCountry(ctx context.Context, day time.Time, countryFilter externalDashboardCountryFilter) (map[string]int64, error) {
match := bson.M{
"sysOrigin": s.sysOrigin,
"activeDate": dashboardSQLDate(day),
"registerCountryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(match, "registerCountryCode", countryFilter)
out, err := s.aggregateUniqueUsersByCountry(ctx, aslanMongoUserActiveCollection, match, "registerCountryCode", "userId")
if err != nil {
return nil, fmt.Errorf("query aslan mongo active users for %s: %w", dashboardSQLDate(day), err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadRechargeByCountry(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]aslanMongoRechargeMetric, 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)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$countryCode"},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
// Aslan 原 Top 充值统计按 acceptUserId 聚合createUser 只在 acceptUserId 缺失的旧单据上兜底。
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}}}},
}}},
}
cursor, err := s.db.Collection(aslanMongoInAppPurchaseCollection).Aggregate(ctx, pipeline, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return nil, fmt.Errorf("aggregate in_app_purchase_details: %w", err)
}
defer cursor.Close(ctx)
out := map[string]aslanMongoRechargeMetric{}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return nil, fmt.Errorf("decode in_app_purchase_details aggregate: %w", err)
}
countryCode := aslanMongoCountryCode(row["_id"])
if countryCode == "" {
continue
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return nil, fmt.Errorf("parse recharge amount for country %s: %w", countryCode, err)
}
out[countryCode] = aslanMongoRechargeMetric{
AmountUSDMinor: amount,
Users: aslanMongoDistinctValueCount(row["users"]),
}
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate in_app_purchase_details aggregate: %w", err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) aggregateUniqueUsersByCountry(ctx context.Context, collectionName string, match bson.M, countryField string, userField string) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$" + countryField},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$" + userField}}},
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
}}},
}
cursor, err := s.db.Collection(collectionName).Aggregate(ctx, pipeline, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
out := map[string]int64{}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return nil, err
}
countryCode := aslanMongoCountryCode(row["_id"])
if countryCode == "" {
continue
}
count := aslanMongoDistinctValueCount(row["users"])
if count == 0 {
count = aslanMongoWholeInt64(row["count"])
}
out[countryCode] = count
}
if err := cursor.Err(); err != nil {
return nil, 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) {
cursor, err := s.db.Collection(aslanMongoBankBalanceCollection).Find(ctx, bson.M{
"sysOrigin": s.sysOrigin,
"_id": bson.M{"$in": chunk},
}, options.Find().SetProjection(bson.M{"_id": 1, "balance": 1}))
if err != nil {
return nil, fmt.Errorf("query user_bank_balance: %w", err)
}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(ctx)
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"])
}
if err := cursor.Err(); err != nil {
_ = cursor.Close(ctx)
return nil, fmt.Errorf("iterate user_bank_balance: %w", err)
}
_ = cursor.Close(ctx)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadTeamMemberIDs(ctx context.Context) ([]int64, error) {
cursor, err := s.db.Collection(aslanMongoTeamMemberCollection).Find(ctx, 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(ctx)
seen := map[int64]struct{}{}
out := []int64{}
for cursor.Next(ctx) {
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)
cursor, err := s.db.Collection(aslanMongoUserProfileCollection).Find(ctx, filter, options.Find().SetProjection(bson.M{"_id": 1, "countryCode": 1}))
if err != nil {
return nil, fmt.Errorf("query user_run_profile countries: %w", err)
}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(ctx)
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
}
}
if err := cursor.Err(); err != nil {
_ = cursor.Close(ctx)
return nil, fmt.Errorf("iterate user_run_profile countries: %w", err)
}
_ = cursor.Close(ctx)
}
return out, nil
}
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 aslanMongoCountryKeys(registers map[string]int64, activeUsers map[string]int64, recharges map[string]aslanMongoRechargeMetric, salarySnapshot map[string]int64) []string {
seen := map[string]struct{}{}
for countryCode := range registers {
seen[countryCode] = struct{}{}
}
for countryCode := range activeUsers {
seen[countryCode] = struct{}{}
}
for countryCode := range recharges {
seen[countryCode] = struct{}{}
}
for countryCode := range salarySnapshot {
seen[countryCode] = struct{}{}
}
keys := make([]string, 0, len(seen))
for countryCode := range seen {
keys = append(keys, countryCode)
}
sort.Strings(keys)
return keys
}
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
}
var aslanMongoUnavailableMetricFields = []string{
"new_user_recharge_usd_minor",
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"coin_seller_transfer_coin",
"google_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"game_turnover",
"game_payout",
"game_players",
"game_profit",
"game_profit_rate",
"game_payout_delta_rate",
"lucky_gift_turnover",
"lucky_gift_payout",
"lucky_gift_payout_rate",
"lucky_gift_payers",
"lucky_gift_anchor_share",
"lucky_gift_profit",
"lucky_gift_profit_rate",
"lucky_gift_payout_delta_rate",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"super_lucky_gift_profit",
"super_lucky_gift_profit_rate",
"room_lucky_gift_turnover",
"gift_coin_spent",
"coin_total",
"coin_total_delta_rate",
"consumed_coin",
"output_coin",
"consume_output_ratio",
"platform_grant_coin",
"manual_grant_coin",
"new_dealer_recharge_usd_minor",
"salary_exchange_usd_minor",
"salary_transfer_coin",
"salary_transfer_usd_minor",
"mic_online_ms",
"avg_mic_online_ms",
"gift_coin_spent_delta_rate",
"game_turnover_delta_rate",
"game_profit_delta_rate",
"lucky_gift_turnover_delta_rate",
"lucky_gift_profit_delta_rate",
"d1_retention_rate",
"d7_retention_rate",
"d30_retention_rate",
}
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": "Aslan Mongo user_run_profile createTime/countryCode"},
{"field": "active_users", "available": true, "source": "Aslan Mongo user_daily_active_log activeDate/registerCountryCode"},
{"field": "paid_users", "available": true, "source": "Aslan Mongo in_app_purchase_details SUCCESS distinct acceptUserId"},
{"field": "recharge_users", "available": true, "source": "Aslan Mongo in_app_purchase_details SUCCESS distinct acceptUserId"},
{"field": "recharge_usd_minor", "available": true, "source": "Aslan Mongo in_app_purchase_details amountUsd"},
{"field": "new_user_recharge_usd_minor", "available": false, "missing_reason": "Mongo 中未确认新用户充值 cohort 聚合口径"},
{"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "Mongo 中未确认币商充值金额聚合口径"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "Mongo 中未确认币商充值金币聚合口径"},
{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "Mongo 中未确认币商出货金币聚合口径"},
{"field": "google_recharge_usd_minor", "available": false, "missing_reason": "in_app_purchase_details 当前接入只做总充值,未拆 Google/三方渠道"},
{"field": "mifapay_recharge_usd_minor", "available": false, "missing_reason": "in_app_purchase_details 当前接入只做总充值,未拆 Google/三方渠道"},
{"field": "game_turnover", "available": false, "missing_reason": "Mongo 中未确认游戏流水和返奖集合"},
{"field": "lucky_gift_turnover", "available": false, "missing_reason": "Mongo 中未确认幸运礼物流水和返奖集合"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "Mongo 中未确认超级幸运礼物流水和返奖集合"},
{"field": "gift_coin_spent", "available": false, "missing_reason": "Mongo 中未确认礼物流水总口径"},
{"field": "coin_total", "available": false, "missing_reason": "Mongo 中未发现可按国家低成本汇总的当前金币余额快照"},
{"field": "consumed_coin", "available": false, "missing_reason": "Mongo 中未确认消耗金币总口径"},
{"field": "output_coin", "available": false, "missing_reason": "Mongo 中未确认产出金币总口径"},
{"field": "consume_output_ratio", "available": false, "missing_reason": "缺少消耗金币和产出金币总口径"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "Mongo 中未确认平台发放金币来源枚举"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "Mongo 中未确认人工发放金币来源枚举"},
{"field": "salary_usd_minor", "available": true, "source": "Aslan Mongo team_member + user_bank_balance + user_run_profile current snapshot"},
{"field": "salary_transfer_coin", "available": false, "missing_reason": "Mongo 中未确认工资兑换金币流水口径"},
{"field": "avg_mic_online_ms", "available": false, "missing_reason": "Mongo 中未确认麦上时长集合"},
{"field": "mic_online_ms", "available": false, "missing_reason": "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": false, "missing_reason": "Mongo 中未确认留存 cohort 活跃事实"},
{"field": "retention.day7_rate", "available": false, "missing_reason": "Mongo 中未确认留存 cohort 活跃事实"},
{"field": "retention.day30_rate", "available": false, "missing_reason": "Mongo 中未确认留存 cohort 活跃事实"},
}
}