package config import ( "errors" "fmt" "net/url" "os" "strconv" "strings" "time" ) type Config struct { MySQL MySQLConfig Mongo MongoConfig Redis RedisConfig CDC CDCConfig Dashboard DashboardConfig Recharge RechargeReconcileConfig Gift GiftReconcileConfig Game GameReconcileConfig User UserReconcileConfig Active ActiveReconcileConfig Health HealthConfig } type MySQLConfig struct { AppDSN string CDCAddr string CDCUser string CDCPassword string Schema string WalletSchema string MaxOpenConns int MaxIdleConns int } type MongoConfig struct { Enabled bool URI string Database string } type RedisConfig struct { Enabled bool Addr string Username string Password string DB int } type CDCConfig struct { ServerID uint32 StartMode string DryRun bool IncludeTables []string FlushInterval time.Duration HeartbeatEvery time.Duration BatchSize int BackfillLockWait time.Duration MasterPollEvery time.Duration } type DashboardConfig struct { SysOrigins []string StatTimezones []string StorageTimezone string RedisKeyPrefix string RealtimeRedisTTL time.Duration UnknownCountryCode string CleanupInterval time.Duration DedupRetention time.Duration UserMetricDays int CleanupBatchSize int } type RechargeReconcileConfig struct { Enabled bool Interval time.Duration CurrentDayLag time.Duration BatchLimit int } type GiftReconcileConfig struct { Enabled bool Interval time.Duration CurrentDayLag time.Duration BatchLimit int } type GameReconcileConfig struct { Enabled bool Interval time.Duration CurrentDayLag time.Duration } type UserReconcileConfig struct { Enabled bool Interval time.Duration LookbackDays int CurrentDayLag time.Duration BatchLimit int CreateTimeDisplayOffset time.Duration } type ActiveReconcileConfig struct { Enabled bool Interval time.Duration LookbackDays int BatchLimit int } type HealthConfig struct { Addr string } func Load() (Config, error) { cfg := Config{ MySQL: MySQLConfig{ AppDSN: env("DASHBOARD_MYSQL_DSN", env("MYSQL_DSN", "")), CDCAddr: env("CDC_MYSQL_ADDR", ""), CDCUser: env("CDC_MYSQL_USER", ""), CDCPassword: env("CDC_MYSQL_PASSWORD", ""), Schema: env("CDC_MYSQL_SCHEMA", "likei"), WalletSchema: env("CDC_WALLET_SCHEMA", "likei_wallet"), MaxOpenConns: envInt("MYSQL_MAX_OPEN_CONNS", 8), MaxIdleConns: envInt("MYSQL_MAX_IDLE_CONNS", 4), }, Mongo: MongoConfig{ Enabled: envBool("MONGO_ENABLED", false), URI: env("MONGO_URI", env("LIKEI_MONGO_URI", "")), Database: env("MONGO_DATABASE", env("LIKEI_MONGO_DB", "")), }, Redis: RedisConfig{ Enabled: envBool("REDIS_ENABLED", false), Addr: env("REDIS_ADDR", ""), Username: env("REDIS_USERNAME", ""), Password: env("REDIS_PASSWORD", ""), DB: envInt("REDIS_DB", 0), }, CDC: CDCConfig{ ServerID: uint32(envInt("CDC_SERVER_ID", 29301)), StartMode: strings.ToLower(env("CDC_START_MODE", "checkpoint")), DryRun: envBool("CDC_DRY_RUN", true), IncludeTables: csv(env("CDC_INCLUDE_TABLES", defaultTables())), FlushInterval: envDuration("CDC_FLUSH_INTERVAL", 3*time.Second), HeartbeatEvery: envDuration("CDC_HEARTBEAT_EVERY", 30*time.Second), BatchSize: envInt("CDC_BATCH_SIZE", 1000), BackfillLockWait: envDuration("CDC_BACKFILL_LOCK_WAIT", time.Second), MasterPollEvery: envDuration("CDC_MASTER_POSITION_POLL_INTERVAL", time.Minute), }, Dashboard: DashboardConfig{ SysOrigins: csv(env("DASHBOARD_SYS_ORIGINS", "LIKEI")), StatTimezones: csv(env("DASHBOARD_STAT_TIMEZONES", "Asia/Riyadh")), StorageTimezone: env("DASHBOARD_STORAGE_TIMEZONE", "Asia/Riyadh"), RedisKeyPrefix: env("DASHBOARD_REDIS_KEY_PREFIX", "country_dashboard"), RealtimeRedisTTL: envDuration("DASHBOARD_REALTIME_REDIS_TTL", 48*time.Hour), UnknownCountryCode: env("DASHBOARD_UNKNOWN_COUNTRY_CODE", "UNKNOWN"), CleanupInterval: envDuration("DASHBOARD_CLEANUP_INTERVAL", 30*time.Minute), DedupRetention: envDuration("DASHBOARD_DEDUP_RETENTION", 72*time.Hour), UserMetricDays: envInt("DASHBOARD_USER_METRIC_RETENTION_DAYS", 120), CleanupBatchSize: envInt("DASHBOARD_CLEANUP_BATCH_SIZE", 5000), }, Recharge: RechargeReconcileConfig{ Enabled: envBool("DASHBOARD_RECHARGE_RECONCILE_ENABLED", true), Interval: envDuration("DASHBOARD_RECHARGE_RECONCILE_INTERVAL", 10*time.Minute), CurrentDayLag: envDuration("DASHBOARD_RECHARGE_RECONCILE_CURRENT_DAY_LAG", 15*time.Minute), BatchLimit: envInt("DASHBOARD_RECHARGE_RECONCILE_BATCH_LIMIT", 5000), }, Gift: GiftReconcileConfig{ Enabled: envBool("DASHBOARD_GIFT_RECONCILE_ENABLED", true), Interval: envDuration("DASHBOARD_GIFT_RECONCILE_INTERVAL", 10*time.Minute), CurrentDayLag: envDuration("DASHBOARD_GIFT_RECONCILE_CURRENT_DAY_LAG", 5*time.Minute), BatchLimit: envInt("DASHBOARD_GIFT_RECONCILE_BATCH_LIMIT", 50000), }, Game: GameReconcileConfig{ Enabled: envBool("DASHBOARD_GAME_RECONCILE_ENABLED", true), Interval: envDuration("DASHBOARD_GAME_RECONCILE_INTERVAL", 10*time.Minute), CurrentDayLag: envDuration("DASHBOARD_GAME_RECONCILE_CURRENT_DAY_LAG", 5*time.Minute), }, User: UserReconcileConfig{ Enabled: envBool("DASHBOARD_USER_RECONCILE_ENABLED", true), Interval: envDuration("DASHBOARD_USER_RECONCILE_INTERVAL", 10*time.Minute), LookbackDays: envInt("DASHBOARD_USER_RECONCILE_LOOKBACK_DAYS", 3), CurrentDayLag: envDuration("DASHBOARD_USER_RECONCILE_CURRENT_DAY_LAG", time.Minute), BatchLimit: envInt("DASHBOARD_USER_RECONCILE_BATCH_LIMIT", 10000), CreateTimeDisplayOffset: envDuration("DASHBOARD_USER_CREATE_TIME_DISPLAY_OFFSET", 8*time.Hour), }, Active: ActiveReconcileConfig{ Enabled: envBool("DASHBOARD_ACTIVE_RECONCILE_ENABLED", false), Interval: envDuration("DASHBOARD_ACTIVE_RECONCILE_INTERVAL", 10*time.Minute), LookbackDays: envInt("DASHBOARD_ACTIVE_RECONCILE_LOOKBACK_DAYS", 35), BatchLimit: envInt("DASHBOARD_ACTIVE_RECONCILE_BATCH_LIMIT", 200000), }, Health: HealthConfig{ Addr: env("HEALTH_ADDR", ":2910"), }, } if cfg.MySQL.AppDSN == "" { return Config{}, errors.New("DASHBOARD_MYSQL_DSN or MYSQL_DSN is required") } if cfg.MySQL.CDCAddr == "" || cfg.MySQL.CDCUser == "" { return Config{}, errors.New("CDC_MYSQL_ADDR and CDC_MYSQL_USER are required") } if cfg.CDC.CDCPasswordRequired() && cfg.MySQL.CDCPassword == "" { return Config{}, errors.New("CDC_MYSQL_PASSWORD is required") } if cfg.Redis.Enabled && cfg.Redis.Addr == "" { return Config{}, errors.New("REDIS_ADDR is required when REDIS_ENABLED=true") } if cfg.Mongo.Enabled && cfg.Mongo.URI == "" { return Config{}, errors.New("MONGO_URI or LIKEI_MONGO_URI is required when MONGO_ENABLED=true") } if cfg.Mongo.Enabled && cfg.Mongo.Database == "" { return Config{}, errors.New("MONGO_DATABASE or LIKEI_MONGO_DB is required when MONGO_ENABLED=true") } if cfg.CDC.ServerID == 0 { return Config{}, errors.New("CDC_SERVER_ID must be greater than 0") } if cfg.CDC.BatchSize <= 0 { cfg.CDC.BatchSize = 1000 } if cfg.Dashboard.CleanupBatchSize <= 0 { cfg.Dashboard.CleanupBatchSize = 5000 } if cfg.Recharge.BatchLimit <= 0 { cfg.Recharge.BatchLimit = 5000 } if cfg.Gift.BatchLimit <= 0 { cfg.Gift.BatchLimit = 50000 } if cfg.User.LookbackDays <= 0 { cfg.User.LookbackDays = 3 } if cfg.User.BatchLimit <= 0 { cfg.User.BatchLimit = 10000 } if cfg.Active.LookbackDays <= 0 { cfg.Active.LookbackDays = 35 } if cfg.Active.BatchLimit <= 0 { cfg.Active.BatchLimit = 200000 } if cfg.CDC.StartMode != "checkpoint" && cfg.CDC.StartMode != "current" { return Config{}, fmt.Errorf("unsupported CDC_START_MODE=%s", cfg.CDC.StartMode) } if len(cfg.Dashboard.SysOrigins) == 0 { return Config{}, errors.New("DASHBOARD_SYS_ORIGINS is empty") } if len(cfg.Dashboard.StatTimezones) == 0 { return Config{}, errors.New("DASHBOARD_STAT_TIMEZONES is empty") } return cfg, nil } func (c CDCConfig) CDCPasswordRequired() bool { return true } func defaultTables() string { return strings.Join([]string{ "likei.user_base_info", "likei.order_purchase_history", "likei.order_user_purchase_pay", "likei.game_lucky_gift_count", "likei.game_lucky_box_count", "likei.baishun_order_idempotency", "likei.game_open_callback_log", "likei.baishun_game_catalog", "likei.lingxian_game_catalog", "likei.sys_game_list_config", "likei_wallet.wallet_gold_asset_record_1", "likei_wallet.wallet_gold_asset_record_2", "likei_wallet.wallet_gold_asset_record_3", "likei_wallet.wallet_gold_asset_record_4", "likei_wallet.wallet_gold_asset_record_5", "likei_wallet.wallet_gold_asset_record_6", "likei_wallet.wallet_gold_asset_record_7", "likei_wallet.wallet_gold_asset_record_8", "likei_wallet.wallet_gold_asset_record_9", "likei_wallet.wallet_gold_asset_record_10", "likei_wallet.wallet_gold_asset_record_11", "likei_wallet.wallet_gold_asset_record_12", "likei_wallet.user_freight_balance_running_water", "likei_wallet.user_salary_account_running_water", "likei_wallet.user_salary_diamond_running_water", }, ",") } func env(key string, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func envBool(key string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func envInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func envDuration(key string, fallback time.Duration) time.Duration { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := time.ParseDuration(value) if err == nil { return parsed } seconds, err := strconv.Atoi(value) if err != nil { return fallback } return time.Duration(seconds) * time.Second } func csv(value string) []string { if strings.TrimSpace(value) == "" { return nil } items := strings.Split(value, ",") out := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { item = strings.TrimSpace(item) if item == "" { continue } if _, exists := seen[item]; exists { continue } seen[item] = struct{}{} out = append(out, item) } return out } func RedactedDSN(dsn string) string { parsed, err := url.Parse("mysql://" + dsn) if err != nil || parsed.User == nil { return "mysql://***" } parsed.User = url.UserPassword(parsed.User.Username(), "***") return strings.TrimPrefix(parsed.String(), "mysql://") }