package config import ( "errors" "fmt" "os" "strings" "time" "hyapp-admin-server/internal/platform/logging" "gopkg.in/yaml.v3" ) type Config struct { ServiceName string `yaml:"service_name"` NodeID string `yaml:"node_id"` Environment string `yaml:"environment"` Log logging.Config `yaml:"log"` HTTPAddr string `yaml:"http_addr"` MySQLDSN string `yaml:"mysql_dsn"` UserMySQLDSN string `yaml:"user_mysql_dsn"` WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"` MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"` FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` Migrations MigrationConfig `yaml:"migrations"` JWTSecret string `yaml:"jwt_secret"` AccessTokenTTL time.Duration `yaml:"access_token_ttl"` RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"` CORSAllowedOrigins []string `yaml:"cors_allowed_origins"` RefreshCookieSecure bool `yaml:"refresh_cookie_secure"` RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"` Bootstrap BootstrapConfig `yaml:"bootstrap"` BootstrapPassword string `yaml:"bootstrap_password"` UserService UserServiceConfig `yaml:"user_service"` TencentCOS TencentCOSConfig `yaml:"tencent-cos"` Redis RedisConfig `yaml:"redis"` Jobs JobsConfig `yaml:"jobs"` WalletService WalletServiceConfig `yaml:"wallet_service"` RoomService RoomServiceConfig `yaml:"room_service"` RobotService RobotServiceConfig `yaml:"robot_service"` ActivityService ActivityServiceConfig `yaml:"activity_service"` GameService GameServiceConfig `yaml:"game_service"` StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"` } type MigrationConfig struct { Enabled bool `yaml:"enabled"` Dir string `yaml:"dir"` AllowChecksumRepair bool `yaml:"allow_checksum_repair"` } type UserServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` } type WalletServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` } type RoomServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` } type RobotServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` } type ActivityServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` } type GameServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` } type StatisticsServiceConfig struct { BaseURL string `yaml:"base_url"` RequestTimeout time.Duration `yaml:"request_timeout"` } type DashboardExternalSourceConfig struct { Enabled bool `yaml:"enabled"` SourceType string `yaml:"type"` AppCode string `yaml:"app_code"` AppName string `yaml:"app_name"` SysOrigin string `yaml:"sys_origin"` MySQLDSN string `yaml:"mysql_dsn"` LegacyMySQLDSN string `yaml:"legacy_mysql_dsn"` LegacyWalletDatabase string `yaml:"legacy_wallet_database"` 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"` RequestTimeout time.Duration `yaml:"request_timeout"` } // LegacyCoinSellerRechargeSourceConfig 是 Aslan/Yumi 等独立账套的币商进货写入源。 // 它与 dashboard_external_sources 分离,避免把只读报表连接误当成可写钱包连接。 type LegacyCoinSellerRechargeSourceConfig struct { Enabled bool `yaml:"enabled"` AppCode string `yaml:"app_code"` AppName string `yaml:"app_name"` SysOrigin string `yaml:"sys_origin"` AppMySQLDSN string `yaml:"app_mysql_dsn"` WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` RequestTimeout time.Duration `yaml:"request_timeout"` } type FinanceNotificationsConfig struct { DingTalk DingTalkRobotConfig `yaml:"dingtalk"` } type DingTalkRobotConfig struct { Enabled bool `yaml:"enabled"` WebhookURL string `yaml:"webhook_url"` Secret string `yaml:"secret"` AtMobiles []string `yaml:"at_mobiles"` AtAll bool `yaml:"at_all"` RequestTimeout time.Duration `yaml:"request_timeout"` } type RobotProfileSourceConfig struct { MySQLDSN string `yaml:"mysql_dsn"` RequestTimeout time.Duration `yaml:"request_timeout"` } type MoneyRegionSourceConfig struct { Enabled bool `yaml:"enabled"` AppCode string `yaml:"app_code"` AppName string `yaml:"app_name"` SysOrigin string `yaml:"sys_origin"` MongoURI string `yaml:"mongo_uri"` MongoDatabase string `yaml:"mongo_database"` MongoCollection string `yaml:"mongo_collection"` RequestTimeout time.Duration `yaml:"request_timeout"` } // FinanceBillSourceConfig 描述 legacy App(Yumi/Aslan 等 likei 平台)的充值账单外部 Mongo 源; // 财务系统 APP 充值详情按 app_code 命中后直接读 in_app_purchase_details,不经过 wallet-service。 type FinanceBillSourceConfig struct { Enabled bool `yaml:"enabled"` AppCode string `yaml:"app_code"` AppName string `yaml:"app_name"` SysOrigin string `yaml:"sys_origin"` MongoURI string `yaml:"mongo_uri"` MongoDatabase string `yaml:"mongo_database"` MongoCollection string `yaml:"mongo_collection"` RequestTimeout time.Duration `yaml:"request_timeout"` // Google 实付同步(可选):配置该 App 在 Play Console 的包名与服务账号后, // 财务页“查询谷歌实付”按钮即可对 legacy 谷歌账单拉取实付币种/总额/税/净收入。 // 服务账号需要对应 Play Console 的“查看财务数据”权限;JSON 优先于文件路径。 GooglePackageName string `yaml:"google_package_name"` GoogleServiceAccountJSON string `yaml:"google_service_account_json"` GoogleServiceAccountFile string `yaml:"google_service_account_file"` } type TencentCOSConfig struct { Enabled bool `yaml:"enabled"` SecretID string `yaml:"secret-id"` SecretKey string `yaml:"secret-key"` BucketName string `yaml:"bucket-name"` Region string `yaml:"region"` AccessURL string `yaml:"access-url"` ObjectPrefix string `yaml:"object-prefix"` } type BootstrapConfig struct { Enabled bool `yaml:"enabled"` SeedDemoData bool `yaml:"seed_demo_data"` Username string `yaml:"username"` Name string `yaml:"name"` Team string `yaml:"team"` Password string `yaml:"password"` } type RedisConfig struct { Enabled bool `yaml:"enabled"` Addr string `yaml:"addr"` Password string `yaml:"password"` DB int `yaml:"db"` KeyPrefix string `yaml:"key_prefix"` } type JobsConfig struct { Enabled bool `yaml:"enabled"` WorkerInterval time.Duration `yaml:"worker_interval"` LeaseTTL time.Duration `yaml:"lease_ttl"` MaxAttempts int `yaml:"max_attempts"` ArtifactDir string `yaml:"artifact_dir"` } func Default() Config { return Config{ ServiceName: "admin-server", NodeID: "admin-local", Environment: "local", Log: logging.DefaultConfig(), HTTPAddr: ":13100", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", RobotProfileSource: RobotProfileSourceConfig{ RequestTimeout: 5 * time.Second, }, MoneyRegionSources: []MoneyRegionSourceConfig{ { Enabled: false, AppCode: "yumi", AppName: "Yumi", SysOrigin: "LIKEI", MongoDatabase: "tarab_all", MongoCollection: "sys_region_config", RequestTimeout: 5 * time.Second, }, { Enabled: false, AppCode: "aslan", AppName: "Aslan", SysOrigin: "ATYOU", MongoDatabase: "tarab_all", MongoCollection: "sys_region_config", RequestTimeout: 5 * time.Second, }, }, FinanceBillSources: []FinanceBillSourceConfig{ { Enabled: false, AppCode: "yumi", AppName: "Yumi", SysOrigin: "LIKEI", MongoDatabase: "tarab_all", MongoCollection: "in_app_purchase_details", RequestTimeout: 5 * time.Second, }, { Enabled: false, AppCode: "aslan", AppName: "Aslan", // Aslan(likei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。 SysOrigin: "ATYOU", MongoDatabase: "atyou", MongoCollection: "in_app_purchase_details", RequestTimeout: 5 * time.Second, }, }, MySQLAutoMigrate: true, Migrations: MigrationConfig{ Enabled: true, Dir: "migrations", AllowChecksumRepair: true, }, JWTSecret: "dev-shared-secret", AccessTokenTTL: 12 * time.Hour, RefreshTokenTTL: 7 * 24 * time.Hour, CORSAllowedOrigins: []string{ "http://localhost:7001", "http://127.0.0.1:7001", }, RefreshCookieSecure: false, RefreshCookieSameSite: "lax", Bootstrap: BootstrapConfig{ Enabled: true, SeedDemoData: true, Username: "admin", Name: "admin", Team: "运营团队", Password: "admin123", }, BootstrapPassword: "admin123", UserService: UserServiceConfig{ Addr: "127.0.0.1:13005", RequestTimeout: 3 * time.Second, }, WalletService: WalletServiceConfig{ Addr: "127.0.0.1:13004", RequestTimeout: 3 * time.Second, }, RoomService: RoomServiceConfig{ Addr: "127.0.0.1:13001", RequestTimeout: 3 * time.Second, }, RobotService: RobotServiceConfig{ Addr: "127.0.0.1:13011", RequestTimeout: 3 * time.Second, }, ActivityService: ActivityServiceConfig{ Addr: "127.0.0.1:13006", RequestTimeout: 3 * time.Second, }, GameService: GameServiceConfig{ Addr: "127.0.0.1:13008", RequestTimeout: 3 * time.Second, }, StatisticsService: StatisticsServiceConfig{ BaseURL: "http://127.0.0.1:13010", RequestTimeout: 3 * time.Second, }, DashboardExternalSources: []DashboardExternalSourceConfig{ { Enabled: false, SourceType: "mysql", AppCode: "yumi", AppName: "Yumi", SysOrigin: "LIKEI", StatTimezone: "Asia/Riyadh", RequestTimeout: 5 * time.Second, }, { Enabled: false, SourceType: "aslan_mongo", AppCode: "aslan", AppName: "Aslan", SysOrigin: "ATYOU", MongoDatabase: "test", StatTimezone: "Asia/Shanghai", RequestTimeout: 5 * time.Second, }, }, LegacyCoinSellerRechargeSources: []LegacyCoinSellerRechargeSourceConfig{ { Enabled: false, AppCode: "yumi", AppName: "Yumi", SysOrigin: "LIKEI", RequestTimeout: 5 * time.Second, }, { Enabled: false, AppCode: "aslan", AppName: "Aslan", SysOrigin: "ATYOU", RequestTimeout: 5 * time.Second, }, }, FinanceNotifications: FinanceNotificationsConfig{ DingTalk: DingTalkRobotConfig{ Enabled: true, RequestTimeout: 3 * time.Second, }, }, TencentCOS: TencentCOSConfig{ Enabled: false, ObjectPrefix: "admin", }, Redis: RedisConfig{ Enabled: true, Addr: "127.0.0.1:13379", KeyPrefix: "admin:", }, Jobs: JobsConfig{ Enabled: true, WorkerInterval: 5 * time.Second, LeaseTTL: 2 * time.Minute, MaxAttempts: 3, ArtifactDir: "storage/exports", }, } } func Load(path string) (Config, error) { cfg := Default() if path == "" { return cfg, nil } body, err := os.ReadFile(path) if err != nil { return Config{}, err } if err := yaml.Unmarshal(body, &cfg); err != nil { return Config{}, err } cfg.Normalize() if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func (cfg *Config) Normalize() { cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) if cfg.Environment == "" { cfg.Environment = "local" } if cfg.Log.Level == "" { cfg.Log.Level = "info" } if cfg.Log.Format == "" { cfg.Log.Format = "json" } if cfg.Log.MaxPayloadBytes <= 0 { cfg.Log.MaxPayloadBytes = 2048 } cfg.RefreshCookieSameSite = strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) if cfg.RefreshCookieSameSite == "" { cfg.RefreshCookieSameSite = "lax" } cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) cfg.NodeID = strings.TrimSpace(cfg.NodeID) if cfg.NodeID == "" { cfg.NodeID = cfg.ServiceName } if cfg.Bootstrap.Password == "" && cfg.BootstrapPassword != "" { cfg.Bootstrap.Password = cfg.BootstrapPassword } if cfg.Bootstrap.Username == "" { cfg.Bootstrap.Username = "admin" } if cfg.Bootstrap.Name == "" { cfg.Bootstrap.Name = cfg.Bootstrap.Username } if cfg.Bootstrap.Team == "" { cfg.Bootstrap.Team = "运营团队" } if cfg.Jobs.LeaseTTL <= 0 { cfg.Jobs.LeaseTTL = 2 * time.Minute } if cfg.Jobs.MaxAttempts <= 0 { cfg.Jobs.MaxAttempts = 3 } if cfg.Jobs.ArtifactDir == "" { cfg.Jobs.ArtifactDir = "storage/exports" } cfg.UserService.Addr = strings.TrimSpace(cfg.UserService.Addr) if cfg.UserService.RequestTimeout <= 0 { cfg.UserService.RequestTimeout = 3 * time.Second } cfg.WalletService.Addr = strings.TrimSpace(cfg.WalletService.Addr) if cfg.WalletService.RequestTimeout <= 0 { cfg.WalletService.RequestTimeout = 3 * time.Second } cfg.RoomService.Addr = strings.TrimSpace(cfg.RoomService.Addr) if cfg.RoomService.Addr == "" { cfg.RoomService.Addr = "127.0.0.1:13001" } if cfg.RoomService.RequestTimeout <= 0 { cfg.RoomService.RequestTimeout = 3 * time.Second } cfg.RobotService.Addr = strings.TrimSpace(cfg.RobotService.Addr) if cfg.RobotService.Addr == "" { cfg.RobotService.Addr = "127.0.0.1:13011" } if cfg.RobotService.RequestTimeout <= 0 { cfg.RobotService.RequestTimeout = 3 * time.Second } cfg.ActivityService.Addr = strings.TrimSpace(cfg.ActivityService.Addr) if cfg.ActivityService.RequestTimeout <= 0 { cfg.ActivityService.RequestTimeout = 3 * time.Second } cfg.GameService.Addr = strings.TrimSpace(cfg.GameService.Addr) if cfg.GameService.Addr == "" { cfg.GameService.Addr = "127.0.0.1:13008" } if cfg.GameService.RequestTimeout <= 0 { cfg.GameService.RequestTimeout = 3 * time.Second } cfg.StatisticsService.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.StatisticsService.BaseURL), "/") if cfg.StatisticsService.BaseURL == "" { cfg.StatisticsService.BaseURL = "http://127.0.0.1:13010" } if cfg.StatisticsService.RequestTimeout <= 0 { cfg.StatisticsService.RequestTimeout = 3 * time.Second } for index := range cfg.DashboardExternalSources { source := &cfg.DashboardExternalSources[index] // 外接 App 大屏的数据源类型在配置层归一化;查询层只根据类型读取对应的外部聚合事实,不回退到 hyapp statistics-service。 source.SourceType = strings.ToLower(strings.TrimSpace(source.SourceType)) source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppName = strings.TrimSpace(source.AppName) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.MySQLDSN = strings.TrimSpace(source.MySQLDSN) source.LegacyMySQLDSN = strings.TrimSpace(source.LegacyMySQLDSN) source.LegacyWalletDatabase = strings.Trim(strings.TrimSpace(source.LegacyWalletDatabase), "/") 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.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" } if source.SourceType == "aslan_mongo" && source.LegacyWalletDatabase == "" && source.MongoDatabase != "" { // Aslan legacy RDS 把用户资料放在 atyou,把金币代理钱包流水放在 atyou_wallet;默认按主库名派生。 source.LegacyWalletDatabase = source.MongoDatabase + "_wallet" } source.StatTimezone = strings.TrimSpace(source.StatTimezone) if source.StatTimezone == "" { if source.SourceType == "aslan_http" || source.SourceType == "aslan_mongo" { source.StatTimezone = "Asia/Shanghai" } else { source.StatTimezone = "Asia/Riyadh" } } if source.RequestTimeout <= 0 { source.RequestTimeout = 5 * time.Second } } cfg.applyDashboardExternalSourceEnvOverrides() for index := range cfg.LegacyCoinSellerRechargeSources { source := &cfg.LegacyCoinSellerRechargeSources[index] source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppName = strings.TrimSpace(source.AppName) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.AppMySQLDSN = strings.TrimSpace(source.AppMySQLDSN) source.WalletMySQLDSN = strings.TrimSpace(source.WalletMySQLDSN) if source.RequestTimeout <= 0 { source.RequestTimeout = 5 * time.Second } } cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() cfg.applyFinanceNotificationEnvOverrides() cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret) cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles) if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 { cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second } cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) if cfg.RobotProfileSource.RequestTimeout <= 0 { cfg.RobotProfileSource.RequestTimeout = 5 * time.Second } for index := range cfg.MoneyRegionSources { source := &cfg.MoneyRegionSources[index] // legacy 区域来源只服务财务范围目录;这里先规整配置,handler 层只处理业务过滤,不重复处理大小写和默认集合名。 source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppName = strings.TrimSpace(source.AppName) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.MongoURI = strings.TrimSpace(source.MongoURI) source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/") if source.MongoDatabase == "" { source.MongoDatabase = "tarab_all" } source.MongoCollection = strings.TrimSpace(source.MongoCollection) if source.MongoCollection == "" { source.MongoCollection = "sys_region_config" } if source.RequestTimeout <= 0 { source.RequestTimeout = 5 * time.Second } } cfg.applyMoneyRegionSourceEnvOverrides() for index := range cfg.FinanceBillSources { source := &cfg.FinanceBillSources[index] // legacy 账单源只服务财务充值明细;配置层统一大小写和默认集合,handler 只按 app_code 命中。 source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppName = strings.TrimSpace(source.AppName) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.MongoURI = strings.TrimSpace(source.MongoURI) source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/") if source.MongoDatabase == "" { // 线上 likei Mongo(10.2.21.9)的账单和区域目录都在 tarab_all 库,与 money_region_sources 保持一致。 source.MongoDatabase = "tarab_all" } source.MongoCollection = strings.TrimSpace(source.MongoCollection) if source.MongoCollection == "" { source.MongoCollection = "in_app_purchase_details" } source.GooglePackageName = strings.TrimSpace(source.GooglePackageName) source.GoogleServiceAccountJSON = strings.TrimSpace(source.GoogleServiceAccountJSON) source.GoogleServiceAccountFile = strings.TrimSpace(source.GoogleServiceAccountFile) if source.RequestTimeout <= 0 { source.RequestTimeout = 5 * time.Second } } cfg.applyFinanceBillSourceEnvOverrides() cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID) cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey) cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName) cfg.TencentCOS.Region = strings.TrimSpace(cfg.TencentCOS.Region) cfg.TencentCOS.AccessURL = strings.TrimRight(strings.TrimSpace(cfg.TencentCOS.AccessURL), "/") cfg.TencentCOS.ObjectPrefix = strings.Trim(strings.TrimSpace(cfg.TencentCOS.ObjectPrefix), "/") if cfg.TencentCOS.ObjectPrefix == "" { cfg.TencentCOS.ObjectPrefix = "admin" } } func (cfg Config) Validate() error { if !validEnvironment(cfg.Environment) { return errors.New("environment must be one of local, dev, staging, prod") } if strings.TrimSpace(cfg.ServiceName) == "" { return errors.New("service_name is required") } if strings.TrimSpace(cfg.NodeID) == "" { return errors.New("node_id is required") } if strings.TrimSpace(cfg.HTTPAddr) == "" { return errors.New("http_addr is required") } if strings.TrimSpace(cfg.MySQLDSN) == "" { return errors.New("mysql_dsn is required") } if strings.TrimSpace(cfg.UserMySQLDSN) == "" { return errors.New("user_mysql_dsn is required") } if strings.TrimSpace(cfg.WalletMySQLDSN) == "" { return errors.New("wallet_mysql_dsn is required") } if cfg.Migrations.Enabled && strings.TrimSpace(cfg.Migrations.Dir) == "" { return errors.New("migrations.dir is required when migrations are enabled") } if isLockedEnvironment(cfg.Environment) && cfg.Migrations.AllowChecksumRepair { return errors.New("migrations.allow_checksum_repair must be false outside local/dev") } if strings.TrimSpace(cfg.JWTSecret) == "" { return errors.New("jwt_secret is required") } if cfg.AccessTokenTTL <= 0 { return errors.New("access_token_ttl must be greater than 0") } if cfg.RefreshTokenTTL <= 0 { return errors.New("refresh_token_ttl must be greater than 0") } switch strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) { case "lax", "strict", "none": default: return errors.New("refresh_cookie_same_site must be one of lax, strict, none") } if strings.EqualFold(strings.TrimSpace(cfg.RefreshCookieSameSite), "none") && !cfg.RefreshCookieSecure { return errors.New("refresh_cookie_secure must be true when refresh_cookie_same_site is none") } if isLockedEnvironment(cfg.Environment) { if cfg.JWTSecret == "dev-shared-secret" || len(cfg.JWTSecret) < 32 { return errors.New("jwt_secret must be a non-default value with at least 32 characters outside local/dev") } if cfg.MySQLAutoMigrate { return errors.New("mysql_auto_migrate must be false outside local/dev") } if cfg.Bootstrap.Enabled && cfg.Bootstrap.Password == "admin123" { return errors.New("bootstrap.password must not use the default value outside local/dev") } } for _, source := range cfg.LegacyCoinSellerRechargeSources { if !source.Enabled { continue } if strings.TrimSpace(source.AppCode) == "" { return errors.New("legacy_coin_seller_recharge_sources app_code is required when enabled") } if strings.TrimSpace(source.SysOrigin) == "" { return errors.New("legacy_coin_seller_recharge_sources sys_origin is required when enabled") } if strings.TrimSpace(source.AppMySQLDSN) == "" || strings.TrimSpace(source.WalletMySQLDSN) == "" { return errors.New("legacy_coin_seller_recharge_sources app_mysql_dsn and wallet_mysql_dsn are required when enabled") } } if len(cfg.CORSAllowedOrigins) == 0 { return errors.New("cors_allowed_origins must not be empty") } if cfg.Bootstrap.Enabled && strings.TrimSpace(cfg.Bootstrap.Password) == "" { return errors.New("bootstrap.password is required when bootstrap is enabled") } if strings.TrimSpace(cfg.UserService.Addr) == "" { return errors.New("user_service.addr is required") } if cfg.UserService.RequestTimeout <= 0 { return errors.New("user_service.request_timeout must be greater than 0") } if strings.TrimSpace(cfg.WalletService.Addr) == "" { return errors.New("wallet_service.addr is required") } if cfg.WalletService.RequestTimeout <= 0 { return errors.New("wallet_service.request_timeout must be greater than 0") } if strings.TrimSpace(cfg.RoomService.Addr) == "" { return errors.New("room_service.addr is required") } if cfg.RoomService.RequestTimeout <= 0 { return errors.New("room_service.request_timeout must be greater than 0") } if strings.TrimSpace(cfg.RobotService.Addr) == "" { return errors.New("robot_service.addr is required") } if cfg.RobotService.RequestTimeout <= 0 { return errors.New("robot_service.request_timeout must be greater than 0") } if strings.TrimSpace(cfg.ActivityService.Addr) == "" { return errors.New("activity_service.addr is required") } if cfg.ActivityService.RequestTimeout <= 0 { return errors.New("activity_service.request_timeout must be greater than 0") } if strings.TrimSpace(cfg.GameService.Addr) == "" { return errors.New("game_service.addr is required") } if cfg.GameService.RequestTimeout <= 0 { return errors.New("game_service.request_timeout must be greater than 0") } if strings.TrimSpace(cfg.StatisticsService.BaseURL) == "" { return errors.New("statistics_service.base_url is required") } if cfg.StatisticsService.RequestTimeout <= 0 { return errors.New("statistics_service.request_timeout must be greater than 0") } for index, source := range cfg.DashboardExternalSources { if !source.Enabled { continue } if strings.TrimSpace(source.AppCode) == "" { return fmt.Errorf("dashboard_external_sources[%d].app_code is required when enabled", index) } switch strings.ToLower(strings.TrimSpace(source.SourceType)) { case "mysql": if strings.TrimSpace(source.SysOrigin) == "" { return fmt.Errorf("dashboard_external_sources[%d].sys_origin is required when mysql source is enabled", index) } if strings.TrimSpace(source.MySQLDSN) == "" { return fmt.Errorf("dashboard_external_sources[%d].mysql_dsn is required when mysql source is enabled", index) } case "aslan_http": if strings.TrimSpace(source.BaseURL) == "" { return fmt.Errorf("dashboard_external_sources[%d].base_url is required when aslan_http source is enabled", index) } if !strings.HasPrefix(source.BaseURL, "http://") && !strings.HasPrefix(source.BaseURL, "https://") { return fmt.Errorf("dashboard_external_sources[%d].base_url must be an absolute url", index) } 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) } if strings.TrimSpace(source.LegacyMySQLDSN) == "" { return fmt.Errorf("dashboard_external_sources[%d].legacy_mysql_dsn is required when aslan_mongo source is enabled", index) } if containsConfigPlaceholder(source.LegacyMySQLDSN) { return fmt.Errorf("dashboard_external_sources[%d].legacy_mysql_dsn must be injected by environment, not left as a placeholder", index) } if strings.TrimSpace(source.LegacyWalletDatabase) == "" { return fmt.Errorf("dashboard_external_sources[%d].legacy_wallet_database is required when aslan_mongo source is enabled", index) } if !isMySQLIdentifier(source.LegacyWalletDatabase) { return fmt.Errorf("dashboard_external_sources[%d].legacy_wallet_database must be a mysql identifier", index) } default: 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) } } if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.WebhookURL != "" { if !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "http://") && !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "https://") { return errors.New("finance_notifications.dingtalk.webhook_url must be an absolute url") } } if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 { return errors.New("finance_notifications.dingtalk.request_timeout must be greater than 0") } if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") } if cfg.RobotProfileSource.RequestTimeout <= 0 { return errors.New("robot_profile_source.request_timeout must be greater than 0") } for index, source := range cfg.MoneyRegionSources { if !source.Enabled { continue } // 线上 likei Mongo 是外部只读事实来源;启用但缺 URI/库/集合时必须启动失败,不能让财务范围静默漏掉 Yumi 区域。 if strings.TrimSpace(source.AppCode) == "" { return fmt.Errorf("money_region_sources[%d].app_code is required when enabled", index) } if strings.TrimSpace(source.SysOrigin) == "" { return fmt.Errorf("money_region_sources[%d].sys_origin is required when enabled", index) } if strings.TrimSpace(source.MongoURI) == "" { return fmt.Errorf("money_region_sources[%d].mongo_uri is required when enabled", index) } if strings.TrimSpace(source.MongoDatabase) == "" { return fmt.Errorf("money_region_sources[%d].mongo_database is required when enabled", index) } if strings.TrimSpace(source.MongoCollection) == "" { return fmt.Errorf("money_region_sources[%d].mongo_collection is required when enabled", index) } if source.RequestTimeout <= 0 { return fmt.Errorf("money_region_sources[%d].request_timeout must be greater than 0", index) } } for index, source := range cfg.FinanceBillSources { if !source.Enabled { continue } // legacy 账单源启用但配置不全时必须启动失败,避免财务充值明细静默漏掉 Yumi 账单。 if strings.TrimSpace(source.AppCode) == "" { return fmt.Errorf("finance_bill_sources[%d].app_code is required when enabled", index) } if strings.TrimSpace(source.SysOrigin) == "" { return fmt.Errorf("finance_bill_sources[%d].sys_origin is required when enabled", index) } if strings.TrimSpace(source.MongoURI) == "" { return fmt.Errorf("finance_bill_sources[%d].mongo_uri is required when enabled", index) } if strings.TrimSpace(source.MongoDatabase) == "" { return fmt.Errorf("finance_bill_sources[%d].mongo_database is required when enabled", index) } if strings.TrimSpace(source.MongoCollection) == "" { return fmt.Errorf("finance_bill_sources[%d].mongo_collection is required when enabled", index) } if source.RequestTimeout <= 0 { return fmt.Errorf("finance_bill_sources[%d].request_timeout must be greater than 0", index) } } if cfg.TencentCOS.Enabled { if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" { return errors.New("tencent-cos config is incomplete") } if !strings.HasPrefix(cfg.TencentCOS.AccessURL, "http://") && !strings.HasPrefix(cfg.TencentCOS.AccessURL, "https://") { return errors.New("tencent-cos.access-url must be an absolute url") } } if cfg.Redis.Enabled && strings.TrimSpace(cfg.Redis.Addr) == "" { return errors.New("redis.addr is required when redis is enabled") } if cfg.Redis.DB < 0 { return fmt.Errorf("redis.db must be greater than or equal to 0") } if cfg.Jobs.Enabled && cfg.Jobs.WorkerInterval <= 0 { return errors.New("jobs.worker_interval must be greater than 0") } if cfg.Jobs.Enabled && cfg.Jobs.LeaseTTL <= 0 { return errors.New("jobs.lease_ttl must be greater than 0") } if cfg.Jobs.Enabled && cfg.Jobs.MaxAttempts <= 0 { return errors.New("jobs.max_attempts must be greater than 0") } if cfg.Jobs.Enabled && strings.TrimSpace(cfg.Jobs.ArtifactDir) == "" { return errors.New("jobs.artifact_dir is required when jobs are enabled") } return nil } func (cfg *Config) applyFinanceNotificationEnvOverrides() { if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" { cfg.FinanceNotifications.DingTalk.Enabled = parseBoolEnv(value) } if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" { // 机器人 Webhook 属于密钥级配置;生产可以只在运行环境注入,避免把真实 access_token 写进仓库里的 YAML。 cfg.FinanceNotifications.DingTalk.WebhookURL = value cfg.FinanceNotifications.DingTalk.Enabled = true } if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" { cfg.FinanceNotifications.DingTalk.Secret = value } if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" { cfg.FinanceNotifications.DingTalk.AtMobiles = strings.Split(value, ",") } if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" { cfg.FinanceNotifications.DingTalk.AtAll = parseBoolEnv(value) } } 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 != "" { previousMongoDatabase := source.MongoDatabase usesDerivedWalletDatabase := source.LegacyWalletDatabase == "" || source.LegacyWalletDatabase == previousMongoDatabase+"_wallet" source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/") if source.SourceType == "aslan_mongo" && usesDerivedWalletDatabase && source.MongoDatabase != "" { source.LegacyWalletDatabase = source.MongoDatabase + "_wallet" } } if value := strings.TrimSpace(firstEnv( "HYAPP_ADMIN_"+appKey+"_DASHBOARD_LEGACY_MYSQL_DSN", "HYAPP_ADMIN_"+appKey+"_LEGACY_MYSQL_DSN", )); value != "" { // Aslan Mongo 大屏只把 legacy MySQL 作为补充事实源,真实 likei RDS 凭证由运行环境注入。 source.LegacyMySQLDSN = value } if value := strings.TrimSpace(firstEnv( "HYAPP_ADMIN_"+appKey+"_DASHBOARD_LEGACY_WALLET_DATABASE", "HYAPP_ADMIN_"+appKey+"_LEGACY_WALLET_DATABASE", )); value != "" { source.LegacyWalletDatabase = strings.Trim(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 (cfg *Config) applyFinanceBillSourceEnvOverrides() { for index := range cfg.FinanceBillSources { source := &cfg.FinanceBillSources[index] appKey := envAppKey(source.AppCode) if appKey == "" { continue } if value := strings.TrimSpace(firstEnv( "HYAPP_ADMIN_"+appKey+"_FINANCE_BILL_MONGO_URI", "HYAPP_ADMIN_"+appKey+"_MONGO_URI", )); value != "" { // legacy 账单源与区域源/大屏共享同一个 likei Mongo 集群;真实密码只由运行环境注入。 source.MongoURI = value } if value := strings.TrimSpace(firstEnv( "HYAPP_ADMIN_"+appKey+"_FINANCE_BILL_MONGO_DATABASE", "HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE", )); value != "" { source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/") } if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_PACKAGE_NAME")); value != "" { source.GooglePackageName = value } // 服务账号 JSON 含私钥;生产优先用环境变量注入,仓库 YAML 只放文件路径或占位。 if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_SERVICE_ACCOUNT_JSON")); value != "" { source.GoogleServiceAccountJSON = value } if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_SERVICE_ACCOUNT_FILE")); value != "" { source.GoogleServiceAccountFile = value } } } func (cfg *Config) applyLegacyCoinSellerRechargeSourceEnvOverrides() { for index := range cfg.LegacyCoinSellerRechargeSources { source := &cfg.LegacyCoinSellerRechargeSources[index] appKey := envAppKey(source.AppCode) if appKey == "" { continue } if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_LEGACY_COIN_SELLER_RECHARGE_ENABLED")); value != "" { source.Enabled = parseBoolEnv(value) } if value := strings.TrimSpace(firstEnv( "HYAPP_ADMIN_"+appKey+"_COIN_SELLER_RECHARGE_APP_MYSQL_DSN", "HYAPP_ADMIN_"+appKey+"_LEGACY_APP_MYSQL_DSN", )); value != "" { // legacy 用户库 DSN 带可写权限,只能由运行环境注入;仓库配置保持占位。 source.AppMySQLDSN = value } if value := strings.TrimSpace(firstEnv( "HYAPP_ADMIN_"+appKey+"_COIN_SELLER_RECHARGE_WALLET_MYSQL_DSN", "HYAPP_ADMIN_"+appKey+"_LEGACY_WALLET_MYSQL_DSN", )); value != "" { // legacy 钱包库 DSN 用于写 user_freight_balance_running_water,不能复用只读报表源。 source.WalletMySQLDSN = 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) != "" { return value } } return "" } func parseBoolEnv(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "y", "on": return true default: return false } } func compactStrings(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { out = append(out, trimmed) } } return out } func containsConfigPlaceholder(value string) bool { upper := strings.ToUpper(strings.TrimSpace(value)) return strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "<") } func isMySQLIdentifier(value string) bool { if value == "" { return false } for _, r := range value { if r == '_' || r == '$' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { continue } return false } return true } func validEnvironment(value string) bool { switch value { case "local", "dev", "staging", "prod": return true default: return false } } func isLockedEnvironment(value string) bool { return value == "staging" || value == "prod" }