2026-07-06 11:10:19 +08:00

732 lines
30 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 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/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"
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"
paymentmodule "hyapp-admin-server/internal/modules/payment"
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())
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 *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()
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)
var runner *jobrunner.Runner
var jobStatus healthmodule.JobStatusProvider
if cfg.Jobs.Enabled {
runner = jobrunner.NewRunner(store, redisClient, cfg)
runner.Start()
defer runner.Close()
jobStatus = runner
}
auditHandler := auditmodule.New(store)
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), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...))
databiService := databimodule.NewService(
store,
dashboardService,
appregistrymodule.NewService(userDB),
userclient.NewGRPC(userConn),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
)
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: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
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...),
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),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...), paymentmodule.WithRechargeBillSources(financeBillSources...)),
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 内网 CLBgRPC 长连接如果长时间空闲,
// 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
}
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
}