fix config watcher lifecycle and snapshots

This commit is contained in:
杭州明婳科技
2026-07-08 18:38:01 +08:00
parent 0610a159f0
commit d73a46447c
6 changed files with 374 additions and 87 deletions
+4
View File
@@ -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.goM1):`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 不再静默回退 MySQLMySQL 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` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码。
+182 -75
View File
@@ -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 DSNM9
// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 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 是否开发环境
+168
View File
@@ -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)
}
}
+5 -5
View File
@@ -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")
}
}
}
+11 -5
View File
@@ -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))
}
}
+4 -2
View File
@@ -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)