package config import ( "encoding/json" "errors" "fmt" "os" "strconv" "strings" "time" mysqlDriver "github.com/go-sql-driver/mysql" ) type Config struct { Listen string `json:"listen"` MySQL MySQLConfig `json:"mysql"` CoreMySQL MySQLConfig `json:"core_mysql"` Redis RedisConfig `json:"redis"` Kafka KafkaConfig `json:"kafka"` Auth AuthConfig `json:"auth"` Manager ManagerConfig `json:"manager"` AI AIConfig `json:"ai"` ReadTimeout time.Duration `json:"-"` WriteTimeout time.Duration `json:"-"` } type MySQLConfig struct { DSN string `json:"dsn"` MaxOpenConns int `json:"max_open_conns"` MaxIdleConns int `json:"max_idle_conns"` } type RedisConfig struct { Addr string `json:"addr"` Username string `json:"username"` Password string `json:"password"` DB int `json:"db"` KeyPrefix string `json:"key_prefix"` } type KafkaConfig struct { Enabled bool `json:"enabled"` Brokers []string `json:"brokers"` Topic string `json:"topic"` Username string `json:"username"` Password string `json:"password"` SASL string `json:"sasl"` ClientID string `json:"client_id"` BatchSize int `json:"batch_size"` } type AuthConfig struct { APIKeys []string `json:"api_keys"` SessionKeyPattern string `json:"session_key_pattern"` UIDKey string `json:"uid_key"` AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` RequiredTenantCode bool `json:"required_tenant_code"` RequiredAPIKey bool `json:"required_api_key"` SkipSessionInDev bool `json:"skip_session_in_dev"` TrustedInternalAddr string `json:"trusted_internal_addr"` InternalServiceName string `json:"internal_service_name"` InternalSecret string `json:"internal_secret"` } type ManagerConfig struct { BaseURL string `json:"base_url"` ServiceName string `json:"service_name"` Secret string `json:"secret"` UIDKeyGroup string `json:"uid_key_group"` UIDKeyKeys []string `json:"uid_key_keys"` TimeoutMS int `json:"timeout_ms"` } type AIConfig struct { Provider string `json:"provider"` DeepSeek DeepSeekConfig `json:"deepseek"` DefaultModel string `json:"default_model"` ComplexModel string `json:"complex_model"` Temperature float64 `json:"temperature"` MaxOutputTokens int `json:"max_output_tokens"` } type DeepSeekConfig struct { BaseURL string `json:"base_url"` APIKey string `json:"api_key"` TimeoutMS int `json:"timeout_ms"` MaxRetries int `json:"max_retries"` } func Load() (*Config, error) { cfg := defaultConfig() if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" { data, err := os.ReadFile(path) if err != nil { return nil, err } if err := json.Unmarshal(data, cfg); err != nil { return nil, err } } applyEnv(cfg) if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } func defaultConfig() *Config { return &Config{ Listen: ":6061", MySQL: MySQLConfig{ DSN: "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local", MaxOpenConns: 15, MaxIdleConns: 5, }, Redis: RedisConfig{ Addr: "127.0.0.1:6379", KeyPrefix: "im:", }, Kafka: KafkaConfig{ Topic: "im.workbench.event", ClientID: "im_workbench", }, Auth: AuthConfig{ SessionKeyPattern: "im:session:token:%s", UIDKey: "dkXxWLwqUNRfYmdXRzOkZw==", RequiredTenantCode: true, RequiredAPIKey: true, }, Manager: ManagerConfig{ ServiceName: "im-workbench", UIDKeyGroup: "im", UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}, TimeoutMS: 3000, }, AI: AIConfig{ Provider: "deepseek", DefaultModel: "deepseek-flash", ComplexModel: "deepseek-v4-pro", Temperature: 0.2, MaxOutputTokens: 4096, DeepSeek: DeepSeekConfig{ BaseURL: "https://api.deepseek.com", TimeoutMS: 60000, MaxRetries: 2, }, }, ReadTimeout: 8 * time.Second, WriteTimeout: 8 * time.Second, } } func applyEnv(cfg *Config) { setString(&cfg.Listen, "IM_WORKBENCH_LISTEN") setString(&cfg.MySQL.DSN, "IM_WORKBENCH_MYSQL_DSN") setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS") setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS") setString(&cfg.CoreMySQL.DSN, "IM_WORKBENCH_CORE_MYSQL_DSN") if strings.TrimSpace(cfg.CoreMySQL.DSN) == "" { cfg.CoreMySQL.DSN = cfg.MySQL.DSN } if cfg.CoreMySQL.MaxOpenConns == 0 { cfg.CoreMySQL.MaxOpenConns = cfg.MySQL.MaxOpenConns } if cfg.CoreMySQL.MaxIdleConns == 0 { cfg.CoreMySQL.MaxIdleConns = cfg.MySQL.MaxIdleConns } setString(&cfg.Redis.Addr, "IM_WORKBENCH_REDIS_ADDR") setString(&cfg.Redis.Username, "IM_WORKBENCH_REDIS_USERNAME") setString(&cfg.Redis.Password, "IM_WORKBENCH_REDIS_PASSWORD") setString(&cfg.Redis.KeyPrefix, "IM_WORKBENCH_REDIS_KEY_PREFIX") setString(&cfg.Kafka.Topic, "IM_WORKBENCH_KAFKA_TOPIC") setString(&cfg.Kafka.Username, "IM_WORKBENCH_KAFKA_USERNAME") setString(&cfg.Kafka.Password, "IM_WORKBENCH_KAFKA_PASSWORD") setString(&cfg.Kafka.SASL, "IM_WORKBENCH_KAFKA_SASL") setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID") setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN") setString(&cfg.Auth.UIDKey, "IM_WORKBENCH_UID_KEY") setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR") setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME") setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET") setString(&cfg.Manager.BaseURL, "IM_WORKBENCH_MANAGER_BASE_URL") setString(&cfg.Manager.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME") setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET") setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP") setString(&cfg.AI.Provider, "IM_WORKBENCH_AI_PROVIDER") setString(&cfg.AI.DeepSeek.BaseURL, "DEEPSEEK_BASE_URL") setString(&cfg.AI.DeepSeek.APIKey, "DEEPSEEK_API_KEY") setString(&cfg.AI.DefaultModel, "DEEPSEEK_DEFAULT_MODEL") setString(&cfg.AI.ComplexModel, "DEEPSEEK_COMPLEX_MODEL") setInt(&cfg.AI.DeepSeek.TimeoutMS, "DEEPSEEK_TIMEOUT_MS") setInt(&cfg.AI.DeepSeek.MaxRetries, "DEEPSEEK_MAX_RETRIES") setInt(&cfg.AI.MaxOutputTokens, "DEEPSEEK_MAX_OUTPUT_TOKENS") setFloat(&cfg.AI.Temperature, "DEEPSEEK_TEMPERATURE") if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET") } if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { setString(&cfg.Auth.InternalSecret, "IM_ADMIN_API_SECRET") } if strings.TrimSpace(cfg.Manager.Secret) == "" { setString(&cfg.Manager.Secret, "IM_CONFIG_API_SECRET") } if strings.TrimSpace(cfg.Manager.Secret) == "" { setString(&cfg.Manager.Secret, "IM_INTERNAL_API_SECRET") } if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" { cfg.Kafka.Brokers = splitCSV(brokers) } if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_API_KEYS")); keys != "" { cfg.Auth.APIKeys = splitCSV(keys) } if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_UID_KEY_KEYS")); keys != "" { cfg.Manager.UIDKeyKeys = splitCSV(keys) } if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BATCH_SIZE")); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Kafka.BatchSize = parsed } } if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_TIMEOUT_MS")); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Manager.TimeoutMS = parsed } } if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_REDIS_DB")); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Redis.DB = parsed } } setBool(&cfg.Auth.AllowDevHeaderAuth, "IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH") setBool(&cfg.Auth.RequiredTenantCode, "IM_WORKBENCH_REQUIRED_TENANT_CODE") setBool(&cfg.Auth.RequiredAPIKey, "IM_WORKBENCH_REQUIRED_API_KEY") setBool(&cfg.Auth.SkipSessionInDev, "IM_WORKBENCH_SKIP_SESSION_IN_DEV") setBool(&cfg.Kafka.Enabled, "IM_WORKBENCH_KAFKA_ENABLED") } func (c *Config) validate() error { if strings.TrimSpace(c.Listen) == "" { return errors.New("listen is required") } if strings.TrimSpace(c.MySQL.DSN) == "" { return errors.New("mysql dsn is required") } mysqlDSN := strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://") parsedDSN, err := mysqlDriver.ParseDSN(mysqlDSN) if err != nil { return fmt.Errorf("mysql dsn is invalid: %w", err) } if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") { return errors.New("mysql database tinode is deprecated; use workbench") } coreMySQLDSN := strings.TrimPrefix(strings.TrimSpace(c.CoreMySQL.DSN), "mysql://") if _, err := mysqlDriver.ParseDSN(coreMySQLDSN); err != nil { return fmt.Errorf("core mysql dsn is invalid: %w", err) } if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev { return errors.New("redis addr is required") } if strings.TrimSpace(c.Auth.SessionKeyPattern) == "" { return errors.New("session key pattern is required") } if !strings.Contains(c.Auth.SessionKeyPattern, "%s") { return fmt.Errorf("session key pattern must include %%s") } if strings.TrimSpace(c.AI.Provider) == "" { c.AI.Provider = "deepseek" } if strings.TrimSpace(c.AI.DeepSeek.BaseURL) == "" { c.AI.DeepSeek.BaseURL = "https://api.deepseek.com" } if c.AI.DeepSeek.TimeoutMS <= 0 { c.AI.DeepSeek.TimeoutMS = 60000 } if strings.TrimSpace(c.AI.DefaultModel) == "" { c.AI.DefaultModel = "deepseek-flash" } if strings.TrimSpace(c.AI.ComplexModel) == "" { c.AI.ComplexModel = c.AI.DefaultModel } if c.AI.Temperature == 0 { c.AI.Temperature = 0.2 } if c.AI.MaxOutputTokens <= 0 { c.AI.MaxOutputTokens = 4096 } return nil } func setString(target *string, key string) { if value := strings.TrimSpace(os.Getenv(key)); value != "" { *target = value } } func setInt(target *int, key string) { if value := strings.TrimSpace(os.Getenv(key)); value != "" { if parsed, err := strconv.Atoi(value); err == nil { *target = parsed } } } func setBool(target *bool, key string) { switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { case "1", "true", "yes", "on": *target = true case "0", "false", "no", "off": *target = false } } func setFloat(target *float64, key string) { if value := strings.TrimSpace(os.Getenv(key)); value != "" { if parsed, err := strconv.ParseFloat(value, 64); err == nil { *target = parsed } } } func splitCSV(value string) []string { parts := strings.Split(value, ",") items := make([]string, 0, len(parts)) for _, part := range parts { if item := strings.TrimSpace(part); item != "" { items = append(items, item) } } return items }