fix: 兼容旧媒体路由约束迁移 (#95)

This commit is contained in:
QiuSW
2026-08-15 15:48:10 +08:00
parent 40409707cc
commit 3dd489066c
2 changed files with 284 additions and 16 deletions
@@ -1,7 +1,9 @@
package version
import (
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -19,6 +21,9 @@ func init() {
func migrateSenseMedia(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := prepareLegacyMediaRouteSchema(tx); err != nil {
return err
}
if err := tx.AutoMigrate(&media.Route{}); err != nil {
return err
}
@@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error {
return tx.Create(&common.Migration{Version: version}).Error
})
}
type mediaPathConstraint struct {
Name string
Columns string
}
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
// the runtime-state columns now required by the Route model.
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
var tableCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
return fmt.Errorf("inspect legacy media route table: %w", err)
}
if tableCount == 0 {
return nil
}
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
}
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
return err
}
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
return err
}
return nil
}
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
var constraints []mediaPathConstraint
if err := tx.Raw(`SELECT c.conname AS name,
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.contype = 'u'
AND EXISTS (
SELECT 1
FROM unnest(c.conkey) AS key(attnum)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
WHERE a.attname = 'path'
)
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
return fmt.Errorf("inspect legacy media path constraints: %w", err)
}
if len(constraints) == 0 {
return nil
}
if len(constraints) != 1 || constraints[0].Columns != "path" {
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
}
const expectedName = "uni_sense_media_routes_path"
if constraints[0].Name == expectedName {
return nil
}
var conflictingNameCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
return fmt.Errorf("inspect target media path constraint name: %w", err)
}
if conflictingNameCount != 0 {
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
}
rename := fmt.Sprintf(
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
quotePostgresIdentifier(constraints[0].Name),
quotePostgresIdentifier(expectedName),
)
if err := tx.Exec(rename).Error; err != nil {
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
}
return nil
}
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
for _, statement := range []struct {
name string
sql string
}{
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
SET "source_ready" = COALESCE("source_ready", false),
"failure_count" = COALESCE("failure_count", 0),
"last_error_code" = COALESCE("last_error_code", '')
WHERE "source_ready" IS NULL
OR "failure_count" IS NULL
OR "last_error_code" IS NULL`},
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
} {
if err := tx.Exec(statement.sql).Error; err != nil {
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
}
}
return nil
}
func quotePostgresIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
@@ -2,6 +2,7 @@ package version
import (
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
@@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
const schema = "sense_media_95_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
if err = db.Exec(`INSERT INTO sense_media_routes
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
VALUES
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
t.Fatal(err)
}
const migrationVersion = "2026081419000_media.go"
if err = migrateSenseMedia(db, migrationVersion); err != nil {
t.Fatal(err)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
}
assertMediaPathUniqueIndex(t, db)
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
t.Fatal("expected path uniqueness violation")
}
var runtimeState struct {
SourceReady bool
FailureCount int64
LastError string
}
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
t.Fatal(err)
}
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatalf("repeat compatibility migration: %v", err)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatal(err)
}
})
t.Run("creates a fresh media table", func(t *testing.T) {
resetEmptyMediaMigration(t, db)
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
t.Fatal(err)
}
var routes int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if routes != 0 {
t.Fatalf("fresh route count=%d", routes)
}
assertMediaPathUniqueIndex(t, db)
})
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
t.Fatalf("expected unsupported uniqueness error, got %v", err)
}
var constraints int64
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
t.Fatal(err)
}
if constraints != 1 {
t.Fatalf("constraint rollback count=%d", constraints)
}
})
}
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
}
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
t.Fatal(err)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
createRouteTable := `CREATE TABLE sense_media_routes (
id text PRIMARY KEY,
device_id text NOT NULL,
profile_token text NOT NULL,
path text NOT NULL,
desired text NOT NULL,
actual text NOT NULL,
readers integer NOT NULL DEFAULT 0,
detail text NOT NULL DEFAULT '',
version bigint NOT NULL,
updated_at timestamptz NOT NULL,
` + pathConstraint + `
)`
if err := db.Exec(createRouteTable).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
}
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
t.Helper()
var indexes []struct {
IndexName string
IndexDef string
}
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'sense_media_routes'
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
for _, index := range indexes {
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
return
}
}
t.Fatalf("missing unique path index: %#v", indexes)
}