698 lines
25 KiB
Go
698 lines
25 KiB
Go
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 字段"},
|
||
}
|
||
}
|