diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1be23..964ef62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **`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.InitDB` / `(*database.Manager).InitDB` / `InitDBWithReplicas` 必须显式传入 `context.Context`**:旧 API 无法取消初始化过程中的 Ping 与重试等待,shutdown 或启动失败回滚时可能长时间卡住。现在调用方必须传入生命周期 ctx;普通测试或一次性脚本可使用 `context.Background()`,App 初始化会使用 App root ctx。 - **`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)。 @@ -47,8 +48,9 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **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,`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 nil 边界修复**:`InitDB(ctx, nil)` / `InitDBWithReplicas(ctx, 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` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。 +- **M4 database 初始化生命周期修复**:DB 初始化与主从库初始化统一接收 ctx,主库/从库 Ping 使用 `PingContext`,重试等待改为 `select ctx.Done()/time.After`;`Manager` 用生命周期锁串行化 Init/Close,避免 shutdown 与运行期重建交错;运行期重建从库后会立即重建健康标记,探活循环也能在发现健康标记缺失时自愈;包级 `HealthCheck()` 固定读取一次默认 manager 快照。 - **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` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`。 @@ -743,8 +745,8 @@ database.InitMySQL(cfg) database.InitMySQLWithReplicas(cfg, replicas) // ✅ 新(驱动由 cfg.Database.Driver 决定,可以是 mysql / postgres / 自定义注册的方言) -database.InitDB(cfg) -database.InitDBWithReplicas(cfg, replicas) +database.InitDB(ctx, cfg) +database.InitDBWithReplicas(ctx, cfg, replicas) ``` **为什么现在动手**: diff --git a/app.go b/app.go index c66cdcf..f99ba4b 100644 --- a/app.go +++ b/app.go @@ -649,7 +649,7 @@ func (a *App) doInit() error { if a.enableMySQL { dbm := database.NewManager(cfg) - if err := dbm.InitDB(cfg); err != nil { + if err := dbm.InitDB(a.rootCtx, cfg); err != nil { return fmt.Errorf("初始化数据库失败: %w", err) } a.previousDB = database.SwapDefaultManager(dbm) diff --git a/database/manager.go b/database/manager.go index 96cf0a8..5851be4 100644 --- a/database/manager.go +++ b/database/manager.go @@ -86,6 +86,9 @@ type Manager struct { replicas []*gorm.DB picker ReplicaPicker mu sync.Mutex + // opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。 + // 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。 + opMu sync.Mutex // #21 健康自愈 healthy atomic.Bool // 主库是否健康 @@ -206,6 +209,15 @@ func (m *Manager) initReplicaHealth() { m.replicaHealthSet = true } +// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。 +func (m *Manager) ensureReplicaHealthLocked() { + m.replicaHealthy = make([]atomic.Bool, len(m.replicas)) + for i := range m.replicaHealthy { + m.replicaHealthy[i].Store(true) + } + m.replicaHealthSet = true +} + // resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。 // 重建从库(InitDBWithReplicas)/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。 // 调用方须持有 m.mu。 @@ -272,6 +284,9 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) { m.mu.Lock() replicas := make([]*gorm.DB, len(m.replicas)) copy(replicas, m.replicas) + if len(replicas) > 0 && !m.replicaHealthSet { + m.ensureReplicaHealthLocked() + } healthSet := m.replicaHealthSet replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争 m.mu.Unlock() @@ -327,7 +342,7 @@ func (m *Manager) Open(ctx context.Context) error { if cfg == nil { return errors.New("数据库配置未设置") } - return m.InitDB(cfg) + return m.InitDB(ctx, cfg) } // OpenWithReplicas 打开主库与从库连接 @@ -336,7 +351,7 @@ func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) er if cfg == nil { return errors.New("数据库配置未设置") } - return m.InitDBWithReplicas(cfg, replicaDSNs) + return m.InitDBWithReplicas(ctx, cfg, replicaDSNs) } // closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。 @@ -361,6 +376,8 @@ func warnCloseDB(db *gorm.DB, context string) { // Close 关闭主库与全部从库连接,并重置从库健康状态。 // 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。 func (m *Manager) Close() error { + m.opMu.Lock() + defer m.opMu.Unlock() m.mu.Lock() master := m.master replicas := m.replicas @@ -446,11 +463,22 @@ func GetDefaultManager() *Manager { return DefaultManager.Load() } -// InitDB 初始化数据库连接(带重试机制),驱动由配置决定 -func (m *Manager) InitDB(cfg *config.Config) error { +// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。 +// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。 +func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error { + ctx = normalizeContext(ctx) + m.opMu.Lock() + defer m.opMu.Unlock() + return m.initDB(ctx, cfg) +} + +func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error { if cfg == nil { return errors.New("数据库配置未设置") } + if err := ctx.Err(); err != nil { + return fmt.Errorf("数据库初始化已取消: %w", err) + } m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态 // GORM 日志配置 @@ -471,6 +499,9 @@ func (m *Manager) InitDB(cfg *config.Config) error { var lastErr error for i := range maxRetries { + if err := ctx.Err(); err != nil { + return fmt.Errorf("数据库初始化已取消: %w", err) + } // 连接主库:先打开到局部变量,仅 Ping 成功后才安装为 m.master, // 避免 Ping 失败时下轮覆盖 m.master 泄漏旧池(C11b)。 db, err := gorm.Open(Dialector(cfg), gormConfig) @@ -493,7 +524,7 @@ func (m *Manager) InitDB(cfg *config.Config) error { sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime) } - if err := sqlDB.Ping(); err == nil { + if err := sqlDB.PingContext(ctx); err == nil { // 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b) m.mu.Lock() old := m.master @@ -515,7 +546,14 @@ func (m *Manager) InitDB(cfg *config.Config) error { } logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr) - time.Sleep(retryDelay) + if i == maxRetries-1 { + break + } + select { + case <-ctx.Done(): + return fmt.Errorf("数据库初始化已取消: %w", ctx.Err()) + case <-time.After(retryDelay): + } retryDelay *= 2 if retryDelay > 30*time.Second { retryDelay = 30 * time.Second @@ -572,12 +610,15 @@ func replicaMaxOpenConns(masterMax int) int { // InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定 // replicaDSNs: 从库连接字符串列表(需与主库驱动匹配) -func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error { +func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error { + ctx = normalizeContext(ctx) + m.opMu.Lock() + defer m.opMu.Unlock() if cfg == nil { return errors.New("数据库配置未设置") } // 先初始化主库 - if err := m.InitDB(cfg); err != nil { + if err := m.initDB(ctx, cfg); err != nil { return err } @@ -607,6 +648,12 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e // 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态 var newReplicas []*gorm.DB for i, dsn := range replicaDSNs { + if err := ctx.Err(); err != nil { + for _, r := range newReplicas { + warnCloseDB(r, "关闭已打开的数据库从库连接池失败") + } + return fmt.Errorf("数据库从库初始化已取消: %w", err) + } replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig) if err != nil { logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err) @@ -629,7 +676,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns)) sqlDB.SetConnMaxLifetime(time.Hour) - if err := sqlDB.Ping(); err != nil { + if err := sqlDB.PingContext(ctx); err != nil { logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err) warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏 continue @@ -641,20 +688,21 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e m.mu.Lock() m.replicas = newReplicas + m.ensureReplicaHealthLocked() m.mu.Unlock() } return nil } -// InitDB 初始化数据库连接(带重试机制),驱动由配置决定 -func InitDB(cfg *config.Config) error { - return DefaultManager.Load().InitDB(cfg) +// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。 +func InitDB(ctx context.Context, cfg *config.Config) error { + return DefaultManager.Load().InitDB(ctx, cfg) } // InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定 -func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error { - return DefaultManager.Load().InitDBWithReplicas(cfg, replicaDSNs) +func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error { + return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs) } // GetReadDB 获取读库实例(按策略选择从库) @@ -764,7 +812,7 @@ func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) err return db.WithContext(ctx).Transaction(fn) } -// ReadQuery 读查询(自动路由到从库) +// ReadQuery 读查询。遵循 ctx 中的数据库路由标记;未指定时默认走从库。 func ReadQuery(ctx context.Context, model any, query string, args ...any) error { ctx = normalizeContext(ctx) db := GetDBFromContext(ctx) @@ -801,9 +849,10 @@ func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error { func HealthCheck() map[string]bool { result := make(map[string]bool) ctx := context.Background() + m := DefaultManager.Load() // 检查主库 - if master := DefaultManager.Load().Master(); master != nil { + if master := m.Master(); master != nil { sqlDB, err := master.DB() if err == nil && pingWithTimeout(sqlDB, ctx) == nil { result["master"] = true @@ -815,7 +864,7 @@ func HealthCheck() map[string]bool { } // 检查从库 - for i, replica := range DefaultManager.Load().Replicas() { + for i, replica := range m.Replicas() { if replica != nil { sqlDB, err := replica.DB() if err == nil && pingWithTimeout(sqlDB, ctx) == nil { diff --git a/database/manager_c11_internal_test.go b/database/manager_c11_internal_test.go index 1c8f2a6..9e12e53 100644 --- a/database/manager_c11_internal_test.go +++ b/database/manager_c11_internal_test.go @@ -2,13 +2,17 @@ package database import ( "context" + "errors" "sync" "sync/atomic" "testing" "time" + "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/logger" "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" ) func init() { @@ -131,6 +135,25 @@ func TestC11ReplicaHealthResetOnRebuild(t *testing.T) { } } +func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + m.mu.Lock() + m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()} + m.resetReplicaHealth() + m.mu.Unlock() + + m.probeOnce(context.Background(), 1) + + m.mu.Lock() + defer m.mu.Unlock() + if !m.replicaHealthSet { + t.Fatal("probeOnce 应在从库重建后自动重建健康标记") + } + if len(m.replicaHealthy) != 2 { + t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy)) + } +} + // TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时 // initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。 func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) { @@ -308,3 +331,74 @@ func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) { t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚") } } + +type transientDialector struct{} + +func (d transientDialector) Name() string { return "m4_transient" } + +func (d transientDialector) Initialize(*gorm.DB) error { + return errors.New("temporary connection failure") +} + +func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil } + +func (d transientDialector) DataTypeOf(*schema.Field) string { return "" } + +func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil } + +func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {} + +func (d transientDialector) QuoteTo(writer clause.Writer, str string) { + _, _ = writer.WriteString(str) +} + +func (d transientDialector) Explain(sql string, _ ...any) string { return sql } + +func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) { + const driver = "m4_transient_retry" + RegisterDialect(DialectSpec{ + Name: driver, + Dialector: func(string) gorm.Dialector { return transientDialector{} }, + DSN: func(*config.DatabaseConfig) string { return "m4://retry" }, + }) + + m := NewManager(nil) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}}) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed) + } +} + +func TestM4CloseWaitsForLifecycleOperation(t *testing.T) { + m := NewManager(nil) + m.opMu.Lock() + + done := make(chan struct{}) + go func() { + _ = m.Close() + close(done) + }() + + select { + case <-done: + t.Fatal("Close 不应越过正在执行的生命周期操作") + case <-time.After(50 * time.Millisecond): + } + + m.opMu.Unlock() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("释放生命周期锁后 Close 未返回") + } +} diff --git a/database/manager_test.go b/database/manager_test.go index 14dbc0d..935f406 100644 --- a/database/manager_test.go +++ b/database/manager_test.go @@ -148,16 +148,16 @@ func TestNilContextHelpersDoNotPanic(t *testing.T) { func TestNilConfigInitializationReturnsError(t *testing.T) { mgr := database.NewManager(nil) - if err := mgr.InitDB(nil); err == nil { + if err := mgr.InitDB(context.Background(), nil); err == nil { t.Fatal("InitDB(nil) 应返回错误") } - if err := mgr.InitDBWithReplicas(nil, nil); err == nil { + if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil { t.Fatal("InitDBWithReplicas(nil) 应返回错误") } - if err := database.InitDB(nil); err == nil { + if err := database.InitDB(context.Background(), nil); err == nil { t.Fatal("包级 InitDB(nil) 应返回错误") } - if err := database.InitDBWithReplicas(nil, nil); err == nil { + if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil { t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误") } } diff --git a/examples/full/main_test.go b/examples/full/main_test.go index 87a5e2b..a5d7f38 100644 --- a/examples/full/main_test.go +++ b/examples/full/main_test.go @@ -48,7 +48,7 @@ func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) { } mgr := database.NewManager(cfg) - if err := mgr.InitDB(cfg); err != nil { + if err := mgr.InitDB(context.Background(), cfg); err != nil { t.Fatalf("初始化测试数据库失败: %v", err) } db := mgr.Master() diff --git a/repository/repository_h6_internal_test.go b/repository/repository_h6_internal_test.go index f530a45..1f4c982 100644 --- a/repository/repository_h6_internal_test.go +++ b/repository/repository_h6_internal_test.go @@ -403,7 +403,7 @@ func setupH6Manager(t *testing.T) (masterFile, replicaFile string) { cfg.Database.Driver = "sqlite" cfg.Database.CustomDSN = masterFile - if err := database.InitDBWithReplicas(cfg, []string{replicaFile}); err != nil { + if err := database.InitDBWithReplicas(context.Background(), cfg, []string{replicaFile}); err != nil { t.Fatalf("InitDBWithReplicas: %v", err) } if err := database.GetWriteDB().AutoMigrate(&h6User{}); err != nil {