阿斯兰数据

This commit is contained in:
zhx 2026-07-02 19:23:36 +08:00
parent 7f2387ba95
commit c6ec9fe6aa
10 changed files with 898 additions and 15 deletions

View File

@ -446,12 +446,20 @@ func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashb
func connectDashboardExternalSources(ctx context.Context, configs []config.DashboardExternalSourceConfig, regionResolvers ...dashboardmodule.ExternalDashboardRegionResolver) ([]dashboardmodule.ExternalDashboardSource, func(), error) {
sources := []dashboardmodule.ExternalDashboardSource{}
dbs := []*sql.DB{}
mongoClients := []*mongo.Client{}
cleanup := func() {
for _, db := range dbs {
if db != nil {
_ = db.Close()
}
}
closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, client := range mongoClients {
if client != nil {
_ = client.Disconnect(closeCtx)
}
}
}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
@ -469,6 +477,35 @@ func connectDashboardExternalSources(ctx context.Context, configs []config.Dashb
sources = append(sources, source)
}
continue
case "aslan_mongo":
// Aslan 当前要求 admin-server 直接读外接项目 Mongo 聚合事实;启动期先 ping
// 避免前端选择 Aslan 后才暴露连接或凭证错误。
client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
if err != nil {
cleanup()
return nil, nil, err
}
pingCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
if err := client.Ping(pingCtx, readpref.PrimaryPreferred()); err != nil {
cancel()
_ = client.Disconnect(ctx)
cleanup()
return nil, nil, err
}
cancel()
source, err := dashboardmodule.NewAslanMongoDashboardSource(client.Database(sourceConfig.MongoDatabase), sourceConfig, regionResolvers...)
if err != nil {
_ = client.Disconnect(ctx)
cleanup()
return nil, nil, err
}
if source == nil {
_ = client.Disconnect(ctx)
continue
}
mongoClients = append(mongoClients, client)
sources = append(sources, source)
continue
case "", "mysql":
default:
cleanup()

View File

@ -23,6 +23,14 @@ money_region_sources:
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
- enabled: true
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: "mongodb://mongouser:REPLACE_ME@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin"
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
mysql_auto_migrate: false
migrations:
enabled: true
@ -78,11 +86,12 @@ dashboard_external_sources:
stat_timezone: "Asia/Riyadh"
request_timeout: "5s"
- enabled: true
type: "aslan_http"
type: "aslan_mongo"
app_code: "aslan"
app_name: "Aslan"
base_url: "https://console.atuchat.com"
overview_path: "/console/datav/aslan/region-country/statistics"
sys_origin: "ATYOU"
mongo_uri: "mongodb://mongouser:REPLACE_ME@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin"
mongo_database: "test"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
finance_notifications:

View File

@ -23,6 +23,14 @@ money_region_sources:
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
- enabled: false
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
mysql_auto_migrate: true
migrations:
enabled: true
@ -77,11 +85,12 @@ dashboard_external_sources:
stat_timezone: "Asia/Riyadh"
request_timeout: "5s"
- enabled: false
type: "aslan_http"
type: "aslan_mongo"
app_code: "aslan"
app_name: "Aslan"
base_url: "https://console.atuchat.com"
overview_path: "/console/datav/aslan/region-country/statistics"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "test"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
finance_notifications:

View File

@ -95,6 +95,8 @@ type DashboardExternalSourceConfig struct {
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MySQLDSN string `yaml:"mysql_dsn"`
MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"`
BaseURL string `yaml:"base_url"`
OverviewPath string `yaml:"overview_path"`
StatTimezone string `yaml:"stat_timezone"`
@ -262,11 +264,11 @@ func Default() Config {
},
{
Enabled: false,
SourceType: "aslan_http",
SourceType: "aslan_mongo",
AppCode: "aslan",
AppName: "Aslan",
BaseURL: "https://console.atuchat.com",
OverviewPath: "/console/datav/aslan/region-country/statistics",
SysOrigin: "ATYOU",
MongoDatabase: "test",
StatTimezone: "Asia/Shanghai",
RequestTimeout: 5 * time.Second,
},
@ -411,21 +413,28 @@ func (cfg *Config) Normalize() {
source.AppName = strings.TrimSpace(source.AppName)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MySQLDSN = strings.TrimSpace(source.MySQLDSN)
source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/")
source.OverviewPath = "/" + strings.TrimLeft(strings.TrimSpace(source.OverviewPath), "/")
if source.OverviewPath == "/" {
source.OverviewPath = ""
}
if source.SourceType == "" {
if source.BaseURL != "" || source.OverviewPath != "" {
if source.MongoURI != "" || source.MongoDatabase != "" {
source.SourceType = "aslan_mongo"
} else if source.BaseURL != "" || source.OverviewPath != "" {
source.SourceType = "aslan_http"
} else {
source.SourceType = "mysql"
}
}
if source.SourceType == "aslan_mongo" && source.MongoDatabase == "" {
source.MongoDatabase = "test"
}
source.StatTimezone = strings.TrimSpace(source.StatTimezone)
if source.StatTimezone == "" {
if source.SourceType == "aslan_http" {
if source.SourceType == "aslan_http" || source.SourceType == "aslan_mongo" {
source.StatTimezone = "Asia/Shanghai"
} else {
source.StatTimezone = "Asia/Riyadh"
@ -435,6 +444,7 @@ func (cfg *Config) Normalize() {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyDashboardExternalSourceEnvOverrides()
cfg.applyFinanceNotificationEnvOverrides()
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
@ -465,6 +475,7 @@ func (cfg *Config) Normalize() {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyMoneyRegionSourceEnvOverrides()
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -605,8 +616,18 @@ func (cfg Config) Validate() error {
if strings.TrimSpace(source.OverviewPath) == "" {
return fmt.Errorf("dashboard_external_sources[%d].overview_path is required when aslan_http source is enabled", index)
}
case "aslan_mongo":
if strings.TrimSpace(source.SysOrigin) == "" {
return fmt.Errorf("dashboard_external_sources[%d].sys_origin is required when aslan_mongo source is enabled", index)
}
if strings.TrimSpace(source.MongoURI) == "" {
return fmt.Errorf("dashboard_external_sources[%d].mongo_uri is required when aslan_mongo source is enabled", index)
}
if strings.TrimSpace(source.MongoDatabase) == "" {
return fmt.Errorf("dashboard_external_sources[%d].mongo_database is required when aslan_mongo source is enabled", index)
}
default:
return fmt.Errorf("dashboard_external_sources[%d].type must be one of mysql, aslan_http", index)
return fmt.Errorf("dashboard_external_sources[%d].type must be one of mysql, aslan_http, aslan_mongo", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
@ -699,6 +720,60 @@ func (cfg *Config) applyFinanceNotificationEnvOverrides() {
}
}
func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_DASHBOARD_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// Mongo URI 含线上密码;仓库 YAML 只放占位,真实值由运行环境注入后覆盖。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_DASHBOARD_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
}
}
func (cfg *Config) applyMoneyRegionSourceEnvOverrides() {
for index := range cfg.MoneyRegionSources {
source := &cfg.MoneyRegionSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_MONEY_REGION_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// legacy 区域源和 Aslan 大屏可以共享同一个 Mongo 集群;只在运行环境注入真实密码。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_MONEY_REGION_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
}
}
func envAppKey(appCode string) string {
appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" {
return ""
}
return strings.NewReplacer("-", "_", ".", "_").Replace(appCode)
}
func firstEnv(keys ...string) string {
for _, key := range keys {
if value := os.Getenv(key); strings.TrimSpace(value) != "" {

View File

@ -71,3 +71,40 @@ func TestFinanceDingTalkEnvOverride(t *testing.T) {
t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles)
}
}
func TestAslanMongoEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")
t.Setenv("HYAPP_ADMIN_ASLAN_MONEY_REGION_MONGO_DATABASE", "tarab_region")
cfg := Default()
cfg.Normalize()
var dashboardSource DashboardExternalSourceConfig
for _, source := range cfg.DashboardExternalSources {
if source.AppCode == "aslan" {
dashboardSource = source
break
}
}
if dashboardSource.MongoURI != "mongodb://mongouser:secret@example:27017/test?authSource=admin" {
t.Fatalf("dashboard mongo uri mismatch: %q", dashboardSource.MongoURI)
}
if dashboardSource.MongoDatabase != "test_dashboard" {
t.Fatalf("dashboard mongo database mismatch: %q", dashboardSource.MongoDatabase)
}
var regionSource MoneyRegionSourceConfig
for _, source := range cfg.MoneyRegionSources {
if source.AppCode == "aslan" {
regionSource = source
break
}
}
if regionSource.MongoURI != "mongodb://mongouser:secret@example:27017/test?authSource=admin" {
t.Fatalf("region mongo uri mismatch: %q", regionSource.MongoURI)
}
if regionSource.MongoDatabase != "tarab_region" {
t.Fatalf("region mongo database mismatch: %q", regionSource.MongoDatabase)
}
}

View File

@ -63,6 +63,7 @@ type aslanDashboardMetric struct {
RechargeUSDMinor int64
ActiveUsers int64
NewUsers int64
PaidUsers int64
CoinTotal int64
SalaryUSDMinor int64
}
@ -407,6 +408,7 @@ func aslanCombinedMetric(flow aslanDashboardMetric, snapshot aslanDashboardMetri
metric := externalDashboardMetric{
NewUsers: flow.NewUsers,
ActiveUsers: flow.ActiveUsers,
PaidUsers: flow.PaidUsers,
UserRechargeUSDMinor: flow.RechargeUSDMinor,
RechargeUSDMinor: flow.RechargeUSDMinor,
CoinTotal: int64Ptr(snapshot.CoinTotal),
@ -426,6 +428,7 @@ func (m *aslanDashboardMetric) addFlow(item aslanDashboardMetric) {
m.RechargeUSDMinor += item.RechargeUSDMinor
m.ActiveUsers += item.ActiveUsers
m.NewUsers += item.NewUsers
m.PaidUsers += item.PaidUsers
}
func (m *aslanDashboardMetric) addSnapshot(item aslanDashboardMetric) {

View File

@ -0,0 +1,690 @@
package dashboard
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const (
aslanMongoInAppPurchaseCollection = "in_app_purchase_details"
aslanMongoUserProfileCollection = "user_run_profile"
aslanMongoUserActiveCollection = "user_daily_active_log"
aslanMongoTeamMemberCollection = "team_member"
aslanMongoBankBalanceCollection = "user_bank_balance"
aslanMongoChunkSize = 800
)
type AslanMongoDashboardSource struct {
db *mongo.Database
appCode string
appName string
sysOrigin string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
}
type aslanMongoRechargeMetric struct {
AmountUSDMinor int64
Users int64
}
// NewAslanMongoDashboardSource 直接读取 Aslan 外接项目 Mongo 里的已存在事实集合。
// 这里只做查询聚合和字段映射,不把 Aslan 数据复制到 hyapp statistics-service也不回退旧 console HTTP 接口。
func NewAslanMongoDashboardSource(database *mongo.Database, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
if database == nil || !sourceConfig.Enabled {
return nil, nil
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
if appCode == "" || sysOrigin == "" {
return nil, nil
}
statTimezone := strings.TrimSpace(sourceConfig.StatTimezone)
if statTimezone == "" {
statTimezone = "Asia/Shanghai"
}
if _, err := time.LoadLocation(statTimezone); err != nil {
return nil, fmt.Errorf("load aslan mongo storage timezone %s: %w", statTimezone, err)
}
timeout := sourceConfig.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return &AslanMongoDashboardSource{
db: database,
appCode: appCode,
appName: strings.TrimSpace(sourceConfig.AppName),
sysOrigin: sysOrigin,
statTimezone: statTimezone,
requestTimeout: timeout,
regionResolvers: compactExternalDashboardRegionResolvers(regionResolvers),
}, nil
}
func (s *AslanMongoDashboardSource) AppCode() string {
if s == nil {
return ""
}
return s.appCode
}
func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("aslan mongo dashboard source is not configured")
}
countryFilter, err := s.countryFilter(ctx, query)
if err != nil {
return nil, err
}
requestTimezone := strings.TrimSpace(query.StatTZ)
if requestTimezone == "" {
requestTimezone = s.statTimezone
}
requestLocation, err := time.LoadLocation(requestTimezone)
if err != nil {
return nil, fmt.Errorf("load aslan mongo request timezone %s: %w", requestTimezone, err)
}
startDate, endDate, startMS, endMS := externalDashboardDateRange(query, requestLocation)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
current, err := s.loadRange(queryCtx, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
previous, err := s.loadRange(queryCtx, previousStart, startDate, countryFilter)
if err != nil {
return nil, err
}
response := current.Total.toMap()
response["app_code"] = s.appCode
response["app_name"] = firstExternalDashboardValue(s.appName, s.appCode)
response["sys_origin"] = s.sysOrigin
response["stat_tz"] = s.statTimezone
response["request_stat_tz"] = requestTimezone
response["start_ms"] = startMS
response["end_ms"] = endMS
response["daily_series"] = current.DailySeries
response["country_breakdown"] = current.CountryBreakdown
response["daily_country_breakdown"] = current.DailyCountryBreakdown
response["retention"] = aslanRetentionMap(current.Total)
response["report_metric_sources"] = aslanMongoDashboardMetricSources()
response["updated_at_ms"] = time.Now().UTC().UnixMilli()
applyExternalDashboardDeltas(response, current.Total, previous.Total)
response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor))
applyAslanMongoUnavailableFields(response)
return response, nil
}
func (s *AslanMongoDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) {
if query.CountryID > 0 {
return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s does not support numeric country_id filter", s.appCode)
}
if query.RegionID <= 0 {
return externalDashboardCountryFilter{}, nil
}
for _, resolver := range s.regionResolvers {
codes, handled, err := resolver.CountryCodesForRegion(ctx, s.appCode, query.RegionID)
if err != nil {
return externalDashboardCountryFilter{}, err
}
if !handled {
continue
}
return externalDashboardCountryFilter{
Applied: true,
RegionID: query.RegionID,
Codes: normalizeExternalCountryCodes(codes),
}, nil
}
// Aslan Mongo 集合只有国家码,没有 hyapp 的数字 region_id缺 legacy 区域映射时失败,避免误把全量数据当成某个区域返回。
return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}
func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (aslanRangeOverview, error) {
totalFlow := aslanDashboardMetric{}
totalSnapshot := aslanDashboardMetric{}
dailySeries := []map[string]any{}
dailyCountries := []map[string]any{}
countryAggregates := map[string]*aslanCountryAccumulator{}
// user_bank_balance 是余额快照而不是日报表;这里按当前快照填充每一天,区间汇总只保留最后一天快照,避免把余额按天累加。
salarySnapshot, err := s.loadHostSalaryBalanceByCountry(ctx, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
nextDay := day.AddDate(0, 0, 1)
registers, err := s.loadRegisteredUsersByCountry(ctx, day, nextDay, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
activeUsers, err := s.loadActiveUsersByCountry(ctx, day, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
recharges, err := s.loadRechargeByCountry(ctx, day, nextDay, countryFilter)
if err != nil {
return aslanRangeOverview{}, err
}
dayFlow := aslanDashboardMetric{}
daySnapshot := aslanDashboardMetric{}
dayCountryAggregates := map[string]*aslanCountryAccumulator{}
for _, countryCode := range aslanMongoCountryKeys(registers, activeUsers, recharges, salarySnapshot) {
metric := aslanDashboardMetric{
NewUsers: registers[countryCode],
ActiveUsers: activeUsers[countryCode],
PaidUsers: recharges[countryCode].Users,
RechargeUSDMinor: recharges[countryCode].AmountUSDMinor,
SalaryUSDMinor: salarySnapshot[countryCode],
}
dayFlow.addFlow(metric)
daySnapshot.addSnapshot(metric)
totalFlow.addFlow(metric)
country := dayCountryAggregates[countryCode]
if country == nil {
country = &aslanCountryAccumulator{CountryCode: countryCode, CountryName: countryCode, RegionID: countryFilter.RegionID}
dayCountryAggregates[countryCode] = country
}
country.Flow.addFlow(metric)
country.Snapshot.addSnapshot(metric)
}
totalSnapshot = daySnapshot
dayMetric := aslanCombinedMetric(dayFlow, daySnapshot)
dayRow := dayMetric.toMap()
dayLabel := dashboardSQLDate(day)
dayRow["label"] = dayLabel
dayRow["stat_day"] = dayLabel
applyAslanMongoUnavailableFields(dayRow)
dailySeries = append(dailySeries, dayRow)
mergeAslanCountryDay(countryAggregates, dayCountryAggregates)
for _, row := range aslanDailyCountryRows(dayLabel, dayCountryAggregates) {
applyAslanMongoUnavailableFields(row)
dailyCountries = append(dailyCountries, row)
}
}
countryRows := aslanCountryRows(countryAggregates)
for _, row := range countryRows {
applyAslanMongoUnavailableFields(row)
}
return aslanRangeOverview{
Total: aslanCombinedMetric(totalFlow, totalSnapshot),
DailySeries: dailySeries,
CountryBreakdown: countryRows,
DailyCountryBreakdown: dailyCountries,
}, nil
}
func (s *AslanMongoDashboardSource) loadRegisteredUsersByCountry(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]int64, error) {
match := bson.M{
"originSys": s.sysOrigin,
"del": bson.M{"$ne": true},
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"countryCode": bson.M{"$exists": true, "$ne": ""},
"accountStatus": bson.M{"$ne": "DELETED"},
}
aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
out, err := s.aggregateUniqueUsersByCountry(ctx, aslanMongoUserProfileCollection, match, "countryCode", "_id")
if err != nil {
return nil, fmt.Errorf("query aslan mongo registered users for %s: %w", dashboardSQLDate(startDate), err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadActiveUsersByCountry(ctx context.Context, day time.Time, countryFilter externalDashboardCountryFilter) (map[string]int64, error) {
match := bson.M{
"sysOrigin": s.sysOrigin,
"activeDate": dashboardSQLDate(day),
"registerCountryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(match, "registerCountryCode", countryFilter)
out, err := s.aggregateUniqueUsersByCountry(ctx, aslanMongoUserActiveCollection, match, "registerCountryCode", "userId")
if err != nil {
return nil, fmt.Errorf("query aslan mongo active users for %s: %w", dashboardSQLDate(day), err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadRechargeByCountry(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]aslanMongoRechargeMetric, error) {
match := bson.M{
"sysOrigin": s.sysOrigin,
"status": "SUCCESS",
"trialPeriod": bson.M{"$ne": true},
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"countryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$countryCode"},
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
// Aslan 原 Top 充值统计按 acceptUserId 聚合createUser 只在 acceptUserId 缺失的旧单据上兜底。
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}}}},
}}},
}
cursor, err := s.db.Collection(aslanMongoInAppPurchaseCollection).Aggregate(ctx, pipeline, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return nil, fmt.Errorf("aggregate in_app_purchase_details: %w", err)
}
defer cursor.Close(ctx)
out := map[string]aslanMongoRechargeMetric{}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return nil, fmt.Errorf("decode in_app_purchase_details aggregate: %w", err)
}
countryCode := aslanMongoCountryCode(row["_id"])
if countryCode == "" {
continue
}
amount, err := aslanMongoDecimalMinor(row["amount"])
if err != nil {
return nil, fmt.Errorf("parse recharge amount for country %s: %w", countryCode, err)
}
out[countryCode] = aslanMongoRechargeMetric{
AmountUSDMinor: amount,
Users: aslanMongoDistinctValueCount(row["users"]),
}
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate in_app_purchase_details aggregate: %w", err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) aggregateUniqueUsersByCountry(ctx context.Context, collectionName string, match bson.M, countryField string, userField string) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: match}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$" + countryField},
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$" + userField}}},
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
}}},
}
cursor, err := s.db.Collection(collectionName).Aggregate(ctx, pipeline, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
out := map[string]int64{}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return nil, err
}
countryCode := aslanMongoCountryCode(row["_id"])
if countryCode == "" {
continue
}
count := aslanMongoDistinctValueCount(row["users"])
if count == 0 {
count = aslanMongoWholeInt64(row["count"])
}
out[countryCode] = count
}
if err := cursor.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadHostSalaryBalanceByCountry(ctx context.Context, countryFilter externalDashboardCountryFilter) (map[string]int64, error) {
memberIDs, err := s.loadTeamMemberIDs(ctx)
if err != nil {
return nil, err
}
if len(memberIDs) == 0 {
return map[string]int64{}, nil
}
countryByUser, err := s.loadUserCountries(ctx, memberIDs, countryFilter)
if err != nil {
return nil, err
}
if len(countryByUser) == 0 {
return map[string]int64{}, nil
}
out := map[string]int64{}
for _, chunk := range aslanMongoInt64Chunks(memberIDs, aslanMongoChunkSize) {
cursor, err := s.db.Collection(aslanMongoBankBalanceCollection).Find(ctx, bson.M{
"sysOrigin": s.sysOrigin,
"_id": bson.M{"$in": chunk},
}, options.Find().SetProjection(bson.M{"_id": 1, "balance": 1}))
if err != nil {
return nil, fmt.Errorf("query user_bank_balance: %w", err)
}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(ctx)
return nil, fmt.Errorf("decode user_bank_balance: %w", err)
}
userID := aslanMongoWholeInt64(row["_id"])
countryCode := countryByUser[userID]
if countryCode == "" {
continue
}
// user_bank_balance.balance 是 PennyAmount 的 long 值admin 响应也用 USD minor所以不用再做美元小数换算。
out[countryCode] += aslanMongoWholeInt64(row["balance"])
}
if err := cursor.Err(); err != nil {
_ = cursor.Close(ctx)
return nil, fmt.Errorf("iterate user_bank_balance: %w", err)
}
_ = cursor.Close(ctx)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadTeamMemberIDs(ctx context.Context) ([]int64, error) {
cursor, err := s.db.Collection(aslanMongoTeamMemberCollection).Find(ctx, bson.M{
"sysOrigin": s.sysOrigin,
"role": bson.M{"$in": bson.A{"MEMBER", "ADMIN"}},
}, options.Find().SetProjection(bson.M{"memberId": 1}))
if err != nil {
return nil, fmt.Errorf("query team_member: %w", err)
}
defer cursor.Close(ctx)
seen := map[int64]struct{}{}
out := []int64{}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
return nil, fmt.Errorf("decode team_member: %w", err)
}
userID := aslanMongoWholeInt64(row["memberId"])
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
out = append(out, userID)
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate team_member: %w", err)
}
return out, nil
}
func (s *AslanMongoDashboardSource) loadUserCountries(ctx context.Context, userIDs []int64, countryFilter externalDashboardCountryFilter) (map[int64]string, error) {
out := map[int64]string{}
for _, chunk := range aslanMongoInt64Chunks(userIDs, aslanMongoChunkSize) {
filter := bson.M{
"_id": bson.M{"$in": chunk},
"originSys": s.sysOrigin,
"del": bson.M{"$ne": true},
"countryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoApplyCountryFilter(filter, "countryCode", countryFilter)
cursor, err := s.db.Collection(aslanMongoUserProfileCollection).Find(ctx, filter, options.Find().SetProjection(bson.M{"_id": 1, "countryCode": 1}))
if err != nil {
return nil, fmt.Errorf("query user_run_profile countries: %w", err)
}
for cursor.Next(ctx) {
row := bson.M{}
if err := cursor.Decode(&row); err != nil {
_ = cursor.Close(ctx)
return nil, fmt.Errorf("decode user_run_profile country: %w", err)
}
userID := aslanMongoWholeInt64(row["_id"])
countryCode := aslanMongoCountryCode(row["countryCode"])
if userID > 0 && countryCode != "" {
out[userID] = countryCode
}
}
if err := cursor.Err(); err != nil {
_ = cursor.Close(ctx)
return nil, fmt.Errorf("iterate user_run_profile countries: %w", err)
}
_ = cursor.Close(ctx)
}
return out, nil
}
func aslanMongoApplyCountryFilter(match bson.M, field string, countryFilter externalDashboardCountryFilter) {
if !countryFilter.Applied {
return
}
if len(countryFilter.Codes) == 0 {
match[field] = bson.M{"$in": bson.A{"__NO_COUNTRY__"}}
return
}
codes := bson.A{}
for _, code := range countryFilter.Codes {
if normalized := aslanMongoCountryCode(code); normalized != "" {
codes = append(codes, normalized)
}
}
if len(codes) == 0 {
codes = bson.A{"__NO_COUNTRY__"}
}
match[field] = bson.M{"$in": codes}
}
func aslanMongoCountryKeys(registers map[string]int64, activeUsers map[string]int64, recharges map[string]aslanMongoRechargeMetric, salarySnapshot map[string]int64) []string {
seen := map[string]struct{}{}
for countryCode := range registers {
seen[countryCode] = struct{}{}
}
for countryCode := range activeUsers {
seen[countryCode] = struct{}{}
}
for countryCode := range recharges {
seen[countryCode] = struct{}{}
}
for countryCode := range salarySnapshot {
seen[countryCode] = struct{}{}
}
keys := make([]string, 0, len(seen))
for countryCode := range seen {
keys = append(keys, countryCode)
}
sort.Strings(keys)
return keys
}
func aslanMongoCountryCode(value any) string {
code := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value)))
if code == "" || code == "<NIL>" || code == "NULL" {
return ""
}
return code
}
func aslanMongoDistinctValueCount(value any) int64 {
values, ok := value.(bson.A)
if !ok {
if raw, ok := value.([]any); ok {
values = raw
}
}
seen := map[string]struct{}{}
for _, item := range values {
key := strings.TrimSpace(fmt.Sprint(item))
if key == "" || key == "<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
}
var aslanMongoUnavailableMetricFields = []string{
"new_user_recharge_usd_minor",
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"coin_seller_transfer_coin",
"google_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"game_turnover",
"game_payout",
"game_players",
"game_profit",
"game_profit_rate",
"game_payout_delta_rate",
"lucky_gift_turnover",
"lucky_gift_payout",
"lucky_gift_payout_rate",
"lucky_gift_payers",
"lucky_gift_anchor_share",
"lucky_gift_profit",
"lucky_gift_profit_rate",
"lucky_gift_payout_delta_rate",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"super_lucky_gift_profit",
"super_lucky_gift_profit_rate",
"room_lucky_gift_turnover",
"gift_coin_spent",
"coin_total",
"coin_total_delta_rate",
"consumed_coin",
"output_coin",
"consume_output_ratio",
"platform_grant_coin",
"manual_grant_coin",
"new_dealer_recharge_usd_minor",
"salary_exchange_usd_minor",
"salary_transfer_coin",
"salary_transfer_usd_minor",
"mic_online_ms",
"avg_mic_online_ms",
"gift_coin_spent_delta_rate",
"game_turnover_delta_rate",
"game_profit_delta_rate",
"lucky_gift_turnover_delta_rate",
"lucky_gift_profit_delta_rate",
"d1_retention_rate",
"d7_retention_rate",
"d30_retention_rate",
}
func applyAslanMongoUnavailableFields(row map[string]any) {
for _, field := range aslanMongoUnavailableMetricFields {
row[field] = nil
}
}
func aslanMongoDashboardMetricSources() []map[string]any {
return []map[string]any{
{"field": "new_users", "available": true, "source": "Aslan Mongo user_run_profile createTime/countryCode"},
{"field": "active_users", "available": true, "source": "Aslan Mongo user_daily_active_log activeDate/registerCountryCode"},
{"field": "paid_users", "available": true, "source": "Aslan Mongo in_app_purchase_details SUCCESS distinct acceptUserId"},
{"field": "recharge_users", "available": true, "source": "Aslan Mongo in_app_purchase_details SUCCESS distinct acceptUserId"},
{"field": "recharge_usd_minor", "available": true, "source": "Aslan Mongo in_app_purchase_details amountUsd"},
{"field": "new_user_recharge_usd_minor", "available": false, "missing_reason": "Mongo 中未确认新用户充值 cohort 聚合口径"},
{"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "Mongo 中未确认币商充值金额聚合口径"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "Mongo 中未确认币商充值金币聚合口径"},
{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "Mongo 中未确认币商出货金币聚合口径"},
{"field": "google_recharge_usd_minor", "available": false, "missing_reason": "in_app_purchase_details 当前接入只做总充值,未拆 Google/三方渠道"},
{"field": "mifapay_recharge_usd_minor", "available": false, "missing_reason": "in_app_purchase_details 当前接入只做总充值,未拆 Google/三方渠道"},
{"field": "game_turnover", "available": false, "missing_reason": "Mongo 中未确认游戏流水和返奖集合"},
{"field": "lucky_gift_turnover", "available": false, "missing_reason": "Mongo 中未确认幸运礼物流水和返奖集合"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "Mongo 中未确认超级幸运礼物流水和返奖集合"},
{"field": "gift_coin_spent", "available": false, "missing_reason": "Mongo 中未确认礼物流水总口径"},
{"field": "coin_total", "available": false, "missing_reason": "Mongo 中未发现可按国家低成本汇总的当前金币余额快照"},
{"field": "consumed_coin", "available": false, "missing_reason": "Mongo 中未确认消耗金币总口径"},
{"field": "output_coin", "available": false, "missing_reason": "Mongo 中未确认产出金币总口径"},
{"field": "consume_output_ratio", "available": false, "missing_reason": "缺少消耗金币和产出金币总口径"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "Mongo 中未确认平台发放金币来源枚举"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "Mongo 中未确认人工发放金币来源枚举"},
{"field": "salary_usd_minor", "available": true, "source": "Aslan Mongo team_member + user_bank_balance + user_run_profile current snapshot"},
{"field": "salary_transfer_coin", "available": false, "missing_reason": "Mongo 中未确认工资兑换金币流水口径"},
{"field": "avg_mic_online_ms", "available": false, "missing_reason": "Mongo 中未确认麦上时长集合"},
{"field": "mic_online_ms", "available": false, "missing_reason": "Mongo 中未确认麦上时长集合"},
{"field": "arpu_usd_minor", "available": true, "source": "recharge_usd_minor / active_users"},
{"field": "arppu_usd_minor", "available": true, "source": "recharge_usd_minor / paid_users"},
{"field": "paid_conversion_rate", "available": true, "source": "paid_users / active_users"},
{"field": "recharge_conversion_rate", "available": true, "source": "recharge_users / active_users"},
{"field": "retention.day1_rate", "available": false, "missing_reason": "Mongo 中未确认留存 cohort 活跃事实"},
{"field": "retention.day7_rate", "available": false, "missing_reason": "Mongo 中未确认留存 cohort 活跃事实"},
{"field": "retention.day30_rate", "available": false, "missing_reason": "Mongo 中未确认留存 cohort 活跃事实"},
}
}

View File

@ -1959,6 +1959,17 @@ func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 {
t.Fatalf("seller transfer should write one recharge record, got %d", got)
}
bills, total, err := svc.ListRechargeBills(context.Background(), ledger.ListRechargeBillsQuery{
AppCode: appcode.Default,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListRechargeBills after seller transfer failed: %v", err)
}
if total != 0 || len(bills) != 0 {
t.Fatalf("finance recharge bills must hide coin seller user transfers: total=%d bills=%+v", total, bills)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 {
t.Fatalf("seller transfer should write two balance events, one transfer event and one recharge event, got %d", got)
}

View File

@ -102,8 +102,10 @@ func normalizeRechargeBillsQuery(query ledger.ListRechargeBillsQuery) ledger.Lis
}
func rechargeBillsWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any) {
where := `WHERE rr.app_code = ?`
args := []any{query.AppCode}
// 财务系统的 APP 充值详情只展示用户主动向平台充值的账单;币商给用户转普通金币虽然会落充值事实供活动/统计消费,
// 但它本质是币商出货,不应混入财务充值明细和分页总数。
where := `WHERE rr.app_code = ? AND wt.biz_type <> ?`
args := []any{query.AppCode, bizTypeCoinSellerTransfer}
if query.UserID > 0 {
where += ` AND rr.user_id = ?`
args = append(args, query.UserID)

View File

@ -16,7 +16,17 @@ func TestRechargeBillsWhereUsesExclusiveEndMs(t *testing.T) {
if !strings.Contains(where, "rr.created_at_ms >= ?") || !strings.Contains(where, "rr.created_at_ms < ?") {
t.Fatalf("recharge bill range must use [start_ms, end_ms): where=%s", where)
}
if len(args) != 3 || args[1] != int64(1778284800000) || args[2] != int64(1778371200000) {
if len(args) != 4 || args[2] != int64(1778284800000) || args[3] != int64(1778371200000) {
t.Fatalf("range args mismatch: %#v", args)
}
}
func TestRechargeBillsWhereExcludesCoinSellerTransfers(t *testing.T) {
where, args := rechargeBillsWhereSQL(ledger.ListRechargeBillsQuery{AppCode: "lalu"})
if !strings.Contains(where, "wt.biz_type <> ?") {
t.Fatalf("finance recharge bills must exclude coin seller user transfers: where=%s", where)
}
if len(args) < 2 || args[1] != bizTypeCoinSellerTransfer {
t.Fatalf("coin seller transfer exclusion arg mismatch: %#v", args)
}
}