package main import ( "context" "database/sql" "errors" "flag" "fmt" "log" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" "hyapp-admin-server/internal/cache" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/integration/activityclient" dingtalkclient "hyapp-admin-server/internal/integration/dingtalk" "hyapp-admin-server/internal/integration/gameclient" "hyapp-admin-server/internal/integration/googleorders" "hyapp-admin-server/internal/integration/luckygiftadmin" "hyapp-admin-server/internal/integration/robotclient" "hyapp-admin-server/internal/integration/roomclient" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/walletclient" jobrunner "hyapp-admin-server/internal/job" "hyapp-admin-server/internal/migration" achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig" adminusermodule "hyapp-admin-server/internal/modules/adminuser" agencyopeningmodule "hyapp-admin-server/internal/modules/agencyopening" appconfigmodule "hyapp-admin-server/internal/modules/appconfig" appregistrymodule "hyapp-admin-server/internal/modules/appregistry" appusermodule "hyapp-admin-server/internal/modules/appuser" auditmodule "hyapp-admin-server/internal/modules/audit" authmodule "hyapp-admin-server/internal/modules/auth" coinledgermodule "hyapp-admin-server/internal/modules/coinledger" countryregionmodule "hyapp-admin-server/internal/modules/countryregion" cprelationmodule "hyapp-admin-server/internal/modules/cprelation" cpweeklyrankmodule "hyapp-admin-server/internal/modules/cpweeklyrank" cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward" dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dashboardmodule "hyapp-admin-server/internal/modules/dashboard" databimodule "hyapp-admin-server/internal/modules/databi" financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication" financeordermodule "hyapp-admin-server/internal/modules/financeorder" financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice" gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement" giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond" healthmodule "hyapp-admin-server/internal/modules/health" hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy" hostorgmodule "hyapp-admin-server/internal/modules/hostorg" hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement" hostwithdrawalmodule "hyapp-admin-server/internal/modules/hostwithdrawal" inviteactivityrewardmodule "hyapp-admin-server/internal/modules/inviteactivityreward" jobmodule "hyapp-admin-server/internal/modules/job" levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig" luckygiftmodule "hyapp-admin-server/internal/modules/luckygift" menumodule "hyapp-admin-server/internal/modules/menu" opscentermodule "hyapp-admin-server/internal/modules/opscenter" paymentmodule "hyapp-admin-server/internal/modules/payment" policyconfigmodule "hyapp-admin-server/internal/modules/policyconfig" prettyidmodule "hyapp-admin-server/internal/modules/prettyid" rbacmodule "hyapp-admin-server/internal/modules/rbac" redpacketmodule "hyapp-admin-server/internal/modules/redpacket" regionblockmodule "hyapp-admin-server/internal/modules/regionblock" registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward" reportmodule "hyapp-admin-server/internal/modules/report" resourcemodule "hyapp-admin-server/internal/modules/resource" riskconfigmodule "hyapp-admin-server/internal/modules/riskconfig" roomadminmodule "hyapp-admin-server/internal/modules/roomadmin" roomrocketmodule "hyapp-admin-server/internal/modules/roomrocket" roomturnoverrewardmodule "hyapp-admin-server/internal/modules/roomturnoverreward" searchmodule "hyapp-admin-server/internal/modules/search" sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin" teammodule "hyapp-admin-server/internal/modules/team" teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy" teamsalarysettlementmodule "hyapp-admin-server/internal/modules/teamsalarysettlement" uploadmodule "hyapp-admin-server/internal/modules/upload" userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard" vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig" weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar" wheelmodule "hyapp-admin-server/internal/modules/wheel" "hyapp-admin-server/internal/platform/logging" "hyapp-admin-server/internal/platform/tencentcos" "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/router" "hyapp-admin-server/internal/service" mysqlDriver "github.com/go-sql-driver/mysql" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/readpref" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" walletv1 "hyapp.local/api/proto/wallet/v1" ) func main() { configPath := flag.String("config", "configs/config.yaml", "path to admin-server config") runBootstrap := flag.Bool("bootstrap", false, "run configured bootstrap seed before starting") flag.Parse() cfg, err := config.Load(*configPath) if err != nil { log.Fatal(err) } if err := logging.Init(cfg.ServiceName, cfg.Environment, cfg.NodeID, cfg.Log); err != nil { log.Fatal(err) } if err := ensureDatabase(cfg.MySQLDSN); err != nil { fatalRuntime("ensure_mysql_database_failed", err) } dbLogger := logger.New(log.New(logging.StdWriter(), "", 0), logger.Config{ LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, }) db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{ Logger: dbLogger, NowFunc: func() time.Time { return time.Now().UTC() }, }) if err != nil { fatalRuntime("connect_mysql_failed", err) } sqlDB, err := db.DB() if err != nil { fatalRuntime("mysql_db_handle_failed", err) } if cfg.Migrations.Enabled { if err := migration.ApplyWithOptions(context.Background(), sqlDB, cfg.Migrations.Dir, migration.Options{ AllowChecksumRepair: cfg.Migrations.AllowChecksumRepair, }); err != nil { fatalRuntime("apply_migrations_failed", err) } } redisClient, err := cache.NewRedis(context.Background(), cfg.Redis) if err != nil { fatalRuntime("connect_redis_failed", err) } if redisClient != nil { defer redisClient.Close() } userDB, err := sql.Open("mysql", cfg.UserMySQLDSN) if err != nil { fatalRuntime("connect_user_mysql_failed", err) } if err := userDB.PingContext(context.Background()); err != nil { fatalRuntime("ping_user_mysql_failed", err) } defer userDB.Close() walletDB, err := sql.Open("mysql", cfg.WalletMySQLDSN) if err != nil { fatalRuntime("connect_wallet_mysql_failed", err) } if err := walletDB.PingContext(context.Background()); err != nil { fatalRuntime("ping_wallet_mysql_failed", err) } defer walletDB.Close() var robotProfileSource gamemanagementmodule.RobotProfileSource if strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) != "" { robotProfileDB, err := sql.Open("mysql", cfg.RobotProfileSource.MySQLDSN) if err != nil { fatalRuntime("connect_robot_profile_mysql_failed", err) } robotProfileCtx, robotProfileCancel := context.WithTimeout(context.Background(), cfg.RobotProfileSource.RequestTimeout) if err := robotProfileDB.PingContext(robotProfileCtx); err != nil { robotProfileCancel() fatalRuntime("ping_robot_profile_mysql_failed", err) } robotProfileCancel() defer robotProfileDB.Close() robotProfileSource = gamemanagementmodule.NewLikeiRobotProfileSource(robotProfileDB) } moneyRegionSources, closeMoneyRegionSources, err := connectMoneyRegionSources(context.Background(), cfg.MoneyRegionSources) if err != nil { fatalRuntime("connect_money_region_sources_failed", err) } defer closeMoneyRegionSources(context.Background()) dashboardExternalSources, closeDashboardExternalSources, err := connectDashboardExternalSources(context.Background(), cfg.DashboardExternalSources, dashboardRegionResolvers(moneyRegionSources)...) if err != nil { fatalRuntime("connect_dashboard_external_sources_failed", err) } defer closeDashboardExternalSources() financeBillSources, closeFinanceBillSources, err := connectFinanceBillSources(context.Background(), cfg.FinanceBillSources, moneyRegionSources, dashboardExternalSourceIndex(dashboardExternalSources)) if err != nil { closeDashboardExternalSources() fatalRuntime("connect_finance_bill_sources_failed", err) } defer closeFinanceBillSources(context.Background()) legacyCoinSellerRechargeWriters, closeLegacyCoinSellerRechargeWriters, err := connectLegacyCoinSellerRechargeWriters(context.Background(), cfg.LegacyCoinSellerRechargeSources) if err != nil { fatalRuntime("connect_legacy_coin_seller_recharge_sources_failed", err) } defer closeLegacyCoinSellerRechargeWriters(context.Background()) store := repository.New(db) if cfg.MySQLAutoMigrate { if err := store.AutoMigrate(); err != nil { fatalRuntime("auto_migrate_failed", err) } } // legacy 账单源的谷歌实付同步依赖 admin 库缓存表,store 建好后再注入;凭证异常只降级该 App,不阻断启动。 wireLegacyGooglePaidSync(financeBillSources, cfg.FinanceBillSources, store) if cfg.FinanceGooglePaidSync.Enabled { // Polling is process-owned rather than HTTP-owned: startup immediately drains one batch, then // every 15 seconds prioritizes newest orders while gradually repairing the complete backlog. googlePaidSyncWorker := paymentmodule.NewLegacyGooglePaidSyncWorker(financeBillSources, store, cfg.FinanceGooglePaidSync) googlePaidSyncWorker.Start() defer googlePaidSyncWorker.Close() } if *runBootstrap || cfg.Bootstrap.Enabled { if err := store.Seed(cfg); err != nil { fatalRuntime("seed_database_failed", err) } } userConn, err := dialBackendGRPC(cfg.UserService.Addr) if err != nil { fatalRuntime("connect_user_service_failed", err) } defer userConn.Close() walletConn, err := dialBackendGRPC(cfg.WalletService.Addr) if err != nil { fatalRuntime("connect_wallet_service_failed", err) } defer walletConn.Close() roomConn, err := dialBackendGRPC(cfg.RoomService.Addr) if err != nil { fatalRuntime("connect_room_service_failed", err) } defer roomConn.Close() roomClient := roomclient.NewGRPC(roomConn) robotConn, err := dialBackendGRPC(cfg.RobotService.Addr) if err != nil { fatalRuntime("connect_robot_service_failed", err) } defer robotConn.Close() robotClient := robotclient.NewGRPC(robotConn) activityConn, err := dialBackendGRPC(cfg.ActivityService.Addr) if err != nil { fatalRuntime("connect_activity_service_failed", err) } defer activityConn.Close() luckyGiftConn, err := dialBackendGRPC(cfg.LuckyGiftService.Addr) if err != nil { fatalRuntime("connect_lucky_gift_service_failed", err) } defer luckyGiftConn.Close() gameConn, err := dialBackendGRPC(cfg.GameService.Addr) if err != nil { fatalRuntime("connect_game_service_failed", err) } defer gameConn.Close() auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL) auditHandler := auditmodule.New(store) countryRegionHandler := countryregionmodule.New(userclient.NewGRPC(userConn), userDB, store, roomClient, cfg, auditHandler) appUserHandler := appusermodule.New( userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler, appusermodule.WithWalletClient(walletclient.NewGRPC(walletConn)), appusermodule.WithExportJobs(store, cfg), ) var runner *jobrunner.Runner var jobStatus healthmodule.JobStatusProvider if cfg.Jobs.Enabled { runner = jobrunner.NewRunner(store, redisClient, cfg, jobrunner.WithHandler(countryregionmodule.CountryCodeRenameJobType, countryRegionHandler.Service().HandleCountryCodeRenameJob), jobrunner.WithArtifactHandler(appusermodule.AppUserExportJobType, appUserHandler.HandleExportJob), ) runner.Start() defer runner.Close() jobStatus = runner } teamSalarySettlementHandler := teamsalarysettlementmodule.New(sqlDB, walletDB, userDB, auditHandler) if cfg.Jobs.Enabled { teamSalaryRunner := teamsalarysettlementmodule.NewAutoRunner(teamSalarySettlementHandler.Service(), "lalu", cfg.NodeID) teamSalaryRunner.Start() defer teamSalaryRunner.Close() } var objectUploader uploadmodule.ObjectUploader if cfg.TencentCOS.Enabled { uploader, err := tencentcos.NewUploader(tencentcos.Config{ SecretID: cfg.TencentCOS.SecretID, SecretKey: cfg.TencentCOS.SecretKey, Bucket: cfg.TencentCOS.BucketName, Region: cfg.TencentCOS.Region, AccessURL: cfg.TencentCOS.AccessURL, }, nil) if err != nil { fatalRuntime("create_tencent_cos_uploader_failed", err) } objectUploader = uploader } var financeApplicationOptions []financeapplicationmodule.Option if cfg.FinanceNotifications.DingTalk.Enabled && strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) != "" { client, err := dingtalkclient.New(dingtalkclient.Config{ WebhookURL: cfg.FinanceNotifications.DingTalk.WebhookURL, Secret: cfg.FinanceNotifications.DingTalk.Secret, AtMobiles: cfg.FinanceNotifications.DingTalk.AtMobiles, AtAll: cfg.FinanceNotifications.DingTalk.AtAll, RequestTimeout: cfg.FinanceNotifications.DingTalk.RequestTimeout, }, nil) if err != nil { fatalRuntime("create_finance_dingtalk_client_failed", err) } financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithApplicationNotifier(financeapplicationmodule.NewDingTalkNotifier(client))) } financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)), financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)), ) // 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。 dashboardService := dashboardmodule.NewService( store, cfg, userclient.NewGRPC(userConn), // 用户画像从 userDB 读取已完成真实用户的总量、性别和最近一次成功登录版本; // 当天新增与日活继续走 statistics-service,避免重算事件去重口径。 dashboardmodule.WithUserDB(userDB), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...), ) appRegistryService := appregistrymodule.NewService(userDB) // Finance 与 Social BI 必须复用同一 Handler 实例,确保普通充值、已核验币商订单和 H5 币商充值完全同口径。 paymentHandler := paymentmodule.New( walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...), paymentmodule.WithRechargeBillSources(financeBillSources...), ) databiService := databimodule.NewService( store, dashboardService, appRegistryService, userclient.NewGRPC(userConn), databimodule.WithFinanceRechargeOverviewSource(paymentHandler), databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...), databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...), ) luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler) handlers := router.Handlers{ Audit: auditHandler, Auth: authmodule.New(store, auth, auditHandler, cfg), AdminUser: adminusermodule.New(store, cfg, auditHandler), AgencyOpening: agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler), AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), AppConfig: appconfigmodule.New(store, auditHandler), AppRegistry: appregistrymodule.New(userDB), AppUser: appUserHandler, CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler), CountryRegion: countryRegionHandler, CPRelation: cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler), CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler), CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler), Dashboard: dashboardmodule.NewWithService(dashboardService), Databi: databimodule.New(databiService, store, auditHandler), FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...), FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)), FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), Game: gamemanagementmodule.New( gameclient.NewGRPC(gameConn), userclient.NewGRPC(userConn), auditHandler, gamemanagementmodule.WithRobotProfileSource(robotProfileSource), gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)), ), GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler), Health: healthmodule.New(store, redisClient, jobStatus), HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler), HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler), HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB), InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Job: jobmodule.New(store, cfg, auditHandler), LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler), Menu: menumodule.New(store, auditHandler), OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler), Payment: paymentHandler, PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler), PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler), RBAC: rbacmodule.New(store, auditHandler), RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler), Report: reportmodule.New(userDB, roomClient), RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler), Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler), RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler), RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler), RoomRocket: roomrocketmodule.New(roomClient, auditHandler), RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Search: searchmodule.New(store), SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Team: teammodule.New(store, auditHandler), TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler), TeamSalarySettlement: teamSalarySettlementHandler, Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient), VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler), WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler), Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), } engine := router.New(cfg, auth, handlers) server := &http.Server{ Addr: cfg.HTTPAddr, Handler: engine, ReadHeaderTimeout: 5 * time.Second, } go func() { slog.Info("service_listening", "addr", cfg.HTTPAddr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { fatalRuntime("listen_failed", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { slog.Error("shutdown_failed", "error", err.Error()) } } func fatalRuntime(msg string, err error) { slog.Error(msg, "error", err.Error()) os.Exit(1) } func dialBackendGRPC(addr string) (*grpc.ClientConn, error) { // admin-server 在广州跨区访问 Saudi 内网 CLB;gRPC 长连接如果长时间空闲, // TCP 层可能留下半开连接。客户端 keepalive 主动探活并触发重连,避免请求卡到 HTTP 超时。 return grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 30 * time.Second, Timeout: 10 * time.Second, PermitWithoutStream: true, }), ) } func connectMoneyRegionSources(ctx context.Context, configs []config.MoneyRegionSourceConfig) ([]paymentmodule.MoneyRegionSource, func(context.Context), error) { sources := []paymentmodule.MoneyRegionSource{} clients := []*mongo.Client{} cleanup := func(closeCtx context.Context) { for _, client := range clients { if client != nil { _ = client.Disconnect(closeCtx) } } } for _, sourceConfig := range configs { if !sourceConfig.Enabled { continue } connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout) // 财务范围页面依赖 legacy likei 区域目录;启用后启动阶段先 ping,避免运行时静默漏掉 Yumi/Aslan 区域。 client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout)) if err != nil { cancel() cleanup(ctx) return nil, nil, err } if err := client.Ping(connectCtx, readpref.Primary()); err != nil { cancel() _ = client.Disconnect(ctx) cleanup(ctx) return nil, nil, err } cancel() clients = append(clients, client) source := paymentmodule.NewMongoMoneyRegionSource(client.Database(sourceConfig.MongoDatabase), sourceConfig) if source != nil { sources = append(sources, source) } } return sources, cleanup, nil } // connectFinanceBillSources 连接 legacy App 的账单 Mongo;启动阶段 ping 失败直接退出,避免财务明细静默漏掉 Yumi 账单。 func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBillSourceConfig, regionSources []paymentmodule.MoneyRegionSource, dashboardSources map[string]dashboardmodule.ExternalDashboardSource) ([]paymentmodule.RechargeBillSource, func(context.Context), error) { sources := []paymentmodule.RechargeBillSource{} clients := []*mongo.Client{} cleanup := func(closeCtx context.Context) { for _, client := range clients { if client != nil { _ = client.Disconnect(closeCtx) } } } for _, sourceConfig := range configs { if !sourceConfig.Enabled { continue } connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout) client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout)) if err != nil { cancel() cleanup(ctx) return nil, nil, err } if err := client.Ping(connectCtx, readpref.Primary()); err != nil { cancel() _ = client.Disconnect(ctx) cleanup(ctx) return nil, nil, err } cancel() clients = append(clients, client) source := paymentmodule.NewMongoRechargeBillSource(client.Database(sourceConfig.MongoDatabase), sourceConfig, regionSources...) if source != nil { dashboardSource := dashboardSources[source.AppCode()] if dashboardSource == nil { cleanup(ctx) return nil, nil, fmt.Errorf("finance bill source %s requires matching dashboard_external_sources entry for coin seller stats", source.AppCode()) } source.SetDashboardCoinSellerSource(dashboardSource) sources = append(sources, source) } } return sources, cleanup, nil } // connectLegacyCoinSellerRechargeWriters 只连接显式启用的 legacy 可写账套; // dashboard/账单源是只读报表连接,不能复用来写 Aslan/Yumi 钱包流水。 func connectLegacyCoinSellerRechargeWriters(ctx context.Context, configs []config.LegacyCoinSellerRechargeSourceConfig) ([]financeordermodule.LegacyCoinSellerRechargeWriter, func(context.Context), error) { writers := []financeordermodule.LegacyCoinSellerRechargeWriter{} cleanup := func(context.Context) { for _, writer := range writers { if writer != nil { _ = writer.Close() } } } for _, sourceConfig := range configs { if !sourceConfig.Enabled { continue } appDB, err := sql.Open("mysql", sourceConfig.AppMySQLDSN) if err != nil { cleanup(ctx) return nil, nil, err } walletDB, err := sql.Open("mysql", sourceConfig.WalletMySQLDSN) if err != nil { _ = appDB.Close() cleanup(ctx) return nil, nil, err } timeout := sourceConfig.RequestTimeout if timeout <= 0 { timeout = 5 * time.Second } connectCtx, cancel := context.WithTimeout(ctx, timeout) if err := appDB.PingContext(connectCtx); err != nil { cancel() _ = appDB.Close() _ = walletDB.Close() cleanup(ctx) return nil, nil, err } if err := walletDB.PingContext(connectCtx); err != nil { cancel() _ = appDB.Close() _ = walletDB.Close() cleanup(ctx) return nil, nil, err } cancel() writers = append(writers, financeordermodule.NewMySQLLegacyCoinSellerRechargeWriter(sourceConfig, appDB, walletDB)) } return writers, cleanup, nil } func dashboardExternalSourceIndex(sources []dashboardmodule.ExternalDashboardSource) map[string]dashboardmodule.ExternalDashboardSource { index := map[string]dashboardmodule.ExternalDashboardSource{} for _, source := range sources { if source == nil { continue } appCode := strings.ToLower(strings.TrimSpace(source.AppCode())) if appCode == "" { continue } index[appCode] = source } return index } // wireLegacyGooglePaidSync 为配置了 Play Console 包名与服务账号的 legacy 账单源开启谷歌实付同步。 func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, configs []config.FinanceBillSourceConfig, store *repository.Store) { configsByApp := map[string]config.FinanceBillSourceConfig{} for _, sourceConfig := range configs { configsByApp[strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))] = sourceConfig } for _, source := range sources { mongoSource, ok := source.(*paymentmodule.MongoRechargeBillSource) if !ok { continue } sourceConfig := configsByApp[source.AppCode()] if sourceConfig.GooglePackageName == "" || (sourceConfig.GoogleServiceAccountJSON == "" && sourceConfig.GoogleServiceAccountFile == "") { continue } client, err := googleorders.New(googleorders.Config{ PackageName: sourceConfig.GooglePackageName, ServiceAccountJSON: sourceConfig.GoogleServiceAccountJSON, ServiceAccountFile: sourceConfig.GoogleServiceAccountFile, }) if err != nil { slog.Error("legacy_google_paid_sync_disabled", "app_code", source.AppCode(), "error", err.Error()) continue } mongoSource.SetGooglePaidSync(store, client) slog.Info("legacy_google_paid_sync_enabled", "app_code", source.AppCode(), "package_name", sourceConfig.GooglePackageName) } } // databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录; // 同一 App 配多个源(如 cdc MySQL + Mongo 补充)时只取一次。 func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimodule.LegacyAppDescriptor { out := []databimodule.LegacyAppDescriptor{} seen := map[string]struct{}{} for _, sourceConfig := range configs { if !sourceConfig.Enabled { continue } appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode)) if appCode == "" { continue } if _, ok := seen[appCode]; ok { continue } seen[appCode] = struct{}{} out = append(out, databimodule.LegacyAppDescriptor{AppCode: appCode, AppName: strings.TrimSpace(sourceConfig.AppName)}) } return out } func databiLegacyRegionCatalogs(sources []paymentmodule.MoneyRegionSource) []databimodule.LegacyRegionCatalogSource { out := []databimodule.LegacyRegionCatalogSource{} for _, source := range sources { if catalog, ok := source.(databimodule.LegacyRegionCatalogSource); ok && catalog != nil { out = append(out, catalog) } } return out } func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver { resolvers := []dashboardmodule.ExternalDashboardRegionResolver{} for _, source := range sources { resolver, ok := source.(dashboardmodule.ExternalDashboardRegionResolver) if ok && resolver != nil { resolvers = append(resolvers, resolver) } } return resolvers } 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 { continue } switch strings.ToLower(strings.TrimSpace(sourceConfig.SourceType)) { case "aslan_http": // Aslan 大屏数据由对方 console 免鉴权接口返回;这里不建立数据库连接,避免 admin 绕过 Aslan 服务端口径。 source, err := dashboardmodule.NewAslanHTTPDashboardSource(sourceConfig, regionResolvers...) if err != nil { cleanup() return nil, nil, err } if source != nil { 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() var legacyDB *sql.DB if strings.TrimSpace(sourceConfig.LegacyMySQLDSN) != "" { // 金币代理发货充值金额只存在 legacy RDS;它是 Aslan Mongo 大屏的补充事实源,失败应在启动期暴露。 legacyDB, err = sql.Open("mysql", sourceConfig.LegacyMySQLDSN) if err != nil { _ = client.Disconnect(ctx) cleanup() return nil, nil, fmt.Errorf("open dashboard legacy mysql for app %s: %w", sourceConfig.AppCode, err) } legacyCtx, legacyCancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout) if err := legacyDB.PingContext(legacyCtx); err != nil { legacyCancel() _ = legacyDB.Close() _ = client.Disconnect(ctx) cleanup() return nil, nil, fmt.Errorf("ping dashboard legacy mysql for app %s: %w", sourceConfig.AppCode, err) } legacyCancel() } source, err := dashboardmodule.NewAslanMongoDashboardSourceWithLegacyDB(client.Database(sourceConfig.MongoDatabase), legacyDB, sourceConfig, regionResolvers...) if err != nil { if legacyDB != nil { _ = legacyDB.Close() } _ = client.Disconnect(ctx) cleanup() return nil, nil, err } if source == nil { if legacyDB != nil { _ = legacyDB.Close() } _ = client.Disconnect(ctx) continue } if legacyDB != nil { dbs = append(dbs, legacyDB) } mongoClients = append(mongoClients, client) sources = append(sources, source) continue case "", "mysql": default: cleanup() return nil, nil, errors.New("unsupported dashboard external source type: " + sourceConfig.SourceType) } db, err := sql.Open("mysql", sourceConfig.MySQLDSN) if err != nil { cleanup() return nil, nil, err } pingCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout) // Yumi 大屏使用外接项目 dashboard-cdc-worker 的只读聚合库;启动期先 ping,避免 admin 正常启动但 Yumi 查询静默回退到错误事实域。 if err := db.PingContext(pingCtx); err != nil { cancel() _ = db.Close() cleanup() return nil, nil, err } cancel() source := dashboardmodule.NewMySQLExternalDashboardSource(db, sourceConfig, regionResolvers...) if source == nil { _ = db.Close() continue } dbs = append(dbs, db) sources = append(sources, source) } return sources, cleanup, nil } func ensureDatabase(dsn string) error { parsed, err := mysqlDriver.ParseDSN(dsn) if err != nil { return err } if parsed.DBName == "" { return nil } dbName := parsed.DBName parsed.DBName = "" db, err := sql.Open("mysql", parsed.FormatDSN()) if err != nil { return err } defer db.Close() escaped := strings.ReplaceAll(dbName, "`", "``") _, err = db.Exec("CREATE DATABASE IF NOT EXISTS `" + escaped + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") return err }