fix(config): 模块 01 评审修复 + 对抗复核

config 模块(glm_check_report_module_01_config.md)11 项 finding 修复:

- H-config-1: Set(cfg) 用 mapstructure 重建 m.v,消除 Get/GetString/viper 视图分裂(C1)
- M-config-1: 热重载回调独立 recover,panic 不杀 watcher、不阻断后续回调(C9)
- M-config-2: DB SSL/TLS -- Postgres SSLMode(默认 prefer)+ MySQL TLS/TLSRootCA;
  database.ensureMySQLTLSRegistered 注册私有 CA 命名配置,fail-fast 不回退明文。
  O-1 文档准确性修正:同名注册会覆盖 master,改为 per-host 不同名 + 自建 replica DSN 指引
- M-config-3: App.Shutdown closeResources 停 configManager watcher(C7)
- M-config-4: Clone 切片/map 字段反射守卫测试
- M-config-5: WithConfig/WithConfigPath 全局可见性文档强化
- L-config-2: watchLoop 增 ctx 逃生通道,StopWatcher cancel+Close 双重退出
- L-config-3/4/5/6: Validate 连接池交叉校验、哨兵 errors.New、去冗余 TrimSpace、nil receiver 防御

含契约级回归测试(同源一致性/生命周期闭环/扩展点防御),-race/vet/build 全绿。
附 module 01 评审报告 + 全局结构图 + 扫描收敛协议。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
杭州明婳科技
2026-07-09 12:54:52 +08:00
parent 21021d4001
commit 08ed43385d
11 changed files with 890 additions and 17 deletions
+17
View File
@@ -17,9 +17,13 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
## [Unreleased]
> 复审报告 `gpt_check_report_review.md` 第一优先级(致命/进程级可用性)修复。P1 共 4 项,本次发布已推进 M13 cron panic、M1 App 生命周期、M3 logger 生命周期临界区;M8 CSRF JSON body 上限随后推进。
>
> config 模块评审(`glm_check_report_module_01_config.md`)修复:H-config-1 Set/viper 同源、M-config-1 回调 panic 隔离、M-config-2 DB SSL/TLS、M-config-3 App 关闭停 watcher、M-config-4 Clone 守卫、L-config-2/3/4/5/6。
### Breaking ⚠️
- **PostgresDSN 默认 sslmode 由 `disable` 改为 `prefer`**M-config-2):原 `PostgresDSN` 硬编码 `sslmode=disable`,生产 DB 流量明文。新增 `DatabaseConfig.SSLMode` 字段,空值默认 `prefer`(优先加密、失败回退明文),可显式配置 `disable/allow/prefer/require/verify-ca/verify-full`(非法值在 `Validate` 阶段报错)。依赖明文 Postgres 连接的下游若因 `prefer` 回退行为受影响,请显式设置 `ssl_mode: disable`。MySQL 不受影响(用 `TLS`/`TLSRootCA`)。
- **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)` 崩溃。
- **`cache.WithLock` / `cache.WithLockAutoExtend` 的业务函数签名改为 `func(context.Context) error`**:旧签名 `func() error` 无法强制业务函数接收取消信号,容易在请求取消/超时后继续访问 DB/HTTP 等下游资源;现在框架会把调用方 ctx 传入业务函数。nil 业务函数返回新增 `ErrLockFuncNil`
@@ -43,8 +47,21 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **`cron.ParseCron` 非法表达式改为 fail-fast panic**cron/cron.goM13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`
- **repository 查询保护默认开启**repository/repository.goM5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded``FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100``MaxPage=10000``Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder``UpdateBatch` 字段名不合法返回 `ErrUnsafeField`
### Security 🔒
- **MySQL 连接支持 TLS**M-config-2):`DatabaseConfig.TLS` 为 true 时 `MySQLDSN` 追加 `tls=true`go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 `DatabaseConfig.TLSRootCA`(PEM 路径)可指定私有 CA/自签证书,由 `database` 包在 `InitDB``RegisterTLSConfig` 注册命名配置(`config.MySQLTLSConfigName`);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。
- **Postgres 连接支持 sslmode 配置**M-config-2):见 Breaking 项,默认 `prefer` 优先加密。
### Fixed 🐛
- **config `Set(cfg)` 与 viper 视图同源修复**H-config-1):`Set` 原只更新类型化视图 `m.cfg``m.v`viper)停留旧值,导致 `Get()``GetString/GetInt/GetBool/GetViper` 返回不同世界(违反 C1 单一配置源)。现在 `Set` 用 mapstructure 将 `*Config` 重建为不含 `AutomaticEnv` 的 viper 视图,保证 Get 与 GetString 同源。
- **config 热重载回调 panic 隔离**M-config-1):单个 `onChange` 回调 panic 原会传播致 watcher 泄漏、后续热更新静默失效。现在每个回调独立 `recover`(标准库 `log` 记录 + 堆栈),不阻断后续回调、不杀 watcher。
- **App.Shutdown 停止 configManager watcher**M-config-3):`closeResources` 末尾新增 `configManager.StopWatcher()`,用户对 App 的 configManager 调 `LoadWithWatch`/`StartWatcher` 后由 Shutdown 统一收口,避免关闭后遗留监听 goroutine(违反 C7)。
- **config `Clone` 切片字段覆盖守卫**(M-config-4):新增反射测试枚举 `Config` 所有切片/map 字段,断言 `Clone` 深拷贝;新增切片字段未同步 fixture 或 `Clone` 时测试失败,形成机械守卫。
- **config `watchLoop` 增加 ctx 逃生通道**L-config-2):原仅靠 `w.Events` 关闭退出,与"for 消费循环须 ctx.Done"红线有张力。`StopWatcher` 改为 cancel ctx + Close watcher 双重退出。
- **config `Validate` 连接池交叉校验**L-config-3):`MaxOpenConns>0``MaxIdleConns>MaxOpenConns` 视为配置错误。
- **config 哨兵错误改用 `errors.New`**L-config-4);**`DSN()` 去除冗余 TrimSpace**L-config-5);**`DSN/MySQLDSN/PostgresDSN/Addr` nil receiver 防御**L-config-6)。
- **M9 JWT issuer / refresh expiry 契约修复**`ParseToken``InvalidateToken``GetClaimsFromToken` 统一按当前配置校验 issuer`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键;新增 `ParseTokenFailClosed` / `ParseTokenWithBlacklistPolicy` / `TokenBlacklist.IsBlacklistedE`,默认 `ParseToken` 仍保持黑名单检查 fail-open 兼容语义,安全敏感路由可显式选择 fail-closed;解析侧配置错误现在可通过 `errors.Is` 区分 `ErrEmptySecret` / `ErrUnsupportedAlgorithm``InvalidateToken` 使用不校验时序的解析路径,允许提前吊销 `nbf` 在未来的外部 token。
- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady``WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。
- **M10 cache 剩余错误语义收口**:新增 `cache.GetE` / `cache.ExistsE` 与可选 `CacheGetter` / `CacheExistChecker`,让调用方能区分 cache miss、Redis/backend 故障和反序列化错误;保留旧 `Get` / `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
+17 -2
View File
@@ -143,7 +143,13 @@ type App struct {
// Option 应用选项
type Option func(*App)
// WithConfigPath 设置配置文件路径
// WithConfigPath 设置配置文件路径
//
// M-config-5 全局可见性提示:WithConfigPath 在 Init 时会把 App 持有的 configManager 提升为
// 全局默认(config.SetDefaultManager),故 config.Get / config.GetString 等包级便捷函数可读到
// 该配置。这与 WithConfig 形成不对称:WithConfig 仅保留在 App 实例内,不污染全局,包级
// config.Get() 取不到注入配置。下游若混用 a.config 与 config.GetString,两种注入方式行为不同--
// 需要 config.Get/GetString 的下游请用 WithConfigPath(或自行 config.SetDefaultManager)。
func WithConfigPath(path string) Option {
return func(a *App) {
a.configPath = path
@@ -154,7 +160,9 @@ func WithConfigPath(path string) Option {
// WithConfig 设置配置对象。
// 配置会在传入时深拷贝成 App 私有快照,并在 Init 时 Validate;调用方后续修改 cfg
// 不会污染 App 内部配置。不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath。
//
// M-config-5:因此 config.Get / config.GetString 等包级函数取不到此注入配置(默认空 manager)。
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath(会提升为全局默认)。
func WithConfig(cfg *config.Config) Option {
return func(a *App) {
a.config = cfg.Clone()
@@ -522,6 +530,13 @@ func (a *App) closeResources() error {
a.initializedLogger = false
a.loggerManager = nil
}
// M-config-3:停止 App 持有的 configManager 热重载 watcher(若已启用),避免 App 关闭后
// 遗留监听 goroutine。默认流程(resolveConfig 用 Load 不启 watcher)为 no-op;用户对
// configManager 调用 LoadWithWatch/StartWatcher 后由这里统一收口。StopWatcher 幂等且会等待
// 进行中的 reload 回调结束,符合 C7 生命周期契约。configManager 可能为 nilWithConfig 注入)。
if a.configManager != nil {
a.configManager.StopWatcher()
}
return errors.Join(errs...)
}
+60
View File
@@ -0,0 +1,60 @@
package xlgo_test
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// TestAppShutdownStopsConfigWatcher_Mconfig3 固化 M-config-3App.Shutdown 须停止其
// configManager 的热重载 watcher,避免关闭后遗留监听 goroutine(违反 C7 生命周期契约)。
//
// Init 后 resolveConfig 把 a.configManager 提升为全局默认,故经包级 config.StartWatcher
// 在其上启动 watcher(等价于用户对 configManager 调 LoadWithWatch/StartWatcher);
// Shutdown 后断言 watchLoop goroutine 已退出。
func TestAppShutdownStopsConfigWatcher_Mconfig3(t *testing.T) {
dir := filepath.Join(os.TempDir(), "xlgo_app_mconfig3")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
defer os.RemoveAll(dir)
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("app:\n name: m3\n env: dev\nserver:\n port: 18080\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
app := xlgo.New(xlgo.WithConfigPath(cfgPath))
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
// 经全局默认(=a.configManager)启动 watcher,模拟用户开启热重载
if err := config.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
defer config.SetDefaultManager(nil)
time.Sleep(120 * time.Millisecond) // 等 watchLoop 就绪
before := runtime.NumGoroutine()
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// watchLoop goroutine 应随 Shutdown 退出
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() < before {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after >= before {
t.Errorf("App.Shutdown 未停止 config watcherM-config-3: before=%d after=%d", before, after)
}
}
+126 -14
View File
@@ -1,10 +1,13 @@
package config
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
@@ -17,8 +20,9 @@ import (
// 配置错误
var (
ErrConfigNotLoaded = fmt.Errorf("配置未加载")
ErrInvalidConfig = fmt.Errorf("配置非法")
// L-config-4:无格式化的哨兵错误用 errors.Newfmt.Errorf 无动词等价但语义上后者暗示格式化)。
ErrConfigNotLoaded = errors.New("配置未加载")
ErrInvalidConfig = errors.New("配置非法")
)
// Config 全局配置结构体
@@ -230,6 +234,12 @@ const (
DriverPostgres = "postgres"
)
// MySQLTLSConfigName 是 database 包为 MySQL 私有 CA TLS 注册的命名配置名(M-config-2)。
// 当 DatabaseConfig.TLS=true 且 TLSRootCA 非空时,MySQLDSN 追加 tls=<本常量>
// 由 database 包在 InitDB 时通过 go-sql-driver/mysql.RegisterTLSConfig 注册自定义 *tls.Config。
// TLS=true 但 TLSRootCA 为空时则用内置 tls=true(系统根 CA),无需注册。
const MySQLTLSConfigName = "xlgo-mysql"
// DSNBuilder 根据 DatabaseConfig 生成连接字符串
type DSNBuilder func(*DatabaseConfig) string
@@ -311,17 +321,34 @@ type DatabaseConfig struct {
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"`
// HealthCheckFailureThreshold 连续探活失败多少次标记不健康(#21)。0 表示用默认 3
HealthCheckFailureThreshold int `mapstructure:"health_check_failure_threshold"`
// SSLMode PostgreSQL sslmodedisable/allow/prefer/require/verify-ca/verify-full)。
// 空时默认 "prefer"M-config-2:原硬编码 disable 已改为默认 prefer,优先加密、失败回退明文)。
// 该字段仅对 PostgreSQL 生效;MySQL 用 TLS/TLSRootCA。
SSLMode string `mapstructure:"ssl_mode"`
// TLS 是否对 MySQL 启用 TLS。true 时 MySQLDSN 追加 tls=true(内置:系统根 CA + 证书校验,
// ServerName 自动取自 Host)。配合 TLSRootCA 可指定私有 CAM-config-2)。
TLS bool `mapstructure:"tls"`
// TLSRootCA MySQL TLS 自定义 CA 证书 PEM 路径(用于私有 CA/自签证书)。
// 非空时 MySQLDSN 改用 tls=MySQLTLSConfigName,由 database 包在 InitDB 时注册命名 TLS 配置
// ServerName 取自 Host)。该命名配置仅覆盖「replica DSN host 与主库 Host 相同」的单 host 集群;
// 多 host replicas + 私有 CA 时须为每个 replica host 注册不同名 TLS 配置并自建 replica DSN(框架
// MySQLDSN 硬编码 MySQLTLSConfigName,不适用),详见 database.ensureMySQLTLSRegistered 注释。
// 空时用内置 tls=true(系统根 CA)。仅 MySQL 生效。
TLSRootCA string `mapstructure:"tls_root_ca"`
}
// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN。
// 未指定 Driver 时按 MySQL 处理;非空但未注册的 Driver 返回空字符串,
// 避免把拼写错误静默当作 MySQL 连接,正常加载路径会由 Validate 提前报错。
func (c *DatabaseConfig) DSN() string {
if c == nil { // L-config-6:防御 nil receiver
return ""
}
if c.CustomDSN != "" {
return c.CustomDSN
}
driver := strings.TrimSpace(c.Driver)
if strings.TrimSpace(driver) == "" {
if driver == "" { // L-config-5:去除原重复的 TrimSpace(driver)
driver = DriverMySQL
}
if builder, ok := LookupDSNBuilder(driver); ok {
@@ -343,25 +370,47 @@ func (c DatabaseConfig) isConfigured() bool {
// MySQLDSN 返回 MySQL 连接字符串。
// 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。
// loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。
// TLS=true 时追加 tls 参数(M-config-2):TLSRootCA 为空用内置 tls=true(系统根 CA + 证书校验);
// TLSRootCA 非空用 tls=MySQLTLSConfigNamedatabase 包注册的私有 CA 命名配置)。
func (c *DatabaseConfig) MySQLDSN() string {
if c == nil { // L-config-6
return ""
}
loc := c.Timezone
if loc == "" {
loc = "Local"
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
url.QueryEscape(c.User), url.QueryEscape(c.Password), c.Host, c.Port, url.PathEscape(c.Name), url.QueryEscape(loc))
if c.TLS {
if strings.TrimSpace(c.TLSRootCA) == "" {
dsn += "&tls=true"
} else {
dsn += "&tls=" + MySQLTLSConfigName
}
}
return dsn
}
// PostgresDSN 返回 PostgreSQL 连接字符串。
// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。
// TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。
// sslmode 由 SSLMode 配置,空则默认 "prefer"M-config-2:原硬编码 disable 改为 prefer,优先加密)。
func (c *DatabaseConfig) PostgresDSN() string {
if c == nil { // L-config-6
return ""
}
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",
postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), postgresQuote(tz))
sslmode := strings.TrimSpace(c.SSLMode)
if sslmode == "" {
sslmode = "prefer"
}
// sslmode 不加引号(与原 sslmode=disable 格式一致);SSLMode 已被 Validate 限定为固定枚举,无注入风险。
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), sslmode, postgresQuote(tz))
}
func postgresQuote(s string) string {
@@ -380,6 +429,9 @@ type RedisConfig struct {
// Addr 返回 Redis 地址
func (c *RedisConfig) Addr() string {
if c == nil { // L-config-6
return ""
}
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
@@ -524,6 +576,10 @@ type Manager struct {
watcher *fsnotify.Watcher
// watchDone 在监听 goroutine 退出时被 close,供 StopWatcher 等待退出确认。
watchDone chan struct{}
// watchCancel 取消 watchLoop 的 ctxL-config-2)。watchLoop 同时监听 ctx.Done 与 w.Events
// 提供 fsnotify 致命错误且 Events 未关闭时的逃生通道,避免监听 goroutine 永驻。
// StopWatcher 时 cancel + Close(w) 双重退出保障。
watchCancel context.CancelFunc
}
// defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写,
@@ -558,6 +614,38 @@ func cloneViper(src *viper.Viper, configPath string) *viper.Viper {
return cp
}
// configToMap 将 *Config 按 mapstructure tag 转为嵌套 map[string]anyH-config-1)。
// mapstructure struct->map 会递归嵌套结构体为 map,切片/map 字段原样保留,
// 用于在 Set(cfg) 后重建 m.v,使 GetString/GetInt/GetBool/GetViper 与 Get() 读到同一配置。
func configToMap(cfg *Config) map[string]any {
if cfg == nil {
return nil
}
out := map[string]any{}
if err := mapstructure.Decode(cfg, &out); err != nil {
return nil
}
return out
}
// viperFromConfig 由 *Config 重建一个不含 AutomaticEnv 的 viperH-config-1)。
// 用于 Set(cfg) 后同步 m.v:不启用 AutomaticEnv 是为了确保 GetString 等只读取 cfg 派生的值,
// 与 Get()(返回 m.cfg 即调用方传入的 cfg)严格同源,避免 env 覆盖造成二者的二次分裂。
// 保留 SetConfigFile(m.path) 以便后续 Reload 从文件重读。
//
// 已知差异(Duration 字段):mapstructure struct->map 将 time.Duration 原样保留为 time.Duration
// 故 GetString("jwt.expire") 在 Set 后返回 Duration.String() 格式(如 "24h0m0s"),与文件加载路径
// 返回的原始字符串(如 "24h")字面不同。二者语义一致(均可 ParseDuration),typed viewGet().JWT.Expire
// 与 GetDuration 在两条路径下完全一致。Duration 字段应经 typed view 或 GetDuration 读取,勿用 GetString 字面比较。
func viperFromConfig(configPath string, cfg *Config) *viper.Viper {
v := viper.New()
v.SetConfigFile(configPath)
if m := configToMap(cfg); m != nil {
_ = v.MergeConfigMap(m)
}
return v
}
// unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook
// 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。
func unmarshalConfig(v *viper.Viper, cfg *Config) error {
@@ -653,19 +741,22 @@ func (m *Manager) StartWatcher() error {
}
m.watcher = w
m.watchDone = make(chan struct{})
// L-config-2:为 watchLoop 创建可取消 ctx,作为 w.Events 关闭之外的逃生通道。
ctx, cancel := context.WithCancel(context.Background())
m.watchCancel = cancel
target := filepath.Base(m.path)
done := m.watchDone
m.mu.Unlock()
go m.watchLoop(w, target, done)
go m.watchLoop(ctx, w, target, done)
return nil
}
// watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload;
// watcher 被 CloseEvents 通道关闭)时退出并 close done。
// done 由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone 字段
// 与 StopWatcher 写入竞争。
func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) {
// watcher 被 CloseEvents 通道关闭)或 ctx 被 cancelL-config-2时退出并 close done。
// done 与 ctx 均由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone /
// m.watchCancel 字段与 StopWatcher 写入竞争。ctx 提供 fsnotify 致命错误下的逃生通道。
func (m *Manager) watchLoop(ctx context.Context, w *fsnotify.Watcher, target string, done chan struct{}) {
defer close(done)
const debounce = 200 * time.Millisecond
timer := time.NewTimer(time.Hour)
@@ -676,6 +767,8 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct
var timerC <-chan time.Time
for {
select {
case <-ctx.Done(): // L-config-2StopWatcher cancel 时退出,不单靠 w.Events 关闭
return
case ev, ok := <-w.Events:
if !ok {
return
@@ -711,7 +804,7 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct
}
// StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。
// 关闭 fsnotify watcher → Events 通道关闭 → watchLoop 退出 → 等待 watchDone
// cancel ctx 与关闭 fsnotify watcher 双重退出(L-config-2),等待 watchDone 确认 goroutine 退出
func (m *Manager) StopWatcher() {
if m == nil {
return
@@ -719,13 +812,18 @@ func (m *Manager) StopWatcher() {
m.mu.Lock()
w := m.watcher
done := m.watchDone
cancel := m.watchCancel
m.watcher = nil
m.watchDone = nil
m.watchCancel = nil
m.mu.Unlock()
if cancel != nil {
cancel() // L-config-2:先 cancel 让 watchLoop 经 ctx.Done 退出
}
if w == nil {
return
}
_ = w.Close()
_ = w.Close() // 再 Close watcher 让 w.Events 关闭,双重保障
if done != nil {
<-done
}
@@ -807,6 +905,9 @@ func (m *Manager) GetStringMap(key string) map[string]any {
}
// Set 手动设置配置。非 nil 配置会先 Validate,并以深拷贝形式保存。
// H-config-1:非 nil 配置同时用其重建 m.v(不含 AutomaticEnv),使 Get() 与
// GetString/GetInt/GetBool/GetViper 读到同一配置世界,消除原"Set 只更新类型化视图、
// viper 视图停留在旧值"的静默分裂(违反 C1 单一配置源)。
func (m *Manager) Set(cfg *Config) error {
if m == nil {
return ErrConfigNotLoaded
@@ -821,6 +922,8 @@ func (m *Manager) Set(cfg *Config) error {
m.cfg = cfg.Clone()
if cfg == nil {
m.v = nil
} else {
m.v = viperFromConfig(m.path, cfg)
}
return nil
}
@@ -864,8 +967,17 @@ func (m *Manager) reload() error {
// M-G:回调传入 newCfg 的深拷贝,避免回调修改切片字段与 Get() 读者(持有 &newCfg)竞态。
// 回调为 onChange 通知语义,应观察而非改写配置;改写副本不影响内部 m.cfg。
// M-config-1:每个回调独立 recover,单个回调 panic 不得杀掉 watcher 或阻断后续回调。
// config 是叶子包(logger 依赖 config),不能用框架 zap,故用标准库 log 记录 + 堆栈。
for _, cb := range cbs {
cb(newCfg.Clone())
func(cb func(*Config)) {
defer func() {
if r := recover(); r != nil {
log.Printf("config: 配置变更回调 panic(已隔离,继续后续回调): %v\n%s", r, debug.Stack())
}
}()
cb(newCfg.Clone())
}(cb)
}
return nil
}
+329
View File
@@ -0,0 +1,329 @@
package config_test
import (
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// TestSetKeepsViperInSync_Hconfig1 固化 H-config-1Set(cfg) 后 Get() 与
// GetString/GetInt/GetBool/GetViper 必须读到同一配置世界,消除原"Set 只更新类型化视图、
// viper 视图停留旧值"的静默分裂(违反 C1 单一配置源)。
func TestSetKeepsViperInSync_Hconfig1(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{Name: "sync-test", SiteName: "sync_site", Env: "prod", Debug: true},
Server: config.ServerConfig{Port: 7777},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
// 类型化视图
got := config.Get()
if got == nil || got.App.Name != "sync-test" || got.Server.Port != 7777 {
t.Fatalf("Get() 不一致: %+v", got)
}
// viper 视图必须同源
if v := config.GetString("app.name"); v != "sync-test" {
t.Errorf("GetString(app.name) = %q, want sync-testH-config-1 同源)", v)
}
if v := config.GetInt("server.port"); v != 7777 {
t.Errorf("GetInt(server.port) = %d, want 7777H-config-1", v)
}
if v := config.GetBool("app.debug"); v != true {
t.Errorf("GetBool(app.debug) = %v, want trueH-config-1", v)
}
if v := config.GetString("app.env"); v != "prod" {
t.Errorf("GetString(app.env) = %q, want prodH-config-1", v)
}
vp := config.GetViper()
if vp == nil || vp.GetString("app.name") != "sync-test" {
t.Errorf("GetViper().GetString(app.name) 不一致(H-config-1: %v", vp)
}
// Set(nil) 清空,GetString 返回空(m.v=nil
if err := config.Set(nil); err != nil {
t.Fatalf("Set(nil): %v", err)
}
if v := config.GetString("app.name"); v != "" {
t.Errorf("Set(nil) 后 GetString 应为空, got %q", v)
}
}
// TestSetDurationGetDurationConsistent_Hconfig1 锁定 H-config-1 已知行为:Duration 字段经 Set
// 重建 viper 后,GetString 字面格式与文件加载不同(mapstructure 存 time.Duration -> Duration.String()),
// 但 typed view 与 GetDuration 在两条路径下一致。Duration 应经 typed view / GetDuration 读取。
func TestSetDurationGetDurationConsistent_Hconfig1(t *testing.T) {
cfg := &config.Config{
JWT: config.JWTConfig{Secret: strings.Repeat("k", 32), Expire: 24 * time.Hour},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
if got := config.Get().JWT.Expire; got != 24*time.Hour {
t.Errorf("Get().JWT.Expire = %v, want 24h", got)
}
if got := config.GetViper().GetDuration("jwt.expire"); got != 24*time.Hour {
t.Errorf("GetDuration(jwt.expire) = %v, want 24hDuration 同源应读 GetDuration/typed view", got)
}
}
// TestReloadCallbackPanicIsolation_Mconfig1 固化 M-config-1:单个热重载回调 panic 不得
// 向上传播、不得阻断后续回调、不得让 watcher/reload 链路静默失效。
func TestReloadCallbackPanicIsolation_Mconfig1(t *testing.T) {
p := writeConfig(t, "m1_panic.yaml", validConfigYAML(8301))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
called := make(chan int, 4)
m.RegisterCallback(func(*config.Config) {
panic("boom from user callback") // 模拟用户回调 panic
})
m.RegisterCallback(func(c *config.Config) {
called <- c.Server.Port
})
// Reload 不应把回调 panic 向上传播
if err := m.Reload(); err != nil {
t.Fatalf("Reload 不应传播回调 panic: %v", err)
}
// 第二个回调仍触发:panic 被隔离,未阻断后续回调
select {
case port := <-called:
if port != 8301 {
t.Errorf("第二个回调端口 = %d, want 8301", port)
}
case <-time.After(time.Second):
t.Fatal("panic 后续回调未触发(M-config-1 隔离失败)")
}
// 后续 reload 仍正常,watcher/reload 链路未静默失效
if err := os.WriteFile(p, []byte(validConfigYAML(8302)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := m.Reload(); err != nil {
t.Fatalf("panic 后 Reload 应仍可用: %v", err)
}
select {
case port := <-called:
if port != 8302 {
t.Errorf("第二次 reload 回调端口 = %d, want 8302", port)
}
case <-time.After(time.Second):
t.Fatal("panic 后 reload 链路静默失效(M-config-1")
}
}
// TestMySQLDSN_TLS_Mconfig2 固化 M-config-2 MySQL TLSTLS=false 无 tls 参数;
// TLS=true 无 CA 用内置 tls=trueTLS=true 有 CA 用 tls=MySQLTLSConfigName。
func TestMySQLDSN_TLS_Mconfig2(t *testing.T) {
db := config.DatabaseConfig{Host: "h", Port: 3306, User: "u", Password: "p", Name: "n"}
if dsn := db.MySQLDSN(); strings.Contains(dsn, "tls=") {
t.Errorf("TLS=false 不应含 tls 参数, dsn=%s", dsn)
}
db.TLS = true
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls=true") {
t.Errorf("TLS=true 无 CA 应用内置 tls=true, dsn=%s", dsn)
}
db.TLSRootCA = "/path/ca.pem"
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls="+config.MySQLTLSConfigName) {
t.Errorf("TLS=true 有 CA 应用 tls=%s, dsn=%s", config.MySQLTLSConfigName, dsn)
}
// CustomDSN 优先,TLS 字段不影响 DSN()
db.CustomDSN = "custom-dsn"
if dsn := db.DSN(); dsn != "custom-dsn" {
t.Errorf("CustomDSN 应优先, got %s", dsn)
}
}
// TestPostgresDSN_SSLMode_Mconfig2 固化 M-config-2 Postgres SSLMode:空默认 prefer
// 显式值透传(sslmode 不加引号,与原 disable 格式一致)。
func TestPostgresDSN_SSLMode_Mconfig2(t *testing.T) {
db := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n"}
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode=prefer ") {
t.Errorf("空 SSLMode 默认 prefer, dsn=%s", dsn)
}
for _, mode := range []string{"disable", "allow", "require", "verify-ca", "verify-full"} {
db.SSLMode = mode
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode="+mode+" ") {
t.Errorf("SSLMode=%s 应透传, dsn=%s", mode, dsn)
}
}
}
// TestValidateMaxIdleExceedsOpen_Lconfig3 固化 L-config-3MaxOpenConns>0 时
// MaxIdleConns>MaxOpenConns 视为配置错误;MaxOpenConns=0(无限)不校验。
func TestValidateMaxIdleExceedsOpen_Lconfig3(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
MaxOpenConns: 10, MaxIdleConns: 20,
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "max_idle_conns") {
t.Fatalf("MaxIdleConns>MaxOpenConns 应报错, got: %v", err)
}
cfg.Database.MaxOpenConns = 0 // 未配置/无限,不校验 idle
if err := cfg.Validate(); err != nil {
t.Fatalf("MaxOpenConns=0 时不该校验 idle, got: %v", err)
}
cfg.Database.MaxOpenConns = 20 // 合法
if err := cfg.Validate(); err != nil {
t.Fatalf("合法配置不应报错, got: %v", err)
}
}
// TestValidatePostgresSSLMode_Mconfig2 固化 M-config-2:非法 SSLMode 在 Validate 阶段报错。
func TestValidatePostgresSSLMode_Mconfig2(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
SSLMode: "invalid-mode",
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "ssl_mode") {
t.Fatalf("非法 SSLMode 应报错, got: %v", err)
}
cfg.Database.SSLMode = "require"
if err := cfg.Validate(); err != nil {
t.Fatalf("合法 SSLMode=require 不应报错, got: %v", err)
}
}
// TestValidateTLSRootCAWithoutTLS_Mconfig2 固化 M-config-2TLSRootCA 需配合 tls:true。
func TestValidateTLSRootCAWithoutTLS_Mconfig2(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLSRootCA: "/path/ca.pem", // TLS=false
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "tls_root_ca") {
t.Fatalf("TLSRootCA 无 tls:true 应报错, got: %v", err)
}
cfg.Database.TLS = true
if err := cfg.Validate(); err != nil {
t.Fatalf("TLS=true + TLSRootCA 合法不应报错, got: %v", err)
}
}
// TestDSNAddrNilReceiver_Lconfig6 固化 L-config-6DSN/MySQLDSN/PostgresDSN/Addr 对 nil receiver 安全。
func TestDSNAddrNilReceiver_Lconfig6(t *testing.T) {
var db *config.DatabaseConfig
if v := db.DSN(); v != "" {
t.Errorf("nil DatabaseConfig.DSN() = %q, want empty", v)
}
if v := db.MySQLDSN(); v != "" {
t.Errorf("nil MySQLDSN() = %q, want empty", v)
}
if v := db.PostgresDSN(); v != "" {
t.Errorf("nil PostgresDSN() = %q, want empty", v)
}
var r *config.RedisConfig
if v := r.Addr(); v != "" {
t.Errorf("nil RedisConfig.Addr() = %q, want empty", v)
}
}
// TestCloneDeepCopiesAllSliceFields_Mconfig4 固化 M-config-4:反射枚举 Config 所有切片/map 字段,
// 断言 Clone 深拷贝(不同底层数组/map)。fixture 必须填充每个切片字段--新增切片字段若未在 Clone
// 中处理、或未在 fixture 中填充,本测试都会失败,形成机械守卫。
func TestCloneDeepCopiesAllSliceFields_Mconfig4(t *testing.T) {
original := allSlicesPopulatedConfig()
clone := original.Clone()
var check func(o, c reflect.Value, path string)
check = func(o, c reflect.Value, path string) {
for o.Kind() == reflect.Pointer {
o = o.Elem()
}
for c.Kind() == reflect.Pointer {
c = c.Elem()
}
if o.Kind() != reflect.Struct {
return
}
for i := 0; i < o.NumField(); i++ {
of := o.Field(i)
cf := c.Field(i)
p := path + "." + o.Type().Field(i).Name
switch of.Kind() {
case reflect.Pointer, reflect.Struct:
check(of, cf, p)
case reflect.Slice:
if of.IsNil() || of.Len() == 0 {
t.Errorf("M-config-4: fixture 未填充切片 %s(新增切片字段须同步 fixture 与 Clone", p)
continue
}
if cf.IsNil() || cf.Len() != of.Len() {
t.Errorf("M-config-4: Clone 后切片 %s 为 nil 或长度不一致", p)
continue
}
if of.Pointer() == cf.Pointer() {
t.Errorf("M-config-4: Clone 未深拷贝切片 %s(共享底层数组)", p)
}
case reflect.Map:
if of.IsNil() || of.Len() == 0 {
t.Errorf("M-config-4: fixture 未填充 map %s", p)
continue
}
if of.Pointer() == cf.Pointer() {
t.Errorf("M-config-4: Clone 未深拷贝 map %s(共享 map", p)
}
}
}
}
check(reflect.ValueOf(original), reflect.ValueOf(clone), "Config")
}
// allSlicesPopulatedConfig 返回一个填充了所有切片字段的 Config,供 Clone 覆盖断言使用。
// 新增任何切片/map 字段到 Config 或其子结构体时,必须同步在此填充,否则
// TestCloneDeepCopiesAllSliceFields_Mconfig4 会以"未填充"失败提醒。
func allSlicesPopulatedConfig() *config.Config {
return &config.Config{
CORS: config.CORSConfig{
AllowedOrigins: []string{"https://a.example.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"X-Test"},
ExposedHeaders: []string{"X-Exp"},
},
Upload: config.UploadConfig{
AllowedImageTypes: []string{"image/jpeg"},
AllowedVideoTypes: []string{"video/mp4"},
},
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Upload: config.UploadPolicy{
AllowedExts: []string{".jpg"},
AllowedMIMEs: []string{"image/jpeg"},
},
},
OSS: config.OSSStorageConfig{
Upload: config.UploadPolicy{
AllowedExts: []string{".png"},
AllowedMIMEs: []string{"image/png"},
},
},
},
}
}
+1 -1
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=prefer TimeZone='Asia/Shanghai'"
if dsn != expected {
t.Errorf("Postgres DSN = %s, want %s", dsn, expected)
}
+26
View File
@@ -56,6 +56,23 @@ func (c *Config) Validate() error {
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))
}
// L-config-3:连接池配置交叉校验。MaxOpenConns>0 时 MaxIdleConns 不应超过它,
// 否则 database/sql 会按 MaxOpenConns 截断空闲连接,配置意图与实际不符。
if c.Database.MaxOpenConns > 0 && c.Database.MaxIdleConns > c.Database.MaxOpenConns {
problems = append(problems, fmt.Sprintf(
"database.max_idle_conns(%d) 不应大于 max_open_conns(%d)", c.Database.MaxIdleConns, c.Database.MaxOpenConns))
}
// M-config-2Postgres SSLMode 合法性(非空时校验,空由 PostgresDSN 默认 prefer)。
if sslmode := strings.TrimSpace(c.Database.SSLMode); sslmode != "" {
if !validPostgresSSLMode(sslmode) {
problems = append(problems, fmt.Sprintf(
"database.ssl_mode 非法: %s(允许: disable/allow/prefer/require/verify-ca/verify-full", sslmode))
}
}
// M-config-2TLSRootCA 仅在 TLS=true 时生效,单独配置而无 tls:true 视为配置不一致。
if strings.TrimSpace(c.Database.TLSRootCA) != "" && !c.Database.TLS {
problems = append(problems, "database.tls_root_ca 需配合 tls: true 才生效")
}
}
// Redis:仅当配置了 host 时校验
@@ -73,3 +90,12 @@ func (c *Config) Validate() error {
// validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。
func validDuration(d time.Duration) bool { return d >= 0 }
// validPostgresSSLMode 校验 PostgreSQL sslmode 取值(M-config-2)。
func validPostgresSSLMode(s string) bool {
switch s {
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
return true
}
return false
}
+6
View File
@@ -493,6 +493,12 @@ func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
Logger: gormlogger.Default.LogMode(gormLogLevel),
}
// M-config-2MySQL 启用 TLS 且配置自定义 CA 时,注册命名 TLS 配置,使 DSN 中 tls=<name> 生效。
// 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。
if err := ensureMySQLTLSRegistered(cfg); err != nil {
return err
}
// 重试配置
maxRetries := 5
retryDelay := time.Second
+72
View File
@@ -0,0 +1,72 @@
package database
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"github.com/EthanCodeCraft/xlgo-core/config"
mysqldriver "github.com/go-sql-driver/mysql"
)
// ensureMySQLTLSRegistered 按 cfg.Database 的 TLS 配置注册命名 TLS 配置到 go-sql-driver/mysqlM-config-2)。
//
// go-sql-driver/mysql v1.7.0 的 tls DSN 参数语义:
// - tls=true:内置安全(&tls.Config{},系统根 CA + ServerName 自动取自 host + 证书校验),无需注册。
// - tls=<name>:引用经 RegisterTLSConfig 注册的命名配置,用于私有 CA/自签证书。
//
// 本函数仅在「TLS=true 且 TLSRootCA 非空」时注册 config.MySQLTLSConfigName 命名配置:
// 加载 TLSRootCA 的 PEM 到 RootCAsServerName 取自 HostMinVersion=TLS1.2。其余情况(TLS 未启用、
// 或启用但用内置 tls=true)直接返回 nil。
//
// 非 MySQL 驱动(如 postgres,用 SSLMode)跳过。失败返回错误,由 initDB fail-fast
// 绝不静默回退明文连接(生产 DB 流量明文是安全风险)。
//
// 注意:RegisterTLSConfig 是驱动级全局状态——一个名字对应唯一 *tls.Config(含唯一 ServerName),
// 同名重复注册会覆盖前者且不报错,故无法用「同名」同时匹配多个不同 host。
//
// 覆盖范围(注册配置的 ServerName 固定取自主库 Host):
// - 单 host 集群:replica DSN 的 host 与主库 Host 相同(同机不同端口,或经同一 LB/主机名暴露)——
// master 与 replica DSN 均用 tls=MySQLTLSConfigNameServerName 一致,握手通过。
// - 多 host replicas + 私有 CAreplica DSN 的 host 与主库 Host 不同时,本注册的 ServerName(主库
// host)与 replica 证书 SAN 不匹配,握手失败。此场景须用户为每个 replica host 注册**不同名**的
// TLS 配置(各自 ServerName=该 replica host)并自行构造对应 replica DSNtls=<该名>);框架
// MySQLDSN() 硬编码 tls=MySQLTLSConfigName,不能用于这些 replica DSN。切勿对 MySQLTLSConfigName
// 同名重复注册——会覆盖主库注册、导致主库握手失败。
//
// replica 路径(InitDBWithReplicas 的 replicaDSNs)由调用方传原始 DSN,不经本函数注册。
// 多集群不同 CA 亦属已知多 App 限制,需用户按上述方式自行注册。
func ensureMySQLTLSRegistered(cfg *config.Config) error {
if cfg == nil {
return nil
}
db := &cfg.Database
if !db.TLS || strings.TrimSpace(db.TLSRootCA) == "" {
return nil
}
// 仅 MySQL 走注册路径;postgres 用 SSLMode,其他驱动自管 TLS。空 driver 默认 MySQL。
drv := normalizeDriver(db.Driver)
if drv != "" && drv != DriverMySQL {
return nil
}
pem, err := os.ReadFile(db.TLSRootCA)
if err != nil {
return fmt.Errorf("读取 MySQL TLS CA 文件失败 %q: %w", db.TLSRootCA, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return fmt.Errorf("解析 MySQL TLS CA 文件失败 %q: 非 PEM 格式或无有效证书", db.TLSRootCA)
}
tlsCfg := &tls.Config{
RootCAs: pool,
ServerName: db.Host,
MinVersion: tls.VersionTLS12, // 禁用 TLS1.0/1.1,符合安全基线
}
if err := mysqldriver.RegisterTLSConfig(config.MySQLTLSConfigName, tlsCfg); err != nil {
return fmt.Errorf("注册 MySQL TLS 配置 %q 失败: %w", config.MySQLTLSConfigName, err)
}
return nil
}
+146
View File
@@ -0,0 +1,146 @@
package database
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
mysqldriver "github.com/go-sql-driver/mysql"
)
// writeSelfSignedCAPEM 生成一个自签名 CA 证书并写入临时 PEM 文件,返回路径。
func writeSelfSignedCAPEM(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "xlgo-test-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
p := filepath.Join(os.TempDir(), "xlgo_tls_ca_test.pem")
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
// TestEnsureMySQLTLSRegistered_NoOpCases 固化 M-config-2:非 MySQL / 未启用 TLS / 无 CA 时
// ensureMySQLTLSRegistered 为 no-op,不注册、不报错。
func TestEnsureMySQLTLSRegistered_NoOpCases(t *testing.T) {
if err := ensureMySQLTLSRegistered(nil); err != nil {
t.Errorf("nil cfg 应 no-op, got %v", err)
}
mysqlNoTLS := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
}}
if err := ensureMySQLTLSRegistered(mysqlNoTLS); err != nil {
t.Errorf("TLS=false 应 no-op, got %v", err)
}
mysqlBuiltIn := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, // 无 TLSRootCA,用内置 tls=true
}}
if err := ensureMySQLTLSRegistered(mysqlBuiltIn); err != nil {
t.Errorf("TLS=true 无 CA 应 no-op(内置 tls=true, got %v", err)
}
// postgres 用 SSLMode,不走 MySQL TLS 注册
pg := &config.Config{Database: config.DatabaseConfig{
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: "/path/ca.pem",
}}
if err := ensureMySQLTLSRegistered(pg); err != nil {
t.Errorf("postgres 应跳过 MySQL TLS 注册, got %v", err)
}
// 空 driver(默认 MySQL+ 无 TLSno-op
emptyDrv := &config.Config{Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
}}
if err := ensureMySQLTLSRegistered(emptyDrv); err != nil {
t.Errorf("空 driver 无 TLS 应 no-op, got %v", err)
}
}
// TestEnsureMySQLTLSRegistered_BadCA 固化 M-config-2CA 文件不可读或非 PEM 时 fail-fast
// 绝不静默回退明文连接。
func TestEnsureMySQLTLSRegistered_BadCA(t *testing.T) {
missing := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: "/no/such/ca.pem",
}}
if err := ensureMySQLTLSRegistered(missing); err == nil || !strings.Contains(err.Error(), "读取") {
t.Fatalf("CA 文件不存在应报读取错误, got: %v", err)
}
nonPEM := filepath.Join(os.TempDir(), "xlgo_tls_nonpem.pem")
if err := os.WriteFile(nonPEM, []byte("this is not a pem file"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
bad := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: nonPEM,
}}
if err := ensureMySQLTLSRegistered(bad); err == nil || !strings.Contains(err.Error(), "解析") {
t.Fatalf("非 PEM 文件应报解析错误, got: %v", err)
}
}
// TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN 固化 M-config-2 端到端:
// 注册前 ParseDSN(tls=xlgo-mysql) 报 unknown config name;注册后成功解析且 TLS 非 nil。
// 利用 go-sql-driver/mysql 的 TLS 解析发生在 ParseDSN 的 normalize() 阶段,无需真实 DB 连接。
func TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN(t *testing.T) {
caPath := writeSelfSignedCAPEM(t)
defer os.Remove(caPath)
// 清理同名残留注册,保证「注册前」断言不被前序测试污染
mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName)
defer mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName)
cfg := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "127.0.0.1", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: caPath,
}}
dsn := cfg.Database.DSN()
if !strings.Contains(dsn, "tls="+config.MySQLTLSConfigName) {
t.Fatalf("DSN 应含 tls=%s: %s", config.MySQLTLSConfigName, dsn)
}
// 注册前:ParseDSN 应报 unknown config name
if _, err := mysqldriver.ParseDSN(dsn); err == nil {
t.Fatal("注册前 ParseDSN 不应成功(未知 TLS 配置名)")
} else if !strings.Contains(err.Error(), "unknown config name") {
t.Logf("注册前 ParseDSN 错误(预期未知配置名): %v", err)
}
// 注册后:ParseDSN 成功,TLS 已解析为非 nil
if err := ensureMySQLTLSRegistered(cfg); err != nil {
t.Fatalf("ensureMySQLTLSRegistered: %v", err)
}
mc, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatalf("注册后 ParseDSN 应成功, got: %v", err)
}
if mc.TLS == nil {
t.Error("注册后 ParseDSN 的 TLS 配置应为非 nilCA 已注册)")
}
if mc.TLS.ServerName != "127.0.0.1" {
t.Errorf("TLS ServerName = %q, want 127.0.0.1", mc.TLS.ServerName)
}
}
+90
View File
@@ -0,0 +1,90 @@
# 扫描收敛协议(review discipline
> 本文件是 CLAUDE.md「开发纪律」的姊妹篇。开发纪律管"怎么写代码",本协议管"怎么审代码"。
> 起源:多轮大模型全局复审非收敛--每换一个 reviewer 就重新生成一批 nits/功能建议/风格偏好,finding 数永不归零,压迫感持续累积却无关真实质量。本协议把复审收敛到"只产出严重问题"。
## 0. 一句话原则
**只报可达、真实、严重的缺陷。功能正常、质量良好、无 bug 的代码不报。低于 HIGH 一律不报。**
## 1. 栅栏:只报 CRITICAL 与 HIGH
仅当缺陷**同时**满足以下三条才计入交付:
- **可达**:存在真实调用路径触发(必须给出路径:哪条调用链、什么输入、什么并发时序)。纸上推演"理论上如果有人这么调"不算,必须有真实调用方或合理下游用法。
- **是缺陷,非设计**:不是已文档化的设计取舍,不是功能缺口,不是已知 footgun。
- **严重**:达到 CRITICAL 或 HIGH。
级别定义:
- **CRITICAL**:正常使用下可触发的 数据损坏 / 安全突破 / 死锁 / panic / 资源泄漏。
- **HIGH**:真实(不必常见)路径下的 契约违反 / 并发竞态 / 生命周期泄漏 / 安全弱点,且无优雅降级。
低于 HIGH(MEDIUM/LOW)**默认不报**。若某条 MEDIUM 实际后果严重(如静默停摆),应在证伪后上调为 HIGH 再报,而不是以 MEDIUM 身份进清单。
## 2. 吹毛求疵禁区(明确不报)
以下一律**不作为 finding**。可在交付末尾"观察区(非 bug)"单列,但**不计入、不阻塞、不计数**:
1. **风格 / 格式 / 命名**:`fmt.Errorf` vs `errors.New`、变量命名、注释措辞、import 顺序。
2. **"无害冗余" / 微优化**:无热路径实测证据的重复调用、可选的零拷贝、可省的临时变量。
3. **功能缺口 / 未实现能力**:TLS 未加、交叉校验未加、某配置项未暴露--这些是 **roadmap**,不是 bug。归观察区。
4. **已文档化的设计决策 / footgun**:代码注释或 CLAUDE.md 已声明"刻意如此"的,不重挂。
5. **不可达路径的防御性补丁**:找不到真实 nil 调用方,就不报"缺 nil-check";找不到触发 panic 的输入,就不报"缺 recover"。防御性编码是好习惯,但不构成缺陷。
6. **与红线有张力但场景"可接受"**:逐条对照开发纪律判定,若该场景被纪律明文豁免(如 watcher 的 for-range 关闭语义),不报。
7. **维护性风险但当前无缺陷**:Clone 手动列举字段今天工作正确、某 switch 缺 default 今天不会命中--只有当"未来扩展会漏"已造成**当前**可证缺陷时才报,否则不报。
## 3. 证伪门(计入前强制)
每条 finding 在写入交付前,reviewer 必须主动执行证伪:
1. **写出触发路径**(具体到调用链 / 输入 / 并发时序)。
2. **尝试证伪**:路径上有无 guard?调用方是否真实存在?是否已文档化?是否已是已知设计?是否其实有优雅降级?
3. **裁定**:证伪成功 → 丢弃或降级;证伪失败 → 计入。
只有**证伪失败**的 finding 进入交付清单。证伪成功的进入"证伪台账"(见 §5),透明留痕但不制造压迫。
## 4. 范围规则(防重新生成噪声)
非收敛的头号成因是"对已加固模块反复全量重扫"--每个新 reviewer 都会重新生成一批 §2 禁区项。规则:
- **默认扫 diff**:自上次绿灯复审以来的变更,不扫全模块。
- **已加固模块不重扫**,除非本次改动了它。模块是否"已加固"以最近一份"达标"总评为准。
- **全模块扫描**只在"首次复审"或"大重构后"做,且必须在报告头标注"首次 / 重构后重审"。
- **换 reviewer 不算重扫理由**:同一模块换模型复审前,先确认自上次复审有无改动;无改动则拒绝重扫。
## 5. 输出格式
交付物只含三段,严格物理分隔:
### 5.1 真 bug 清单(仅 CRITICAL/HIGH,证伪失败)
每条:
- 标题
- `file:line`(源码唯一证据)
- 触发路径(可达性证明)
- 为何是缺陷(违反哪条契约 / 红线)
- 修复方向(一两句,不展开实现)
### 5.2 观察区(非 bug,不计入,不阻塞)
功能缺口 / roadmap 建议 / 可选增强。明确标注"非 bug"。用户可忽略。
### 5.3 证伪台账(透明留痕)
列出本轮考虑过但丢弃的条目及丢弃理由(落入 §2 哪一条 / 证伪成功)。让用户看到"审过了但拒绝计入",避免"是不是漏审了"的疑虑。
**明确不输出**:风格 nits、"无害冗余"、防御性补丁建议、纯维护性担忧。
## 6. 成功度量(避免把不可达目标绑在自己身上)
-**证伪后剩余的真实 CRITICAL/HIGH 数** -- 可归零,这是唯一硬指标。
-**是否出现新 bug 类别** -- 已收敛的类别(多视图状态 / 公开生命周期 / 用户扩展点 / 间接层吞错)再出现同类的实例,属"扫尾",不属"新问题"。
-**原始 finding 条数** -- 永不归零,不作度量。任何用"finding 条数下降"衡量进步的做法都是错的。
## 7. 给 reviewer 的硬约束(可直接喂入 prompt)
> 你在审查 xlgo 框架代码。遵守 `scan_protocol.md`:
> 1. 只报 CRITICAL/HIGH,且每条必须给出可达触发路径,否则不报。
> 2. 风格、冗余、功能缺口、已文档化设计、不可达防御、可接受场景、纯维护性风险--一律不报。
> 3. 每条 finding 先尝试证伪;证伪成功的丢弃。
> 4. 交付三段:真 bug 清单 / 观察区(非 bug)/ 证伪台账。
> 5. 不重扫已加固模块,只扫本次改动 diff(除非报告头标注"首次/重构后")。
> 6. 若你只能找到 MEDIUM/LOW,直接在证伪台账说明并结束,不要为凑数而上调。