// Package config loads process configuration from YAML + optional env overrides. package config import ( "fmt" "log" "net/url" "os" "strconv" "strings" "gopkg.in/yaml.v3" ) // Config holds runtime settings for the API server. type Config struct { HTTPAddr string DatabaseURL string AppEnv string DeepSeek DeepSeekConfig Admin AdminConfig } // AdminConfig for ops console bootstrap (ECR-006). type AdminConfig struct { BootstrapUsername string BootstrapPassword string } // DeepSeekConfig for Ask LLM. type DeepSeekConfig struct { APIKey string BaseURL string Model string TimeoutSec int } type fileConfig struct { App struct { Env string `yaml:"env"` HTTPAddr string `yaml:"http_addr"` } `yaml:"app"` Database struct { Host string `yaml:"host"` Port int `yaml:"port"` User string `yaml:"user"` Password string `yaml:"password"` Name string `yaml:"name"` SSLMode string `yaml:"sslmode"` } `yaml:"database"` DeepSeek struct { APIKey string `yaml:"api_key"` BaseURL string `yaml:"base_url"` Model string `yaml:"model"` TimeoutSec int `yaml:"timeout_sec"` } `yaml:"deepseek"` Admin struct { BootstrapUsername string `yaml:"bootstrap_username"` BootstrapPassword string `yaml:"bootstrap_password"` } `yaml:"admin"` } // Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides. func Load() Config { cfg := Config{ HTTPAddr: ":8080", DatabaseURL: "postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable", AppEnv: "dev", DeepSeek: DeepSeekConfig{ BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", TimeoutSec: 60, }, } path := resolveConfigPath() if path != "" { if err := mergeFile(&cfg, path); err != nil { log.Printf("config: load %s: %v (using defaults/env)", path, err) } else { log.Printf("config: loaded %s", path) } } else { log.Printf("config: no config.local.yaml found — copy config.example.yaml → config.local.yaml") } applyEnv(&cfg) return cfg } func resolveConfigPath() string { if p := os.Getenv("CONFIG_PATH"); p != "" { return p } candidates := []string{ "config.local.yaml", "apps/api/config.local.yaml", } for _, c := range candidates { if st, err := os.Stat(c); err == nil && !st.IsDir() { return c } } return "" } func mergeFile(cfg *Config, path string) error { raw, err := os.ReadFile(path) if err != nil { return err } var f fileConfig if err := yaml.Unmarshal(raw, &f); err != nil { return err } if f.App.Env != "" { cfg.AppEnv = f.App.Env } if f.App.HTTPAddr != "" { cfg.HTTPAddr = f.App.HTTPAddr } if f.Database.Host != "" || f.Database.User != "" || f.Database.Name != "" { cfg.DatabaseURL = buildDatabaseURL(f) } if f.DeepSeek.APIKey != "" { cfg.DeepSeek.APIKey = f.DeepSeek.APIKey } if f.DeepSeek.BaseURL != "" { cfg.DeepSeek.BaseURL = strings.TrimRight(f.DeepSeek.BaseURL, "/") } if f.DeepSeek.Model != "" { cfg.DeepSeek.Model = f.DeepSeek.Model } if f.DeepSeek.TimeoutSec > 0 { cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec } if f.Admin.BootstrapUsername != "" { cfg.Admin.BootstrapUsername = f.Admin.BootstrapUsername } if f.Admin.BootstrapPassword != "" { cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword } return nil } func buildDatabaseURL(f fileConfig) string { host := f.Database.Host if host == "" { host = "127.0.0.1" } port := f.Database.Port if port == 0 { port = 5432 } user := f.Database.User if user == "" { user = "yuxingu" } pass := f.Database.Password name := f.Database.Name if name == "" { name = "yuxingu" } ssl := f.Database.SSLMode if ssl == "" { ssl = "disable" } u := url.URL{ Scheme: "postgres", User: url.UserPassword(user, pass), Host: fmt.Sprintf("%s:%d", host, port), Path: "/" + name, } q := u.Query() q.Set("sslmode", ssl) u.RawQuery = q.Encode() return u.String() } func applyEnv(cfg *Config) { if v := os.Getenv("HTTP_ADDR"); v != "" { cfg.HTTPAddr = v } if v := os.Getenv("DATABASE_URL"); v != "" { cfg.DatabaseURL = v } if v := os.Getenv("APP_ENV"); v != "" { cfg.AppEnv = v } if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { cfg.DeepSeek.APIKey = v } if v := os.Getenv("DEEPSEEK_BASE_URL"); v != "" { cfg.DeepSeek.BaseURL = strings.TrimRight(v, "/") } if v := os.Getenv("DEEPSEEK_MODEL"); v != "" { cfg.DeepSeek.Model = v } if v := os.Getenv("DEEPSEEK_TIMEOUT_SEC"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.DeepSeek.TimeoutSec = n } } if v := os.Getenv("ADMIN_BOOTSTRAP_USERNAME"); v != "" { cfg.Admin.BootstrapUsername = v } if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" { cfg.Admin.BootstrapPassword = v } } // Enabled reports whether DeepSeek can be called. func (d DeepSeekConfig) Enabled() bool { return strings.TrimSpace(d.APIKey) != "" }