Compare commits

...
Author SHA1 Message Date
QiuSW 15bd000816 docs: 记录工单 #95 验收通过 2026-08-16 19:32:02 +08:00
QiuSW 7f13e595b6 Merge remote-tracking branch 'origin/dev' into fix/95-sense-media-path-constraint
# Conflicts:
#	wiki-docs.json
2026-08-16 19:31:00 +08:00
ila 116318df74 Merge pull request '#98' from feature/97-sense-password-login into dev
[SEN] 恢复免验证码登录并安全设置管理员密码(#97)
2026-08-16 19:05:35 +08:00
QiuSW bdb9a5b474 docs: 记录工单 #97 验收通过 2026-08-16 19:05:17 +08:00
QiuSW a92e4da043 docs: 归档 Sense 免验证码登录任务 (#97) 2026-08-15 18:08:33 +08:00
QiuSW bc1a01848d docs: 更新 Sense 登录安全边界 (#97) 2026-08-15 18:02:16 +08:00
QiuSW 0bbdb27f6d fix: 恢复 Sense 免验证码登录 (#97) 2026-08-15 18:02:10 +08:00
QiuSW d0185caa0e docs: 完成任务 #95 待验收归档 2026-08-15 16:43:41 +08:00
QiuSW 6257859b83 docs: 记录旧媒体路由迁移排错 (#95) 2026-08-15 16:09:40 +08:00
QiuSW 3dd489066c fix: 兼容旧媒体路由约束迁移 (#95) 2026-08-15 15:48:10 +08:00
ila 40409707cc Merge pull request '#94' from docs/92-acceptance into dev
记录工单 #92 验收通过
2026-08-15 15:31:29 +08:00
QiuSW 9472151103 docs: 校正工单 #92 验收归档 2026-08-15 15:31:14 +08:00
QiuSW e82f15f1fb docs: 记录工单 #92 验收通过 2026-08-15 15:30:10 +08:00
ila eaa6ae0815 Merge pull request '#93' from fix/92-sense-capabilities-jsonb into dev
[SEN] 修复旧设备 capabilities 迁移到 JSONB(#92)
2026-08-15 15:28:03 +08:00
QiuSW 3a686a8151 docs: 完成任务 #92 待验收归档 2026-08-15 15:20:01 +08:00
QiuSW 3c4578c804 docs: 登记任务 #92 归档镜像 2026-08-15 15:19:12 +08:00
QiuSW fe3badfe3e test: 覆盖旧设备完整迁移链 (#92) 2026-08-15 15:17:28 +08:00
QiuSW aee5f45d96 docs: 记录设备能力迁移排错 (#92) 2026-08-15 15:16:32 +08:00
QiuSW 68b436ac62 fix: 兼容旧设备能力字段迁移 (#92) 2026-08-15 15:15:02 +08:00
ila cf50c285f4 Merge pull request '#88' from docs/69-acceptance into dev
记录工单 #69 验收通过。
2026-08-15 09:37:08 +08:00
19 changed files with 985 additions and 149 deletions
@@ -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
@@ -0,0 +1,207 @@
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"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/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)
}
})
t.Run("completes the device migration and records its version", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('legacy', 'video')`).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 {
t.Fatal(err)
}
}
const migrationVersion = "2026081414000_device.go"
if err = migrateSenseDeviceLedger(db, migrationVersion); err != nil {
t.Fatal(err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var migratedValue string
if err = db.Raw(`SELECT capabilities::text FROM sense_devices WHERE id = 'legacy'`).Scan(&migratedValue).Error; err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(migratedValue, " ", "") != `["video"]` {
t.Fatalf("unexpected full-migration value: %q", migratedValue)
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration record count=%d", applied)
}
})
}
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)
}
}
@@ -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)
}
+1 -11
View File
@@ -7,9 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
@@ -82,21 +80,13 @@ func Authenticator(c *gin.Context) (interface{}, error) {
return nil, jwt.ErrMissingLoginValues
}
if config.ApplicationConfig.Mode != "dev" {
if !captcha.Verify(loginVals.UUID, loginVals.Code, true) {
username = loginVals.Username
msg = "验证码错误"
status = "1"
return nil, jwt.ErrInvalidVerificationode
}
}
sysUser, role, e := loginVals.GetUser(db)
if e == nil {
username = loginVals.Username
return map[string]interface{}{"user": sysUser, "role": role}, nil
} else {
username = loginVals.Username
msg = "登录失败"
status = "1"
log.Warnf("%s login failed!", loginVals.Username)
@@ -9,8 +9,6 @@ import (
type Login struct {
Username string `form:"UserName" json:"username" binding:"required"`
Password string `form:"Password" json:"password" binding:"required"`
Code string `form:"Code" json:"code" binding:"required"`
UUID string `form:"UUID" json:"uuid" binding:"required"`
}
func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {
@@ -0,0 +1,35 @@
package handler
import (
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoginAcceptsCredentialsWithoutCaptcha(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", strings.NewReader(`{"username":"operator","password":"valid-password"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
var login Login
if err := ctx.ShouldBindJSON(&login); err != nil {
t.Fatalf("bind credentials-only login: %v", err)
}
if login.Username != "operator" || login.Password != "valid-password" {
t.Fatalf("unexpected login payload: username=%q", login.Username)
}
typeOfLogin := reflect.TypeOf(login)
if typeOfLogin.NumField() != 2 {
t.Fatalf("login payload must only expose username and password, got %d fields", typeOfLogin.NumField())
}
for _, removed := range []string{"Code", "UUID"} {
if _, ok := typeOfLogin.FieldByName(removed); ok {
t.Fatalf("captcha field %s must not be part of the login payload", removed)
}
}
}
+1 -9
View File
@@ -3798,23 +3798,15 @@ const docTemplateadmin = `{
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
+2 -10
View File
@@ -3789,23 +3789,15 @@
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
@@ -4345,4 +4337,4 @@
"in": "header"
}
}
}
}
@@ -685,19 +685,13 @@ definitions:
type: object
handler.Login:
properties:
code:
type: string
password:
type: string
username:
type: string
uuid:
type: string
required:
- code
- password
- username
- uuid
type: object
models.SysApi:
properties:
+4 -85
View File
@@ -102,29 +102,6 @@
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="code">
<div class="captcha-row">
<el-input
v-model="loginForm.code"
placeholder="请输入验证码"
name="code"
type="text"
tabindex="3"
maxlength="5"
autocomplete="off"
size="large"
:prefix-icon="Key"
@keyup.enter="handleLogin"
/>
<div class="captcha-wrap" title="点击刷新" @click="getCode">
<img v-if="codeUrl" :src="codeUrl" class="captcha-img" alt="验证码">
<div v-else class="captcha-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
</el-form-item>
<el-button
:loading="loading"
type="primary"
@@ -142,27 +119,22 @@
</template>
<script>
import { getCodeImg } from '@/api/login'
import { User, Lock, Key, View, Hide, Monitor, Loading } from '@element-plus/icons-vue'
import { User, Lock, View, Hide, Monitor } from '@element-plus/icons-vue'
export default {
name: 'LoginPage',
setup() {
return { User, Lock, Key, View, Hide, Monitor, Loading }
return { User, Lock, View, Hide, Monitor }
},
data() {
return {
codeUrl: '',
loginForm: {
username: '',
password: '',
code: '',
uuid: ''
password: ''
},
loginRules: {
username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }],
code: [{ required: true, trigger: 'change', message: '验证码不能为空' }]
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
},
passwordType: 'password',
capsTooltip: false,
@@ -185,7 +157,6 @@ export default {
}
},
created() {
this.getCode()
this.getSystemSetting()
},
mounted() {
@@ -202,15 +173,6 @@ export default {
document.title = ret.sys_app_name
})
},
getCode() {
this.codeUrl = ''
getCodeImg().then((res) => {
if (res !== undefined) {
this.codeUrl = res.data
this.loginForm.uuid = res.id
}
})
},
checkCapslock({ shiftKey, key } = {}) {
if (key && key.length === 1) {
if ((shiftKey && key >= 'a' && key <= 'z') || (!shiftKey && key >= 'A' && key <= 'Z')) {
@@ -238,7 +200,6 @@ export default {
})
.catch(() => {
this.loading = false
this.getCode()
})
}
})
@@ -562,48 +523,6 @@ export default {
}
}
/* ── 验证码 ── */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
.el-input {
flex: 1;
}
}
.captcha-wrap {
width: 110px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
transition: border-color 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.captcha-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
color: #c1c7d0;
font-size: 18px;
}
/* ── 登录按钮 ── */
.submit-btn {
width: 100%;
@@ -0,0 +1,15 @@
import LoginPage from '@/views/login/index.vue'
describe('Sense login page', () => {
it('uses username and password without a captcha challenge', () => {
const state = LoginPage.data()
const getSystemSetting = jest.fn()
LoginPage.created.call({ getSystemSetting })
expect(Object.keys(state.loginForm)).toEqual(['username', 'password'])
expect(Object.keys(state.loginRules)).toEqual(['username', 'password'])
expect(LoginPage.methods.getCode).toBeUndefined()
expect(getSystemSetting).toHaveBeenCalledTimes(1)
})
})
+34 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
synchronized_at: 2026-08-15T01:13:07Z
wiki_revision: ee4819a84dd40846bc8fe1865e08590e1cf9cb81
synchronized_at: 2026-08-15T09:58:37Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -146,7 +146,7 @@ Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:<端口>/api/v1/bootstrap"
Remove-Variable bootstrapToken, bootstrapBody
```
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。生产登录需要先调用验证码接口并提交验证码;自动化集成验证不得通过关闭生产安全约束来冒充生产结果。
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。Sense 在 production、test、dev 模式均只提交账号和密码,不显示、不请求也不校验验证码;`/api/v1/captcha` 暂时保留作上游兼容接口,但登录页和登录 API 不依赖它。登录成功、错误密码和未认证拒绝仍必须写入脱敏身份审计,密码继续执行 6–72 字节策略。
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
@@ -230,9 +230,38 @@ corepack pnpm@9.15.1 build:prod
<!-- bell-runtime:end -->
<!-- sense-windows-package:start -->
## Sense Windows 打包状态
## Sense Windows 打包与验证
旧 Sense Windows 包脚本只属于 `explore` 快照。新的打包命令必须在 GoAdmin 派生骨架和业务迁移完成后由独立工单重新建立、验证和记录。
在仓库根目录使用冻结工具链构建:
```powershell
Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe
```
构建脚本严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,执行前端生产构建与 Windows 后端构建,并生成 `Sense\dist\sense-windows-amd64\` 和同名 ZIP。未传 `-MediaMTXPath` 时只生成占位说明,交付前必须另外提供已审核的 Windows amd64 MediaMTX。构建末尾会执行包审计,并清理源码目录的 `Sense/ui/node_modules` 与 `Sense/ui/dist`。
提交前验证:
```powershell
cd Sense\server
go test ./...
go vet ./...
go build ./...
go test -race ./app/sense/media ./cmd/api
cd ..\..
Sense\scripts\build\test-package.ps1 -PackageRoot Sense\dist\sense-windows-amd64
```
包内验证从解压目录执行:
```bat
check-sense.bat
start-sense.bat
stop-sense.bat
```
检查项至少覆盖配置解析与进程环境优先级、特殊字符不被执行、production/demo 数据库隔离、迁移失败不启动服务、首页 SPA fallback、`/healthz`、MediaMTX Control API、包外工作目录启动与停止、PostgreSQL custom-format 备份及恢复到独立数据库。真实摄像机、目标客户数据库账号、目标浏览器与干净客户机器仍须在授权交付环境验收。
<!-- sense-windows-package:end -->
+38 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: 3523b7cd1f126ebc3ef77414cb6d658103f2491d
synchronized_at: 2026-08-15T01:13:11Z
wiki_revision: 1a452e9aafdfe01580f37f9179584b89516cf992
synchronized_at: 2026-08-15T07:59:38Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -116,3 +116,39 @@ synchronized_at: 2026-08-15T01:13:11Z
| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 |
| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 |
<!-- sense-area:end -->
<!-- sense-capabilities-jsonb:start -->
## Sense 旧设备能力字段迁移排错
启动迁移出现 `字段 "capabilities" 的默认值不能转换成类型 jsonb (SQLSTATE 42804)`,表示数据库仍保留旧版 `sense_devices.capabilities text DEFAULT ''`,而当前 GoAdmin 派生模型要求 JSONB。不要跳过迁移、删除设备记录或只手工删除默认值;旧单值数据仍可能在下一步转换失败。
工单 #92 的兼容迁移会在同一事务内锁定设备表并先验证全部旧值:空值转为 `[]`,`video`、`radar`、`contact`、`button`、`wearable`、`other` 等旧单值转为 JSON 数组,合法 JSON 数组保持数组。未知值或非数组 JSON 会拒绝迁移并整体回滚,不输出具体业务值。
处理步骤:
1. 停止所有连接该 Sense 数据库的服务实例。
2. 使用 `backup-sense.bat` 创建 PostgreSQL custom-format 备份,并确认备份文件可读取。
3. 部署包含 #92 的新 `sense.exe` 后重新运行 `migrate-sense.bat` 或正常启动。
4. 若提示“unsupported legacy data”,不要直接改表;保留错误、恢复测试副本并由维护人员确认旧能力语义。
5. 迁移成功后确认设备仍存在、能力标签正确,再启动其他实例。
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
<!-- sense-capabilities-jsonb:end -->
<!-- sense-media-path-constraint:start -->
## Sense 旧媒体路由迁移排错
旧库迁移出现 `约束 "uni_sense_media_routes_path" 不存在 (SQLSTATE 42704)`,表示旧 `sense_media_routes.path` 由 PostgreSQL 自动命名的唯一约束保护,而新 GORM 模型准备改用唯一索引;GORM 按推导名称删除旧约束时找不到实际名称。修正该名称后若继续出现 `source_ready ... contains null values (SQLSTATE 23502)`,表示非空旧表还缺少当前模型要求的运行态列。
工单 #95 的兼容迁移只在 PostgreSQL 旧表存在时执行:取得 ACCESS EXCLUSIVE 表锁,确认只有一个单列 `UNIQUE(path)` 约束,将实际约束名规范为 GORM 可识别名称;同时为旧路由初始化保守运行态 `source_ready=false`、`failure_count=0`、`last_error_code=''`,再继续 AutoMigrate。迁移不会把旧路由伪装成已就绪,服务启动后仍由对账恢复真实状态。复合约束、多重 path 约束或其他无法确认的唯一性结构会拒绝迁移并整体回滚。
处理步骤:
1. 停止连接该数据库的全部 Sense 实例,并确认 Sense 与 MediaMTX 相关端口已释放。
2. 使用 `backup-sense.bat` 生成 PostgreSQL custom-format 备份;非标准 PostgreSQL 安装目录需通过 `SENSE_POSTGRES_BIN` 指向包含 `pg_dump.exe`、`pg_restore.exe` 的目录。
3. 使用 `pg_restore --list <备份文件>` 确认备份可读取,再部署包含 #95 的 Windows 包。
4. 先运行 `migrate-sense.bat`;成功后确认旧路由数量不变、运行态列无空值、`path` 仍有唯一索引。
5. 再启动 Sense,检查首页、`/healthz`、MediaMTX Control API 和视频服务对账;验证完成后使用 `stop-sense.bat` 停止。
如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。
<!-- sense-media-path-constraint:end -->
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
wiki_revision: f0d16a60406eed6fd6968c6555c599554bbd1fae
synchronized_at: 2026-08-11T10:30:56Z
wiki_revision: 221fd392c943caab13ee097c19a3d19d4410b65a
synchronized_at: 2026-08-15T09:59:15Z
<!-- gitea-wiki-mirror:end -->
# 产品需求
@@ -48,7 +48,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
### P0
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。
- **SEN-002 设备台账**:以 Device 为根实体,通过 `modality` 和 `capabilities` 表达 video/radar/contact/button/wearable/other;首期只完整实现 video,未实现适配器显示 `adapter_not_ready`。
- **SEN-003 ONVIF/RTSP 接入**:支持发现或手工添加、Profiles、StreamUri、主/子码流、校时、认证失败、重新探测和凭据更新。
- **SEN-004 批量开通**:默认 16 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-92-Sense旧设备能力JSONB兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-92-Sense%E6%97%A7%E8%AE%BE%E5%A4%87%E8%83%BD%E5%8A%9BJSONB%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e
synchronized_at: 2026-08-15T07:30:56Z
<!-- gitea-wiki-mirror:end -->
# 92 Sense旧设备能力JSONB兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92
- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
用户通过 Windows 包启动 Sense 时,设备迁移报 PostgreSQL `SQLSTATE 42804`。只读诊断确认旧数据库的 `sense_devices.capabilities` 为 `text NOT NULL DEFAULT ''::text`,一条旧记录保存受支持的单值能力但不是 JSON;新模型要求 JSONB,GORM AutoMigrate 无法直接转换旧默认值和数据。
目标是在不删除设备、不跳过迁移、不修改当前用户数据库的前提下,为旧 schema 提供确定性、可回滚的 JSONB 兼容迁移。
## 最终方案
在现有 `2026081414000` 设备迁移事务开头执行 PostgreSQL 专用兼容步骤。受影响数据库尚未登记该迁移版本,后置新版本无法越过失败点,因此兼容逻辑必须放在原失败迁移内。
兼容步骤只处理既有 text/varchar 列:取得 ACCESS EXCLUSIVE 表锁,读取并验证全部旧值后才开始改变默认值和数据。空值转为 `[]`;六种受支持旧单值转为单元素 JSON 数组;合法字符串数组规范化后保持语义。未知单值、对象、非字符串数组、未知数组元素和超过 16 项的数组会返回不含业务值的错误,整个事务回滚。
全部验证通过后删除旧 text 默认值,参数化更新规范 JSON,使用显式 `USING capabilities::jsonb` 转型并设置 `'[]'::jsonb` 默认值,再继续原有 GORM AutoMigrate、菜单、权限和迁移版本登记。非 PostgreSQL、表/列不存在以及已是 json/jsonb 时不执行旧值转换。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:事务内旧 text 能力验证、转换、锁表和 JSONB 默认值处理。
- `Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go`:纯函数和隔离 PostgreSQL 17 回归,包括完整设备迁移与版本登记。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:备份、重试、未知旧值和回退说明。
- `wiki-docs.json`、`docs/task/92-Sense旧设备能力JSONB兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧 text 默认值不再触发 SQLSTATE 42804 | 通过;隔离 PostgreSQL 重现结构成功转为 jsonb |
| 旧单值无损转换为 JSONB 数组 | 通过;`video` 转为单元素数组 |
| 空值与合法数组正确处理 | 通过;空字符串转空数组,合法数组保持顺序与值 |
| 未知值拒绝且无半成品 | 通过;类型、默认值和已验证行均保持旧状态 |
| 新库、jsonb 和重复执行兼容 | 通过;无表跳过、jsonb 重复调用无操作 |
| 全量 Go 和迁移回归通过 | 通过 |
| 当前用户数据库未被修改 | 通过;仅执行只读诊断,写测试使用独立数据库并在结束后删除 |
| Wiki 与归档一致 | 受影响页面定向检查通过 |
## 测试
- `go test ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1 -v`:通过。
- 隔离 PostgreSQL 17 `TestDeviceCapabilitiesMigrationOnPostgres`:旧单值/空值/合法数组、未知值事务回滚、无表、重复调用、完整设备迁移和 `sys_migration` 登记全部通过。
- 隔离数据库 `sense92_migration_test` 测试前确认不存在,测试后确认计数为 0。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1`:通过。
- `git diff --check`:通过。
- 受影响 Wiki 定向同步与检查:通过。
- **未验证部分**:按工单安全边界未在当前用户 `sense` 数据库执行写迁移,也未替换当前 `Sense/dist` 中的待验收 #70 二进制;需先备份,再部署包含 #92 的新包进行最终启动验收。全量 Wiki 检查仍会先发现待验收 PR #89 的 #70 镜像尚未合入 `dev`。
## 用户验收
- 用户于 2026-08-15 明确确认 `#92 验收通过`。
- 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。
- #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。
## 遗留问题
- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。
- Harness strict 的 #66/#67 既有归档格式问题不属于本工单。
## 相关提交
- `68b436a` 兼容旧设备能力字段迁移。
- `fe3badf` 覆盖旧设备完整迁移链。
- `aee5f45` 记录设备能力迁移排错。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-95-Sense旧媒体路由唯一约束兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-95-Sense%E6%97%A7%E5%AA%92%E4%BD%93%E8%B7%AF%E7%94%B1%E5%94%AF%E4%B8%80%E7%BA%A6%E6%9D%9F%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: 5f3bdf3786f2a72dac5d29236ff466743e2912b5
synchronized_at: 2026-08-16T11:31:47Z
<!-- gitea-wiki-mirror:end -->
# 95 Sense旧媒体路由唯一约束兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/95
- Wiki 页面:Task-95-Sense旧媒体路由唯一约束兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
#92 修复进入真实旧库后,设备能力字段已成功转换为 JSONB;下一条媒体迁移因 GORM 尝试删除不存在的推导约束名 `uni_sense_media_routes_path` 而报 SQLSTATE 42704。只读核对确认旧 `sense_media_routes.path` 实际由 PostgreSQL 自动命名约束 `sense_media_routes_path_key` 保证唯一,且表中已有 2 条路由。
隔离回归越过约束错误后进一步确认,旧非空表缺少当前模型要求的运行态列,直接新增 `source_ready NOT NULL` 会报 SQLSTATE 23502。目标是在不删除路由、不削弱 path 唯一性、不伪造媒体已就绪的前提下完成旧表迁移。
## 最终方案
在现有 `2026081419000` 媒体迁移事务开头执行 PostgreSQL 专用兼容步骤。仅当旧表存在时取得 ACCESS EXCLUSIVE 锁,从 pg_catalog 读取包含 path 的唯一约束;只接受唯一的单列 `UNIQUE(path)`,复合、多重或冲突结构拒绝迁移并整体回滚。
确认结构后,把数据库实际约束名规范为 GORM 能识别和移除的名称,让 AutoMigrate 转换为模型的 `idx_sense_media_routes_path` 唯一索引。锁在整个迁移事务提交前持续有效,因此约束切换期间没有并发写入窗口。
兼容步骤同时为旧路由添加并回填当前模型要求的运行态列:`source_ready=false`、`failure_count=0`、`last_error_code=''`,随后设为 NOT NULL。保守初值表示服务启动后必须重新对账,不把旧路由冒充为已经就绪;`next_retry_at` 保持可空并由 AutoMigrate 建立。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081419000_media.go`:旧约束识别、规范化、锁表和运行态列兼容。
- `Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go`:隔离 PostgreSQL 旧表、非空路由、空库、无表、重复执行、唯一性和不安全结构回滚测试。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:错误含义、备份、迁移、验证和回退步骤。
- `wiki-docs.json`、`docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧约束名不再触发 SQLSTATE 42704 | 通过;隔离和真实 PostgreSQL 均完成媒体迁移 |
| 既有媒体路由完整保留 | 通过;真实库迁移前后均为 2 条 |
| path 始终具有唯一性保护 | 通过;迁移后 `idx_sense_media_routes_path` 唯一索引有效,重复 path 写入被拒绝 |
| 无表、新库、已迁移库和重复兼容 | 通过 |
| 不安全结构拒绝并回滚 | 通过;复合 path 约束夹具未发生部分变更 |
| Go 全量与隔离 PostgreSQL 回归 | 通过 |
| #70 Windows 包真实迁移与启动 smoke | 通过;Web/SPA/health/MediaMTX 200,停止后端口清空 |
| Wiki 镜像与任务归档一致 | 通过 |
## 测试
- 隔离 PostgreSQL 17 `TestMediaMigrationOnPostgres`:旧 2 路由、旧约束、运行态回填、空库、无表、重复兼容、唯一性冲突和复合约束回滚全部通过;独立测试数据库每次运行后删除。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -count=1`:使用隔离 PostgreSQL 通过。
- #70 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与包审计通过。
- 迁移前 PostgreSQL custom-format 备份通过 `pg_restore --list`;真实迁移完成,2 条旧路由保留、运行态列无空值、媒体迁移版本登记、唯一索引有效。
- Web 首页、SPA、`/healthz` 与 MediaMTX Control API 均返回 200;`stop-sense.bat` 后相关端口无监听。
- `git diff --check`:通过。
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和真实获准摄像机上验收;当前回归使用本机 PostgreSQL、脱敏业务计数和已配置测试摄像机环境。Harness strict 仍只受既存 #66/#67 归档格式影响。
## 遗留问题
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响,访问 loopback 时失败;明确绕过代理的本机 HTTP 请求验证服务正常,不属于 Sense 服务端故障。
- #95 需先经用户验收并合入 `dev`,随后 #70 才能按依赖顺序完成合并。
## 相关提交
- `3dd4890` 兼容旧媒体路由约束和运行态列迁移。
- `6257859` 记录旧媒体路由迁移排错。
- `c088caf` #70 集成 #95 后用于 Windows 包真实回归。
## 人工验收
- 2026-08-16:用户明确验收通过 #95。
- 按依赖顺序先将 PR #96 合入 `dev`;`main` 保持不变。
@@ -0,0 +1,85 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-97-Sense免验证码登录与管理员密码重置
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-97-Sense%E5%85%8D%E9%AA%8C%E8%AF%81%E7%A0%81%E7%99%BB%E5%BD%95%E4%B8%8E%E7%AE%A1%E7%90%86%E5%91%98%E5%AF%86%E7%A0%81%E9%87%8D%E7%BD%AE.-
wiki_revision: 486ebcca10e4cef0bd905df59b928c17006db17e
synchronized_at: 2026-08-16T11:04:57Z
<!-- gitea-wiki-mirror:end -->
# 97 Sense免验证码登录与管理员密码重置
- 类型:安全行为调整 / 缺陷修复
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/97
- Wiki 页面:Task-97-Sense免验证码登录与管理员密码重置
- Wiki revision:见本地镜像头
## 背景与目标
GoAdmin 重建后的 Sense 登录页和生产后端重新启用了验证码,与用户确认的账号密码直接登录流程不一致。用户要求所有运行模式恢复免验证码登录,并把当前本地 PostgreSQL 的管理员账号设置为用户指定、满足现行 6–72 字节策略的密码;密码明文不得进入仓库、工单、Wiki 或日志。
## 最终方案
- 保留冻结 GoAdmin 的 Gin/GORM/JWT/Casbin `Authenticator`、登录路由、Vuex 登录动作、Element Plus 表单和同步身份审计。
- 登录 DTO 只保留 `username/password`;production、test、dev 均不再校验验证码。
- 登录页移除验证码字段、规则、图标、接口请求、失败刷新和样式;兼容保留 `/api/v1/captcha` 端点及上游存储初始化,便于回退。
- Swagger 登录载荷同步为只要求账号和密码。
- 修复失败认证分支未把用户名写入审计的问题,使错误密码尝试可按账号追踪。
- 目标数据库起初没有任何用户,因此没有执行不安全的直接插入;使用一次性高熵进程令牌走既有 `/api/v1/bootstrap` 安全初始化路径创建 `admin`,密码只在进程内传递。随后验证正确密码成功、错误密码拒绝和成功/失败审计。
- 密码最少 6 位、最多 72 字节且不强制字符复杂度的既有策略保持不变;JWT、RBAC、Cookie 和会话有效期未修改。
## 修改文件
- `Sense/server/common/middleware/handler/auth.go`:移除验证码校验并补齐失败登录用户名审计。
- `Sense/server/common/middleware/handler/login.go`:登录载荷只保留账号和密码。
- `Sense/server/common/middleware/handler/login_test.go`:覆盖无验证码登录载荷。
- `Sense/server/docs/admin/admin_docs.go`、`admin_swagger.json`、`admin_swagger.yaml`:同步登录接口模型。
- `Sense/ui/src/views/login/index.vue`:移除验证码 UI、请求和状态。
- `Sense/ui/tests/unit/login/loginPage.spec.js`:覆盖登录页只使用账号密码。
- Wiki `Product-Requirements`、`Local-Development-and-Verification` 及镜像:记录免验证码登录、安全审计和密码策略边界。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 登录页不显示验证码且不请求验证码接口 | 通过:组件状态、规则与方法均只含账号密码;生产构建通过 |
| 所有模式接受仅账号密码的登录载荷 | 通过:后端不再按模式进入 captcha 校验,DTO 定向测试通过 |
| 正确密码成功、错误密码拒绝并有脱敏审计 | 通过:本地 production/PostgreSQL smoke 成功;成功与失败审计均可按 admin 查询 |
| JWT、RBAC、未登录拒绝不变 | 通过:未认证管理路由返回 401,全量后端测试通过 |
| 密码策略仍为 6–72 字节 | 通过:现有密码策略测试通过,相关代码未修改 |
| 管理员密码安全设置且仓库无明文 | 通过:空用户库经一次性 bootstrap 创建,跟踪差异秘密扫描无泄漏 |
| 前后端测试和 production build | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `corepack pnpm@9.15.1 exec eslint src/views/login/index.vue tests/unit/login/loginPage.spec.js`:通过。
- 前端全量单测:17 个 suite、46 个 test 通过。
- `corepack pnpm@9.15.1 run build:prod`:通过;存在既有 Sass、SCSS export、代码生成器和体积 warning,未由本工单引入。
- production/PostgreSQL/MediaMTX smoke:安全初始化成功、免验证码登录成功、错误密码拒绝、未认证接口返回 401;身份审计包含首个管理员创建、登录成功和登录失败记录。
- `python -m unittest discover -s tests -v`:31 项通过。
- `python dev_scripts/check_harness.py --strict`:只因既有 #66/#67 归档缺少当前模板章节失败 4 项,与本工单修改无关。
- `git diff --check` 与跟踪差异密码扫描:通过。
- 测试结束后 Sense、MediaMTX 均已停止,18080/9997 无监听;`Sense/ui/node_modules` 与 `Sense/ui/dist` 已清理。
- **未验证部分**:尚未在 #70 最终 Windows ZIP、客户全新 Windows 主机和客户目标浏览器中重新打包验收;#70 与 #97 合入 `dev` 后需重新生成发布包。
## 遗留问题
- 验证码 API 为上游兼容而保留但登录链不使用;若未来永久删除,需单独清理工单核对依赖和回退。
- 取消验证码降低自动化暴力尝试阻力;本工单按用户确认保留失败登录审计,但未引入未确认的限流或锁定体系。
## 相关提交
- `0bbdb27f6db4f9d08445ca02abae9061c81598b8` 恢复免验证码登录并补充测试。
- `bc1a01848d1769250b706f207d542b63fee2afa6` 更新 Sense 登录安全边界镜像。
## 人工验收
- 2026-08-16:用户明确验收通过 #97。
- 按工作流将 PR #98 合入 `dev`;`main` 保持不变。
+12
View File
@@ -112,6 +112,10 @@
"page": "Task-65-Sense设备台账与凭据边界",
"path": "docs/task/65-Sense设备台账与凭据边界.md"
},
{
"page": "Task-92-Sense旧设备能力JSONB兼容迁移",
"path": "docs/task/92-Sense旧设备能力JSONB兼容迁移.md"
},
{
"page": "Task-66-Sense视频接入与Profile",
"path": "docs/task/66-Sense视频接入与Profile.md"
@@ -127,6 +131,14 @@
{
"page": "Task-69-Sense多边形区域与方向警戒线配置",
"path": "docs/task/69-Sense多边形区域与方向警戒线配置.md"
},
{
"page": "Task-95-Sense旧媒体路由唯一约束兼容迁移",
"path": "docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md"
},
{
"page": "Task-97-Sense免验证码登录与管理员密码重置",
"path": "docs/task/97-Sense免验证码登录与管理员密码重置.md"
}
]
}