2026-07-02 10:53:44 +08:00

698 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 (
"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, &regions); 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, &regions); 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 字段"},
}
}