feat(server/db): 数据层多库支持(1/4)— 连接分派 + 双方言迁移管线

- config 增 DB_DRIVER(mysql 默认 | sqlite);DSN 对 sqlite 为文件路径
- db.OpenDriver 按驱动分派:sqlite 用 modernc(纯 Go 免 CGO)+ WAL/
  busy_timeout/foreign_keys/_txlock=immediate;mysql 路径不变
- store.Open 分派;mysql 保留 UTC/collation 断言,sqlite 跳过
- 迁移拆 migrations/{mysql,sqlite}/ 双套,embed 双 FS,migrate 按驱动选源
  与 golang-migrate 驱动;修复 m.Close() 误关调用方 *sql.DB 的坑
- cmd/migrate 串入 DB_DRIVER;集成测试 MigrateUp 签名更新
- 新增 SQLite 时间往返 smoke 测试与端到端迁移测试(免 docker)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-18 00:01:03 +08:00
parent 9a028ab907
commit f3471ae139
63 changed files with 583 additions and 82 deletions
+21 -26
View File
@@ -5,60 +5,55 @@ import (
"fmt"
"time"
// Register the mysql driver with database/sql.
// mysql.ParseDSN is used to structurally rewrite the DSN for UTC.
"github.com/go-sql-driver/mysql"
_ "github.com/go-sql-driver/mysql"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/db"
)
// Open is the single canonical MySQL entry point for the Pangolin server.
// Open is the canonical database entry point for the Pangolin server. It
// dispatches on cfg.Driver:
//
// It:
// 1. Parses the DSN from cfg.DSN.
// 2. Structurally overrides UTC parameters (ParseTime, Loc, Collation,
// time_zone session variable) — no string manipulation.
// 3. Configures the connection pool.
// 4. Pings the server.
// 5. Asserts SELECT @@session.time_zone = "+00:00" (startup fatal if the
// server ignores our DSN override — e.g. SQL mode forces a different TZ).
// - sqlite: opens the file/in-memory DB (UTC is native; no session assertion).
// - mysql (default): structurally overrides the DSN for UTC (ParseTime, Loc,
// Collation, time_zone), opens the pool, and asserts
// SELECT @@session.time_zone = "+00:00" (startup-fatal if the server ignores
// our UTC override — e.g. SQL mode forces a different TZ).
//
// The actual connection/pool/ping lives in internal/db; this layer adds only the
// MySQL-specific UTC rigor.
func Open(cfg *config.Config) (*sql.DB, error) {
if db.Normalize(cfg.Driver) == "sqlite" {
return db.OpenDriver("sqlite", cfg.DSN)
}
dsn, err := buildDSN(cfg)
if err != nil {
return nil, fmt.Errorf("store.Open: %w", err)
}
db, err := sql.Open("mysql", dsn)
database, err := db.OpenDriver("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("store.Open: %w", err)
}
// Connection pool.
db.SetMaxOpenConns(30)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store.Open: ping: %w", err)
}
// Hard assertion: the session time_zone must be "+00:00".
// This catches MySQL servers that ignore client-supplied time_zone params.
var tz string
if err := db.QueryRow("SELECT @@session.time_zone").Scan(&tz); err != nil {
_ = db.Close()
if err := database.QueryRow("SELECT @@session.time_zone").Scan(&tz); err != nil {
_ = database.Close()
return nil, fmt.Errorf("store.Open: query @@session.time_zone: %w", err)
}
if tz != "+00:00" {
_ = db.Close()
_ = database.Close()
return nil, fmt.Errorf(
"store.Open: session time_zone=%q, want +00:00; UTC DSN override failed",
tz,
)
}
return db, nil
return database, nil
}
// buildDSN parses cfg.DSN and structurally overrides UTC and utf8mb4