diff --git a/Sense/server/cmd/migrate/migration/version/2026081419000_media.go b/Sense/server/cmd/migrate/migration/version/2026081419000_media.go index 98c503e..29cfeed 100644 --- a/Sense/server/cmd/migrate/migration/version/2026081419000_media.go +++ b/Sense/server/cmd/migrate/migration/version/2026081419000_media.go @@ -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, `"`, `""`) + `"` +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go b/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go index 4d06942..834f6dc 100644 --- a/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go +++ b/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go @@ -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) +} diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 49db000..946db2e 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting -wiki_revision: a169b2323323d9de6304e9b430ddbe9888ea1d25 -synchronized_at: 2026-08-15T07:16:25Z +wiki_revision: 1a452e9aafdfe01580f37f9179584b89516cf992 +synchronized_at: 2026-08-15T07:59:38Z # 故障排查 @@ -134,3 +134,21 @@ synchronized_at: 2026-08-15T07:16:25Z 正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。 + + +## 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` 停止。 + +如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。 + diff --git a/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md b/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md new file mode 100644 index 0000000..7ce7189 --- /dev/null +++ b/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md @@ -0,0 +1,80 @@ + +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 + + +# 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` 保持不变。 diff --git a/wiki-docs.json b/wiki-docs.json index eeae211..3012802 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -132,6 +132,10 @@ "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"