diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f1dbdf1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,53 @@ +package config + +import ( + "sync/atomic" +) + +// DoubaoConfig holds 火山引擎豆包 ASR credentials. +type DoubaoConfig struct { + AppKey string `yaml:"app_key"` + AccessKey string `yaml:"access_key"` + ResourceID string `yaml:"resource_id"` +} + +// ServerConfig holds server settings. +type ServerConfig struct { + Port int `yaml:"port"` + TLSAuto bool `yaml:"tls_auto"` +} + +// Config is the top-level configuration. +type Config struct { + Doubao DoubaoConfig `yaml:"doubao"` + Server ServerConfig `yaml:"server"` +} + +// defaults returns a Config with default values. +func defaults() Config { + return Config{ + Doubao: DoubaoConfig{ + ResourceID: "volc.seedasr.sauc.duration", + }, + Server: ServerConfig{ + Port: 8443, + TLSAuto: true, + }, + } +} + +// global holds the current config atomically for concurrent reads. +var global atomic.Value + +// Get returns the current config snapshot. Safe for concurrent use. +func Get() Config { + if v := global.Load(); v != nil { + return v.(Config) + } + return defaults() +} + +// store updates the global config. +func store(cfg Config) { + global.Store(cfg) +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..38dcc81 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,125 @@ +package config + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + + "github.com/fsnotify/fsnotify" + "gopkg.in/yaml.v3" +) + +// Load reads config from file (optional), applies env overrides, validates, and stores globally. +// If configPath is empty, it tries "config.yaml" in the working directory. +func Load(configPath string) (Config, error) { + cfg := defaults() + + // Try loading YAML file + if configPath == "" { + configPath = "config.yaml" + } + if data, err := os.ReadFile(configPath); err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parse config %s: %w", configPath, err) + } + slog.Info("loaded config file", "path", configPath) + } + + // Env overrides + applyEnv(&cfg) + + // Validate + if err := validate(cfg); err != nil { + return cfg, err + } + + store(cfg) + return cfg, nil +} + +// applyEnv overrides config fields with environment variables. +func applyEnv(cfg *Config) { + if v := os.Getenv("DOUBAO_APP_KEY"); v != "" { + cfg.Doubao.AppKey = v + } + if v := os.Getenv("DOUBAO_ACCESS_KEY"); v != "" { + cfg.Doubao.AccessKey = v + } + if v := os.Getenv("DOUBAO_RESOURCE_ID"); v != "" { + cfg.Doubao.ResourceID = v + } + if v := os.Getenv("PORT"); v != "" { + if port, err := strconv.Atoi(v); err == nil { + cfg.Server.Port = port + } + } + if v := os.Getenv("TLS_AUTO"); v != "" { + cfg.Server.TLSAuto = v == "true" || v == "1" + } +} + +// validate checks required fields. +func validate(cfg Config) error { + if cfg.Doubao.AppKey == "" { + return fmt.Errorf("doubao.app_key is required (set DOUBAO_APP_KEY or config.yaml)") + } + if cfg.Doubao.AccessKey == "" { + return fmt.Errorf("doubao.access_key is required (set DOUBAO_ACCESS_KEY or config.yaml)") + } + return nil +} + +// WatchAndReload watches the config file for changes and hot-reloads. +func WatchAndReload(configPath string) { + if configPath == "" { + configPath = "config.yaml" + } + + absPath, err := filepath.Abs(configPath) + if err != nil { + slog.Warn("cannot resolve config path for watching", "error", err) + return + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + slog.Warn("cannot create file watcher", "error", err) + return + } + + dir := filepath.Dir(absPath) + if err := watcher.Add(dir); err != nil { + slog.Warn("cannot watch config directory", "error", err) + watcher.Close() + return + } + + slog.Info("watching config for changes", "path", absPath) + + go func() { + defer watcher.Close() + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if filepath.Clean(event.Name) == absPath && (event.Has(fsnotify.Write) || event.Has(fsnotify.Create)) { + slog.Info("config file changed, reloading") + if _, err := Load(configPath); err != nil { + slog.Error("failed to reload config", "error", err) + } else { + slog.Info("config reloaded successfully") + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + slog.Error("config watcher error", "error", err) + } + } + }() +}