2026-07-03 15:53:36 +08:00

920 lines
34 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 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"`
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"`
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"`
}
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 AppYumi/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"`
}
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",
// Aslanlikei-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,
},
},
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.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"
}
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()
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 Mongo10.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"
}
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")
}
}
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)
}
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 != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
}
}
func (cfg *Config) applyMoneyRegionSourceEnvOverrides() {
for index := range cfg.MoneyRegionSources {
source := &cfg.MoneyRegionSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_MONEY_REGION_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// legacy 区域源和 Aslan 大屏可以共享同一个 Mongo 集群;只在运行环境注入真实密码。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_MONEY_REGION_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
}
}
func (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), "/")
}
}
}
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 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"
}