package store import ( "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" gormlogger "gorm.io/gorm/logger" ) // schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增, // 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。 // AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP), // 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。 // // 注意:c39c3e7 给 Model 加了 cache_write_price/context_length/supports_tools/ // supports_vision/supports_reasoning 等列并删掉了 display_name,但当时漏改这里, // 导致已建库的实例永远跳过重迁移、新增模型时 Create 因缺列而失败。这里补到 3 触发一次迁移。 // v4:users 加 token_version(会话撤销)。 const schemaVersion = 4 // Open 打开数据库连接并自动迁移。 // 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。 func Open(driver, dsn string) (*gorm.DB, error) { var dialector gorm.Dialector switch driver { case "postgres": dialector = postgresDialector(dsn) default: dsn = withSQLitePragmas(dsn) // 确保 SQLite 文件所在目录存在 if dir := sqliteDir(dsn); dir != "" { _ = os.MkdirAll(dir, 0o755) } dialector = sqlite.Open(dsn) } db, err := gorm.Open(dialector, &gorm.Config{ Logger: gormlogger.Default.LogMode(gormlogger.Warn), }) if err != nil { return nil, err } configurePool(db, driver) if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion { log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion) return db, nil } if err := db.AutoMigrate(AllModels()...); err != nil { return nil, err } if driver != "postgres" { setSQLiteVersion(db, schemaVersion) } log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion) // 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作) db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000) return db, nil } // withSQLitePragmas 给 DSN 补上 busy_timeout 与 WAL。 // glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数—— // 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。 // 用户 DSN 里已显式声明的项不覆盖。 func withSQLitePragmas(dsn string) string { for _, p := range []string{"_pragma=busy_timeout(10000)", "_pragma=journal_mode(WAL)"} { if strings.Contains(dsn, p) { continue } if strings.Contains(dsn, "?") { dsn += "&" + p } else { dsn += "?" + p } } return dsn } // configurePool 连接池上限。此前全仓无任何配置:PG 连接数无上限, // SQLite 多连接并发写叠加无 WAL 加剧锁竞争。 func configurePool(db *gorm.DB, driver string) { sqlDB, err := db.DB() if err != nil { return } if driver == "postgres" { sqlDB.SetMaxOpenConns(30) sqlDB.SetMaxIdleConns(10) sqlDB.SetConnMaxLifetime(30 * time.Minute) sqlDB.SetConnMaxIdleTime(5 * time.Minute) return } // SQLite:WAL 下读写可并发,写仍串行——限连接数让写竞争在 busy_timeout 内排队 sqlDB.SetMaxOpenConns(8) sqlDB.SetConnMaxIdleTime(10 * time.Minute) } // currentSQLiteVersion 读取 PRAGMA user_version。 func currentSQLiteVersion(db *gorm.DB) int { var v int db.Raw("PRAGMA user_version").Scan(&v) return v } // setSQLiteVersion 写入 PRAGMA user_version。 func setSQLiteVersion(db *gorm.DB, v int) { db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v)) } // sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。 func sqliteDir(dsn string) string { d := dsn if i := strings.IndexByte(d, '?'); i >= 0 { d = d[:i] } if strings.HasPrefix(d, "file:") { d = d[len("file:"):] } if d == "" || d == ":memory:" || strings.Contains(d, "::") { return "" } return filepath.Dir(d) }