From 68b436ac623d7658281292de463ae4b170a9af86 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:15:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E6=97=A7=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=83=BD=E5=8A=9B=E5=AD=97=E6=AE=B5=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/version/2026081414000_device.go | 104 +++++++++++ .../version/2026081414000_device_test.go | 171 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go diff --git a/Sense/server/cmd/migrate/migration/version/2026081414000_device.go b/Sense/server/cmd/migrate/migration/version/2026081414000_device.go index 7eae8e8..96d8553 100644 --- a/Sense/server/cmd/migrate/migration/version/2026081414000_device.go +++ b/Sense/server/cmd/migrate/migration/version/2026081414000_device.go @@ -1,8 +1,11 @@ package version import ( + "database/sql" + "encoding/json" "fmt" "runtime" + "strings" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -34,6 +37,9 @@ func init() { func migrateSenseDeviceLedger(db *gorm.DB, version string) error { return db.Transaction(func(tx *gorm.DB) error { + if err := prepareLegacyDeviceCapabilities(tx); err != nil { + return err + } if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil { return err } @@ -89,6 +95,104 @@ func migrateSenseDeviceLedger(db *gorm.DB, version string) error { }) } +var supportedLegacyCapabilities = map[string]struct{}{ + "video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {}, +} + +type legacyDeviceCapabilitiesRow struct { + ID string + Capabilities sql.NullString +} + +// prepareLegacyDeviceCapabilities upgrades the pre-GoAdmin text column before +// GORM sees it. PostgreSQL cannot cast the old empty-string default to jsonb, +// and old rows stored a single capability token rather than a JSON array. +func prepareLegacyDeviceCapabilities(tx *gorm.DB) error { + if tx.Dialector.Name() != "postgres" { + return nil + } + type columnMetadata struct { + DataType string + } + var column columnMetadata + result := tx.Raw(`SELECT data_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'sense_devices' + AND column_name = 'capabilities'`).Scan(&column) + if result.Error != nil { + return fmt.Errorf("inspect sense_devices.capabilities: %w", result.Error) + } + if result.RowsAffected == 0 || column.DataType == "json" || column.DataType == "jsonb" { + return nil + } + if column.DataType != "text" && column.DataType != "character varying" { + return fmt.Errorf("sense_devices.capabilities has unsupported legacy type %q", column.DataType) + } + if err := tx.Exec(`LOCK TABLE "sense_devices" IN ACCESS EXCLUSIVE MODE`).Error; err != nil { + return fmt.Errorf("lock sense_devices for capabilities migration: %w", err) + } + var rows []legacyDeviceCapabilitiesRow + if err := tx.Raw(`SELECT id, capabilities FROM "sense_devices" ORDER BY id`).Scan(&rows).Error; err != nil { + return fmt.Errorf("read legacy device capabilities: %w", err) + } + canonical := make(map[string]string, len(rows)) + invalid := 0 + for _, row := range rows { + value, err := canonicalLegacyCapabilities(row.Capabilities) + if err != nil { + invalid++ + continue + } + canonical[row.ID] = value + } + if invalid > 0 { + return fmt.Errorf("sense_devices.capabilities contains unsupported legacy data in %d row(s); migration rolled back", invalid) + } + if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" DROP DEFAULT`).Error; err != nil { + return fmt.Errorf("drop legacy capabilities default: %w", err) + } + for _, row := range rows { + if err := tx.Exec(`UPDATE "sense_devices" SET "capabilities" = ? WHERE "id" = ?`, canonical[row.ID], row.ID).Error; err != nil { + return fmt.Errorf("normalize legacy device capabilities: %w", err) + } + } + if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" TYPE jsonb USING "capabilities"::jsonb`).Error; err != nil { + return fmt.Errorf("convert capabilities to jsonb: %w", err) + } + if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" SET DEFAULT '[]'::jsonb`).Error; err != nil { + return fmt.Errorf("set jsonb capabilities default: %w", err) + } + return nil +} + +func canonicalLegacyCapabilities(value sql.NullString) (string, error) { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return "[]", nil + } + trimmed := strings.TrimSpace(value.String) + if _, ok := supportedLegacyCapabilities[trimmed]; ok { + encoded, _ := json.Marshal([]string{trimmed}) + return string(encoded), nil + } + if !strings.HasPrefix(trimmed, "[") { + return "", fmt.Errorf("legacy capability value is not an array") + } + var values []string + if err := json.Unmarshal([]byte(trimmed), &values); err != nil || values == nil || len(values) > 16 { + return "", fmt.Errorf("legacy capability array is invalid") + } + for index, item := range values { + item = strings.TrimSpace(item) + if _, ok := supportedLegacyCapabilities[item]; !ok { + return "", fmt.Errorf("legacy capability array contains an unsupported value") + } + values[index] = item + } + encoded, _ := json.Marshal(values) + return string(encoded), nil +} + func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) { var menu migrationModels.SysMenu err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error diff --git a/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go b/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go new file mode 100644 index 0000000..c4a74a4 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go @@ -0,0 +1,171 @@ +package version + +import ( + "database/sql" + "os" + "strings" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" +) + +func TestCanonicalLegacyCapabilities(t *testing.T) { + tests := []struct { + name string + input sql.NullString + want string + wantErr bool + }{ + {name: "null", input: sql.NullString{}, want: "[]"}, + {name: "blank", input: sql.NullString{String: " ", Valid: true}, want: "[]"}, + {name: "single", input: sql.NullString{String: " video ", Valid: true}, want: `["video"]`}, + {name: "array", input: sql.NullString{String: `["radar", "contact"]`, Valid: true}, want: `["radar","contact"]`}, + {name: "empty array", input: sql.NullString{String: `[]`, Valid: true}, want: `[]`}, + {name: "unknown", input: sql.NullString{String: "unknown", Valid: true}, wantErr: true}, + {name: "object", input: sql.NullString{String: `{"video":true}`, Valid: true}, wantErr: true}, + {name: "unknown array item", input: sql.NullString{String: `["video","unknown"]`, Valid: true}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := canonicalLegacyCapabilities(test.input) + if test.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q", got, err, test.want) + } + }) + } +} + +func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) { + dsn := os.Getenv("SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + const schema = "sense_device_92_test" + if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil { + t.Fatal(err) + } + 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("converts legacy values and remains idempotent", func(t *testing.T) { + resetLegacyDeviceTable(t, db) + for _, row := range [][2]string{{"single", "video"}, {"blank", ""}, {"array", `["radar","contact"]`}} { + if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES (?, ?)`, row[0], row[1]).Error; err != nil { + t.Fatal(err) + } + } + if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&deviceModels.Device{}); err != nil { + t.Fatalf("AutoMigrate after compatibility conversion: %v", err) + } + assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb") + var values []struct{ ID, Capabilities string } + if err = db.Raw(`SELECT id, capabilities::text AS capabilities FROM sense_devices ORDER BY id`).Scan(&values).Error; err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, value := range values { + got[value.ID] = strings.ReplaceAll(value.Capabilities, " ", "") + } + if got["single"] != `["video"]` || got["blank"] != `[]` || got["array"] != `["radar","contact"]` { + t.Fatalf("unexpected converted values: %#v", got) + } + if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil { + t.Fatalf("repeat migration: %v", err) + } + }) + + t.Run("rejects unknown values without partial conversion", func(t *testing.T) { + resetLegacyDeviceTable(t, db) + if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('valid', 'video'), ('invalid', 'unknown')`).Error; err != nil { + t.Fatal(err) + } + if err = db.Transaction(prepareLegacyDeviceCapabilities); err == nil { + t.Fatal("expected unsupported legacy data error") + } + assertCapabilitiesColumn(t, db, "text", "''::text") + var value string + if err = db.Raw(`SELECT capabilities FROM sense_devices WHERE id = 'valid'`).Scan(&value).Error; err != nil { + t.Fatal(err) + } + if value != "video" { + t.Fatalf("transaction left partial data: %q", value) + } + }) + + t.Run("does nothing when the table is absent", func(t *testing.T) { + if err = db.Exec(`DROP TABLE sense_devices`).Error; err != nil { + t.Fatal(err) + } + if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil { + t.Fatal(err) + } + }) +} + +func resetLegacyDeviceTable(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.Exec(`DROP TABLE IF EXISTS sense_devices`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`CREATE TABLE sense_devices ( + id text PRIMARY KEY, + name varchar(128) NOT NULL DEFAULT '', + location varchar(255) NOT NULL DEFAULT '', + modality varchar(32) NOT NULL DEFAULT 'video', + capabilities text NOT NULL DEFAULT '', + status varchar(32) NOT NULL DEFAULT 'active', + adapter_status varchar(32) NOT NULL DEFAULT 'ready', + rtsp_credential_same_as_onvif boolean NOT NULL DEFAULT true, + credential_updated_at timestamptz, + retry_requested_at timestamptz, + version bigint NOT NULL DEFAULT 1, + create_by bigint NOT NULL DEFAULT 0, + update_by bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + deleted_at timestamptz + )`).Error; err != nil { + t.Fatal(err) + } +} + +func assertCapabilitiesColumn(t *testing.T, db *gorm.DB, wantType, wantDefault string) { + t.Helper() + var column struct{ DataType, ColumnDefault string } + if err := db.Raw(`SELECT data_type, column_default + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'sense_devices' + AND column_name = 'capabilities'`).Scan(&column).Error; err != nil { + t.Fatal(err) + } + if column.DataType != wantType || column.ColumnDefault != wantDefault { + t.Fatalf("type/default=%q/%q; want %q/%q", column.DataType, column.ColumnDefault, wantType, wantDefault) + } +}