From d73a46447c712b0f0db0662439ca9a367409b1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Wed, 8 Jul 2026 18:38:01 +0800 Subject: [PATCH] fix config watcher lifecycle and snapshots --- CHANGELOG.md | 4 + config/config.go | 257 +++++++++++++++++++++++++++----------- config/config_c10_test.go | 168 +++++++++++++++++++++++++ config/config_test.go | 10 +- config/validate.go | 16 ++- jwt/jwt_test.go | 6 +- 6 files changed, 374 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c099132..159ecbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。 - **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。 - **`test.Request.Execute()` 改为返回 `*test.Response`**:旧返回值是 `*httptest.ResponseRecorder`,与文档示例中的 `resp.AssertOK(t)` / `resp.ParseJSON(...)` 不一致;现在 `Execute()` 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 `ExecuteRecorder()`。 +- **`config.Get()` / `(*config.Manager).Get()` 改为返回配置副本**:旧行为暴露内部 `*Config`,调用方修改返回值会污染全局配置并可能与热重载并发读写竞态;现在返回深拷贝。需要动态替换配置的测试或工具代码请改用 `config.Set(cfg)`。 +- **`config.Set()` / `(*config.Manager).Set()` 现在返回 `error`**:非 nil 配置会先执行 `Validate()`,非法配置不会覆盖旧配置,并返回 `ErrInvalidConfig` 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。 +- **`config.GetViper()` / `(*config.Manager).GetViper()` 改为返回 viper 快照**:旧行为暴露内部可变 `*viper.Viper`;现在修改返回对象不会影响全局配置。常规读取请使用 `GetString` / `GetInt` / `GetBool` / `GetStringMap`。 - **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。 - **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。 - **`App.Go()` 在 Shutdown 开始或 Init 失败后为 no-op**(app.go,M1):`state >= stateStopping` 时拒绝 `wg.Add` 直接返回,避免与 `Shutdown` 的 `wg.Wait` 竞争 `sync.WaitGroup` 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。 @@ -39,6 +42,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键。 - **M10 分布式锁参数与未获锁语义修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`。 - **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。 +- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher;`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。 - **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。 - **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。 - **M16 测试工具与脚手架边界修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码。 diff --git a/config/config.go b/config/config.go index d1da7a6..e83d181 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "net/url" "path/filepath" @@ -17,6 +18,7 @@ import ( // 配置错误 var ( ErrConfigNotLoaded = fmt.Errorf("配置未加载") + ErrInvalidConfig = fmt.Errorf("配置非法") ) // Config 全局配置结构体 @@ -39,8 +41,7 @@ type Config struct { // 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值 // 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。 // -// 用于需要可变配置副本的场景。热路径的 Get() 为零分配仍返回内部只读指针——需要改配置时 -// 用 Clone() 或 Load()(Load 内部已返回 Clone)。 +// 用于需要可变配置副本的场景。Load/Get/回调均返回 Clone,避免调用方误改全局配置。 func (c *Config) Clone() *Config { if c == nil { return nil @@ -72,6 +73,34 @@ func cloneStrings(s []string) []string { return out } +func cloneStringAnyMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = cloneAny(v) + } + return out +} + +func cloneAny(v any) any { + switch x := v.(type) { + case map[string]any: + return cloneStringAnyMap(x) + case []any: + out := make([]any, len(x)) + for i, item := range x { + out[i] = cloneAny(item) + } + return out + case []string: + return cloneStrings(x) + default: + return x + } +} + // AppConfig 应用配置 // 使用场景: // - 缓存键名前缀: cache:{site_name}:user:1 @@ -133,7 +162,7 @@ type TLSConfig struct { // ServerConfig 服务配置 type ServerConfig struct { - Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡 + Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡 Port int `mapstructure:"port"` Mode string `mapstructure:"mode"` // development 或 production ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s" @@ -142,7 +171,7 @@ type ServerConfig struct { ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 优雅关闭超时,如 "30s" MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 最大请求头字节数 TLS TLSConfig `mapstructure:"tls"` - UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket + UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode } @@ -291,13 +320,27 @@ func (c *DatabaseConfig) DSN() string { if c.CustomDSN != "" { return c.CustomDSN } - if builder, ok := LookupDSNBuilder(c.Driver); ok { + driver := c.Driver + if strings.TrimSpace(driver) == "" { + driver = DriverMySQL + } + if builder, ok := LookupDSNBuilder(driver); ok { return builder(c) } // 未注册时回退到 MySQL(保持向后兼容) return c.MySQLDSN() } +func (c DatabaseConfig) isConfigured() bool { + return strings.TrimSpace(c.Driver) != "" || + strings.TrimSpace(c.Host) != "" || + c.Port != 0 || + strings.TrimSpace(c.User) != "" || + strings.TrimSpace(c.Password) != "" || + strings.TrimSpace(c.Name) != "" || + strings.TrimSpace(c.CustomDSN) != "" +} + // MySQLDSN 返回 MySQL 连接字符串。 // 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。 // loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。 @@ -307,19 +350,25 @@ func (c *DatabaseConfig) MySQLDSN() string { loc = "Local" } return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s", - c.User, url.QueryEscape(c.Password), c.Host, c.Port, c.Name, url.QueryEscape(loc)) + url.QueryEscape(c.User), url.QueryEscape(c.Password), c.Host, c.Port, url.PathEscape(c.Name), url.QueryEscape(loc)) } // PostgresDSN 返回 PostgreSQL 连接字符串。 -// 密码经单引号转义(内嵌单引号翻倍),避免含空格/引号/反斜杠破坏 key=value DSN(M9)。 +// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。 // TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。 func (c *DatabaseConfig) PostgresDSN() string { tz := c.Timezone if tz == "" { tz = "Asia/Shanghai" } - return fmt.Sprintf("host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable TimeZone=%s", - c.Host, c.Port, c.User, strings.ReplaceAll(c.Password, "'", "''"), c.Name, tz) + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=%s", + postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), postgresQuote(tz)) +} + +func postgresQuote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `'`, `\'`) + return "'" + s + "'" } // RedisConfig Redis 配置 @@ -499,6 +548,17 @@ func newViper(configPath string) *viper.Viper { return v } +func cloneViper(src *viper.Viper, configPath string) *viper.Viper { + if src == nil { + return nil + } + cp := newViper(configPath) + if err := cp.MergeConfigMap(src.AllSettings()); err != nil { + return nil + } + return cp +} + // unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook, // 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。 func unmarshalConfig(v *viper.Viper, cfg *Config) error { @@ -609,14 +669,12 @@ func (m *Manager) StartWatcher() error { func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) { defer close(done) const debounce = 200 * time.Millisecond - var timer *time.Timer - // P1 #16:退出时停掉未触发的去抖 timer,避免 StopWatcher 之后 AfterFunc 仍 - // reload() 一个调用方认为已停止的 manager。 - defer func() { - if timer != nil { - timer.Stop() - } - }() + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() + var timerC <-chan time.Time for { select { case ev, ok := <-w.Events: @@ -631,19 +689,24 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct continue } // 去抖:合并编辑器/工具的连续写事件,仅最后一次触发重载。 - if timer != nil { - timer.Stop() + if !timer.Stop() && timerC != nil { + select { + case <-timer.C: + default: + } } - timer = time.AfterFunc(debounce, func() { - // reload 内部对非法配置保留旧配置(C10b),错误被忽略—— - // 监听路径无法向上传播错误,保留旧配置即正确语义。 - _ = m.reload() - }) + timer.Reset(debounce) + timerC = timer.C case _, ok := <-w.Errors: if !ok { return } // 非致命错误:继续监听。 + case <-timerC: + timerC = nil + // reload 内部对非法配置保留旧配置(C10b),错误被忽略—— + // 监听路径无法向上传播错误,保留旧配置即正确语义。 + _ = m.reload() } } } @@ -669,45 +732,98 @@ func (m *Manager) StopWatcher() { } } -// Get 获取配置。 -// -// 返回的是 Manager 内部持有的配置指针(共享),调用方**必须视为只读**: -// 修改返回值的标量或切片元素会污染全局配置并与其他读取 goroutine 竞态。需要可变副本时 -// 用 Clone()(返回深拷贝)或 Load()(重载并返回深拷贝)。 -// -// M-G:热路径(jwt 鉴权等每请求调用)保持返回内部指针以零分配,可变副本走 Clone()。 -// 框架自身的 reload 创建新 Config 并替换 m.cfg 指针,不改写旧 Config 对象,故 Get() -// 返回的旧指针在 reload 后仍指向一致的(旧)快照,reload 不引入对旧快照的竞态。 +// Get 获取配置副本。返回值可由调用方自由修改,不会污染 Manager 内部配置。 func (m *Manager) Get() *Config { if m == nil { return nil } m.mu.RLock() defer m.mu.RUnlock() - return m.cfg + return m.cfg.Clone() } -// GetViper 获取 viper 实例 +// GetViper 获取 viper 的只读快照。 +// +// 返回值不是 Manager 内部 viper 指针;调用方修改该快照不会影响全局配置。 +// 需要扩展配置读取时优先使用 GetString/GetInt/GetBool 等包级 helper。 func (m *Manager) GetViper() *viper.Viper { if m == nil { return nil } m.mu.RLock() defer m.mu.RUnlock() - return m.v + return cloneViper(m.v, m.path) } -// Set 手动设置配置 -func (m *Manager) Set(cfg *Config) { +// GetString 获取字符串配置。 +func (m *Manager) GetString(key string) string { if m == nil { - return + return "" + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.v == nil { + return "" + } + return m.v.GetString(key) +} + +// GetInt 获取整数配置。 +func (m *Manager) GetInt(key string) int { + if m == nil { + return 0 + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.v == nil { + return 0 + } + return m.v.GetInt(key) +} + +// GetBool 获取布尔配置。 +func (m *Manager) GetBool(key string) bool { + if m == nil { + return false + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.v == nil { + return false + } + return m.v.GetBool(key) +} + +// GetStringMap 获取字符串映射配置副本。 +func (m *Manager) GetStringMap(key string) map[string]any { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.v == nil { + return nil + } + return cloneStringAnyMap(m.v.GetStringMap(key)) +} + +// Set 手动设置配置。非 nil 配置会先 Validate,并以深拷贝形式保存。 +func (m *Manager) Set(cfg *Config) error { + if m == nil { + return ErrConfigNotLoaded + } + if cfg != nil { + if err := cfg.Validate(); err != nil { + return errors.Join(ErrInvalidConfig, err) + } } m.mu.Lock() defer m.mu.Unlock() - m.cfg = cfg + m.cfg = cfg.Clone() if cfg == nil { m.v = nil } + return nil } // Reload 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误; @@ -760,39 +876,42 @@ func (m *Manager) reload() error { // 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。 var pkgLoadMu sync.Mutex -// Load 加载配置文件(C3.5 修复:替换前停止旧 Manager 的 watcher,防止 goroutine 泄漏)。 -// P1 #8:全程持 pkgLoadMu 串行化,消除与并发 Load/LoadWithWatch 的 TOCTOU。 +// Load 加载配置文件。 +// P1 #8:全程持 pkgLoadMu 串行化。新配置加载成功后才替换默认 Manager 并停止旧 watcher; +// 加载失败会保留旧 Manager 与旧 watcher,避免一次错误配置导致热更新链路断掉。 func Load(configPath string) (*Config, error) { pkgLoadMu.Lock() defer pkgLoadMu.Unlock() - if old := defaultManager.Load(); old != nil { - old.StopWatcher() - } m := NewManager(configPath) cfg, err := m.Load() if err != nil { return nil, err } + old := defaultManager.Load() defaultManager.Store(m) + if old != nil && old != m { + old.StopWatcher() + } return cfg, nil } -// LoadWithWatch 加载配置文件并启用热更新(C3.5 修复:替换前停止旧 watcher)。 +// LoadWithWatch 加载配置文件并启用热更新。 // P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认; -// 启动失败则停掉半启动的 watcher 并不置换,避免遗留孤儿 watcher。 +// 启动失败则保留旧 Manager 与旧 watcher,并停掉新 manager 可能半启动的 watcher。 func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) { pkgLoadMu.Lock() defer pkgLoadMu.Unlock() - if old := defaultManager.Load(); old != nil { - old.StopWatcher() - } m := NewManager(configPath) cfg, err := m.LoadWithWatch(onChange) if err != nil { m.StopWatcher() // 清理可能已半启动的 watcher,避免孤儿 goroutine return nil, err } + old := defaultManager.Load() defaultManager.Store(m) + if old != nil && old != m { + old.StopWatcher() + } return cfg, nil } @@ -821,9 +940,9 @@ func GetViper() *viper.Viper { return defaultManager.Load().GetViper() } -// Set 手动设置配置(用于测试或动态修改) -func Set(cfg *Config) { - defaultManager.Load().Set(cfg) +// Set 手动设置配置(用于测试或动态修改)。非 nil 配置会先校验并复制。 +func Set(cfg *Config) error { + return defaultManager.Load().Set(cfg) } // Reload 重新加载配置文件 @@ -837,48 +956,36 @@ func Reload() error { // 传入 nil 表示重置为空管理器。 // // C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。 +// 置换后会停止旧 Manager 的 watcher,避免全局默认 manager 切换后遗留热更新 goroutine。 func SetDefaultManager(m *Manager) { + old := defaultManager.Load() if m == nil { - defaultManager.Store(NewManager("")) - return + m = NewManager("") } defaultManager.Store(m) + if old != nil && old != m { + old.StopWatcher() + } } // GetString 获取字符串配置 func GetString(key string) string { - v := GetViper() - if v == nil { - return "" - } - return v.GetString(key) + return defaultManager.Load().GetString(key) } // GetInt 获取整数配置 func GetInt(key string) int { - v := GetViper() - if v == nil { - return 0 - } - return v.GetInt(key) + return defaultManager.Load().GetInt(key) } // GetBool 获取布尔配置 func GetBool(key string) bool { - v := GetViper() - if v == nil { - return false - } - return v.GetBool(key) + return defaultManager.Load().GetBool(key) } // GetStringMap 获取字符串映射配置 func GetStringMap(key string) map[string]any { - v := GetViper() - if v == nil { - return nil - } - return v.GetStringMap(key) + return defaultManager.Load().GetStringMap(key) } // IsDevelopment 是否开发环境 diff --git a/config/config_c10_test.go b/config/config_c10_test.go index 9aa050c..9554a04 100644 --- a/config/config_c10_test.go +++ b/config/config_c10_test.go @@ -329,3 +329,171 @@ func TestStartWatcherIdempotent(t *testing.T) { } m.StopWatcher() } + +func TestStopWatcherWaitsInFlightReload(t *testing.T) { + p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + entered := make(chan struct{}) + release := make(chan struct{}) + m.RegisterCallback(func(*config.Config) { + close(entered) + <-release + }) + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher: %v", err) + } + + if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("热更新回调未触发") + } + + stopped := make(chan struct{}) + go func() { + m.StopWatcher() + close(stopped) + }() + select { + case <-stopped: + t.Fatal("StopWatcher 不应在 reload 回调结束前返回") + case <-time.After(120 * time.Millisecond): + } + close(release) + select { + case <-stopped: + case <-time.After(2 * time.Second): + t.Fatal("StopWatcher 未等待到 reload 回调结束") + } +} + +func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) { + p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093)) + defer os.Remove(p) + + changes := make(chan int, 4) + if _, err := config.LoadWithWatch(p, func(c *config.Config) { + changes <- c.Server.Port + }); err != nil { + t.Fatalf("LoadWithWatch old: %v", err) + } + defer config.StopWatcher() + + missing := filepath.Join(filepath.Dir(p), "missing.yaml") + if _, err := config.LoadWithWatch(missing, nil); err == nil { + t.Fatal("加载缺失配置应失败") + } + + if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + select { + case port := <-changes: + if port != 8094 { + t.Fatalf("旧 watcher 回调端口错误: %d", port) + } + case <-time.After(3 * time.Second): + t.Fatal("加载失败后旧 watcher 不应被停止") + } +} + +func TestSetDefaultManagerStopsOldWatcher(t *testing.T) { + oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095)) + newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096)) + defer os.Remove(oldPath) + defer os.Remove(newPath) + + oldManager := config.NewManager(oldPath) + if _, err := oldManager.Load(); err != nil { + t.Fatalf("old Load: %v", err) + } + changes := make(chan int, 4) + oldManager.RegisterCallback(func(c *config.Config) { + changes <- c.Server.Port + }) + if err := oldManager.StartWatcher(); err != nil { + t.Fatalf("old StartWatcher: %v", err) + } + config.SetDefaultManager(oldManager) + + newManager := config.NewManager(newPath) + if _, err := newManager.Load(); err != nil { + t.Fatalf("new Load: %v", err) + } + config.SetDefaultManager(newManager) + defer config.SetDefaultManager(nil) + + if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil { + t.Fatalf("WriteFile old: %v", err) + } + select { + case port := <-changes: + t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port) + case <-time.After(500 * time.Millisecond): + } +} + +func TestSetGetAndViperReturnCopies(t *testing.T) { + cfg := &config.Config{ + App: config.AppConfig{Name: "copy", Env: "dev"}, + CORS: config.CORSConfig{ + AllowedOrigins: []string{"https://a.example.com"}, + }, + } + if err := config.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + defer config.SetDefaultManager(nil) + + cfg.App.Name = "mutated-input" + cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com" + got := config.Get() + if got.App.Name != "copy" { + t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name) + } + if got.CORS.AllowedOrigins[0] != "https://a.example.com" { + t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0]) + } + + got.App.Name = "mutated-get" + got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com" + gotAgain := config.Get() + if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" { + t.Fatalf("Get 应返回副本,实际 %+v", gotAgain) + } + + if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil { + t.Fatal("Set 非法配置应返回错误") + } + if got := config.Get().App.Name; got != "copy" { + t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got) + } +} + +func TestGetViperReturnsSnapshot(t *testing.T) { + p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098)) + defer os.Remove(p) + + if _, err := config.Load(p); err != nil { + t.Fatalf("Load: %v", err) + } + defer config.SetDefaultManager(nil) + + v := config.GetViper() + if v == nil { + t.Fatal("GetViper returned nil") + } + v.Set("app.name", "mutated") + if got := config.GetString("app.name"); got != "c10" { + t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got) + } +} diff --git a/config/config_test.go b/config/config_test.go index 5cb8688..7a9b206 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -102,7 +102,7 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) { } dsn := db.DSN() - expected := "host=localhost port=5432 user=postgres password='password' dbname=testdb sslmode=disable TimeZone=Asia/Shanghai" + expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=disable TimeZone='Asia/Shanghai'" if dsn != expected { t.Errorf("Postgres DSN = %s, want %s", dsn, expected) } @@ -140,13 +140,13 @@ func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) { Name: "testdb", } pdsn := pg.PostgresDSN() - if !strings.Contains(pdsn, "password='p''ord'") { + if !strings.Contains(pdsn, `password='p\'ord'`) { t.Errorf("Postgres DSN password not escaped: %s", pdsn) } // Timezone 可配置 pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"} - if !strings.Contains(pg2.PostgresDSN(), "TimeZone=UTC") { + if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") { t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN()) } mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"} @@ -350,7 +350,7 @@ func TestConfigManagerIsolation(t *testing.T) { func TestConfigSet(t *testing.T) { cfg := &config.Config{ App: config.AppConfig{ - Name: "Manual", + Name: "Manual", SiteName: "manual_site", }, } @@ -418,4 +418,4 @@ func TestUploadConfig(t *testing.T) { if upload.MaxFileSize != 10 { t.Error("UploadConfig failed") } -} \ No newline at end of file +} diff --git a/config/validate.go b/config/validate.go index 55796c3..461fcb7 100644 --- a/config/validate.go +++ b/config/validate.go @@ -39,15 +39,21 @@ func (c *Config) Validate() error { } } - // Database:仅当配置了 driver 时校验(未启用 mysql 的项目可留空) - if strings.TrimSpace(c.Database.Driver) != "" { - if c.Database.Host == "" { + // Database:出现任一数据库字段时视为启用;driver 为空按 MySQL 兼容处理。 + if c.Database.isConfigured() { + driver := strings.TrimSpace(c.Database.Driver) + if driver != "" { + if _, ok := LookupDSNBuilder(driver); !ok { + problems = append(problems, fmt.Sprintf("database.driver 未注册: %s", driver)) + } + } + if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Host) == "" { problems = append(problems, "database.host 启用数据库后必填") } - if c.Database.Name == "" { + if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" { problems = append(problems, "database.name 启用数据库后必填") } - if c.Database.Port <= 0 || c.Database.Port > 65535 { + if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) { problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port)) } } diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 6c2c96d..3189647 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -93,11 +93,13 @@ func TestParseTokenWrongSecret(t *testing.T) { // 修改 secret cfg := &config.Config{ JWT: config.JWTConfig{ - Secret: "different-secret", + Secret: "different-secret-key-12345678901234567890", Expire: 3600, }, } - config.Set(cfg) + if err := config.Set(cfg); err != nil { + t.Fatalf("Set wrong secret config: %v", err) + } // 应该解析失败 _, err := jwt.ParseToken(token)