hyapp-server/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go
2026-07-07 18:05:02 +08:00

1839 lines
67 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dashboard
import (
"context"
"database/sql"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"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"
aslanMongoGiftWaterCollection = "gift_give_running_water"
aslanMongoGoldWaterCollection = "user_gold_running_water_v2"
aslanMongoChunkSize = 800
// likei GoldOrigin 枚举以 code 形式落在 user_gold_running_water_v2.origin
// 历史上部分 code 带数字后缀(如 HOT_GAME_200所以统一用前缀匹配。
aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD"
aslanMongoSellerAgentOriginPrefix = "SELLER_AGENT"
)
var (
aslanMongoFreightCommodityTypes = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"}
aslanMongoFreightCommodityFields = []string{"trackCommodityType", "products.code", "products.name", "products.content"}
)
type AslanMongoDashboardSource struct {
db *mongo.Database
legacyDB *sql.DB
legacyWalletDatabase string
appCode string
appName string
sysOrigin string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
// 部分 likei 部署(如 Aslan 的 atyou 库)没有 user_gold_running_water_v2 集合;
// 探测结果缓存住,避免把"集合不存在"算成 0幸运礼物利润率会假性 100%)。
goldWaterMu sync.Mutex
goldWaterChecked bool
goldWaterAvailable bool
}
func (s *AslanMongoDashboardSource) hasGoldWater(ctx context.Context) bool {
s.goldWaterMu.Lock()
defer s.goldWaterMu.Unlock()
if s.goldWaterChecked {
return s.goldWaterAvailable
}
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
count, err := s.db.Collection(aslanMongoGoldWaterCollection).EstimatedDocumentCount(queryCtx)
if err != nil {
// 探测失败不缓存结论(下次请求重试);本次按不可用处理,宁缺毋错。
return false
}
s.goldWaterChecked = true
s.goldWaterAvailable = count > 0
return s.goldWaterAvailable
}
// NewAslanMongoDashboardSource 直接读取 likei 平台Aslan/Yumi 同构Mongo 里的已存在事实集合。
// 这里只做查询聚合和字段映射,不把数据复制到 hyapp statistics-service也不回退旧 console HTTP 接口。
func NewAslanMongoDashboardSource(database *mongo.Database, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
return newAslanMongoDashboardSource(database, nil, sourceConfig, regionResolvers...)
}
// NewAslanMongoDashboardSourceWithLegacyDB 在 Mongo 事实之外接入 likei legacy MySQL 的补充口径。
// legacyDB 由调用方负责生命周期nil 表示对应指标不可用,而不是按 0 处理。
func NewAslanMongoDashboardSourceWithLegacyDB(database *mongo.Database, legacyDB *sql.DB, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
return newAslanMongoDashboardSource(database, legacyDB, sourceConfig, regionResolvers...)
}
func newAslanMongoDashboardSource(database *mongo.Database, legacyDB *sql.DB, 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,
legacyDB: legacyDB,
legacyWalletDatabase: strings.TrimSpace(sourceConfig.LegacyWalletDatabase),
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)
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
var current, previous aslanRangeOverview
// 当前周期与环比周期互不依赖,并行加载;环比只消费流水字段,昂贵的留存链路跳过。
if err := runConcurrently([]func() error{
func() error {
out, err := s.loadRange(ctx, startDate, endDate, countryFilter, true)
if err != nil {
return err
}
current = out
return nil
},
func() error {
out, err := s.loadRange(ctx, previousStart, startDate, countryFilter, false)
if err != nil {
return err
}
previous = out
return nil
},
}); 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
goldWater := s.hasGoldWater(ctx)
response["retention"] = current.Total.retentionMap()
response["report_metric_sources"] = aslanMongoDashboardMetricSources(goldWater, s.legacyDB != nil)
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)
applyAslanMongoLegacyUnavailableFields(response, s.legacyDB != nil)
if !goldWater {
applyAslanMongoGoldWaterUnavailable(response)
response["lucky_gift_payout_delta_rate"] = nil
response["lucky_gift_profit_delta_rate"] = nil
}
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
}
// likei Mongo 集合只有国家码,没有 hyapp 的数字 region_id缺 legacy 区域映射时失败,避免误把全量数据当成某个区域返回。
return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}
// legacyMongoMetric 是 likei Mongo 源按 天×国家 汇总出的事实指标;
// 消耗/产出金币是全 App 口径(按用户拆国家需要全量账变反查用户,代价过高),只挂在日行和合计上。
type legacyMongoMetric struct {
NewUsers int64
ActiveUsers int64
PaidUsers int64
RechargeUSDMinor int64
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
CoinSellerRechargeUSDMinor int64
NewUserRechargeUSDMinor int64
GiftCoinSpent int64
LuckyGiftTurnover int64
LuckyGiftPayout int64
LuckyGiftPayers int64
CoinSellerTransferCoin int64
SalaryUSDMinor int64
D1Users int64
D1Base int64
D7Users int64
D7Base int64
D30Users int64
D30Base int64
}
func (m *legacyMongoMetric) add(other *legacyMongoMetric) {
if other == nil {
return
}
m.NewUsers += other.NewUsers
m.ActiveUsers += other.ActiveUsers
m.PaidUsers += other.PaidUsers
m.RechargeUSDMinor += other.RechargeUSDMinor
m.GoogleUSDMinor += other.GoogleUSDMinor
m.ThirdPartyUSDMinor += other.ThirdPartyUSDMinor
m.CoinSellerRechargeUSDMinor += other.CoinSellerRechargeUSDMinor
m.NewUserRechargeUSDMinor += other.NewUserRechargeUSDMinor
m.GiftCoinSpent += other.GiftCoinSpent
m.LuckyGiftTurnover += other.LuckyGiftTurnover
m.LuckyGiftPayout += other.LuckyGiftPayout
m.LuckyGiftPayers += other.LuckyGiftPayers
m.CoinSellerTransferCoin += other.CoinSellerTransferCoin
m.SalaryUSDMinor += other.SalaryUSDMinor
m.D1Users += other.D1Users
m.D1Base += other.D1Base
m.D7Users += other.D7Users
m.D7Base += other.D7Base
m.D30Users += other.D30Users
m.D30Base += other.D30Base
}
type legacyMongoCoinFlow struct {
ConsumedCoin int64
OutputCoin int64
}
func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric {
userRecharge := m.RechargeUSDMinor - m.CoinSellerRechargeUSDMinor
metric := externalDashboardMetric{
NewUsers: m.NewUsers,
ActiveUsers: m.ActiveUsers,
PaidUsers: m.PaidUsers,
RechargeUSDMinor: m.RechargeUSDMinor,
UserRechargeUSDMinor: userRecharge,
GoogleRechargeUSDMinor: m.GoogleUSDMinor,
MifaPayRechargeUSDMinor: m.ThirdPartyUSDMinor,
CoinSellerRechargeUSDMinor: m.CoinSellerRechargeUSDMinor,
NewUserRechargeUSDMinor: m.NewUserRechargeUSDMinor,
GiftCoinSpent: m.GiftCoinSpent,
LuckyGiftTurnover: m.LuckyGiftTurnover,
LuckyGiftPayout: m.LuckyGiftPayout,
LuckyGiftPayers: m.LuckyGiftPayers,
CoinSellerTransferCoin: int64Ptr(m.CoinSellerTransferCoin),
SalaryUSDMinor: int64Ptr(m.SalaryUSDMinor),
D1RetentionUsers: m.D1Users,
D1RetentionBaseUsers: m.D1Base,
D7RetentionUsers: m.D7Users,
D7RetentionBaseUsers: m.D7Base,
D30RetentionUsers: m.D30Users,
D30RetentionBaseUsers: m.D30Base,
}
if coinFlow != nil {
metric.ConsumedCoin = int64Ptr(coinFlow.ConsumedCoin)
metric.OutputCoin = int64Ptr(coinFlow.OutputCoin)
}
metric.finalize()
// finalize 会按拆分字段重推总充值likei Mongo 源已经把 in-app 与 legacy 币商金额合成总额,最终以事实聚合值为准。
metric.RechargeUSDMinor = m.RechargeUSDMinor
metric.UserRechargeUSDMinor = userRecharge
return metric
}
type legacyMongoGrid struct {
days []string
daySet map[string]struct{}
metrics map[string]map[string]*legacyMongoMetric
coinFlows map[string]*legacyMongoCoinFlow
rechargeUsers map[string]map[string]struct{}
// 各 loader 并行写入map 结构由锁保护;不同 loader 写 metric 的不同字段,无字段级竞争。
mu sync.Mutex
}
func newLegacyMongoGrid(startDate time.Time, endDate time.Time) *legacyMongoGrid {
grid := &legacyMongoGrid{
daySet: map[string]struct{}{},
metrics: map[string]map[string]*legacyMongoMetric{},
coinFlows: map[string]*legacyMongoCoinFlow{},
rechargeUsers: map[string]map[string]struct{}{},
}
for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
text := dashboardSQLDate(day)
grid.days = append(grid.days, text)
grid.daySet[text] = struct{}{}
}
return grid
}
func (g *legacyMongoGrid) at(day string, countryCode string) *legacyMongoMetric {
g.mu.Lock()
defer g.mu.Unlock()
byCountry := g.metrics[day]
if byCountry == nil {
byCountry = map[string]*legacyMongoMetric{}
g.metrics[day] = byCountry
}
metric := byCountry[countryCode]
if metric == nil {
metric = &legacyMongoMetric{}
byCountry[countryCode] = metric
}
return metric
}
func (g *legacyMongoGrid) hasDay(day string) bool {
_, ok := g.daySet[day]
return ok
}
// runConcurrently 并发执行各 loader 并返回第一个错误;每个 loader 内部有独立的查询超时。
func runConcurrently(tasks []func() error) error {
var wg sync.WaitGroup
errs := make(chan error, len(tasks))
for _, task := range tasks {
wg.Add(1)
go func(task func() error) {
defer wg.Done()
if err := task(); err != nil {
errs <- err
}
}(task)
}
wg.Wait()
close(errs)
return <-errs
}
func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, includeRetention bool) (aslanRangeOverview, error) {
grid := newLegacyMongoGrid(startDate, endDate)
// 注册 cohort 是新用户充值与留存的前置依赖,先串行取;其余 loader 集合互不相交,并发执行,
// 让整段耗时收敛到最慢一条聚合而不是全部相加(跨地域 Mongo 时差异是数量级的)。
cohorts, err := s.loadRegistrationCohorts(ctx, startDate, endDate, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
for day, byCountry := range cohorts {
for countryCode, userIDs := range byCountry {
grid.at(day, countryCode).NewUsers = int64(len(userIDs))
}
}
goldWater := s.hasGoldWater(ctx)
var salarySnapshot map[string]int64
tasks := []func() error{
func() error { return s.loadActiveCounts(ctx, startDate, endDate, countryFilter, grid) },
func() error { return s.loadRechargeStats(ctx, startDate, endDate, countryFilter, cohorts, grid) },
func() error { return s.loadGiftStats(ctx, startDate, endDate, countryFilter, grid) },
func() error {
snapshot, err := s.loadHostSalaryBalanceByCountry(ctx, countryFilter)
if err != nil {
return err
}
salarySnapshot = snapshot
return nil
},
}
if goldWater {
tasks = append(tasks,
func() error { return s.loadGoldOriginStats(ctx, startDate, endDate, countryFilter, grid) },
func() error { return s.loadCoinFlows(ctx, startDate, endDate, grid) },
)
}
if includeRetention {
tasks = append(tasks, func() error { return s.applyRetention(ctx, cohorts, grid) })
}
if err := runConcurrently(tasks); err != nil {
return aslanRangeOverview{}, err
}
// user_bank_balance 是余额快照而不是日报表;这里按当前快照填充每一天,区间汇总只保留一份快照,避免把余额按天累加。
for _, day := range grid.days {
for countryCode, balance := range salarySnapshot {
grid.at(day, countryCode).SalaryUSDMinor = balance
}
}
return s.assembleRange(grid, countryFilter, goldWater, s.legacyDB != nil), nil
}
func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, countryFilter externalDashboardCountryFilter, goldWater bool, legacyRecharge bool) aslanRangeOverview {
dailySeries := []map[string]any{}
dailyCountries := []map[string]any{}
totalFlow := legacyMongoMetric{}
totalCoinFlow := legacyMongoCoinFlow{}
countryTotals := map[string]*legacyMongoMetric{}
for _, day := range grid.days {
byCountry := grid.metrics[day]
dayFlow := legacyMongoMetric{}
countryCodes := make([]string, 0, len(byCountry))
for countryCode := range byCountry {
countryCodes = append(countryCodes, countryCode)
}
sort.Strings(countryCodes)
for _, countryCode := range countryCodes {
metric := byCountry[countryCode]
dayFlow.add(metric)
countryTotal := countryTotals[countryCode]
if countryTotal == nil {
countryTotal = &legacyMongoMetric{}
countryTotals[countryCode] = countryTotal
}
salary := metric.SalaryUSDMinor
countryTotal.add(metric)
countryTotal.SalaryUSDMinor = salary
row := metric.toExternal(nil).toMap()
row["label"] = day
row["stat_day"] = day
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = countryCode
row["country"] = countryCode
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
applyAslanMongoUnavailableFields(row)
applyAslanMongoLegacyUnavailableFields(row, legacyRecharge)
if !goldWater {
applyAslanMongoGoldWaterUnavailable(row)
}
dailyCountries = append(dailyCountries, row)
}
coinFlow := grid.coinFlows[day]
if coinFlow != nil {
totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin
totalCoinFlow.OutputCoin += coinFlow.OutputCoin
}
// 工资余额是快照:日行取当日各国快照之和,区间合计不能跨天累加。
daySalary := dayFlow.SalaryUSDMinor
if dayRechargeUsers := grid.rechargeUsers[day]; len(dayRechargeUsers) > 0 {
// 国家拆分仍保留各国家充值人数;日合计按 user_id 去重,避免同一用户因不同事实源国家码不一致而被算两次。
dayFlow.PaidUsers = int64(len(dayRechargeUsers))
}
totalFlow.add(&dayFlow)
totalFlow.SalaryUSDMinor = daySalary
dayRow := dayFlow.toExternal(coinFlow).toMap()
dayRow["label"] = day
dayRow["stat_day"] = day
applyAslanMongoUnavailableFields(dayRow)
applyAslanMongoLegacyUnavailableFields(dayRow, legacyRecharge)
if !goldWater {
applyAslanMongoGoldWaterUnavailable(dayRow)
}
dailySeries = append(dailySeries, dayRow)
}
countryRows := make([]map[string]any, 0, len(countryTotals))
countryCodes := make([]string, 0, len(countryTotals))
for countryCode := range countryTotals {
countryCodes = append(countryCodes, countryCode)
}
sort.Strings(countryCodes)
for _, countryCode := range countryCodes {
row := countryTotals[countryCode].toExternal(nil).toMap()
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = countryCode
row["country"] = countryCode
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
applyAslanMongoUnavailableFields(row)
applyAslanMongoLegacyUnavailableFields(row, legacyRecharge)
if !goldWater {
applyAslanMongoGoldWaterUnavailable(row)
}
countryRows = append(countryRows, row)
}
sort.Slice(countryRows, func(i, j int) bool {
left, _ := countryRows[i]["recharge_usd_minor"].(int64)
right, _ := countryRows[j]["recharge_usd_minor"].(int64)
if left == right {
return fmt.Sprint(countryRows[i]["country_code"]) < fmt.Sprint(countryRows[j]["country_code"])
}
return left > right
})
return aslanRangeOverview{
Total: totalFlow.toExternal(&totalCoinFlow),
DailySeries: dailySeries,
CountryBreakdown: countryRows,
DailyCountryBreakdown: dailyCountries,
}
}
// loadRegistrationCohorts 返回 天→国家→注册用户ID同时用于新增数、新用户充值和留存 cohort。
func (s *AslanMongoDashboardSource) loadRegistrationCohorts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]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)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "country", Value: "$countryCode"},
}},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$_id"}}},
}}},
}
out := map[string]map[string][]int64{}
err := s.aggregateEach(ctx, aslanMongoUserProfileCollection, pipeline, func(row bson.M) error {
day, countryCode := aslanMongoDayCountry(row["_id"])
if day == "" || countryCode == "" {
return nil
}
userIDs := aslanMongoInt64Values(row["users"])
if len(userIDs) == 0 {
return nil
}
byCountry := out[day]
if byCountry == nil {
byCountry = map[string][]int64{}
out[day] = byCountry
}
byCountry[countryCode] = userIDs
return nil
})
if err != nil {
return nil, fmt.Errorf("aggregate registration cohorts: %w", err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadActiveCounts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
// user_daily_active_log 的 activeDate 是 yyyy-MM-dd 字符串likei 固定按其统计时区落盘ISO 日期字符串可直接做范围比较。
match := bson.M{
"sysOrigin": s.sysOrigin,
"activeDate": bson.M{"$gte": dashboardSQLDate(startDate), "$lt": dashboardSQLDate(endDate)},
"registerCountryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(match, "registerCountryCode", countryFilter)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: "$activeDate"},
{Key: "country", Value: "$registerCountryCode"},
}},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$userId"}}},
}}},
}
err := s.aggregateEach(ctx, aslanMongoUserActiveCollection, pipeline, func(row bson.M) error {
day, countryCode := aslanMongoDayCountry(row["_id"])
if day == "" || countryCode == "" || !grid.hasDay(day) {
return nil
}
grid.at(day, countryCode).ActiveUsers = aslanMongoDistinctValueCount(row["users"])
return nil
})
if err != nil {
return fmt.Errorf("aggregate active users: %w", err)
}
return nil
}
type aslanMongoDayCountryUsers struct {
day string
country string
}
// loadRechargeStats 一次聚合出 天×国家×渠道 的充值金额与充值用户,再按当日注册 cohort 拆出新用户充值。
func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
match := bson.M{
"sysOrigin": s.sysOrigin,
"status": "SUCCESS",
"trialPeriod": bson.M{"$ne": true},
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
}
aslanMongoExcludeFreightGoldRecharge(match)
// Aslan 原 Top 充值统计按 acceptUserId 聚合createUser 只在 acceptUserId 缺失的旧单据上兜底。
userExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
}
pipeline = append(pipeline, aslanMongoRechargeCountryStages(userExpression, countryFilter)...)
pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "country", Value: "$aslanBillCountry"},
{Key: "factory", Value: bson.D{{Key: "$toUpper", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$factory.factoryCode", ""}}}}}},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$aslanBillUserID"}}},
}}})
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
err := s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, pipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
if key == nil {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
countryCode := aslanMongoCountryCode(key["country"])
factory := strings.TrimSpace(fmt.Sprint(key["factory"]))
if day == "" || countryCode == "" || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse recharge amount for %s/%s: %w", day, countryCode, err)
}
metric := grid.at(day, countryCode)
metric.RechargeUSDMinor += amount
if factory == "GOOGLE" {
metric.GoogleUSDMinor += amount
} else {
metric.ThirdPartyUSDMinor += amount
}
usersKey := aslanMongoDayCountryUsers{day: day, country: countryCode}
userSet := distinctUsers[usersKey]
if userSet == nil {
userSet = map[string]struct{}{}
distinctUsers[usersKey] = userSet
}
for _, value := range aslanMongoInt64Values(row["users"]) {
userSet[strconv.FormatInt(value, 10)] = struct{}{}
}
return nil
})
if err != nil {
return fmt.Errorf("aggregate recharge stats: %w", err)
}
// 新用户充值:按 天×用户 聚合当日充值,再与当日注册 cohort 求交集cohort 自带国家归属。
perUserPipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
}
perUserPipeline = append(perUserPipeline, aslanMongoRechargeCountryStages(userExpression, countryFilter)...)
perUserPipeline = append(perUserPipeline, bson.D{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "user", Value: "$aslanBillUserID"},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
}}})
perUser := map[string]map[int64]int64{}
err = s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, perUserPipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
if key == nil {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
userID := aslanMongoWholeInt64(key["user"])
if day == "" || userID <= 0 || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse per-user recharge amount for %s: %w", day, err)
}
byUser := perUser[day]
if byUser == nil {
byUser = map[int64]int64{}
perUser[day] = byUser
}
byUser[userID] += amount
return nil
})
if err != nil {
return fmt.Errorf("aggregate per-user recharge: %w", err)
}
if err := s.loadLegacyOtherRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
if err := s.loadLegacyCoinSellerRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)
return nil
}
func aslanMongoRechargeCountryStages(userExpression any, countryFilter externalDashboardCountryFilter) mongo.Pipeline {
// Google 账单普遍没有 countryCodefinance 口径已按付款用户资料兜底dashboard/social 也必须一致,
// 否则全 App、按 App 对比和国家拆分会漏掉这些真实充值。
stages := mongo.Pipeline{
bson.D{{Key: "$addFields", Value: bson.M{"aslanBillUserID": userExpression}}},
bson.D{{Key: "$lookup", Value: bson.M{
"from": aslanMongoUserProfileCollection,
"localField": "aslanBillUserID",
"foreignField": "_id",
"as": "aslanBillUser",
}}},
bson.D{{Key: "$addFields", Value: bson.M{
"aslanBillCountry": bson.M{"$toUpper": bson.M{"$cond": bson.A{
bson.M{"$ne": bson.A{bson.M{"$ifNull": bson.A{"$countryCode", ""}}, ""}},
"$countryCode",
bson.M{"$ifNull": bson.A{bson.M{"$first": "$aslanBillUser.countryCode"}, ""}},
}}},
}}},
bson.D{{Key: "$match", Value: bson.M{"aslanBillCountry": bson.M{"$ne": ""}}}},
}
if countryFilter.Applied {
codes := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
if normalized := aslanMongoCountryCode(code); normalized != "" {
codes = append(codes, normalized)
}
}
if len(codes) == 0 {
codes = append(codes, "__NO_COUNTRY__")
}
stages = append(stages, bson.D{{Key: "$match", Value: bson.M{"aslanBillCountry": bson.M{"$in": codes}}}})
}
return stages
}
func applyAslanRechargeUserMetrics(cohorts map[string]map[string][]int64, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) {
for key, userSet := range distinctUsers {
grid.at(key.day, key.country).PaidUsers = int64(len(userSet))
dayUsers := grid.rechargeUsers[key.day]
if dayUsers == nil {
dayUsers = map[string]struct{}{}
grid.rechargeUsers[key.day] = dayUsers
}
for userID := range userSet {
dayUsers[userID] = struct{}{}
}
}
for day, byCountry := range cohorts {
byUser := perUser[day]
if len(byUser) == 0 {
continue
}
for countryCode, userIDs := range byCountry {
total := int64(0)
for _, userID := range userIDs {
total += byUser[userID]
}
if total != 0 {
grid.at(day, countryCode).NewUserRechargeUSDMinor = total
}
}
}
}
func (s *AslanMongoDashboardSource) loadLegacyOtherRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
if s.legacyDB == nil {
return nil
}
where := []string{
"oor.sys_origin = ?",
"oor.payment_date_number >= ?",
"oor.payment_date_number < ?",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
args := []any{s.sysOrigin, dashboardDateNumber(startDate), dashboardDateNumber(endDate)}
if countryFilter.Applied {
if len(countryFilter.Codes) == 0 {
return nil
}
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
normalized := aslanMongoCountryCode(code)
if normalized == "" {
continue
}
placeholders = append(placeholders, "?")
args = append(args, normalized)
}
if len(placeholders) == 0 {
return nil
}
where = append(where, "UPPER(ubi.country_code) IN ("+strings.Join(placeholders, ",")+")")
}
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT DATE_FORMAT(STR_TO_DATE(oor.payment_date_number, '%Y%m%d'), '%Y-%m-%d') AS stat_day,
UPPER(ubi.country_code) AS country_code,
oor.user_id,
CAST(COALESCE(SUM(oor.amount), 0) AS CHAR) AS amount
FROM order_other_recharge oor
INNER JOIN user_base_info ubi ON ubi.id = oor.user_id
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(STR_TO_DATE(oor.payment_date_number, '%Y%m%d'), '%Y-%m-%d'), UPPER(ubi.country_code), oor.user_id`, args...)
if err != nil {
return fmt.Errorf("query legacy other recharge: %w", err)
}
defer rows.Close()
for rows.Next() {
var day string
var countryCode string
var userID int64
var amountText string
if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
return fmt.Errorf("scan legacy other recharge: %w", err)
}
day = strings.TrimSpace(day)
countryCode = aslanMongoCountryCode(countryCode)
if day == "" || countryCode == "" || userID <= 0 || !grid.hasDay(day) {
continue
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return fmt.Errorf("parse legacy other recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
}
addLegacyStandardRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate legacy other recharge: %w", err)
}
return nil
}
func (s *AslanMongoDashboardSource) loadLegacyCoinSellerRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
if s.legacyDB == nil {
return nil
}
if err := s.loadLegacyGoldCoinRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
if err := s.loadLegacyFreightStockRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
return nil
}
func (s *AslanMongoDashboardSource) loadLegacyGoldCoinRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
where := []string{
"ugcr.sys_origin = ?",
"ugcr.gold_coin_source = 2",
"ugcr.create_time >= ?",
"ugcr.create_time < ?",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
args := []any{s.sysOrigin, startDate, endDate}
if countryFilter.Applied {
if len(countryFilter.Codes) == 0 {
return nil
}
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
normalized := aslanMongoCountryCode(code)
if normalized == "" {
continue
}
placeholders = append(placeholders, "?")
args = append(args, normalized)
}
if len(placeholders) == 0 {
return nil
}
where = append(where, "UPPER(ubi.country_code) IN ("+strings.Join(placeholders, ",")+")")
}
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT DATE_FORMAT(ugcr.create_time, '%Y-%m-%d') AS stat_day,
UPPER(ubi.country_code) AS country_code,
ugcr.user_id,
CAST(COALESCE(SUM(ugcr.recharge_amount), 0) AS CHAR) AS amount
FROM user_gold_coin_recharge ugcr
INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(ugcr.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), ugcr.user_id`, args...)
if err != nil {
return fmt.Errorf("query legacy coin seller recharge: %w", err)
}
defer rows.Close()
for rows.Next() {
var day string
var countryCode string
var userID int64
var amountText string
if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
return fmt.Errorf("scan legacy coin seller recharge: %w", err)
}
day = strings.TrimSpace(day)
countryCode = aslanMongoCountryCode(countryCode)
if day == "" || countryCode == "" || userID <= 0 || !grid.hasDay(day) {
continue
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return fmt.Errorf("parse legacy coin seller recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
}
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate legacy coin seller recharge: %w", err)
}
return nil
}
func (s *AslanMongoDashboardSource) loadLegacyFreightStockRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
walletDatabase := strings.TrimSpace(s.legacyWalletDatabase)
if walletDatabase == "" {
return nil
}
// 后台“金币代理 > 发货”实际是给代理进货origin=PURCHASE/type=0表单金额落 amount
// “扣减”是库存/USDT 冲减origin=DEDUCTION/type=1amount 在源系统仍是正数,统计时必须转成负数。
// App/经销商路径可能只落 usd_quantity因此金额优先取 amount空值再回退 usd_quantity。
where := []string{
"fbrw.sys_origin = ?",
"((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))",
"fbrw.create_time >= ?",
"fbrw.create_time < ?",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
args := []any{s.sysOrigin, startDate, endDate}
if countryFilter.Applied {
if len(countryFilter.Codes) == 0 {
return nil
}
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
normalized := aslanMongoCountryCode(code)
if normalized == "" {
continue
}
placeholders = append(placeholders, "?")
args = append(args, normalized)
}
if len(placeholders) == 0 {
return nil
}
where = append(where, "UPPER(ubi.country_code) IN ("+strings.Join(placeholders, ",")+")")
}
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT DATE_FORMAT(fbrw.create_time, '%Y-%m-%d') AS stat_day,
UPPER(ubi.country_code) AS country_code,
fbrw.user_id,
CAST(COALESCE(SUM(CASE
WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END)
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END), 0) AS CHAR) AS amount
FROM `+quoteMySQLIdentifier(walletDatabase)+`.user_freight_balance_running_water fbrw
INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbrw.user_id`, args...)
if err != nil {
return fmt.Errorf("query legacy freight stock recharge: %w", err)
}
defer rows.Close()
for rows.Next() {
var day string
var countryCode string
var userID int64
var amountText string
if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
return fmt.Errorf("scan legacy freight stock recharge: %w", err)
}
day = strings.TrimSpace(day)
countryCode = aslanMongoCountryCode(countryCode)
if day == "" || countryCode == "" || userID <= 0 || !grid.hasDay(day) {
continue
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return fmt.Errorf("parse legacy freight stock recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
}
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate legacy freight stock recharge: %w", err)
}
return nil
}
func addLegacyCoinSellerRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
if amount == 0 {
return
}
metric := grid.at(day, countryCode)
metric.RechargeUSDMinor += amount
metric.CoinSellerRechargeUSDMinor += amount
// 扣减流水按负数冲减金额,但不是一次新的付费行为,不能增加付费用户或新用户充值。
if amount > 0 {
addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
}
}
func addLegacyStandardRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
if amount <= 0 {
return
}
metric := grid.at(day, countryCode)
metric.RechargeUSDMinor += amount
metric.ThirdPartyUSDMinor += amount
addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
}
func addLegacyRechargeUser(distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
usersKey := aslanMongoDayCountryUsers{day: day, country: countryCode}
userSet := distinctUsers[usersKey]
if userSet == nil {
userSet = map[string]struct{}{}
distinctUsers[usersKey] = userSet
}
userSet[strconv.FormatInt(userID, 10)] = struct{}{}
byUser := perUser[day]
if byUser == nil {
byUser = map[int64]int64{}
perUser[day] = byUser
}
byUser[userID] += amount
}
// loadGiftStats 按 天×赠送人 聚合礼物价值(金币),再经用户资料把流水拆到国家;
// luckyGift=true 的部分同时计入幸运礼物流水。
func (s *AslanMongoDashboardSource) loadGiftStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
match := bson.M{
"sysOrigin": s.sysOrigin,
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"refunded": bson.M{"$ne": true},
}
giftValueExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$giftValue.actualAmount", bson.D{{Key: "$ifNull", Value: bson.A{"$giftValue.giftValue", 0}}}}}}
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "user", Value: "$userId"},
}},
{Key: "total", Value: bson.D{{Key: "$sum", Value: giftValueExpression}}},
{Key: "lucky", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$luckyGift", true}}},
giftValueExpression,
0,
}}}}}},
}}},
}
type giftEntry struct {
day string
userID int64
total int64
lucky int64
}
entries := []giftEntry{}
err := s.aggregateEach(ctx, aslanMongoGiftWaterCollection, pipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
if key == nil {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
userID := aslanMongoWholeInt64(key["user"])
if day == "" || userID <= 0 || !grid.hasDay(day) {
return nil
}
total, err := aslanMongoDecimalMinor(row["total"])
if err != nil {
return fmt.Errorf("parse gift total for %s: %w", day, err)
}
lucky, err := aslanMongoDecimalMinor(row["lucky"])
if err != nil {
return fmt.Errorf("parse lucky gift total for %s: %w", day, err)
}
// 礼物价值以金币计amount 十进制只是 Decimal128 载体,这里换回整数金币。
entries = append(entries, giftEntry{day: day, userID: userID, total: roundMinorToWhole(total), lucky: roundMinorToWhole(lucky)})
return nil
})
if err != nil {
return fmt.Errorf("aggregate gift stats: %w", err)
}
if len(entries) == 0 {
return nil
}
userIDs := make([]int64, 0, len(entries))
seen := map[int64]struct{}{}
for _, entry := range entries {
if _, ok := seen[entry.userID]; ok {
continue
}
seen[entry.userID] = struct{}{}
userIDs = append(userIDs, entry.userID)
}
countryByUser, err := s.loadUserCountries(ctx, userIDs, countryFilter)
if err != nil {
return err
}
for _, entry := range entries {
countryCode := countryByUser[entry.userID]
if countryCode == "" {
continue
}
metric := grid.at(entry.day, countryCode)
metric.GiftCoinSpent += entry.total
metric.LuckyGiftTurnover += entry.lucky
if entry.lucky > 0 {
metric.LuckyGiftPayers++
}
}
return nil
}
// loadGoldOriginStats 聚合金币流水里两类来源幸运礼物返奖LUCKY_GIFT_GOLD_REWARD*与币商出货SELLER_AGENT*)。
func (s *AslanMongoDashboardSource) loadGoldOriginStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
assigners := []struct {
originPrefix string
assign func(metric *legacyMongoMetric, amount int64)
}{
{aslanMongoLuckyGiftPayoutOriginPrefix, func(metric *legacyMongoMetric, amount int64) { metric.LuckyGiftPayout += amount }},
{aslanMongoSellerAgentOriginPrefix, func(metric *legacyMongoMetric, amount int64) { metric.CoinSellerTransferCoin += amount }},
}
for _, item := range assigners {
match := bson.M{
"sysOrigin": s.sysOrigin,
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"type": 0,
"origin": bson.M{"$regex": "^" + item.originPrefix},
}
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "user", Value: "$userId"},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$quantity"}}},
}}},
}
type goldEntry struct {
day string
userID int64
amount int64
}
entries := []goldEntry{}
err := s.aggregateEach(ctx, aslanMongoGoldWaterCollection, pipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
if key == nil {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
userID := aslanMongoWholeInt64(key["user"])
if day == "" || userID <= 0 || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse gold origin %s amount for %s: %w", item.originPrefix, day, err)
}
entries = append(entries, goldEntry{day: day, userID: userID, amount: roundMinorToWhole(amount)})
return nil
})
if err != nil {
return fmt.Errorf("aggregate gold origin %s: %w", item.originPrefix, err)
}
if len(entries) == 0 {
continue
}
userIDs := make([]int64, 0, len(entries))
seen := map[int64]struct{}{}
for _, entry := range entries {
if _, ok := seen[entry.userID]; ok {
continue
}
seen[entry.userID] = struct{}{}
userIDs = append(userIDs, entry.userID)
}
countryByUser, err := s.loadUserCountries(ctx, userIDs, countryFilter)
if err != nil {
return err
}
for _, entry := range entries {
countryCode := countryByUser[entry.userID]
if countryCode == "" {
continue
}
item.assign(grid.at(entry.day, countryCode), entry.amount)
}
}
return nil
}
// loadCoinFlows 汇总每日全 App 消耗/产出金币type=1 是消耗、type=0 是收入(产出)。
// 区域筛选时无法低成本把全量账变拆到国家,保持整段 nil 交由上层展示 "--"。
func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate time.Time, endDate time.Time, grid *legacyMongoGrid) error {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"sysOrigin": s.sysOrigin,
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
}}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "day", Value: s.dayExpression("$createTime")},
{Key: "type", Value: "$type"},
}},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$quantity"}}},
}}},
}
err := s.aggregateEach(ctx, aslanMongoGoldWaterCollection, pipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
if key == nil {
return nil
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
if day == "" || !grid.hasDay(day) {
return nil
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return fmt.Errorf("parse coin flow amount for %s: %w", day, err)
}
flow := grid.coinFlows[day]
if flow == nil {
flow = &legacyMongoCoinFlow{}
grid.coinFlows[day] = flow
}
if aslanMongoWholeInt64(key["type"]) == 1 {
flow.ConsumedCoin += roundMinorToWhole(amount)
} else {
flow.OutputCoin += roundMinorToWhole(amount)
}
return nil
})
if err != nil {
return fmt.Errorf("aggregate coin flows: %w", err)
}
return nil
}
// applyRetention 用注册 cohort × user_daily_active_log 推 D1/D7/D30目标观察日未到时基数保持 0
// 上层会把 0 基数转成 nil避免把“还不能观察”展示成真实 0% 留存。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
location, err := time.LoadLocation(s.statTimezone)
if err != nil {
return err
}
today := dashboardSQLDate(time.Now().In(location))
offsets := []struct {
days int
assign func(metric *legacyMongoMetric, users int64, base int64)
}{
{1, func(metric *legacyMongoMetric, users, base int64) { metric.D1Users, metric.D1Base = users, base }},
{7, func(metric *legacyMongoMetric, users, base int64) { metric.D7Users, metric.D7Base = users, base }},
{30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }},
}
for day, byCountry := range cohorts {
if !grid.hasDay(day) {
continue
}
dayTime, err := time.ParseInLocation("2006-01-02", day, location)
if err != nil {
continue
}
allIDs := []int64{}
for _, userIDs := range byCountry {
allIDs = append(allIDs, userIDs...)
}
if len(allIDs) == 0 {
continue
}
for _, offset := range offsets {
target := dashboardSQLDate(dayTime.AddDate(0, 0, offset.days))
if target > today {
continue
}
activeSet, err := s.loadActiveUserSet(ctx, target, allIDs)
if err != nil {
return err
}
for countryCode, userIDs := range byCountry {
active := int64(0)
for _, userID := range userIDs {
if _, ok := activeSet[userID]; ok {
active++
}
}
offset.assign(grid.at(day, countryCode), active, int64(len(userIDs)))
}
}
}
return nil
}
func (s *AslanMongoDashboardSource) loadActiveUserSet(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
out := map[int64]struct{}{}
for _, chunk := range aslanMongoInt64Chunks(userIDs, aslanMongoChunkSize) {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
cursor, err := s.db.Collection(aslanMongoUserActiveCollection).Find(queryCtx, bson.M{
"sysOrigin": s.sysOrigin,
"activeDate": activeDate,
"userId": bson.M{"$in": chunk},
}, options.Find().SetProjection(bson.M{"userId": 1}))
if err != nil {
cancel()
return nil, fmt.Errorf("query retention actives for %s: %w", activeDate, err)
}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(queryCtx)
cancel()
return nil, fmt.Errorf("decode retention active row for %s: %w", activeDate, err)
}
if userID := aslanMongoWholeInt64(row["userId"]); userID > 0 {
out[userID] = struct{}{}
}
}
err = cursor.Err()
_ = cursor.Close(queryCtx)
cancel()
if err != nil {
return nil, fmt.Errorf("iterate retention actives for %s: %w", activeDate, 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) {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
cursor, err := s.db.Collection(aslanMongoBankBalanceCollection).Find(queryCtx, bson.M{
"sysOrigin": s.sysOrigin,
"_id": bson.M{"$in": chunk},
}, options.Find().SetProjection(bson.M{"_id": 1, "balance": 1}))
if err != nil {
cancel()
return nil, fmt.Errorf("query user_bank_balance: %w", err)
}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(queryCtx)
cancel()
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"])
}
err = cursor.Err()
_ = cursor.Close(queryCtx)
cancel()
if err != nil {
return nil, fmt.Errorf("iterate user_bank_balance: %w", err)
}
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadTeamMemberIDs(ctx context.Context) ([]int64, error) {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
cursor, err := s.db.Collection(aslanMongoTeamMemberCollection).Find(queryCtx, 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(queryCtx)
seen := map[int64]struct{}{}
out := []int64{}
for cursor.Next(queryCtx) {
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)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
cursor, err := s.db.Collection(aslanMongoUserProfileCollection).Find(queryCtx, filter, options.Find().SetProjection(bson.M{"_id": 1, "countryCode": 1}))
if err != nil {
cancel()
return nil, fmt.Errorf("query user_run_profile countries: %w", err)
}
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(queryCtx)
cancel()
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
}
}
err = cursor.Err()
_ = cursor.Close(queryCtx)
cancel()
if err != nil {
return nil, fmt.Errorf("iterate user_run_profile countries: %w", err)
}
}
return out, nil
}
func (s *AslanMongoDashboardSource) aggregateEach(ctx context.Context, collectionName string, pipeline mongo.Pipeline, handle func(bson.M) error) error {
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
cursor, err := s.db.Collection(collectionName).Aggregate(queryCtx, pipeline, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return err
}
defer cursor.Close(queryCtx)
for cursor.Next(queryCtx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return err
}
if err := handle(row); err != nil {
return err
}
}
return cursor.Err()
}
func (s *AslanMongoDashboardSource) dayExpression(field string) bson.D {
return bson.D{{Key: "$dateToString", Value: bson.D{
{Key: "format", Value: "%Y-%m-%d"},
{Key: "date", Value: field},
{Key: "timezone", Value: s.statTimezone},
}}}
}
func aslanMongoExcludeFreightGoldRecharge(match bson.M) {
// FREIGHT_GOLD 成功单会落入 atyou_wallet.user_freight_balance_running_water 进货流水;
// legacy 版本商品字段不统一顶层类型、products.code/name/content 均出现过),必须全部排除避免双算。
for _, field := range aslanMongoFreightCommodityFields {
match[field] = bson.M{"$nin": aslanMongoFreightCommodityTypes}
}
}
func dashboardDateNumber(day time.Time) string {
return day.Format("20060102")
}
// aslanMongoDocument 把聚合结果里的嵌套文档统一转成 bson.M
// driver 默认把嵌套文档解码成 bson.D直接断言 bson.M 会静默丢行(表现为全零无报错)。
func aslanMongoDocument(value any) bson.M {
switch typed := value.(type) {
case bson.M:
return typed
case bson.D:
out := make(bson.M, len(typed))
for _, element := range typed {
out[element.Key] = element.Value
}
return out
case map[string]any:
return bson.M(typed)
default:
return nil
}
}
func aslanMongoDayCountry(value any) (string, string) {
key := aslanMongoDocument(value)
if key == nil {
return "", ""
}
day := strings.TrimSpace(fmt.Sprint(key["day"]))
if day == "" || day == "<nil>" {
return "", ""
}
return day, aslanMongoCountryCode(key["country"])
}
func aslanMongoInt64Values(value any) []int64 {
values, ok := value.(bson.A)
if !ok {
if raw, ok := value.([]any); ok {
values = raw
}
}
out := make([]int64, 0, len(values))
seen := map[int64]struct{}{}
for _, item := range values {
parsed := aslanMongoWholeInt64(item)
if parsed <= 0 {
continue
}
if _, ok := seen[parsed]; ok {
continue
}
seen[parsed] = struct{}{}
out = append(out, parsed)
}
return out
}
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 aslanMongoCountryCode(value any) string {
code := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value)))
if code == "" || code == "<NIL>" || code == "NULL" {
return ""
}
return code
}
func quoteMySQLIdentifier(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
func aslanMongoDistinctValueCount(value any) int64 {
values, ok := value.(bson.A)
if !ok {
if raw, ok := value.([]any); ok {
values = raw
}
}
seen := map[string]struct{}{}
for _, item := range values {
key := strings.TrimSpace(fmt.Sprint(item))
if key == "" || key == "<nil>" || key == "0" {
continue
}
seen[key] = struct{}{}
}
return int64(len(seen))
}
func aslanMongoDecimalMinor(value any) (int64, error) {
switch typed := value.(type) {
case nil:
return 0, nil
case bson.Decimal128:
return decimalTextToMinor(typed.String())
case string:
return decimalTextToMinor(typed)
case int32:
return int64(typed) * 100, nil
case int64:
return typed * 100, nil
case int:
return int64(typed) * 100, nil
case float32:
return decimalTextToMinor(strconv.FormatFloat(float64(typed), 'f', -1, 32))
case float64:
return decimalTextToMinor(strconv.FormatFloat(typed, 'f', -1, 64))
default:
return decimalTextToMinor(fmt.Sprint(value))
}
}
func aslanMongoWholeInt64(value any) int64 {
switch typed := value.(type) {
case nil:
return 0
case int64:
return typed
case int32:
return int64(typed)
case int:
return int64(typed)
case float64:
return int64(typed)
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
case bson.Decimal128:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed.String()), 10, 64)
return parsed
default:
parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
return parsed
}
}
func aslanMongoInt64Chunks(values []int64, size int) [][]int64 {
if size <= 0 {
size = aslanMongoChunkSize
}
out := [][]int64{}
for start := 0; start < len(values); start += size {
end := start + size
if end > len(values) {
end = len(values)
}
out = append(out, values[start:end])
}
return out
}
// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径;
// 游戏流水的 GoldOrigin code 枚举过杂(大量带后缀的游戏 code 与奖励类混在一起),未确认前不硬凑。
var aslanMongoUnavailableMetricFields = []string{
"coin_seller_stock_coin",
"new_dealer_recharge_usd_minor",
"game_turnover",
"game_payout",
"game_players",
"game_profit",
"game_profit_rate",
"game_payout_delta_rate",
"game_turnover_delta_rate",
"game_profit_delta_rate",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"super_lucky_gift_profit",
"super_lucky_gift_profit_rate",
"coin_total",
"coin_total_delta_rate",
"platform_grant_coin",
"manual_grant_coin",
"salary_exchange_usd_minor",
"salary_transfer_usd_minor",
"salary_transfer_coin",
"mic_online_ms",
"avg_mic_online_ms",
}
func applyAslanMongoUnavailableFields(row map[string]any) {
for _, field := range aslanMongoUnavailableMetricFields {
row[field] = nil
}
}
var aslanMongoLegacyMetricFields = []string{
"coin_seller_recharge_usd_minor",
}
func applyAslanMongoLegacyUnavailableFields(row map[string]any, legacyRecharge bool) {
if legacyRecharge {
return
}
for _, field := range aslanMongoLegacyMetricFields {
row[field] = nil
}
}
// 金币流水集合缺失时必须显示 "--" 的字段;置 0 会让幸运礼物利润率假性 100%、币商/金币口径失真。
var aslanMongoGoldWaterMetricFields = []string{
"lucky_gift_payout",
"lucky_gift_payout_rate",
"lucky_gift_profit",
"lucky_gift_profit_rate",
"lucky_gift_anchor_share",
"coin_seller_transfer_coin",
"consumed_coin",
"output_coin",
"consume_output_ratio",
"consume_output_delta",
}
func applyAslanMongoGoldWaterUnavailable(row map[string]any) {
for _, field := range aslanMongoGoldWaterMetricFields {
row[field] = nil
}
}
func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map[string]any {
goldWaterSources := []map[string]any{
{"field": "lucky_gift_payout", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=LUCKY_GIFT_GOLD_REWARD*"},
{"field": "coin_seller_transfer_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=SELLER_AGENT*"},
{"field": "consumed_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 type=1仅全 App 口径,不拆国家)"},
{"field": "output_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 type=0仅全 App 口径,不拆国家)"},
{"field": "consume_output_ratio", "available": true, "source": "consumed_coin / output_coin仅全 App 口径)"},
}
if !goldWater {
goldWaterSources = []map[string]any{
{"field": "lucky_gift_payout", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
{"field": "consumed_coin", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
{"field": "output_coin", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
{"field": "consume_output_ratio", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
}
}
legacySources := []map[string]any{
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"},
}
rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId排除 FREIGHT_GOLD国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底)"
rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd排除 FREIGHT_GOLD国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底)"
mifaPaySource := "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE排除 FREIGHT_GOLD全部三方渠道合计"
newUserRechargeSource := "likei Mongo 当日注册 cohort × in_app_purchase_details排除 FREIGHT_GOLD"
if !legacyRecharge {
legacySources = []map[string]any{
{"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "未配置 legacy_mysql_dsn无法读取 Aslan 金币代理发货充值金额"},
}
} else {
rechargeUsersSource += " + legacy MySQL order_other_recharge/user_gold_coin_recharge/user_freight_balance_running_water distinct user_id"
rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"
mifaPaySource += " + legacy MySQL order_other_recharge amount"
newUserRechargeSource += "/legacy 其他充值/legacy 币商充值"
}
baseSources := []map[string]any{
{"field": "new_users", "available": true, "source": "likei Mongo user_run_profile createTime/countryCode"},
{"field": "active_users", "available": true, "source": "likei Mongo user_daily_active_log activeDate/registerCountryCode"},
{"field": "paid_users", "available": true, "source": rechargeUsersSource},
{"field": "recharge_users", "available": true, "source": rechargeUsersSource},
{"field": "recharge_usd_minor", "available": true, "source": rechargeAmountSource},
{"field": "google_recharge_usd_minor", "available": true, "source": "likei Mongo in_app_purchase_details factory.factoryCode=GOOGLE"},
{"field": "mifapay_recharge_usd_minor", "available": true, "source": mifaPaySource},
{"field": "new_user_recharge_usd_minor", "available": true, "source": newUserRechargeSource},
{"field": "gift_coin_spent", "available": true, "source": "likei Mongo gift_give_running_water giftValue.actualAmount含幸运礼物"},
{"field": "lucky_gift_turnover", "available": true, "source": "likei Mongo gift_give_running_water luckyGift=true"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "likei Mongo 未确认币商进货金币口径"},
{"field": "game_turnover", "available": false, "missing_reason": "likei GoldOrigin 游戏 code 枚举过多且与奖励类混排,未确认口径前不聚合"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"},
{"field": "coin_total", "available": false, "missing_reason": "likei Mongo 没有可按国家低成本汇总的金币余额快照"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认平台发放金币来源枚举"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认人工发放金币来源枚举"},
{"field": "salary_usd_minor", "available": true, "source": "likei Mongo team_member + user_bank_balance + user_run_profile 当前快照"},
{"field": "mic_online_ms", "available": false, "missing_reason": "likei 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": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+1"},
{"field": "retention.day7_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+7"},
{"field": "retention.day30_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+30"},
}
out := append(baseSources, legacySources...)
return append(out, goldWaterSources...)
}