feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj)
- internal/config: rewrite Config with PANGOLIN_ prefix fields (HTTPAddr, AdminAddr, GRPCAddr, MySQL DSN/fields, RedisAddr, AutoMigrate, JWTSecret placeholder, WebhookSecret); Load() replaces FromEnv(); missing required MySQL vars return named-field error. - internal/store/mysql.go: single Open(cfg) entry point; uses mysql.ParseDSN to structurally override ParseTime=true, Loc=UTC, Collation=utf8mb4_unicode_ci, Params[time_zone]='+00:00'; asserts SELECT @@session.time_zone=+00:00 after Ping (startup fatal). - migrations/embed.go: //go:embed *.sql exposes var FS embed.FS. - internal/store/migrate.go: MigrateUp/MigrateDown/MigrateVersion backed by golang-migrate iofs source + mysql driver; ErrNoChange treated as success. - cmd/migrate/main.go: filled — up/down/version subcommands, reads config.Load() + store.Open. - cmd/server/main.go: startup sequence Load → store.Open (UTC assert) → MigrateUp (if PANGOLIN_AUTO_MIGRATE=true) → HTTP listen; structured slog output at each step. - internal/store/mysql_test.go: pure-function unit tests for buildDSN (empty-fields case + conflicting params overridden case); both pass. - internal/store/mysql_integration_test.go: //go:build integration; testcontainers mysql:8 — UTC assertion + MigrateUp idempotency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Register the mysql driver with database/sql.
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
)
|
||||
|
||||
// Open is the single canonical MySQL entry point for the Pangolin server.
|
||||
//
|
||||
// It:
|
||||
// 1. Builds or parses the DSN from cfg.
|
||||
// 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).
|
||||
func Open(cfg config.Config) (*sql.DB, error) {
|
||||
dsn, err := buildDSN(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.Open: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("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()
|
||||
return nil, fmt.Errorf("store.Open: query @@session.time_zone: %w", err)
|
||||
}
|
||||
if tz != "+00:00" {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf(
|
||||
"store.Open: session time_zone=%q, want +00:00; UTC DSN override failed",
|
||||
tz,
|
||||
)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// buildDSN constructs the final DSN from cfg, structurally overriding UTC
|
||||
// and utf8mb4 parameters regardless of what the caller supplied.
|
||||
// This is a pure function and is tested independently.
|
||||
func buildDSN(cfg config.Config) (string, error) {
|
||||
rawDSN := cfg.MySQLDSN
|
||||
if rawDSN == "" {
|
||||
// Build a minimal DSN from individual fields; ParseDSN will validate it.
|
||||
rawDSN = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
|
||||
cfg.MySQLUser,
|
||||
cfg.MySQLPassword,
|
||||
cfg.MySQLHost,
|
||||
cfg.MySQLPort,
|
||||
cfg.MySQLDBName,
|
||||
)
|
||||
}
|
||||
|
||||
dsnCfg, err := mysql.ParseDSN(rawDSN)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("buildDSN: parse DSN: %w", err)
|
||||
}
|
||||
|
||||
// Force UTC — overwrite any caller-supplied values (structural, not string).
|
||||
dsnCfg.ParseTime = true
|
||||
dsnCfg.Loc = time.UTC
|
||||
// Collation implicitly sets charset to utf8mb4 via SET NAMES.
|
||||
dsnCfg.Collation = "utf8mb4_unicode_ci"
|
||||
if dsnCfg.Params == nil {
|
||||
dsnCfg.Params = make(map[string]string)
|
||||
}
|
||||
// The time_zone connection parameter sends SET time_zone = '+00:00' on connect.
|
||||
dsnCfg.Params["time_zone"] = "'+00:00'"
|
||||
|
||||
return dsnCfg.FormatDSN(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user