feat(config): 配置加载与 schema 校验(.env/env 注入 + 反射解码 + 校验)

This commit is contained in:
Aska committed 2026-09-06 04:15:31 +08:00
1 parent 0236010136
commit efed5f05b1
5 files changed
+551

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
// Package config loads, defaults, and validates the Cyrene v2 configuration
// from environment variables and an optional .env file (development only).
//
// Every setting is sourced from an environment variable (no hard-coded values
// in code). Nested groups map to struct fields tagged with `env:"NAME"`.
// Missing required values cause Load/MustLoad to return an error so startup
// can fail fast.
package config
import (
"fmt"
"reflect"
"strings"
)
// Config is the root configuration tree.
type Config struct {
Env string `env:"ENV" default:"development"`
LogLevel string `env:"LOG_LEVEL" default:"debug"`
Timezone string `env:"TZ" default:"Asia/Shanghai"`
DataDir string `env:"DATA_DIR" default:"./data"`
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
Server ServerConfig
Auth AuthConfig
DB DBConfig
LLM LLMConfig
Embedding EmbeddingConfig
IoT IoTConfig
Voice VoiceConfig
Background BackgroundConfig
WSHub WSHubConfig
}
// ServerConfig holds the single gateway listener settings.
type ServerConfig struct {
Host string `env:"HOST" default:"0.0.0.0"`
Port int `env:"PORT" default:"8080"`
}
// AuthConfig holds authentication and admin credential settings.
type AuthConfig struct {
JWTSecret string `env:"JWT_SECRET" validate:"required,min=32"`
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
}
// DBConfig holds database connection settings. SQLite is the default; PGURL is
// the optional PostgreSQL+pgvector enhancement.
type DBConfig struct {
SQLitePath string `env:"DB_SQLITE_PATH" default:"./data/cyrene.db"`
PGURL string `env:"DB_PG_URL"`
}
// LLMConfig holds the multi-supplier LLM adapter settings.
type LLMConfig struct {
BaseURL string `env:"LLM_BASE_URL"`
APIKey string `env:"LLM_API_KEY"`
Model string `env:"LLM_MODEL"`
Fallback string `env:"LLM_FALLBACK_MODEL"`
}
// EmbeddingConfig holds the optional semantic embedding API settings. When
// unset, the runtime falls back to built-in n-gram/FTS vectors.
type EmbeddingConfig struct {
APIURL string `env:"EMBEDDING_API_URL"`
APIKey string `env:"EMBEDDING_API_KEY"`
Model string `env:"EMBEDDING_MODEL"`
}
// IoTConfig selects the IoT device provider.
type IoTConfig struct {
Provider string `env:"IOT_PROVIDER" default:"mock"`
}
// VoiceConfig holds TTS/STT provider settings.
type VoiceConfig struct {
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
}
// BackgroundConfig holds the autonomous thinking settings.
type BackgroundConfig struct {
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
}
// WSHubConfig holds the WebSocket hub limits.
type WSHubConfig struct {
MaxConnections int `env:"WS_MAX_CONNECTIONS" default:"1000"`
SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"`
}
// Load reads configuration, merging an optional .env file (development only)
// with process environment variables. Process environment variables always take
// precedence over .env values, which take precedence over code defaults.
//
// Passing an empty envPath skips .env entirely. A missing .env file is not an
// error; only missing required values are.
func Load(envPath string) (*Config, error) {
fromEnv := map[string]string{}
if envPath != "" {
m, err := parseDotEnv(envPath)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", envPath, err)
}
fromEnv = m
}
cfg := &Config{}
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
return nil, err
}
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
parts := make([]string, 0, len(errs))
for _, e := range errs {
parts = append(parts, e.Error())
}
return nil, fmt.Errorf("config: validation failed: %s", strings.Join(parts, "; "))
}
return cfg, nil
}
// MustLoad is Load that panics on error. It is intended for startup, matching
// the "schema validation fails at boot" rule.
func MustLoad(envPath string) *Config {
cfg, err := Load(envPath)
if err != nil {
panic(err)
}
return cfg
}
+151
View File
@@ -0,0 +1,151 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func writeEnvFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
return path
}
func TestLoad_DefaultsWhenUnset(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Env != "development" {
t.Errorf("Env = %q, want development", cfg.Env)
}
if cfg.Server.Host != "0.0.0.0" || cfg.Server.Port != 8080 {
t.Errorf("Server = %+v, want 0.0.0.0:8080", cfg.Server)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
}
if cfg.DB.SQLitePath != "./data/cyrene.db" {
t.Errorf("SQLitePath = %q, want ./data/cyrene.db", cfg.DB.SQLitePath)
}
want := []string{"http://localhost:5173", "http://localhost:3000"}
if !reflect.DeepEqual(cfg.AllowedOrigins, want) {
t.Errorf("AllowedOrigins = %v, want %v", cfg.AllowedOrigins, want)
}
}
func TestLoad_EnvOverridesDefault(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "9090")
t.Setenv("LOG_LEVEL", "warn")
t.Setenv("IOT_PROVIDER", "none")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Server.Port != 9090 {
t.Errorf("Port = %d, want 9090", cfg.Server.Port)
}
if cfg.LogLevel != "warn" {
t.Errorf("LogLevel = %q, want warn", cfg.LogLevel)
}
if cfg.IoT.Provider != "none" {
t.Errorf("IoT.Provider = %q, want none", cfg.IoT.Provider)
}
}
func TestLoad_DotEnvThenProcessEnv(t *testing.T) {
path := writeEnvFile(t, `PORT=7000
LOG_LEVEL=error
# comment line
IOT_PROVIDER=none
`)
// Process env overrides .env for PORT but leaves LOG_LEVEL from .env.
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "8000")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.Server.Port != 8000 {
t.Errorf("Port = %d, want 8000 (process env wins)", cfg.Server.Port)
}
if cfg.LogLevel != "error" {
t.Errorf("LogLevel = %q, want error from .env", cfg.LogLevel)
}
if cfg.IoT.Provider != "none" {
t.Errorf("IoT.Provider = %q, want none from .env", cfg.IoT.Provider)
}
}
func TestLoad_MissingRequiredFails(t *testing.T) {
// Do not set JWT_SECRET.
if _, err := Load(""); err == nil {
t.Fatal("expected error when JWT_SECRET missing, got nil")
} else if !strings.Contains(err.Error(), "JWT_SECRET") {
t.Errorf("error should mention JWT_SECRET, got: %v", err)
}
}
func TestLoad_ValidationMinLength(t *testing.T) {
t.Setenv("JWT_SECRET", "short")
if _, err := Load(""); err == nil {
t.Fatal("expected error for JWT_SECRET shorter than 32, got nil")
} else if !strings.Contains(err.Error(), "at least 32") {
t.Errorf("error should mention min length, got: %v", err)
}
}
func TestLoad_InvalidPortFails(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "not-a-number")
if _, err := Load(""); err == nil {
t.Fatal("expected error for invalid PORT, got nil")
} else if !strings.Contains(err.Error(), "PORT") {
t.Errorf("error should mention PORT, got: %v", err)
}
}
func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
// A non-existent .env path must not error.
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.env"))
if err != nil {
t.Fatalf("Load(nonexistent) error: %v", err)
}
if cfg.Server.Port != 8080 {
t.Errorf("Port = %d, want default 8080", cfg.Server.Port)
}
}
func TestParseDotEnv_QuotedAndExport(t *testing.T) {
path := writeEnvFile(t, `export FOO=bar
BAZ='quoted value'
EMPTY=
`)
m, err := parseDotEnv(path)
if err != nil {
t.Fatalf("parseDotEnv error: %v", err)
}
if m["FOO"] != "bar" {
t.Errorf("FOO = %q, want bar", m["FOO"])
}
if m["BAZ"] != "quoted value" {
t.Errorf("BAZ = %q, want 'quoted value'", m["BAZ"])
}
if v, ok := m["EMPTY"]; !ok || v != "" {
t.Errorf("EMPTY = %q (present=%v), want empty", v, ok)
}
}
+127
View File
@@ -0,0 +1,127 @@
package config
import (
"fmt"
"os"
"reflect"
"strconv"
"strings"
)
// decodeValue populates the struct pointed to by v. Precedence, highest first:
// process environment variable, the parsed .env file, then the default tag.
func decodeValue(v reflect.Value, fromEnv map[string]string) error {
if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
return fmt.Errorf("config: decode expects a pointer to a struct")
}
return decodeStruct(v.Elem(), fromEnv)
}
func decodeStruct(sv reflect.Value, fromEnv map[string]string) error {
st := sv.Type()
for i := 0; i < st.NumField(); i++ {
field := st.Field(i)
if !field.IsExported() {
continue
}
fv := sv.Field(i)
envTag, hasEnv := field.Tag.Lookup("env")
if !hasEnv {
// Untagged struct fields are nested config groups.
if fv.Kind() == reflect.Struct {
if err := decodeStruct(fv, fromEnv); err != nil {
return err
}
}
continue
}
name := envTagName(envTag)
raw := lookupRaw(name, field.Tag.Get("default"), fromEnv)
if err := setField(fv, raw); err != nil {
return fmt.Errorf("config: %s: %w", name, err)
}
}
return nil
}
// envTagName extracts the variable name from an env tag (dropping any
// comma-separated options that may be added later).
func envTagName(tag string) string {
name, _, _ := strings.Cut(tag, ",")
return strings.TrimSpace(name)
}
// lookupRaw returns the effective raw value for key: process env, then .env,
// then default.
func lookupRaw(key, def string, fromEnv map[string]string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
if v, ok := fromEnv[key]; ok {
return v
}
return def
}
func setField(fv reflect.Value, raw string) error {
switch fv.Kind() {
case reflect.String:
fv.SetString(raw)
return nil
case reflect.Bool:
return setBool(fv, raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return setInt(fv, raw)
case reflect.Slice:
return setStringSlice(fv, raw)
default:
return fmt.Errorf("unsupported kind %s", fv.Kind())
}
}
func setBool(fv reflect.Value, raw string) error {
if raw == "" {
fv.SetBool(false)
return nil
}
b, err := strconv.ParseBool(strings.ToLower(raw))
if err != nil {
return fmt.Errorf("invalid boolean %q", raw)
}
fv.SetBool(b)
return nil
}
func setInt(fv reflect.Value, raw string) error {
if raw == "" {
fv.SetInt(0)
return nil
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid integer %q", raw)
}
fv.SetInt(n)
return nil
}
func setStringSlice(fv reflect.Value, raw string) error {
if fv.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind())
}
if strings.TrimSpace(raw) == "" {
fv.Set(reflect.MakeSlice(fv.Type(), 0, 0))
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
fv.Set(reflect.ValueOf(out))
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"os"
"strings"
)
// parseDotEnv loads a KEY=VALUE .env file into a map. It is tolerant by design:
// a missing file yields an empty map and no error, because .env is a
// development-only convenience. Values may be single- or double-quoted and may
// carry an optional `export` prefix. Lines starting with `#` and blank lines
// are ignored.
func parseDotEnv(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
env := make(map[string]string)
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
val = strings.Trim(val, `"'`)
env[key] = val
}
return env, nil
}
+97
View File
@@ -0,0 +1,97 @@
package config
import (
"fmt"
"reflect"
"strings"
)
// validateStruct returns a list of human-readable validation errors found while
// walking the struct tree. Validation is driven by the `validate` struct tag,
// which holds comma-separated rules. Supported rules: required, min=N.
func validateStruct(sv reflect.Value) []error {
var errs []error
st := sv.Type()
for i := 0; i < st.NumField(); i++ {
field := st.Field(i)
if !field.IsExported() {
continue
}
fv := sv.Field(i)
if rules := field.Tag.Get("validate"); rules != "" {
name := envTagName(field.Tag.Get("env"))
if name == "" {
name = field.Name
}
errs = append(errs, applyRules(name, rules, fv)...)
}
if fv.Kind() == reflect.Struct {
errs = append(errs, validateStruct(fv)...)
}
}
return errs
}
func applyRules(name, rules string, fv reflect.Value) []error {
var errs []error
for _, rule := range strings.Split(rules, ",") {
rule = strings.TrimSpace(rule)
if rule == "" {
continue
}
ruleName, arg, _ := strings.Cut(rule, "=")
switch ruleName {
case "required":
if isZero(fv) {
errs = append(errs, fmt.Errorf("%s is required", name))
}
case "min":
if err := applyMin(name, arg, fv); err != nil {
errs = append(errs, err)
}
}
}
return errs
}
func isZero(fv reflect.Value) bool {
switch fv.Kind() {
case reflect.String:
return fv.String() == ""
case reflect.Bool:
return !fv.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return fv.Int() == 0
default:
return fv.IsZero()
}
}
func applyMin(name, arg string, fv reflect.Value) error {
if fv.Kind() != reflect.String {
return nil
}
min, err := parseIntArg(arg)
if err != nil {
return nil
}
if len(fv.String()) < min {
return fmt.Errorf("%s must be at least %d characters", name, min)
}
return nil
}
func parseIntArg(arg string) (int, error) {
if arg == "" {
return 0, fmt.Errorf("missing min argument")
}
n := 0
for _, c := range arg {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid min argument %q", arg)
}
n = n*10 + int(c-'0')
}
return n, nil
}