From ca822dcaddaee952c2dfbdf9dc73cc528ad58241 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 21:02:21 +0800 Subject: [PATCH] fix config driver fail closed --- CHANGELOG.md | 3 ++- config/config.go | 11 ++++---- config/config_test.go | 20 ++++++++++++++ database/dialect.go | 58 ++++++++++++++++++++++++++++++++-------- database/manager_test.go | 11 +++++--- 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 372f115..cd1be23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **`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`。 +- **未知数据库 driver 不再静默回退 MySQL**:`config.DatabaseConfig.DSN()` 对非空但未注册的 `driver` 返回空字符串,`database.Dialector` 返回初始化即失败的 Dialector;空 `driver` 仍保持默认 MySQL。下游若使用自定义数据库驱动,需先通过 `database.RegisterDialect` 或 `config.RegisterDSNBuilder` 注册。 - **`database.SetDefaultManager` / `database.SetDefaultRedisManager` 会关闭被替换的旧 manager**:旧行为只做 atomic 替换,直接调用会遗留旧 DB/Redis 连接池。现在普通 Set 表示“接管全局默认资源并释放旧资源”;需要失败回滚或延迟释放旧资源的初始化流程请改用新增 `database.SwapDefaultManager` / `database.SwapDefaultRedisManager`。 - **`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)。 @@ -45,7 +46,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`;`WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。 - **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 统一转义字符串字段。 +- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher;`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL,`database.Dialector` 对未知 driver fail-closed;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。 - **M4 database nil 边界修复**:`InitDB(nil)` / `InitDBWithReplicas(nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic;`Dialector(nil)` 安全回退到 MySQL 空 DSN。 - **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。 - **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。 diff --git a/config/config.go b/config/config.go index e83d181..d370cd4 100644 --- a/config/config.go +++ b/config/config.go @@ -313,22 +313,21 @@ type DatabaseConfig struct { HealthCheckFailureThreshold int `mapstructure:"health_check_failure_threshold"` } -// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN; -// 未指定 Driver 时按 MySQL 处理(向后兼容)。 -// 若驱动通过 RegisterDSNBuilder 注册过自定义构建器,则使用注册的构建器。 +// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN。 +// 未指定 Driver 时按 MySQL 处理;非空但未注册的 Driver 返回空字符串, +// 避免把拼写错误静默当作 MySQL 连接,正常加载路径会由 Validate 提前报错。 func (c *DatabaseConfig) DSN() string { if c.CustomDSN != "" { return c.CustomDSN } - driver := c.Driver + driver := strings.TrimSpace(c.Driver) if strings.TrimSpace(driver) == "" { driver = DriverMySQL } if builder, ok := LookupDSNBuilder(driver); ok { return builder(c) } - // 未注册时回退到 MySQL(保持向后兼容) - return c.MySQLDSN() + return "" } func (c DatabaseConfig) isConfigured() bool { diff --git a/config/config_test.go b/config/config_test.go index 7a9b206..ff8f5e8 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -113,6 +113,26 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) { } } +func TestDatabaseConfigUnknownDriverDoesNotFallback(t *testing.T) { + db := config.DatabaseConfig{ + Driver: "no-such-driver", + Host: "localhost", + Port: 3306, + User: "root", + Password: "password", + Name: "testdb", + } + + if dsn := db.DSN(); dsn != "" { + t.Fatalf("未知 driver 不应静默回退 MySQL,实际 DSN=%q", dsn) + } + + cfg := &config.Config{Database: db} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "database.driver") { + t.Fatalf("未知 driver 应在 Validate 阶段报错,实际: %v", err) + } +} + // TestDatabaseConfigDSNPasswordEscape_M9:含特殊字符的密码须被转义,不破坏 DSN。 func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) { // MySQL:密码含 @/:/空格 → url.QueryEscape diff --git a/database/dialect.go b/database/dialect.go index 9b40d1c..b609649 100644 --- a/database/dialect.go +++ b/database/dialect.go @@ -1,6 +1,7 @@ package database import ( + "errors" "fmt" "strings" "sync" @@ -10,6 +11,8 @@ import ( "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" ) // 内置驱动常量(更多驱动可通过 RegisterDialect 扩展) @@ -29,8 +32,8 @@ type DialectSpec struct { Aliases []string // Dialector 由 DSN 构造 GORM Dialector Dialector DialectorFactory - // DSN 由 DatabaseConfig 拼接连接字符串。可选—— - // 不提供时使用 cfg.MySQLDSN() 兜底(适合自定义驱动通过 CustomDSN 指定连接串的场景) + // DSN 由 DatabaseConfig 拼接连接字符串。可选。 + // 不提供时仅 CustomDSN 能直接生效;需要由配置字段拼接连接串的自定义驱动应显式提供该函数。 DSN config.DSNBuilder } @@ -92,7 +95,8 @@ func RegisteredDialects() []string { } // Dialector 根据配置返回 GORM Dialector。 -// 驱动由 cfg.Database.Driver 决定,未指定或未注册时按 MySQL 兜底(向后兼容)。 +// 驱动由 cfg.Database.Driver 决定;未指定时默认 MySQL,非空但未注册时返回会初始化失败的 +// Dialector,避免拼写错误静默回退到 MySQL。 func Dialector(cfg *config.Config) gorm.Dialector { if cfg == nil { logger.Warn("database: 配置为空,回退到 MySQL 空 DSN") @@ -103,17 +107,49 @@ func Dialector(cfg *config.Config) gorm.Dialector { // dialectorForDSN 根据驱动名和 DSN 返回 Dialector func dialectorForDSN(driver, dsn string) gorm.Dialector { - if f, ok := LookupDialect(driver); ok { + normalized := normalizeDriver(driver) + if normalized == "" { + normalized = DriverMySQL + } + if f, ok := LookupDialect(normalized); ok { return f(dsn) } - // 未注册时回退到 MySQL,与 config.DSN() 的回退保持一致。 - // 拼写错误的驱动名(如 "mysq"/"postgrs")会静默回退 MySQL,导致连接错误难排查(M10), - // 故在此告警一次,提示用户驱动名未注册。 - logger.Warnf("database: 驱动 %q 未注册,回退到 MySQL(已注册: %s);若是拼写错误请在配置中修正 driver", - normalizeDriver(driver), strings.Join(RegisteredDialects(), ", ")) - return mysql.Open(dsn) + logger.Warnf("database: 驱动 %q 未注册(已注册: %s),拒绝静默回退到 MySQL;请修正配置或先注册方言", + normalized, strings.Join(RegisteredDialects(), ", ")) + return errorDialector{ + name: "invalid", + err: fmt.Errorf("数据库驱动未注册: %s", normalized), + } } +type errorDialector struct { + name string + err error +} + +func (d errorDialector) Name() string { return d.name } + +func (d errorDialector) Initialize(*gorm.DB) error { + if d.err == nil { + return errors.New("数据库驱动未注册") + } + return d.err +} + +func (d errorDialector) Migrator(*gorm.DB) gorm.Migrator { return nil } + +func (d errorDialector) DataTypeOf(*schema.Field) string { return "" } + +func (d errorDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil } + +func (d errorDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {} + +func (d errorDialector) QuoteTo(writer clause.Writer, str string) { + _, _ = writer.WriteString(str) +} + +func (d errorDialector) Explain(sql string, _ ...any) string { return sql } + // normalizeDriver 规范化驱动名(小写、去空白) func normalizeDriver(name string) string { return strings.ToLower(strings.TrimSpace(name)) @@ -128,7 +164,7 @@ func driverDescription(driver string) string { if _, ok := LookupDialect(key); ok { return key } - return fmt.Sprintf("%s (unregistered, fallback=%s)", key, DriverMySQL) + return fmt.Sprintf("%s (unregistered)", key) } func init() { diff --git a/database/manager_test.go b/database/manager_test.go index abf9d96..14dbc0d 100644 --- a/database/manager_test.go +++ b/database/manager_test.go @@ -2,6 +2,7 @@ package database_test import ( "context" + "strings" "testing" "github.com/EthanCodeCraft/xlgo-core/config" @@ -246,13 +247,17 @@ func TestRegisterDialectAndCustomDriver(t *testing.T) { t.Fatalf("expected DSN built by registered builder, got %q", dsn) } - // 未知驱动回退到 mysql + // 未知驱动应 fail-closed,避免拼写错误静默连向 MySQL。 unknownCfg := &config.Config{Database: config.DatabaseConfig{ Driver: "no-such-driver", Host: "localhost", Port: 3306, User: "root", Password: "pass", Name: "db", }} - if name := database.Dialector(unknownCfg).Name(); name != "mysql" { - t.Fatalf("expected fallback to mysql for unknown driver, got %q", name) + d := database.Dialector(unknownCfg) + if name := d.Name(); name != "invalid" { + t.Fatalf("expected invalid dialector for unknown driver, got %q", name) + } + if _, err := gorm.Open(d, &gorm.Config{}); err == nil || !strings.Contains(err.Error(), "数据库驱动未注册") { + t.Fatalf("未知 driver 应在 GORM 初始化时报错,实际: %v", err) } }