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:
wangjia
2026-06-13 14:52:27 +08:00
parent 30e73b31c2
commit 52857d1d55
9 changed files with 586 additions and 68 deletions
+70 -5
View File
@@ -1,14 +1,79 @@
// Command migrate applies or reverts database schema migrations for the
// Pangolin server.
//
// Usage:
//
// migrate up apply all pending migrations
// migrate down revert all applied migrations
// migrate version print the current migration version
//
// Configuration is read from environment variables (PANGOLIN_ prefix).
// Set PANGOLIN_MYSQL_DSN or the individual PANGOLIN_MYSQL_HOST / USER /
// PASSWORD / DBNAME vars before running.
package main
import (
"fmt"
"log"
"os"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store"
)
func main() {
fmt.Fprintln(os.Stdout, "pangolin-migrate: database migration tool")
fmt.Fprintln(os.Stdout, "Usage: migrate [-up|-down] [-steps N]")
fmt.Fprintln(os.Stdout, "")
fmt.Fprintln(os.Stdout, " (implementation in task 1e)")
os.Exit(0)
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
cmd := os.Args[1]
cfg, err := config.Load()
if err != nil {
log.Fatalf("migrate: config: %v", err)
}
db, err := store.Open(cfg)
if err != nil {
log.Fatalf("migrate: db: %v", err)
}
defer db.Close()
switch cmd {
case "up":
if err := store.MigrateUp(db); err != nil {
log.Fatalf("migrate up: %v", err)
}
log.Println("migrate: up — done")
case "down":
if err := store.MigrateDown(db); err != nil {
log.Fatalf("migrate down: %v", err)
}
log.Println("migrate: down — done")
case "version":
version, dirty, err := store.MigrateVersion(db)
if err != nil {
log.Fatalf("migrate version: %v", err)
}
fmt.Printf("migrate: version=%d dirty=%v\n", version, dirty)
default:
fmt.Fprintf(os.Stderr, "migrate: unknown command %q\n", cmd)
usage()
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "Usage: migrate <up|down|version>")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Environment variables:")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_DSN full DSN (takes precedence)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_HOST MySQL host (required if no DSN)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_PORT MySQL port (default 3306)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_USER MySQL user (required if no DSN)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_PASSWORD MySQL password")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_DBNAME MySQL database (required if no DSN)")
}