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 == "" || 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 == "" || 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 活跃事实"}, } }