package dashboard import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" "hyapp-admin-server/internal/config" ) type AslanHTTPDashboardSource struct { client *http.Client appCode string appName string baseURL string overviewPath string statTimezone string requestTimeout time.Duration regionResolvers []ExternalDashboardRegionResolver } type aslanRegionStatistics struct { Date string `json:"date"` RegionID string `json:"regionId"` RegionCode string `json:"regionCode"` RegionName string `json:"regionName"` DailyRecharge aslanDecimal `json:"dailyRecharge"` DailyActive aslanInt64 `json:"dailyActive"` DailyRegister aslanInt64 `json:"dailyRegister"` GoldBalance aslanInt64 `json:"goldBalance"` HostSalaryBalance aslanDecimal `json:"hostSalaryBalance"` Countries []aslanCountryStatistics `json:"countries"` } type aslanCountryStatistics struct { CountryCode string `json:"countryCode"` CountryName string `json:"countryName"` DailyRecharge aslanDecimal `json:"dailyRecharge"` DailyActive aslanInt64 `json:"dailyActive"` DailyRegister aslanInt64 `json:"dailyRegister"` GoldBalance aslanInt64 `json:"goldBalance"` HostSalaryBalance aslanDecimal `json:"hostSalaryBalance"` } type aslanDecimal struct { raw string } type aslanInt64 struct { value int64 } type aslanDashboardMetric struct { RechargeUSDMinor int64 ActiveUsers int64 NewUsers int64 CoinTotal int64 SalaryUSDMinor int64 } type aslanCountryAccumulator struct { CountryCode string CountryName string RegionID int64 RegionCode string RegionName string Flow aslanDashboardMetric Snapshot aslanDashboardMetric } // NewAslanHTTPDashboardSource 接入 Aslan console 已暴露的免鉴权数据口径;admin 不连接 Aslan 数据库, // 只把远端接口结果转换成本后台大屏已有的响应字段。 func NewAslanHTTPDashboardSource(sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) { if !sourceConfig.Enabled { return nil, nil } appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode)) if appCode == "" { return nil, nil } baseURL := strings.TrimRight(strings.TrimSpace(sourceConfig.BaseURL), "/") overviewPath := "/" + strings.TrimLeft(strings.TrimSpace(sourceConfig.OverviewPath), "/") if baseURL == "" || overviewPath == "/" { return nil, fmt.Errorf("aslan dashboard source %s requires base_url and overview_path", appCode) } parsed, err := url.Parse(baseURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err == nil { err = fmt.Errorf("missing scheme or host") } return nil, fmt.Errorf("parse aslan dashboard base_url %q: %w", baseURL, err) } statTimezone := strings.TrimSpace(sourceConfig.StatTimezone) if statTimezone == "" { statTimezone = "Asia/Shanghai" } timeout := sourceConfig.RequestTimeout if timeout <= 0 { timeout = 5 * time.Second } return &AslanHTTPDashboardSource{ client: &http.Client{Timeout: timeout}, appCode: appCode, appName: strings.TrimSpace(sourceConfig.AppName), baseURL: baseURL, overviewPath: overviewPath, statTimezone: statTimezone, requestTimeout: timeout, regionResolvers: compactExternalDashboardRegionResolvers(regionResolvers), }, nil } func (s *AslanHTTPDashboardSource) AppCode() string { if s == nil { return "" } return s.appCode } func (s *AslanHTTPDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) { if s == nil || s.client == nil { return nil, fmt.Errorf("aslan 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 dashboard request timezone %s: %w", requestTimezone, err) } if _, err := time.LoadLocation(s.statTimezone); err != nil { return nil, fmt.Errorf("load aslan dashboard storage timezone %s: %w", s.statTimezone, 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"] = "ATYOU" 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"] = aslanDashboardMetricSources() response["updated_at_ms"] = time.Now().UTC().UnixMilli() applyExternalDashboardDeltas(response, current.Total, previous.Total) response["coin_total_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.CoinTotal), nullableMetricInt64(previous.Total.CoinTotal)) response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor)) applyAslanUnavailableFields(response) return response, nil } type aslanRangeOverview struct { Total externalDashboardMetric DailySeries []map[string]any CountryBreakdown []map[string]any DailyCountryBreakdown []map[string]any } func (s *AslanHTTPDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) { if query.CountryID > 0 { return externalDashboardCountryFilter{}, fmt.Errorf("aslan 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 接口返回的是 legacy 区域字符串;admin 前端使用的 region_id 可能是 Mongo ObjectId 的稳定 int64 映射。 // 缺 legacy 映射时才回退尝试远端 regionId 数字值,兼容历史区域本身就是数字字符串的环境。 return externalDashboardCountryFilter{RegionID: query.RegionID}, nil } func (s *AslanHTTPDashboardSource) 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{} for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) { regions, err := s.fetchDay(ctx, day) if err != nil { return aslanRangeOverview{}, err } dayFlow := aslanDashboardMetric{} daySnapshot := aslanDashboardMetric{} dayCountries := map[string]*aslanCountryAccumulator{} for _, region := range regions { if !s.regionIncluded(region, countryFilter) { continue } for _, country := range region.Countries { countryCode := strings.ToUpper(strings.TrimSpace(country.CountryCode)) if countryCode == "" || !countryIncluded(countryCode, countryFilter) { continue } metric, err := aslanCountryMetric(country) if err != nil { return aslanRangeOverview{}, fmt.Errorf("map aslan country %s on %s: %w", countryCode, dashboardSQLDate(day), err) } dayFlow.addFlow(metric) daySnapshot.addSnapshot(metric) totalFlow.addFlow(metric) dayAggregate := dayCountries[countryCode] if dayAggregate == nil { dayAggregate = &aslanCountryAccumulator{CountryCode: countryCode} dayCountries[countryCode] = dayAggregate } dayAggregate.addCountry(region, country, metric, countryFilter.RegionID) } } totalSnapshot = daySnapshot dayMetric := aslanCombinedMetric(dayFlow, daySnapshot) dayRow := dayMetric.toMap() dayLabel := dashboardSQLDate(day) dayRow["label"] = dayLabel dayRow["stat_day"] = dayLabel applyAslanUnavailableFields(dayRow) dailySeries = append(dailySeries, dayRow) mergeAslanCountryDay(countryAggregates, dayCountries) dailyCountries = append(dailyCountries, aslanDailyCountryRows(dayLabel, dayCountries)...) } return aslanRangeOverview{ Total: aslanCombinedMetric(totalFlow, totalSnapshot), DailySeries: dailySeries, CountryBreakdown: aslanCountryRows(countryAggregates), DailyCountryBreakdown: dailyCountries, }, nil } func (s *AslanHTTPDashboardSource) fetchDay(ctx context.Context, day time.Time) ([]aslanRegionStatistics, error) { regions, err := s.fetchDayWithDateParam(ctx, day, dashboardSQLDate(day)) if err == nil { return regions, nil } var dateErr aslanDateParameterError if !errors.As(err, &dateErr) { return nil, err } // 线上 Aslan 旧版本把 LocalDate 参数按 yyyyMMdd 转换;当前代码标注是 ISO date。这里只在明确日期转换失败时降级,兼容两版服务。 return s.fetchDayWithDateParam(ctx, day, day.Format("20060102")) } func (s *AslanHTTPDashboardSource) fetchDayWithDateParam(ctx context.Context, day time.Time, dateParam string) ([]aslanRegionStatistics, error) { endpoint, err := url.Parse(s.baseURL + s.overviewPath) if err != nil { return nil, fmt.Errorf("build aslan dashboard request url: %w", err) } values := endpoint.Query() values.Set("date", dateParam) endpoint.RawQuery = values.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, err } resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("request aslan dashboard %s: %w", dashboardSQLDate(day), err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode == http.StatusBadRequest && bytes.Contains(body, []byte("LocalDate")) { return nil, aslanDateParameterError{dateParam: dateParam} } return nil, fmt.Errorf("aslan dashboard returned status %d for %s: %s", resp.StatusCode, dashboardSQLDate(day), truncateExternalDashboardError(body)) } regions, err := decodeAslanRegions(body) if err != nil { return nil, fmt.Errorf("decode aslan dashboard %s: %w", dashboardSQLDate(day), err) } return regions, nil } type aslanDateParameterError struct { dateParam string } func (e aslanDateParameterError) Error() string { return "aslan dashboard date parameter rejected: " + e.dateParam } func truncateExternalDashboardError(body []byte) string { text := strings.TrimSpace(string(body)) if len(text) > 300 { return text[:300] } return text } func decodeAslanRegions(body []byte) ([]aslanRegionStatistics, error) { trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 { return nil, nil } if trimmed[0] == '[' { var regions []aslanRegionStatistics if err := json.Unmarshal(trimmed, ®ions); err != nil { return nil, err } return regions, nil } var envelope map[string]json.RawMessage if err := json.Unmarshal(trimmed, &envelope); err != nil { return nil, err } for _, key := range []string{"data", "result", "body"} { raw := bytes.TrimSpace(envelope[key]) if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { continue } var regions []aslanRegionStatistics if err := json.Unmarshal(raw, ®ions); err != nil { return nil, err } return regions, nil } return nil, fmt.Errorf("missing array payload") } func (s *AslanHTTPDashboardSource) regionIncluded(region aslanRegionStatistics, countryFilter externalDashboardCountryFilter) bool { if countryFilter.Applied || countryFilter.RegionID <= 0 { return true } regionID, err := strconv.ParseInt(strings.TrimSpace(region.RegionID), 10, 64) return err == nil && regionID == countryFilter.RegionID } func countryIncluded(countryCode string, countryFilter externalDashboardCountryFilter) bool { if !countryFilter.Applied { return true } for _, code := range countryFilter.Codes { if code == countryCode { return true } } return false } func aslanCountryMetric(country aslanCountryStatistics) (aslanDashboardMetric, error) { recharge, err := country.DailyRecharge.Minor() if err != nil { return aslanDashboardMetric{}, err } salary, err := country.HostSalaryBalance.Minor() if err != nil { return aslanDashboardMetric{}, err } return aslanDashboardMetric{ RechargeUSDMinor: recharge, ActiveUsers: country.DailyActive.Int64(), NewUsers: country.DailyRegister.Int64(), CoinTotal: country.GoldBalance.Int64(), SalaryUSDMinor: salary, }, nil } func aslanCombinedMetric(flow aslanDashboardMetric, snapshot aslanDashboardMetric) externalDashboardMetric { metric := externalDashboardMetric{ NewUsers: flow.NewUsers, ActiveUsers: flow.ActiveUsers, UserRechargeUSDMinor: flow.RechargeUSDMinor, RechargeUSDMinor: flow.RechargeUSDMinor, CoinTotal: int64Ptr(snapshot.CoinTotal), SalaryUSDMinor: int64Ptr(snapshot.SalaryUSDMinor), D1RetentionUsers: 0, D1RetentionBaseUsers: 0, D7RetentionUsers: 0, D7RetentionBaseUsers: 0, D30RetentionUsers: 0, D30RetentionBaseUsers: 0, } metric.finalize() return metric } func (m *aslanDashboardMetric) addFlow(item aslanDashboardMetric) { m.RechargeUSDMinor += item.RechargeUSDMinor m.ActiveUsers += item.ActiveUsers m.NewUsers += item.NewUsers } func (m *aslanDashboardMetric) addSnapshot(item aslanDashboardMetric) { m.CoinTotal += item.CoinTotal m.SalaryUSDMinor += item.SalaryUSDMinor } func (a *aslanCountryAccumulator) addCountry(region aslanRegionStatistics, country aslanCountryStatistics, metric aslanDashboardMetric, requestRegionID int64) { a.CountryName = firstExternalDashboardValue(country.CountryName, a.CountryName, a.CountryCode) a.RegionCode = firstExternalDashboardValue(region.RegionCode, a.RegionCode) a.RegionName = firstExternalDashboardValue(region.RegionName, a.RegionName) if requestRegionID > 0 { a.RegionID = requestRegionID } else if a.RegionID == 0 { if regionID, err := strconv.ParseInt(strings.TrimSpace(region.RegionID), 10, 64); err == nil { a.RegionID = regionID } } a.Flow.addFlow(metric) a.Snapshot.addSnapshot(metric) } func mergeAslanCountryDay(total map[string]*aslanCountryAccumulator, day map[string]*aslanCountryAccumulator) { for countryCode, dayCountry := range day { aggregate := total[countryCode] if aggregate == nil { aggregate = &aslanCountryAccumulator{ CountryCode: dayCountry.CountryCode, CountryName: dayCountry.CountryName, RegionID: dayCountry.RegionID, RegionCode: dayCountry.RegionCode, RegionName: dayCountry.RegionName, } total[countryCode] = aggregate } aggregate.CountryName = firstExternalDashboardValue(dayCountry.CountryName, aggregate.CountryName, aggregate.CountryCode) aggregate.RegionID = maxPositiveInt64(aggregate.RegionID, dayCountry.RegionID) aggregate.RegionCode = firstExternalDashboardValue(dayCountry.RegionCode, aggregate.RegionCode) aggregate.RegionName = firstExternalDashboardValue(dayCountry.RegionName, aggregate.RegionName) aggregate.Flow.addFlow(dayCountry.Flow) // 金币和工资是 Aslan 接口提供的余额快照,区间国家明细必须使用最后一天快照,不能按天累加成虚高余额。 aggregate.Snapshot = dayCountry.Snapshot } } func maxPositiveInt64(left int64, right int64) int64 { if left > 0 { return left } return right } func aslanCountryRows(countries map[string]*aslanCountryAccumulator) []map[string]any { rows := make([]map[string]any, 0, len(countries)) for _, country := range countries { rows = append(rows, aslanCountryRow("", country)) } sort.Slice(rows, func(i, j int) bool { left, _ := rows[i]["recharge_usd_minor"].(int64) right, _ := rows[j]["recharge_usd_minor"].(int64) if left == right { return fmt.Sprint(rows[i]["country_code"]) < fmt.Sprint(rows[j]["country_code"]) } return left > right }) return rows } func aslanDailyCountryRows(statDay string, countries map[string]*aslanCountryAccumulator) []map[string]any { keys := make([]string, 0, len(countries)) for key := range countries { keys = append(keys, key) } sort.Strings(keys) rows := make([]map[string]any, 0, len(keys)) for _, key := range keys { rows = append(rows, aslanCountryRow(statDay, countries[key])) } return rows } func aslanCountryRow(statDay string, country *aslanCountryAccumulator) map[string]any { metric := aslanCombinedMetric(country.Flow, country.Snapshot) row := metric.toMap() if statDay != "" { row["label"] = statDay row["stat_day"] = statDay } row["country_id"] = 0 row["country_code"] = country.CountryCode row["country_name"] = firstExternalDashboardValue(country.CountryName, country.CountryCode) row["country"] = firstExternalDashboardValue(country.CountryName, country.CountryCode) if country.RegionID > 0 { row["region_id"] = country.RegionID } if country.RegionCode != "" { row["region_code"] = country.RegionCode } if country.RegionName != "" { row["region_name"] = country.RegionName } applyAslanUnavailableFields(row) return row } func (v *aslanDecimal) UnmarshalJSON(data []byte) error { raw := strings.TrimSpace(string(data)) if raw == "" || raw == "null" { v.raw = "0" return nil } if strings.HasPrefix(raw, `"`) { var decoded string if err := json.Unmarshal(data, &decoded); err != nil { return err } raw = decoded } v.raw = strings.TrimSpace(raw) if v.raw == "" { v.raw = "0" } return nil } func (v aslanDecimal) Minor() (int64, error) { return decimalTextToMinor(v.raw) } func (v *aslanInt64) UnmarshalJSON(data []byte) error { raw := strings.TrimSpace(string(data)) if raw == "" || raw == "null" { v.value = 0 return nil } if strings.HasPrefix(raw, `"`) { var decoded string if err := json.Unmarshal(data, &decoded); err != nil { return err } raw = decoded } parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { return fmt.Errorf("parse int64 %q: %w", raw, err) } v.value = parsed return nil } func (v aslanInt64) Int64() int64 { return v.value } func nullableMetricInt64(value *int64) int64 { if value == nil { return 0 } return *value } var aslanUnavailableMetricFields = []string{ "paid_users", "recharge_users", "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", "lucky_gift_turnover", "lucky_gift_payout", "lucky_gift_payers", "lucky_gift_profit", "lucky_gift_profit_rate", "super_lucky_gift_turnover", "super_lucky_gift_payout", "super_lucky_gift_profit", "super_lucky_gift_profit_rate", "gift_coin_spent", "consumed_coin", "output_coin", "consume_output_ratio", "platform_grant_coin", "manual_grant_coin", "salary_transfer_coin", "mic_online_ms", "avg_mic_online_ms", "arppu_usd_minor", "payer_rate", "paid_conversion_rate", "recharge_conversion_rate", "arppu_delta_rate", "paid_users_delta_rate", "payer_rate_delta_pp", "paid_conversion_rate_delta_pp", "recharge_conversion_rate_delta_pp", "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 applyAslanUnavailableFields(row map[string]any) { for _, field := range aslanUnavailableMetricFields { row[field] = nil } } func aslanRetentionMap(metric externalDashboardMetric) map[string]any { return map[string]any{ "registered_users": metric.NewUsers, "day1_base_users": nil, "day1_rate": nil, "day1_users": nil, "day7_base_users": nil, "day7_rate": nil, "day7_users": nil, "day30_base_users": nil, "day30_rate": nil, "day30_users": nil, } } func aslanDashboardMetricSources() []map[string]any { return []map[string]any{ {"field": "new_users", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics dailyRegister"}, {"field": "active_users", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics dailyActive"}, {"field": "paid_users", "available": false, "missing_reason": "Aslan 当前接口没有付费用户数"}, {"field": "recharge_users", "available": false, "missing_reason": "Aslan 当前接口没有充值用户数"}, {"field": "recharge_usd_minor", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics dailyRecharge"}, {"field": "new_user_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有新用户充值字段"}, {"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有币商充值金额字段"}, {"field": "coin_seller_stock_coin", "available": false, "missing_reason": "Aslan 当前接口没有币商充值金币字段"}, {"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "Aslan 当前接口没有币商出货金币字段"}, {"field": "google_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有 Google 充值拆分字段"}, {"field": "mifapay_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有三方充值拆分字段"}, {"field": "game_turnover", "available": false, "missing_reason": "Aslan 当前接口没有游戏流水和返奖字段"}, {"field": "lucky_gift_turnover", "available": false, "missing_reason": "Aslan 当前接口没有幸运礼物流水和返奖字段"}, {"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "Aslan 当前接口没有超级幸运礼物字段"}, {"field": "gift_coin_spent", "available": false, "missing_reason": "Aslan 当前接口没有礼物流水字段"}, {"field": "coin_total", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics goldBalance"}, {"field": "consumed_coin", "available": false, "missing_reason": "Aslan 当前接口没有消耗金币总口径"}, {"field": "output_coin", "available": false, "missing_reason": "Aslan 当前接口没有产出金币总口径"}, {"field": "consume_output_ratio", "available": false, "missing_reason": "缺少消耗金币和产出金币总口径"}, {"field": "platform_grant_coin", "available": false, "missing_reason": "Aslan 当前接口没有平台发放金币字段"}, {"field": "manual_grant_coin", "available": false, "missing_reason": "Aslan 当前接口没有人工发放金币字段"}, {"field": "salary_usd_minor", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics hostSalaryBalance"}, {"field": "salary_transfer_coin", "available": false, "missing_reason": "Aslan 当前接口没有工资兑换金币字段"}, {"field": "avg_mic_online_ms", "available": false, "missing_reason": "Aslan 当前接口没有麦上时长字段"}, {"field": "mic_online_ms", "available": false, "missing_reason": "Aslan 当前接口没有麦上时长字段"}, {"field": "arpu_usd_minor", "available": true, "source": "dailyRecharge / dailyActive"}, {"field": "arppu_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有付费用户数,不能计算 ARPPU"}, {"field": "paid_conversion_rate", "available": false, "missing_reason": "Aslan 当前接口没有付费用户数"}, {"field": "recharge_conversion_rate", "available": false, "missing_reason": "Aslan 当前接口没有充值用户数"}, {"field": "retention.day1_rate", "available": false, "missing_reason": "Aslan 当前接口没有次日留存 cohort 字段"}, {"field": "retention.day7_rate", "available": false, "missing_reason": "Aslan 当前接口没有 7 日留存 cohort 字段"}, {"field": "retention.day30_rate", "available": false, "missing_reason": "Aslan 当前接口没有 30 日留存 cohort 字段"}, } }