feat: 引入版本化数据库迁移与 Schema 启动门禁
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
// Config 全局配置,从环境变量加载
|
||||
type Config struct {
|
||||
AppEnv string `env:"APP_ENV" envDefault:"development"`
|
||||
AllowDevAutoMigrate bool `env:"ALLOW_DEV_AUTOMIGRATE" envDefault:"false"`
|
||||
PG string `env:"PG" envDefault:"postgresql://postgres:pan@localhost:5432/silk"`
|
||||
Redis string `env:"REDIS" envDefault:"redis://:pan@localhost:6379"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"silk-secret-please-change-me"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
@@ -21,30 +22,38 @@ func Init(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
// 自动迁移(错误不阻止启动,仅记录警告)
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||
&model.AuditLog{}, &model.Telemetry{},
|
||||
&model.Permission{}, &model.RolePermission{},
|
||||
&model.Disease{}, &model.KnowledgeArticle{},
|
||||
&model.InspectionRecord{},
|
||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||
&model.WechatBinding{},
|
||||
&model.WeatherAlert{},
|
||||
&model.LampTest{}, &model.LampTestStep{},
|
||||
&model.Consumable{},
|
||||
&model.Consultation{},
|
||||
&model.SpectrumEntry{},
|
||||
&model.TraceRecord{},
|
||||
); err != nil {
|
||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||
if err := RunMigrations(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CheckSchemaVersion(db, CurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.AllowDevAutoMigrate && cfg.AppEnv != "production" {
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||
&model.AuditLog{}, &model.Telemetry{},
|
||||
&model.Permission{}, &model.RolePermission{},
|
||||
&model.Disease{}, &model.KnowledgeArticle{},
|
||||
&model.InspectionRecord{},
|
||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||
&model.WechatBinding{},
|
||||
&model.WeatherAlert{},
|
||||
&model.LampTest{}, &model.LampTestStep{},
|
||||
&model.Consumable{},
|
||||
&model.Consultation{},
|
||||
&model.SpectrumEntry{},
|
||||
&model.TraceRecord{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("开发环境 AutoMigrate 失败: %w", err)
|
||||
}
|
||||
slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源")
|
||||
}
|
||||
// 初始化权限种子数据
|
||||
seedPermissions(db)
|
||||
// 初始化知识库种子数据
|
||||
seedKnowledge(db)
|
||||
slog.Info("数据库连接成功,自动迁移完成")
|
||||
slog.Info("数据库连接成功,版本化迁移完成", "schemaVersion", CurrentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"silk-server-go/migrations"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "1"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取数据库连接: %w", err)
|
||||
}
|
||||
|
||||
sourceDriver, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载嵌入式迁移文件: %w", err)
|
||||
}
|
||||
|
||||
databaseDriver, err := postgres.WithInstance(sqlDB, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化 PostgreSQL 迁移驱动: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", sourceDriver, "postgres", databaseDriver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建迁移实例: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.Close()
|
||||
}()
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("执行数据库迁移: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSchemaVersion 校验 schema_migrations 当前版本与 expected 一致,且不是 dirty。
|
||||
func CheckSchemaVersion(db *gorm.DB, expected string) error {
|
||||
var version int64
|
||||
var dirty bool
|
||||
|
||||
row := db.Raw("SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1").Row()
|
||||
if err := row.Scan(&version, &dirty); err != nil {
|
||||
return fmt.Errorf("读取 schema_migrations: %w", err)
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("schema 迁移处于 dirty 状态,版本 %d", version)
|
||||
}
|
||||
if strconv.FormatInt(version, 10) != expected {
|
||||
return fmt.Errorf("schema 版本不匹配:当前 %d,期望 %s", version, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"silk-server-go/migrations"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newMockGormDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open gorm with sqlmock: %v", err)
|
||||
}
|
||||
|
||||
return gdb, mock
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsMismatch(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||
|
||||
if err := CheckSchemaVersion(db, "2"); err == nil {
|
||||
t.Fatal("expected schema mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionAcceptsMatch(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err != nil {
|
||||
t.Fatalf("expected schema version match, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsMissingVersion(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||
t.Fatal("expected missing schema version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsDirty(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, true))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||
t.Fatal("expected dirty migration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
||||
driver, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("load embedded migrations: %v", err)
|
||||
}
|
||||
|
||||
version, err := driver.First()
|
||||
if err != nil {
|
||||
t.Fatalf("read first migration: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Fatalf("expected baseline migration version 1, got %d", version)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user