feat(config): 配置加载与 schema 校验(.env/env 注入 + 反射解码 + 校验)
This commit is contained in:
1 parent
0236010136
commit
efed5f05b1
5 files changed
+551
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user