From 6b79478414a31b28b855f4f36742dc24c9d0e76e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 10:56:47 +0800 Subject: [PATCH 01/44] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=20Sense=20Wind?= =?UTF-8?q?ows=20=E4=BA=A4=E4=BB=98=E5=8C=85=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/.gitignore | 3 + Sense/README-WINDOWS.md | 156 ++++++++++ Sense/config/mediamtx.yml | 5 + Sense/config/sense.demo.env.example | 18 ++ Sense/config/sense.env.example | 18 ++ Sense/package/README-MEDIAMTX.txt | 5 + Sense/scripts/build/build-windows.bat | 3 + Sense/scripts/build/build-windows.ps1 | 106 +++++++ Sense/scripts/build/test-package.ps1 | 36 +++ Sense/scripts/runtime/backup-sense.bat | 3 + Sense/scripts/runtime/backup-sense.ps1 | 22 ++ Sense/scripts/runtime/check-sense.bat | 3 + Sense/scripts/runtime/check-sense.ps1 | 19 ++ Sense/scripts/runtime/initialize-admin.bat | 3 + Sense/scripts/runtime/initialize-admin.ps1 | 29 ++ Sense/scripts/runtime/migrate-sense.bat | 3 + Sense/scripts/runtime/migrate-sense.ps1 | 19 ++ Sense/scripts/runtime/restore-sense.bat | 3 + Sense/scripts/runtime/restore-sense.ps1 | 27 ++ Sense/scripts/runtime/sense-common.ps1 | 281 ++++++++++++++++++ Sense/scripts/runtime/start-sense.bat | 3 + Sense/scripts/runtime/start-sense.ps1 | 31 ++ Sense/scripts/runtime/stop-sense.bat | 3 + Sense/scripts/runtime/stop-sense.ps1 | 24 ++ Sense/server/app/sense/media/runtime.go | 12 + Sense/server/app/sense/media/runtime_test.go | 22 ++ Sense/server/cmd/api/server.go | 3 +- Sense/server/cmd/api/web.go | 70 +++++ Sense/server/cmd/api/web_test.go | 54 ++++ Sense/tests/package/run-tests.bat | 3 + Sense/tests/package/run-tests.ps1 | 88 ++++++ Sense/tests/package/smoke-start-stop.ps1 | 32 ++ Sense/tests/package/smoke-windows-package.ps1 | 53 ++++ 33 files changed, 1159 insertions(+), 1 deletion(-) create mode 100644 Sense/README-WINDOWS.md create mode 100644 Sense/config/mediamtx.yml create mode 100644 Sense/config/sense.demo.env.example create mode 100644 Sense/config/sense.env.example create mode 100644 Sense/package/README-MEDIAMTX.txt create mode 100644 Sense/scripts/build/build-windows.bat create mode 100644 Sense/scripts/build/build-windows.ps1 create mode 100644 Sense/scripts/build/test-package.ps1 create mode 100644 Sense/scripts/runtime/backup-sense.bat create mode 100644 Sense/scripts/runtime/backup-sense.ps1 create mode 100644 Sense/scripts/runtime/check-sense.bat create mode 100644 Sense/scripts/runtime/check-sense.ps1 create mode 100644 Sense/scripts/runtime/initialize-admin.bat create mode 100644 Sense/scripts/runtime/initialize-admin.ps1 create mode 100644 Sense/scripts/runtime/migrate-sense.bat create mode 100644 Sense/scripts/runtime/migrate-sense.ps1 create mode 100644 Sense/scripts/runtime/restore-sense.bat create mode 100644 Sense/scripts/runtime/restore-sense.ps1 create mode 100644 Sense/scripts/runtime/sense-common.ps1 create mode 100644 Sense/scripts/runtime/start-sense.bat create mode 100644 Sense/scripts/runtime/start-sense.ps1 create mode 100644 Sense/scripts/runtime/stop-sense.bat create mode 100644 Sense/scripts/runtime/stop-sense.ps1 create mode 100644 Sense/server/app/sense/media/runtime_test.go create mode 100644 Sense/server/cmd/api/web.go create mode 100644 Sense/server/cmd/api/web_test.go create mode 100644 Sense/tests/package/run-tests.bat create mode 100644 Sense/tests/package/run-tests.ps1 create mode 100644 Sense/tests/package/smoke-start-stop.ps1 create mode 100644 Sense/tests/package/smoke-windows-package.ps1 diff --git a/Sense/.gitignore b/Sense/.gitignore index bbd6acd..31c0408 100644 --- a/Sense/.gitignore +++ b/Sense/.gitignore @@ -3,5 +3,8 @@ server/*.exe server/*.db ui/node_modules/ ui/dist/ +dist/ +!scripts/build/ +!scripts/build/** .env *.local.yml diff --git a/Sense/README-WINDOWS.md b/Sense/README-WINDOWS.md new file mode 100644 index 0000000..1ece1fe --- /dev/null +++ b/Sense/README-WINDOWS.md @@ -0,0 +1,156 @@ +# Sense Windows 安装与运行 + +本说明适用于 `sense-windows-amd64` 交付包。Sense 后端和管理网页来自冻结的 GoAdmin/go-admin-ui 基线;启动仍使用 GoAdmin Cobra 的 `migrate` 与 `server` 命令。Brain、Bell 不需要启动。 + +## 1. 准备环境 + +- Windows 10/11 或 Windows Server 2019 及以上,amd64。 +- PostgreSQL 17;先由数据库管理员创建独立的 Sense 数据库和最小权限账号。 +- 已审核版本与许可证的 Windows amd64 `mediamtx.exe`,放到 `bin\mediamtx.exe`。 +- 备份、恢复时还需要 PostgreSQL 客户端的 `pg_dump.exe`、`pg_restore.exe`;可把目录加入 PATH,或配置 `SENSE_POSTGRES_BIN`。 + +交付包不包含 PostgreSQL、数据库数据、管理员默认密码、摄像头密码或客户配置。不要把包解压到所有用户都可写的共享目录。 + +## 2. 配置 production + +编辑 `config\sense.env`。脚本只按 `NAME=value` 读取白名单字段,不会执行文件内容。值中可以包含 `#`、`&`、`;`、空格或 `=`;如首尾使用成对单/双引号,外层引号会被移除。 + +至少填写: + +```dotenv +SENSE_DATABASE_URL=host=127.0.0.1 port=5432 user=sense password=请替换 dbname=sense sslmode=disable +SENSE_JWT_SECRET=请替换为至少32字符的随机值 +SENSE_MEDIAMTX_MODE=managed +SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe +SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml +``` + +用 PowerShell 生成随机值,不要把输出写入工单、Wiki 或 Git: + +```powershell +$bytes = New-Object byte[] 48 +[Security.Cryptography.RandomNumberGenerator]::Fill($bytes) +[Convert]::ToBase64String($bytes) +``` + +同名的非空进程环境变量优先于 `sense.env`。这便于由服务管理器或秘密管理工具注入值;空进程变量不会覆盖文件值。脚本不会打印数据库连接串、JWT secret、Bootstrap token 或摄像头密钥。 + +运行启动前检查: + +```bat +check-sense.bat +``` + +它会检查配置格式、HTTP 端口、PostgreSQL TCP 连接、数据库名、JWT 长度、网页文件和 MediaMTX 模式。`managed` 模式要求二进制与配置文件存在;`external` 模式要求本机 Control API 已可连接。production 不允许 `disabled`。 + +## 3. 启动、迁移与停止 + +首次及日常启动: + +```bat +start-sense.bat +``` + +脚本先执行 `sense.exe migrate -c data\runtime\settings.yml`,成功后再执行 `sense.exe server -c ...`。迁移失败时不会启动 HTTP 服务。迁移会检查 PostgreSQL 数据库是否存在;脚本不会自动创建生产数据库。 + +`config\db.sql` 与 `config\pg.sql` 是冻结 GoAdmin 首次初始化所需的无秘密基线数据,必须和 `sense.exe` 同版本保留;删除它们会导致空库首次迁移失败。 + +浏览器访问 `http://127.0.0.1:18080/`。当前窗口按 `Ctrl+C` 可让 Sense 优雅停止,并请求停止由它启动的 MediaMTX。也可在另一管理员终端运行: + +```bat +stop-sense.bat +``` + +停止脚本只会强制停止监听配置端口、且可执行文件确实位于当前交付包的 Sense 进程树;端口属于其他程序时会拒绝操作。日常维护优先在启动窗口按 `Ctrl+C` 完成优雅停止,窗口丢失或进程失去响应时再使用停止脚本。 + +只执行迁移或禁用启动时自动迁移: + +```bat +migrate-sense.bat +start-sense.bat -SkipMigration +``` + +只有已完成备份并明确掌握版本状态时才使用 `-SkipMigration`。也可把 `SENSE_AUTO_MIGRATE=false` 放到外部进程环境中。 + +## 4. 创建首个管理员与修改密码 + +Sense 不提供生产默认管理员。首次初始化: + +1. 生成至少 32 字符的一次性随机值,临时填入 `SENSE_BOOTSTRAP_TOKEN`。 +2. 启动 Sense。 +3. 在另一个终端运行 `initialize-admin.bat -Username admin`,按隐藏提示输入至少 6 位密码。 +4. 成功后立即清空 `SENSE_BOOTSTRAP_TOKEN` 并重启 Sense。 + +Bootstrap 只允许在用户表为空时执行一次,token 通过请求头传递,不放在 JSON 或命令行中。不要把密码作为 bat 参数。 + +管理员登录后,在右上角头像进入“个人中心 → 修改密码”。密码至少 6 个字符;修改成功后重新登录。其他管理员的密码重置只能由授权管理员通过 GoAdmin 用户管理入口完成并形成审计记录。 + +## 5. 备份与恢复 + +创建 PostgreSQL custom-format 备份: + +```bat +backup-sense.bat +backup-sense.bat -OutputDirectory D:\SenseBackups +``` + +默认写入包外可单独保护的 `backups` 目录。脚本从连接串移除密码后再构造 `pg_dump` 命令,密码只通过子进程环境传递。 + +恢复会清理并替换目标库中的对象,必须先停止 Sense、备份当前库,并两次确认数据库名: + +```bat +restore-sense.bat -BackupFile D:\SenseBackups\sense-sense-20260815-120000.dump -ConfirmDatabaseName sense +migrate-sense.bat +``` + +恢复脚本还会交互要求输入 `RESTORE-数据库名`;名称不完全一致时拒绝执行。不要对来源不明或版本不匹配的备份执行恢复。 + +## 6. Demo 隔离 + +Demo 使用独立的 `config\sense.demo.env` 和 `SENSE_DEMO_DATABASE_URL`: + +```bat +start-sense.bat demo +``` + +数据库名必须包含 `demo` 或 `test`,且不会回退到 production 的 `SENSE_DATABASE_URL`。默认 HTTP 端口为 18081、MediaMTX 为 disabled。Demo 数据不属于生产数据,不得迁入生产库或用于客户交付。 + +Demo 启动窗口按 `Ctrl+C` 停止;窗口不可用时执行 `stop-sense.bat -Mode demo`。 + +## 7. 日志与排错 + +- Sense 文件日志:`logs\` +- 运行时生成的 GoAdmin YAML:`data\runtime\settings.yml`(包含秘密,不得复制到工单或发送给无权限人员) +- MediaMTX 日志:由 Sense 启动窗口和 MediaMTX 自身输出提供 +- 包完整性:`MANIFEST.sha256` + +常见错误: + +- `SENSE_DATABASE_URL is required`:编辑当前包的 `config\sense.env`,或设置非空进程变量。 +- `PostgreSQL is unreachable`:确认服务、地址、端口和防火墙;数据库不存在会在迁移阶段明确失败。 +- `port ... already in use`:先运行 `stop-sense.bat`,或确认占用者后修改 `SENSE_PORT`。 +- `Managed MediaMTX binary not found`:把已审核的 `mediamtx.exe` 放入 `bin`,不要只复制配置文件。 +- `External MediaMTX Control API is unreachable`:启动外部实例并确认 API 只监听回环地址。 +- `migration failed`:不要跳过;先备份,保留错误输出,核对数据库账号权限和版本。 +- 网页返回 404:检查 `web\index.html` 与 `SENSE_WEB_ROOT=web`,不要把源码目录或 `node_modules` 放进包。 + +## 8. 构建交付包 + +开发机在仓库根目录执行: + +```bat +Sense\scripts\build\build-windows.bat +``` + +若要把已审核的 MediaMTX 一并放入包: + +```powershell +Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe +``` + +构建严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,生成: + +- `Sense\dist\sense-windows-amd64\` +- `Sense\dist\sense-windows-amd64.zip` + +构建末尾会审计包内容:拒绝 `node_modules`、嵌套 `dist`、Git/缓存目录、数据库/备份文件、非空秘密字段、常见默认密码和私钥标记。`dist` 为可重建产物,不提交 Git。 diff --git a/Sense/config/mediamtx.yml b/Sense/config/mediamtx.yml new file mode 100644 index 0000000..5d59df8 --- /dev/null +++ b/Sense/config/mediamtx.yml @@ -0,0 +1,5 @@ +logLevel: info +api: true +apiAddress: 127.0.0.1:9997 +metrics: false +paths: {} diff --git a/Sense/config/sense.demo.env.example b/Sense/config/sense.demo.env.example new file mode 100644 index 0000000..42884c1 --- /dev/null +++ b/Sense/config/sense.demo.env.example @@ -0,0 +1,18 @@ +# Demo mode must use a disposable PostgreSQL database whose name contains +# "demo" or "test". It never falls back to SENSE_DATABASE_URL. +SENSE_MODE=demo +SENSE_HOST=127.0.0.1 +SENSE_PORT=18081 +SENSE_DEMO_DATABASE_URL= +SENSE_JWT_SECRET= +SENSE_BOOTSTRAP_TOKEN= +SENSE_CREDENTIAL_KEY= +SENSE_ONVIF_DISCOVERY_IP= +SENSE_ONVIF_ALLOWED_CIDRS= +SENSE_MEDIAMTX_MODE=disabled +SENSE_MEDIAMTX_BINARY= +SENSE_MEDIAMTX_CONFIG= +SENSE_MEDIAMTX_API=http://127.0.0.1:9997 +SENSE_WEB_ROOT=web +SENSE_AUTO_MIGRATE=true +SENSE_POSTGRES_BIN= diff --git a/Sense/config/sense.env.example b/Sense/config/sense.env.example new file mode 100644 index 0000000..1bfef50 --- /dev/null +++ b/Sense/config/sense.env.example @@ -0,0 +1,18 @@ +# Sense production configuration. Copy this file as config\sense.env. +# Values are parsed as data; this file is never executed as a script. +SENSE_MODE=production +SENSE_HOST=127.0.0.1 +SENSE_PORT=18080 +SENSE_DATABASE_URL= +SENSE_JWT_SECRET= +SENSE_BOOTSTRAP_TOKEN= +SENSE_CREDENTIAL_KEY= +SENSE_ONVIF_DISCOVERY_IP= +SENSE_ONVIF_ALLOWED_CIDRS= +SENSE_MEDIAMTX_MODE=managed +SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe +SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml +SENSE_MEDIAMTX_API=http://127.0.0.1:9997 +SENSE_WEB_ROOT=web +SENSE_AUTO_MIGRATE=true +SENSE_POSTGRES_BIN= diff --git a/Sense/package/README-MEDIAMTX.txt b/Sense/package/README-MEDIAMTX.txt new file mode 100644 index 0000000..93e8b93 --- /dev/null +++ b/Sense/package/README-MEDIAMTX.txt @@ -0,0 +1,5 @@ +Place the approved Windows amd64 mediamtx.exe in this package's bin directory, +or pass -MediaMTXPath to scripts\build\build-windows.ps1. + +Sense does not redistribute MediaMTX automatically. Verify its version, +license, checksum, and customer approval before delivery. diff --git a/Sense/scripts/build/build-windows.bat b/Sense/scripts/build/build-windows.bat new file mode 100644 index 0000000..df753f3 --- /dev/null +++ b/Sense/scripts/build/build-windows.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/build/build-windows.ps1 b/Sense/scripts/build/build-windows.ps1 new file mode 100644 index 0000000..b084199 --- /dev/null +++ b/Sense/scripts/build/build-windows.ps1 @@ -0,0 +1,106 @@ +param([string]$MediaMTXPath = '') +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$serverRoot = Join-Path $senseRoot 'server' +$uiRoot = Join-Path $senseRoot 'ui' +$distRoot = Join-Path $senseRoot 'dist' +$target = Join-Path $distRoot 'sense-windows-amd64' +$archive = Join-Path $distRoot 'sense-windows-amd64.zip' +$staging = Join-Path $distRoot ('.sense-windows-amd64.staging-' + $PID) + +function Assert-ChildPath([string]$Parent, [string]$Child) { + $parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\' + $childPath = [IO.Path]::GetFullPath($Child) + if (-not $childPath.StartsWith($parentPath, [StringComparison]::OrdinalIgnoreCase)) { + throw "Unsafe build path outside $Parent`: $Child" + } +} + +Assert-ChildPath $senseRoot $distRoot +Assert-ChildPath $distRoot $target +Assert-ChildPath $distRoot $archive +Assert-ChildPath $distRoot $staging + +$goVersion = '' +Push-Location $serverRoot +try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location } +$nodeVersion = (& node --version).Trim().TrimStart('v') +$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim() +if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; module toolchain reported $goVersion." } +if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." } +if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; corepack reported $pnpmVersion." } + +$hadNodeModules = Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules') +$hadUIDist = Test-Path -LiteralPath (Join-Path $uiRoot 'dist') +try { + New-Item -ItemType Directory -Force -Path $distRoot | Out-Null + if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force } + New-Item -ItemType Directory -Path $staging | Out-Null + + Push-Location $uiRoot + try { + & corepack pnpm@9.15.1 install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' } + & corepack pnpm@9.15.1 run build:prod + if ($LASTEXITCODE -ne 0) { throw 'Sense UI production build failed.' } + } finally { Pop-Location } + + $oldGOOS, $oldGOARCH, $oldCGO = $env:GOOS, $env:GOARCH, $env:CGO_ENABLED + try { + $env:GOOS = 'windows'; $env:GOARCH = 'amd64'; $env:CGO_ENABLED = '0' + Push-Location $serverRoot + try { + & go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'sense.exe') . + if ($LASTEXITCODE -ne 0) { throw 'Sense server Windows build failed.' } + } finally { Pop-Location } + } finally { + $env:GOOS, $env:GOARCH, $env:CGO_ENABLED = $oldGOOS, $oldGOARCH, $oldCGO + } + + Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse + New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'), (Join-Path $staging 'config'), (Join-Path $staging 'bin') | Out-Null + Copy-Item -Path (Join-Path $senseRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime') + foreach ($name in @('start-sense', 'stop-sense', 'check-sense', 'migrate-sense', 'backup-sense', 'restore-sense', 'initialize-admin')) { + Copy-Item -LiteralPath (Join-Path $senseRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") + } + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env.example') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env.example') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx.yml') -Destination (Join-Path $staging 'config\mediamtx.yml') + Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql') + Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql') + Copy-Item -LiteralPath (Join-Path $senseRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md') + Copy-Item -LiteralPath (Join-Path $senseRoot 'package\README-MEDIAMTX.txt') -Destination (Join-Path $staging 'bin\README-MEDIAMTX.txt') + Copy-Item -LiteralPath (Join-Path $senseRoot 'LICENSES') -Destination (Join-Path $staging 'LICENSES') -Recurse + if (-not [string]::IsNullOrWhiteSpace($MediaMTXPath)) { + $mediaSource = [IO.Path]::GetFullPath($MediaMTXPath) + if (-not (Test-Path -LiteralPath $mediaSource -PathType Leaf)) { throw "MediaMTX binary not found: $mediaSource" } + Copy-Item -LiteralPath $mediaSource -Destination (Join-Path $staging 'bin\mediamtx.exe') + } + $commit = (& git -C (Split-Path $senseRoot -Parent) rev-parse HEAD).Trim() + [IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'), @( + "source_commit=$commit", 'go=1.26.5', 'node=22.22.1', 'pnpm=9.15.1' + ), (New-Object Text.UTF8Encoding($false))) + + & (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging + if ($LASTEXITCODE -ne 0) { throw 'Sense package audit failed.' } + $manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { + $relative = $file.FullName.Substring($staging.Length + 1).Replace('\', '/') + "$(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 | Select-Object -ExpandProperty Hash) $relative" + } + [IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'), $manifest, (New-Object Text.UTF8Encoding($false))) + + if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force } + Move-Item -LiteralPath $staging -Destination $target + if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force } + Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal + Write-Host "Sense Windows package: $target" + Write-Host "Sense Windows archive: $archive" +} finally { + if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force } + if (-not $hadUIDist -and (Test-Path -LiteralPath (Join-Path $uiRoot 'dist'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'dist') -Recurse -Force } + if (-not $hadNodeModules -and (Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'node_modules') -Recurse -Force } +} diff --git a/Sense/scripts/build/test-package.ps1 b/Sense/scripts/build/test-package.ps1 new file mode 100644 index 0000000..442fa91 --- /dev/null +++ b/Sense/scripts/build/test-package.ps1 @@ -0,0 +1,36 @@ +param([Parameter(Mandatory = $true)][string]$PackageRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($PackageRoot) +if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" } +$required = @( + 'sense.exe', 'start-sense.bat', 'stop-sense.bat', 'check-sense.bat', + 'migrate-sense.bat', 'backup-sense.bat', 'restore-sense.bat', + 'initialize-admin.bat', 'README-WINDOWS.md', 'config\sense.env', + 'config\sense.env.example', 'config\sense.demo.env', + 'config\mediamtx.yml', 'config\db.sql', 'config\pg.sql', + 'web\index.html', 'scripts\runtime\sense-common.ps1' +) +foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } +} +$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules', '.git', 'dist', '.cache') } +if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" } +$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db', '.sqlite', '.sqlite3', '.dump', '.bak') } +if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" } +$configFiles = @((Join-Path $root 'config\sense.env'), (Join-Path $root 'config\sense.demo.env')) +foreach ($configFile in $configFiles) { + $content = Get-Content -LiteralPath $configFile -Raw + foreach ($secret in @('SENSE_DATABASE_URL', 'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN', 'SENSE_CREDENTIAL_KEY')) { + if ($content -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty secret field: $secret" } + } +} +$textExtensions = @('.md', '.txt', '.env', '.example', '.ps1', '.bat', '.yml', '.yaml', '.json', '.html', '.js', '.css', '.sql') +foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) { + $content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue + if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') { + throw "Package contains a forbidden default credential or private key marker: $($file.FullName)" + } +} +Write-Host "Sense package audit passed: $root" diff --git a/Sense/scripts/runtime/backup-sense.bat b/Sense/scripts/runtime/backup-sense.bat new file mode 100644 index 0000000..39831f5 --- /dev/null +++ b/Sense/scripts/runtime/backup-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\backup-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/backup-sense.ps1 b/Sense/scripts/runtime/backup-sense.ps1 new file mode 100644 index 0000000..6302169 --- /dev/null +++ b/Sense/scripts/runtime/backup-sense.ps1 @@ -0,0 +1,22 @@ +param( + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [string]$OutputDirectory = '' +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $pgDump = Get-SensePostgresTool -Name 'pg_dump' + if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $root 'backups' } + $OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) + New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $output = Join-Path $OutputDirectory "sense-$($state.Database.Database)-$stamp.dump" + Invoke-SensePostgresTool -Tool $pgDump -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--format=custom', '--no-owner', '--file', $output) + Write-Host "Sense backup created: $output" + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/check-sense.bat b/Sense/scripts/runtime/check-sense.bat new file mode 100644 index 0000000..6e16275 --- /dev/null +++ b/Sense/scripts/runtime/check-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\check-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/check-sense.ps1 b/Sense/scripts/runtime/check-sense.ps1 new file mode 100644 index 0000000..1654d7f --- /dev/null +++ b/Sense/scripts/runtime/check-sense.ps1 @@ -0,0 +1,19 @@ +param( + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [switch]$Running +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort:$Running + if ($Running -and -not (Test-SenseTcpEndpoint -HostName $state.Host -Port $state.Port)) { + throw "Sense is not accepting TCP connections at $($state.Host):$($state.Port)." + } + Write-Host "Sense $Mode configuration check passed." + Write-Host "PostgreSQL endpoint: reachable; MediaMTX mode: $($state.MediaMode); HTTP port: $($state.Port)." + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/initialize-admin.bat b/Sense/scripts/runtime/initialize-admin.bat new file mode 100644 index 0000000..85e2772 --- /dev/null +++ b/Sense/scripts/runtime/initialize-admin.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\initialize-admin.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/initialize-admin.ps1 b/Sense/scripts/runtime/initialize-admin.ps1 new file mode 100644 index 0000000..bedaa5d --- /dev/null +++ b/Sense/scripts/runtime/initialize-admin.ps1 @@ -0,0 +1,29 @@ +param( + [string]$Username = '', + [ValidateSet('production', 'demo')][string]$Mode = 'production' +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +$passwordPointer = [IntPtr]::Zero +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $token = Get-SenseEnvironmentValue -Name 'SENSE_BOOTSTRAP_TOKEN' + if ($token.Length -lt 32) { throw 'Set a temporary random SENSE_BOOTSTRAP_TOKEN of at least 32 characters, then restart Sense.' } + if ([string]::IsNullOrWhiteSpace($Username)) { $Username = Read-Host 'Administrator username' } + $securePassword = Read-Host 'Administrator password (at least 6 characters)' -AsSecureString + $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + $password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) + $body = @{ username = $Username; password = $password; nickName = $Username } | ConvertTo-Json -Compress + $headers = @{ 'X-Sense-Bootstrap-Token' = $token } + $uri = "http://$($state.Host):$($state.Port)/api/v1/public/bootstrap" + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $body | Out-Null + Write-Host 'Sense administrator created. Remove SENSE_BOOTSTRAP_TOKEN from config/sense.env and restart Sense now.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} finally { + if ($passwordPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) } + Remove-Variable password -ErrorAction SilentlyContinue +} diff --git a/Sense/scripts/runtime/migrate-sense.bat b/Sense/scripts/runtime/migrate-sense.bat new file mode 100644 index 0000000..fabdcb5 --- /dev/null +++ b/Sense/scripts/runtime/migrate-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\migrate-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/migrate-sense.ps1 b/Sense/scripts/runtime/migrate-sense.ps1 new file mode 100644 index 0000000..4d1e5e6 --- /dev/null +++ b/Sense/scripts/runtime/migrate-sense.ps1 @@ -0,0 +1,19 @@ +param([ValidateSet('production', 'demo')][string]$Mode = 'production') +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $sense = Join-Path $root 'sense.exe' + Write-Host 'Applying pending Sense database migrations...' + Push-Location $root + try { + & $sense migrate -c $state.SettingsPath + if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed.' } + } finally { Pop-Location } + Write-Host 'Sense database migration completed.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/restore-sense.bat b/Sense/scripts/runtime/restore-sense.bat new file mode 100644 index 0000000..6c31ec0 --- /dev/null +++ b/Sense/scripts/runtime/restore-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\restore-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/restore-sense.ps1 b/Sense/scripts/runtime/restore-sense.ps1 new file mode 100644 index 0000000..87dd8c7 --- /dev/null +++ b/Sense/scripts/runtime/restore-sense.ps1 @@ -0,0 +1,27 @@ +param( + [Parameter(Mandatory = $true)][string]$BackupFile, + [Parameter(Mandatory = $true)][string]$ConfirmDatabaseName, + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [string]$Confirmation = '' +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $backup = [IO.Path]::GetFullPath($BackupFile) + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { throw "Backup file not found: $backup" } + if ($ConfirmDatabaseName -cne $state.Database.Database) { + throw 'Restore confirmation does not exactly match the configured database name.' + } + $pgRestore = Get-SensePostgresTool -Name 'pg_restore' + Write-Warning "Restoring will replace objects in database '$ConfirmDatabaseName'. Stop Sense before continuing." + $answer = if ([string]::IsNullOrWhiteSpace($Confirmation)) { Read-Host "Type RESTORE-$ConfirmDatabaseName to continue" } else { $Confirmation } + if ($answer -cne "RESTORE-$ConfirmDatabaseName") { throw 'Restore cancelled.' } + Invoke-SensePostgresTool -Tool $pgRestore -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--clean', '--if-exists', '--no-owner', '--exit-on-error', $backup) + Write-Host 'Sense database restore completed. Run migrate-sense.bat before starting Sense.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/sense-common.ps1 b/Sense/scripts/runtime/sense-common.ps1 new file mode 100644 index 0000000..e4cd351 --- /dev/null +++ b/Sense/scripts/runtime/sense-common.ps1 @@ -0,0 +1,281 @@ +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$script:SenseAllowedEnvironment = @( + 'SENSE_MODE', 'SENSE_HOST', 'SENSE_PORT', 'SENSE_DATABASE_URL', + 'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN', + 'SENSE_CREDENTIAL_KEY', 'SENSE_ONVIF_DISCOVERY_IP', + 'SENSE_ONVIF_ALLOWED_CIDRS', 'SENSE_MEDIAMTX_MODE', + 'SENSE_MEDIAMTX_BINARY', 'SENSE_MEDIAMTX_CONFIG', + 'SENSE_MEDIAMTX_API', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE', + 'SENSE_POSTGRES_BIN' +) + +function Get-SensePackageRoot { + param([string]$ScriptDirectory = $PSScriptRoot) + return [System.IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..')) +} + +function Import-SenseEnvironment { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Sense configuration file not found: $Path" + } + $lineNumber = 0 + foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) { + $lineNumber++ + $line = $rawLine.Trim() + if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue } + $separator = $line.IndexOf('=') + if ($separator -lt 1) { + throw "Invalid Sense configuration at line $lineNumber. Expected NAME=value." + } + $name = $line.Substring(0, $separator).Trim() + if ($script:SenseAllowedEnvironment -notcontains $name) { + throw "Unsupported Sense configuration key at line ${lineNumber}: $name" + } + $value = $line.Substring($separator + 1) + if ($value.Length -ge 2) { + $first, $last = $value[0], $value[$value.Length - 1] + if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { + $value = $value.Substring(1, $value.Length - 2) + } + } + $existing = [Environment]::GetEnvironmentVariable($name, 'Process') + if ([string]::IsNullOrWhiteSpace($existing)) { + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + } +} + +function Get-SenseEnvironmentValue { + param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '') + $value = [Environment]::GetEnvironmentVariable($Name, 'Process') + if ([string]::IsNullOrWhiteSpace($value)) { return $Default } + return $value +} + +function ConvertTo-SenseYamlString { + param([AllowEmptyString()][string]$Value) + return ($Value | ConvertTo-Json -Compress) +} + +function Resolve-SenseConfiguredPath { + param([Parameter(Mandatory = $true)][string]$PackageRoot, [AllowEmptyString()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + if ([System.IO.Path]::IsPathRooted($Value)) { + return [System.IO.Path]::GetFullPath($Value) + } + return [System.IO.Path]::GetFullPath((Join-Path $PackageRoot $Value)) +} + +function Get-SenseDatabaseInfo { + param([Parameter(Mandatory = $true)][string]$Connection) + $result = @{ Host = '127.0.0.1'; Port = 5432; Database = ''; Sanitized = $Connection; Password = '' } + if ($Connection -match '^postgres(?:ql)?://') { + $uri = [Uri]$Connection + $result.Host = $uri.Host + if (-not $uri.IsDefaultPort) { $result.Port = $uri.Port } + $result.Database = $uri.AbsolutePath.TrimStart('/') + if ($uri.UserInfo) { + $parts = $uri.UserInfo.Split(':', 2) + $user = [Uri]::UnescapeDataString($parts[0]) + if ($parts.Count -eq 2) { $result.Password = [Uri]::UnescapeDataString($parts[1]) } + $builder = [UriBuilder]$uri + $builder.UserName = $user + $builder.Password = '' + $result.Sanitized = $builder.Uri.AbsoluteUri + } + return $result + } + + $matches = [regex]::Matches($Connection, '(?:^|\s)(?[A-Za-z_][A-Za-z0-9_]*)=(?''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)') + $sanitized = New-Object System.Collections.Generic.List[string] + foreach ($match in $matches) { + $key = $match.Groups['key'].Value + $raw = $match.Groups['value'].Value + $value = $raw + if ($raw.Length -ge 2 -and (($raw[0] -eq "'" -and $raw[$raw.Length - 1] -eq "'") -or ($raw[0] -eq '"' -and $raw[$raw.Length - 1] -eq '"'))) { + $value = $raw.Substring(1, $raw.Length - 2) + } + if ($key.ToLowerInvariant() -eq 'password') { + $result.Password = $value + continue + } + switch ($key.ToLowerInvariant()) { + 'host' { $result.Host = $value } + 'port' { $result.Port = [int]$value } + 'dbname' { $result.Database = $value } + } + $sanitized.Add("$key=$raw") + } + if ($matches.Count -eq 0) { throw 'SENSE_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' } + $result.Sanitized = $sanitized -join ' ' + return $result +} + +function Test-SenseTcpEndpoint { + param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000) + $client = New-Object System.Net.Sockets.TcpClient + try { + $task = $client.ConnectAsync($HostName, $Port) + if (-not $task.Wait($TimeoutMilliseconds)) { return $false } + return $client.Connected + } catch { + return $false + } finally { + $client.Dispose() + } +} + +function Test-SenseListenPortAvailable { + param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port) + $ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -eq 'localhost') { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) } + $listener = New-Object Net.Sockets.TcpListener($ip, $Port) + try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} } +} + +function Initialize-SenseRuntime { + param( + [Parameter(Mandatory = $true)][string]$PackageRoot, + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [switch]$AllowOccupiedPort + ) + $configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' } + Import-SenseEnvironment -Path (Join-Path $PackageRoot "config\$configName") + + $hostName = Get-SenseEnvironmentValue -Name 'SENSE_HOST' -Default '127.0.0.1' + $portText = Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080' + $port = 0 + if (-not [int]::TryParse($portText, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { + throw 'SENSE_PORT must be an integer between 1 and 65535.' + } + if ($hostName -notin @('127.0.0.1', '0.0.0.0', 'localhost')) { + throw 'SENSE_HOST must be 127.0.0.1, localhost, or 0.0.0.0.' + } + if (-not $AllowOccupiedPort -and -not (Test-SenseListenPortAvailable -HostName $hostName -Port $port)) { + throw "Sense HTTP port $hostName`:$port is already in use. Stop the existing process or change SENSE_PORT." + } + + $databaseVariable = if ($Mode -eq 'demo') { 'SENSE_DEMO_DATABASE_URL' } else { 'SENSE_DATABASE_URL' } + $databaseURL = Get-SenseEnvironmentValue -Name $databaseVariable + if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw "$databaseVariable is required." } + $database = Get-SenseDatabaseInfo -Connection $databaseURL + if ([string]::IsNullOrWhiteSpace($database.Database)) { throw "$databaseVariable must name a database." } + if ($Mode -eq 'demo' -and $database.Database -notmatch '(?i)demo|test') { + throw 'Demo mode requires a database name containing demo or test; production data must never be reused as demo data.' + } + if (-not (Test-SenseTcpEndpoint -HostName $database.Host -Port $database.Port)) { + throw "PostgreSQL is unreachable at $($database.Host):$($database.Port). Start PostgreSQL and verify the database connection." + } + + $jwtSecret = Get-SenseEnvironmentValue -Name 'SENSE_JWT_SECRET' + if ($Mode -eq 'production' -and $jwtSecret.Trim().Length -lt 32) { + throw 'SENSE_JWT_SECRET must contain at least 32 characters in production.' + } + if ([string]::IsNullOrWhiteSpace($jwtSecret)) { throw 'SENSE_JWT_SECRET is required.' } + + $mediaMode = (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_MODE' -Default $(if ($Mode -eq 'demo') { 'disabled' } else { 'managed' })).ToLowerInvariant() + if ($mediaMode -notin @('managed', 'external', 'disabled')) { throw 'SENSE_MEDIAMTX_MODE must be managed, external, or disabled.' } + if ($Mode -eq 'production' -and $mediaMode -eq 'disabled') { throw 'MediaMTX cannot be disabled in production.' } + $mediaAPI = Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_API' -Default 'http://127.0.0.1:9997' + $apiUri = [Uri]$mediaAPI + if ($apiUri.Scheme -ne 'http' -or $apiUri.Host -notin @('127.0.0.1', 'localhost', '::1')) { + throw 'SENSE_MEDIAMTX_API must be an HTTP loopback URL.' + } + $mediaBinary = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_BINARY') + $mediaConfig = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_CONFIG') + if ($mediaMode -eq 'managed') { + if (-not (Test-Path -LiteralPath $mediaBinary -PathType Leaf)) { throw 'Managed MediaMTX binary not found. Set SENSE_MEDIAMTX_BINARY to mediamtx.exe.' } + if (-not (Test-Path -LiteralPath $mediaConfig -PathType Leaf)) { throw 'Managed MediaMTX configuration not found. Set SENSE_MEDIAMTX_CONFIG.' } + } + if ($mediaMode -eq 'external' -and -not (Test-SenseTcpEndpoint -HostName $apiUri.Host -Port $apiUri.Port)) { + throw "External MediaMTX Control API is unreachable at $($apiUri.Host):$($apiUri.Port)." + } + + $webRoot = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_WEB_ROOT' -Default 'web') + if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw 'Sense web assets are missing. Rebuild or replace the delivery package.' } + [Environment]::SetEnvironmentVariable('SENSE_WEB_ROOT', $webRoot, 'Process') + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_MODE', $mediaMode, 'Process') + if ($mediaMode -eq 'managed') { + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', $mediaBinary, 'Process') + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', $mediaConfig, 'Process') + } else { + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', '', 'Process') + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', '', 'Process') + } + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_API', $mediaAPI, 'Process') + + $runtimeDir = Join-Path $PackageRoot 'data\runtime' + $logDir = Join-Path $PackageRoot 'logs' + New-Item -ItemType Directory -Force -Path $runtimeDir, $logDir | Out-Null + $settingsPath = Join-Path $runtimeDir 'settings.yml' + $applicationMode = if ($Mode -eq 'production') { 'prod' } else { 'test' } + $lines = @( + 'settings:', + ' application:', + " mode: $applicationMode", + " host: $(ConvertTo-SenseYamlString $hostName)", + ' name: sense', + " port: $port", + ' readtimeout: 10', + ' writertimeout: 20', + ' enabledp: false', + ' logger:', + " path: $(ConvertTo-SenseYamlString $logDir)", + " stdout: ''", + ' level: info', + ' enableddb: false', + ' jwt:', + " secret: $(ConvertTo-SenseYamlString $jwtSecret)", + ' timeout: 3600', + ' database:', + ' driver: postgres', + " source: $(ConvertTo-SenseYamlString $databaseURL)", + ' gen:', + " dbname: $(ConvertTo-SenseYamlString $database.Database)", + " frontpath: ''", + ' extend:', + ' demo:', + ' name: data', + ' cache:', + " memory: ''", + ' queue:', + ' memory:', + ' poolSize: 100', + ' locker:', + ' redis:' + ) + [IO.File]::WriteAllLines($settingsPath, $lines, (New-Object Text.UTF8Encoding($false))) + return @{ PackageRoot = $PackageRoot; SettingsPath = $settingsPath; Host = $hostName; Port = $port; Database = $database; Mode = $Mode; MediaMode = $mediaMode } +} + +function Get-SensePostgresTool { + param([Parameter(Mandatory = $true)][string]$Name) + $configured = Get-SenseEnvironmentValue -Name 'SENSE_POSTGRES_BIN' + if (-not [string]::IsNullOrWhiteSpace($configured)) { + $candidate = Join-Path $configured "$Name.exe" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + } + $command = Get-Command "$Name.exe" -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + throw "$Name.exe was not found. Install PostgreSQL client tools or set SENSE_POSTGRES_BIN." +} + +function Invoke-SensePostgresTool { + param( + [Parameter(Mandatory = $true)][string]$Tool, + [Parameter(Mandatory = $true)][hashtable]$Database, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + $oldPassword = [Environment]::GetEnvironmentVariable('PGPASSWORD', 'Process') + try { + if (-not [string]::IsNullOrEmpty($Database.Password)) { + [Environment]::SetEnvironmentVariable('PGPASSWORD', $Database.Password, 'Process') + } + & $Tool @Arguments + if ($LASTEXITCODE -ne 0) { throw "PostgreSQL tool failed with exit code $LASTEXITCODE." } + } finally { + [Environment]::SetEnvironmentVariable('PGPASSWORD', $oldPassword, 'Process') + } +} diff --git a/Sense/scripts/runtime/start-sense.bat b/Sense/scripts/runtime/start-sense.bat new file mode 100644 index 0000000..766e7a2 --- /dev/null +++ b/Sense/scripts/runtime/start-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\start-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/start-sense.ps1 b/Sense/scripts/runtime/start-sense.ps1 new file mode 100644 index 0000000..ddc4cd4 --- /dev/null +++ b/Sense/scripts/runtime/start-sense.ps1 @@ -0,0 +1,31 @@ +param( + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [switch]$SkipMigration +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode + $sense = Join-Path $root 'sense.exe' + if (-not (Test-Path -LiteralPath $sense -PathType Leaf)) { throw "Sense executable not found: $sense" } + $autoMigrate = (Get-SenseEnvironmentValue -Name 'SENSE_AUTO_MIGRATE' -Default 'true').ToLowerInvariant() + Push-Location $root + try { + if (-not $SkipMigration -and $autoMigrate -notin @('false', '0', 'no')) { + Write-Host 'Applying pending Sense database migrations...' + & $sense migrate -c $state.SettingsPath + if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed. Review the error above and the PostgreSQL connection.' } + } + if ($Mode -eq 'demo') { + Write-Warning 'Sense is running in isolated demo mode. Demo data must not be used as production data.' + } + Write-Host "Starting Sense at http://$($state.Host):$($state.Port)/ ..." + Write-Host 'Press Ctrl+C in this window to stop Sense and its managed MediaMTX process.' + & $sense server -c $state.SettingsPath + exit $LASTEXITCODE + } finally { Pop-Location } +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/stop-sense.bat b/Sense/scripts/runtime/stop-sense.bat new file mode 100644 index 0000000..b5fa021 --- /dev/null +++ b/Sense/scripts/runtime/stop-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\stop-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/stop-sense.ps1 b/Sense/scripts/runtime/stop-sense.ps1 new file mode 100644 index 0000000..0a0c7ab --- /dev/null +++ b/Sense/scripts/runtime/stop-sense.ps1 @@ -0,0 +1,24 @@ +param([ValidateSet('production', 'demo')][string]$Mode = 'production') +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' } + $config = Join-Path $root "config\$configName" + Import-SenseEnvironment -Path $config + $port = [int](Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080') + $connection = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $connection) { Write-Host "Sense is not listening on port $port."; exit 0 } + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $($connection.OwningProcess)" + $expected = [IO.Path]::GetFullPath((Join-Path $root 'sense.exe')) + if (-not $process -or [IO.Path]::GetFullPath($process.ExecutablePath) -ne $expected) { + throw "Port $port belongs to another process; it was not stopped." + } + & taskkill.exe /PID $process.ProcessId /T /F | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Sense process tree.' } + Write-Host 'Sense and its managed child processes were stopped.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/server/app/sense/media/runtime.go b/Sense/server/app/sense/media/runtime.go index defbf45..e34fc14 100644 --- a/Sense/server/app/sense/media/runtime.go +++ b/Sense/server/app/sense/media/runtime.go @@ -3,6 +3,8 @@ package media import ( "context" "errors" + "os" + "strings" "sync" "time" @@ -16,6 +18,10 @@ var runtimeState struct { } func StartRuntime(parent context.Context, db *gorm.DB) error { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_MODE"))) + if mode == "disabled" { + return nil + } if db == nil { return errors.New("Sense database is unavailable for MediaMTX runtime") } @@ -29,6 +35,12 @@ func StartRuntime(parent context.Context, db *gorm.DB) error { } service := NewService(db, controller, NewSupervisor(config.Binary, config.ConfigPath), config) ctx, cancel := context.WithCancel(parent) + if mode == "managed" || mode == "external" { + if err = service.ensureControl(ctx); err != nil { + cancel() + return err + } + } runtimeState.Lock() if runtimeState.cancel != nil { runtimeState.cancel() diff --git a/Sense/server/app/sense/media/runtime_test.go b/Sense/server/app/sense/media/runtime_test.go new file mode 100644 index 0000000..a608577 --- /dev/null +++ b/Sense/server/app/sense/media/runtime_test.go @@ -0,0 +1,22 @@ +package media + +import ( + "context" + "strings" + "testing" +) + +func TestStartRuntimeAllowsExplicitDemoDisable(t *testing.T) { + t.Setenv("SENSE_MEDIAMTX_MODE", "disabled") + if err := StartRuntime(context.Background(), nil); err != nil { + t.Fatalf("disabled demo runtime must not require MediaMTX or a database: %v", err) + } +} + +func TestStartRuntimeManagedModeRequiresDatabase(t *testing.T) { + t.Setenv("SENSE_MEDIAMTX_MODE", "managed") + err := StartRuntime(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "database") { + t.Fatalf("managed runtime must fail before HTTP startup without a database: %v", err) + } +} diff --git a/Sense/server/cmd/api/server.go b/Sense/server/cmd/api/server.go index 1f0af69..bc39b37 100644 --- a/Sense/server/cmd/api/server.go +++ b/Sense/server/cmd/api/server.go @@ -92,7 +92,7 @@ func run() error { for _, db := range sdk.Runtime.GetDb() { runtimeDBFound = true if err := media.StartRuntime(runtimeCtx, db); err != nil { - log.Errorf("MediaMTX runtime unavailable: %v", err) + return fmt.Errorf("MediaMTX runtime unavailable: %w", err) } break } @@ -196,5 +196,6 @@ func initRouter() { Use(api.SetRequestLogger) common.InitMiddleware(r) + configureWebUI(r) } diff --git a/Sense/server/cmd/api/web.go b/Sense/server/cmd/api/web.go new file mode 100644 index 0000000..70d15a2 --- /dev/null +++ b/Sense/server/cmd/api/web.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// configureWebUI adds an optional SPA fallback to the existing GoAdmin Gin +// engine. API and framework routes keep their normal handlers; the fallback is +// enabled only for Windows delivery packages that set SENSE_WEB_ROOT. +func configureWebUI(r *gin.Engine) { + root := strings.TrimSpace(os.Getenv("SENSE_WEB_ROOT")) + if root == "" { + return + } + absRoot, err := filepath.Abs(root) + if err != nil { + return + } + index := filepath.Join(absRoot, "index.html") + if info, statErr := os.Stat(index); statErr != nil || info.IsDir() { + return + } + + r.NoRoute(func(c *gin.Context) { + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + c.Status(http.StatusNotFound) + return + } + if isBackendPath(c.Request.URL.Path) { + c.Status(http.StatusNotFound) + return + } + + requested := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(c.Request.URL.Path, "/"))) + if requested == "." { + requested = "" + } + candidate := filepath.Join(absRoot, requested) + if withinRoot(absRoot, candidate) { + if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() { + c.File(candidate) + return + } + } + if filepath.Ext(requested) != "" { + c.Status(http.StatusNotFound) + return + } + c.File(index) + }) +} + +func withinRoot(root, candidate string) bool { + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func isBackendPath(path string) bool { + for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/"} { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} diff --git a/Sense/server/cmd/api/web_test.go b/Sense/server/cmd/api/web_test.go new file mode 100644 index 0000000..437fad7 --- /dev/null +++ b/Sense/server/cmd/api/web_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestConfigureWebUIServesAssetsAndSPAFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("sense-index"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "js"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "js", "app.js"), []byte("sense-app"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SENSE_WEB_ROOT", root) + r := gin.New() + configureWebUI(r) + + for _, tc := range []struct { + path string + code int + body string + }{ + {path: "/", code: http.StatusOK, body: "sense-index"}, + {path: "/device/list", code: http.StatusOK, body: "sense-index"}, + {path: "/js/app.js", code: http.StatusOK, body: "sense-app"}, + {path: "/js/missing.js", code: http.StatusNotFound}, + {path: "/api/v1/missing", code: http.StatusNotFound}, + } { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + if res.Code != tc.code || (tc.body != "" && res.Body.String() != tc.body) { + t.Fatalf("%s: got %d %q", tc.path, res.Code, res.Body.String()) + } + } +} + +func TestWithinRootRejectsTraversal(t *testing.T) { + root := t.TempDir() + if withinRoot(root, filepath.Join(root, "..", "secret.txt")) { + t.Fatal("path traversal must be rejected") + } +} diff --git a/Sense/tests/package/run-tests.bat b/Sense/tests/package/run-tests.bat new file mode 100644 index 0000000..073b799 --- /dev/null +++ b/Sense/tests/package/run-tests.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-tests.ps1" +exit /b %errorlevel% diff --git a/Sense/tests/package/run-tests.ps1 b/Sense/tests/package/run-tests.ps1 new file mode 100644 index 0000000..9195279 --- /dev/null +++ b/Sense/tests/package/run-tests.ps1 @@ -0,0 +1,88 @@ +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +. (Join-Path $senseRoot 'scripts\runtime\sense-common.ps1') +$script:passed = 0 + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw "ASSERT FAILED: $Message" } + $script:passed++ +} +function Assert-Equal($Expected, $Actual, [string]$Message) { + if ($Expected -cne $Actual) { throw "ASSERT FAILED: $Message; expected [$Expected], got [$Actual]" } + $script:passed++ +} +function Assert-Throws([scriptblock]$Action, [string]$Pattern, [string]$Message) { + try { & $Action; throw "ASSERT FAILED: $Message; no error was raised" } catch { + if ($_.Exception.Message -notmatch $Pattern) { throw "ASSERT FAILED: $Message; unexpected error: $($_.Exception.Message)" } + } + $script:passed++ +} + +$temporary = Join-Path ([IO.Path]::GetTempPath()) ("sense-package-tests-" + [guid]::NewGuid().ToString('N')) +$listener = $null +$oldValues = @{} +foreach ($name in $script:SenseAllowedEnvironment) { + $oldValues[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $null, 'Process') +} +try { + New-Item -ItemType Directory -Path (Join-Path $temporary 'config'), (Join-Path $temporary 'web'), (Join-Path $temporary 'bin') | Out-Null + [IO.File]::WriteAllText((Join-Path $temporary 'web\index.html'), 'sense', (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'bin\mediamtx.exe'), 'fixture', (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'config\mediamtx.yml'), 'api: true', (New-Object Text.UTF8Encoding($false))) + + $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0) + $listener.Start() + $dbPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + $marker = Join-Path $temporary 'must-not-exist.txt' + $envText = @( + 'SENSE_HOST=127.0.0.1', + 'SENSE_PORT=18070', + "SENSE_DATABASE_URL=host=127.0.0.1 port=$dbPort user=sense password=p#&;=x dbname=sense sslmode=disable", + "SENSE_JWT_SECRET=`$(Set-Content -LiteralPath '$marker' hacked)-literal-secret-1234567890", + 'SENSE_MEDIAMTX_MODE=managed', + 'SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe', + 'SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml', + 'SENSE_MEDIAMTX_API=http://127.0.0.1:9997', + 'SENSE_WEB_ROOT=web' + ) -join "`n" + [IO.File]::WriteAllText((Join-Path $temporary 'config\sense.env'), $envText, (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'config\sense.demo.env'), $envText.Replace('SENSE_DATABASE_URL=', 'SENSE_DEMO_DATABASE_URL=').Replace('dbname=sense ', 'dbname=sense_demo '), (New-Object Text.UTF8Encoding($false))) + + [Environment]::SetEnvironmentVariable('SENSE_PORT', '18071', 'Process') + $state = Initialize-SenseRuntime -PackageRoot $temporary -Mode production + Assert-Equal 18071 $state.Port 'non-empty process environment must override sense.env' + Assert-True (-not (Test-Path -LiteralPath $marker)) 'sense.env content must never execute' + $generatedSettings = Get-Content -LiteralPath $state.SettingsPath -Raw + Assert-True ((Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL').Contains('p#&;=x')) 'special characters must survive env parsing' + Assert-True ($generatedSettings.Contains('password=p#\u0026;=x')) 'special characters must be safely JSON-escaped in YAML' + Assert-True (-not ((Get-SenseDatabaseInfo (Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL')).Sanitized.Contains('password='))) 'PostgreSQL tool arguments must not contain password' + + [Environment]::SetEnvironmentVariable('SENSE_PORT', $null, 'Process') + foreach ($name in @('SENSE_DATABASE_URL','SENSE_JWT_SECRET','SENSE_MEDIAMTX_MODE','SENSE_MEDIAMTX_BINARY','SENSE_MEDIAMTX_CONFIG','SENSE_MEDIAMTX_API','SENSE_WEB_ROOT')) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } + $demo = Initialize-SenseRuntime -PackageRoot $temporary -Mode demo + Assert-Equal 'sense_demo' $demo.Database.Database 'demo must use its dedicated database variable' + + $bad = Join-Path $temporary 'config\bad.env' + [IO.File]::WriteAllText($bad, 'SENSE_UNKNOWN=value', (New-Object Text.UTF8Encoding($false))) + Assert-Throws { Import-SenseEnvironment -Path $bad } 'Unsupported Sense configuration key' 'unknown keys must be rejected' + [IO.File]::WriteAllText((Join-Path $temporary 'config\sense.demo.env'), $envText.Replace('SENSE_DATABASE_URL=', 'SENSE_DEMO_DATABASE_URL='), (New-Object Text.UTF8Encoding($false))) + foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } + Assert-Throws { Initialize-SenseRuntime -PackageRoot $temporary -Mode demo } 'database name containing demo or test' 'demo must reject production database names' + + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $senseRoot 'scripts') -Recurse -Filter '*.ps1') { + [void][scriptblock]::Create((Get-Content -LiteralPath $file.FullName -Raw)) + $script:passed++ + } + Write-Host "Sense package tests passed: $script:passed assertions." +} finally { + if ($listener) { $listener.Stop() } + foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $oldValues[$name], 'Process') } + if (Test-Path -LiteralPath $temporary) { + $resolved = [IO.Path]::GetFullPath($temporary) + if (-not $resolved.StartsWith([IO.Path]::GetTempPath(), [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe temporary test path: $resolved" } + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} diff --git a/Sense/tests/package/smoke-start-stop.ps1 b/Sense/tests/package/smoke-start-stop.ps1 new file mode 100644 index 0000000..b177554 --- /dev/null +++ b/Sense/tests/package/smoke-start-stop.ps1 @@ -0,0 +1,32 @@ +param([Parameter(Mandatory = $true)][string]$PackageRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($PackageRoot) +$start = Join-Path $root 'start-sense.bat' +$stop = Join-Path $root 'stop-sense.bat' +$port = [int]$env:SENSE_PORT +$launcher = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d', '/c', "`"$start`" -SkipMigration") -WorkingDirectory (Split-Path $root -Parent) -WindowStyle Hidden -PassThru +try { + $ready = $false + for ($attempt = 0; $attempt -lt 60; $attempt++) { + $client = New-Object Net.Sockets.TcpClient + try { + $task = $client.ConnectAsync('127.0.0.1', $port) + if ($task.Wait(500) -and $client.Connected) { $ready = $true; break } + } catch {} finally { $client.Dispose() } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw 'start-sense.bat did not open the configured HTTP port.' } + & $stop + if ($LASTEXITCODE -ne 0) { throw 'stop-sense.bat failed.' } + Start-Sleep -Seconds 1 + $probe = New-Object Net.Sockets.TcpClient + try { + $task = $probe.ConnectAsync('127.0.0.1', $port) + if ($task.Wait(500) -and $probe.Connected) { throw 'Sense port is still open after stop-sense.bat.' } + } catch [Net.Sockets.SocketException] {} finally { $probe.Dispose() } + Write-Host 'Sense start/stop wrapper smoke passed from an external working directory.' +} finally { + if (-not $launcher.HasExited) { & taskkill.exe /PID $launcher.Id /T /F | Out-Null } +} diff --git a/Sense/tests/package/smoke-windows-package.ps1 b/Sense/tests/package/smoke-windows-package.ps1 new file mode 100644 index 0000000..c2e0b1f --- /dev/null +++ b/Sense/tests/package/smoke-windows-package.ps1 @@ -0,0 +1,53 @@ +param([Parameter(Mandatory = $true)][string]$PackageRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($PackageRoot) +$port = [int]$env:SENSE_PORT +$mediaUri = [Uri]$env:SENSE_MEDIAMTX_API +$stdout = Join-Path ([IO.Path]::GetTempPath()) ("sense-smoke-$PID.out") +$stderr = Join-Path ([IO.Path]::GetTempPath()) ("sense-smoke-$PID.err") +$server = $null +$succeeded = $false +try { + & (Join-Path $root 'migrate-sense.bat') + if ($LASTEXITCODE -ne 0) { throw 'Package migration entry failed.' } + if (-not [IO.Path]::IsPathRooted($env:SENSE_MEDIAMTX_BINARY)) { $env:SENSE_MEDIAMTX_BINARY = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_MEDIAMTX_BINARY)) } + if (-not [IO.Path]::IsPathRooted($env:SENSE_MEDIAMTX_CONFIG)) { $env:SENSE_MEDIAMTX_CONFIG = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_MEDIAMTX_CONFIG)) } + if (-not [IO.Path]::IsPathRooted($env:SENSE_WEB_ROOT)) { $env:SENSE_WEB_ROOT = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_WEB_ROOT)) } + $settings = Join-Path $root 'data\runtime\settings.yml' + $server = Start-Process -FilePath (Join-Path $root 'sense.exe') -ArgumentList 'server', '-c', $settings -WorkingDirectory $root -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru + $response = $null + for ($attempt = 0; $attempt -lt 80; $attempt++) { + $server.Refresh() + if ($server.HasExited) { + Get-Content -LiteralPath $stdout -Tail 120 -ErrorAction SilentlyContinue | Out-Host + Get-Content -LiteralPath $stderr -Tail 120 -ErrorAction SilentlyContinue | Out-Host + throw "Sense exited before HTTP readiness with code $($server.ExitCode)." + } + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$port/" -TimeoutSec 1 + if ($response.StatusCode -eq 200 -and $response.Content.Contains('')) { break } + } catch {} + Start-Sleep -Milliseconds 500 + } + if (-not $response -or $response.StatusCode -ne 200 -or -not $response.Content.Contains('<title>')) { + throw 'Sense package did not serve the GoAdmin UI before the smoke timeout.' + } + $media = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + $media = Invoke-RestMethod -Uri "http://$($mediaUri.Host):$($mediaUri.Port)/v3/config/global/get" -TimeoutSec 1 + if ($null -ne $media) { break } + } catch {} + Start-Sleep -Milliseconds 500 + } + if ($null -eq $media) { throw 'MediaMTX Control API returned no data.' } + Write-Host "Sense package smoke passed: web=200, SPA=true, MediaMTX=true, port=$port." + $succeeded = $true +} finally { + if ($server -and -not $server.HasExited) { + & taskkill.exe /PID $server.Id /T /F | Out-Null + } + Remove-Item -LiteralPath $stdout, $stderr -Force -ErrorAction SilentlyContinue +} From e4544a011f3a3959f2cdaac102545327d7aee35b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 11:04:15 +0800 Subject: [PATCH 02/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Sense=20Wind?= =?UTF-8?q?ows=20=E4=BA=A4=E4=BB=98=E6=B5=81=E7=A8=8B=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-architecture-and-code-map.md | 14 ++++++- docs/04-local-development-and-verification.md | 37 +++++++++++++++++-- docs/delivery/README.md | 18 +++++---- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5d659c3..5013571 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc -synchronized_at: 2026-08-15T01:13:02Z +wiki_revision: a95b7692bc0c6bf4d05f8c57088a83379e15a08f +synchronized_at: 2026-08-15T03:01:57Z <!-- gitea-wiki-mirror:end --> # 架构与代码地图 @@ -130,3 +130,13 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea 认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。 <!-- sense-area:end --> + +<!-- sense-windows-delivery:start --> +## Sense Windows 交付运行链 + +工单 #70 在 GoAdmin 派生入口上建立 Windows amd64 交付链。构建入口为 `Sense/scripts/build/build-windows.ps1`;运行包入口为 `start-sense.bat`,它先调用现有 `sense.exe migrate -c data\runtime\settings.yml`,迁移成功后再调用 `sense.exe server -c ...`。前端生产构建复制到包内 `web/`,后端只在显式配置 `SENSE_WEB_ROOT` 时提供同源静态资源和 SPA fallback,API 与健康检查不会被 fallback 覆盖。 + +运行脚本将 `config\sense.env` 当作数据解析,只接受白名单 `SENSE_*` 字段,不执行文件内容;同名非空进程环境变量优先。生成的 `data\runtime\settings.yml` 含运行秘密,只能留在部署目录。production 固定使用 PostgreSQL,要求至少 32 字符 JWT secret,且 MediaMTX 只能为 `managed` 或 `external`;`managed` 在 HTTP 启动前拉起包内二进制,`external` 在 HTTP 启动前确认回环 Control API 可达。Demo 使用独立配置和名称含 demo/test 的隔离数据库,默认禁用 MediaMTX,绝不回退 production 数据库。 + +交付包同时提供检查、迁移、管理员初始化、停止、备份和恢复入口。停止脚本只操作当前包且监听配置端口的 Sense 进程树;备份密码只进入子进程环境;恢复要求数据库名和二次短语确认。包不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。 +<!-- sense-windows-delivery:end --> diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 8c4709b..c8a1277 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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: ef1ab78f2915af480de123e168afa8f76351a222 +synchronized_at: 2026-08-15T03:02:06Z <!-- gitea-wiki-mirror:end --> # 本地开发与验证 @@ -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 --> diff --git a/docs/delivery/README.md b/docs/delivery/README.md index 443efcf..e8ec8cf 100644 --- a/docs/delivery/README.md +++ b/docs/delivery/README.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Documentation-Guide wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.- -wiki_revision: 45c86f2a0e4d3252e8042df5ee725e633dd497c1 -synchronized_at: 2026-08-14T09:47:31Z +wiki_revision: 3d95c9d392e81cf59d2af1f6f21d8e67f580b68f +synchronized_at: 2026-08-15T03:02:47Z <!-- gitea-wiki-mirror:end --> # 交付文档指南 @@ -97,12 +97,16 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任 <!-- sense-windows-package:start --> ## Sense Windows 运行包交付 -交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。 +交付对象为实施和运维人员。以 `sense-windows-amd64.zip` 交付后端程序、已构建前端、空值示例配置、迁移基线、许可证、启动/检查/停止/备份/恢复脚本与包内说明;不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。可按交付决定是否包含已审核的 `bin\mediamtx.exe`。 -- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。 -- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。 -- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。 -- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。 +- production 先复制 `config\sense.env.example` 为 `config\sense.env`,填写 PostgreSQL、至少 32 字符 JWT secret、凭据密钥、获准 ONVIF 网络和 MediaMTX 模式。进程环境中的同名非空值优先,脚本不执行 env 文件内容,也不打印秘密。 +- 先运行 `check-sense.bat`,再运行 `start-sense.bat`。启动会先迁移,失败时不会开放 HTTP;`managed` MediaMTX 在 Sense HTTP 前启动,`external` 必须已有可达的回环 Control API,production 禁止 `disabled`。 +- 首位管理员通过至少 32 字符的一次性 bootstrap token 和 `initialize-admin.bat -Username admin` 创建,密码由隐藏提示输入;成功后立即清空 token 并重启。仓库与交付包均无默认账号密码。 +- 临时演示必须显式运行 `start-sense.bat demo`,使用独立 `config\sense.demo.env` 和名称含 demo/test 的数据库;不会回退 production 数据库,也不能作为生产部署。 +- 日常停止优先在启动窗口按 Ctrl+C;窗口丢失或进程无响应时使用 `stop-sense.bat`。脚本会验证端口与可执行文件归属,拒绝停止其他程序。 +- 备份使用 `backup-sense.bat` 生成 PostgreSQL custom-format 文件。恢复前停止服务并再次备份,执行 `restore-sense.bat` 时需确认目标数据库名及 `RESTORE-数据库名`,随后重新迁移。 +- 交付时保存 ZIP 和 `MANIFEST.sha256` 的 SHA-256,至少验证首页、`/healthz`、数据库迁移、MediaMTX API、启动/停止和备份/恢复。真实摄像机、目标浏览器与客户数据库账号仍在授权现场验证。 +- 包内 `README-WINDOWS.md` 是现场事实入口;真实 `config\sense.env`、运行生成的 `data\runtime\settings.yml`、日志和备份不得提交 Git 或重新打入 ZIP。 <!-- sense-windows-package:end --> <!-- sense-device-ledger:start --> From 23d3cdbf84130c3079403f22f9095aebcf502034 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 11:08:16 +0800 Subject: [PATCH 03/44] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#70=20=E5=BD=92=E6=A1=A3=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ense-Windows配置启动与打包交付.md | 50 +++++++++++++++++++ wiki-docs.json | 4 ++ 2 files changed, 54 insertions(+) create mode 100644 docs/task/70-Sense-Windows配置启动与打包交付.md diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md new file mode 100644 index 0000000..2eda6c7 --- /dev/null +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -0,0 +1,50 @@ +<!-- gitea-wiki-mirror:start --> +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-70-Sense-Windows配置启动与打包交付 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- +wiki_revision: 6a393e91ae2885f3969bfc6fd2d426df10d81035 +synchronized_at: 2026-08-15T03:06:59Z +<!-- gitea-wiki-mirror:end --> + +# 70 Sense Windows配置启动与打包交付 + +- 类型:需求 / 缺陷 / 重构 +- 所属 Epic:# +- 所属 MVP / 版本:# +- 状态:待验收 / 已完成 +- 日期:2026-08-15 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70 +- Wiki 页面:Task-70-Sense-Windows配置启动与打包交付 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +<!-- 原来有什么问题,这次达到什么结果。 --> + +## 最终方案 + +<!-- 说明实际实现。与建单方案不同之处必须写清原因。 --> + +## 修改文件 + +- `<文件>`:<改动说明> + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| | 通过 / 未通过 | + +## 测试 + +- 执行命令:`<命令>` +- 结果: +- **未验证部分**:<!-- 必填;没有就写“无”。 --> + +## 遗留问题 + +<!-- 没有就删除本节。 --> + +## 相关提交 + +- `<提交哈希>` <提交说明> diff --git a/wiki-docs.json b/wiki-docs.json index 9eab71e..4c6138a 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -127,6 +127,10 @@ { "page": "Task-69-Sense多边形区域与方向警戒线配置", "path": "docs/task/69-Sense多边形区域与方向警戒线配置.md" + }, + { + "page": "Task-70-Sense-Windows配置启动与打包交付", + "path": "docs/task/70-Sense-Windows配置启动与打包交付.md" } ] } From 2e76aafd0d12736feeedb1fe491396a5ef3861f2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 11:10:53 +0800 Subject: [PATCH 04/44] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#70=20=E5=BE=85=E9=AA=8C=E6=94=B6=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ense-Windows配置启动与打包交付.md | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md index 2eda6c7..d86bf22 100644 --- a/docs/task/70-Sense-Windows配置启动与打包交付.md +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -2,16 +2,16 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-70-Sense-Windows配置启动与打包交付 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- -wiki_revision: 6a393e91ae2885f3969bfc6fd2d426df10d81035 -synchronized_at: 2026-08-15T03:06:59Z +wiki_revision: a8e7e7eb355e1863bc4d0aaba02eb2131b2919df +synchronized_at: 2026-08-15T03:10:48Z <!-- gitea-wiki-mirror:end --> # 70 Sense Windows配置启动与打包交付 -- 类型:需求 / 缺陷 / 重构 -- 所属 Epic:# -- 所属 MVP / 版本:# -- 状态:待验收 / 已完成 +- 类型:需求 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:待验收 - 日期:2026-08-15 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70 - Wiki 页面:Task-70-Sense-Windows配置启动与打包交付 @@ -19,32 +19,58 @@ synchronized_at: 2026-08-15T03:06:59Z ## 背景与目标 -<!-- 原来有什么问题,这次达到什么结果。 --> +在 #61–#69 完成冻结 GoAdmin 基线及 Sense 独立业务纵切后,重建 Windows amd64 前后端单包交付能力。交付必须继续使用 GoAdmin Cobra 的迁移与服务入口,并覆盖包内配置、PostgreSQL、MediaMTX、管理员初始化、停止、备份和恢复;不得回用 explore 中的自研运行框架,也不得包含默认密码或客户秘密。 ## 最终方案 -<!-- 说明实际实现。与建单方案不同之处必须写清原因。 --> +- `Sense/scripts/build/build-windows.ps1` 严格检查 Go 1.26.5、Node 22.22.1、pnpm 9.15.1,执行前后端生产构建、许可证和迁移基线复制、内容审计并生成目录与 ZIP。 +- `Sense/scripts/runtime` 提供白名单 env 解析、check/start/stop/migrate/bootstrap/backup/restore。配置文件只作为数据读取,同名非空进程环境优先,日志不打印秘密。 +- production 只接受 PostgreSQL,要求至少 32 字符 JWT secret,MediaMTX 使用 managed 或 external;启动前完成迁移与媒体服务就绪检查,失败不开放 HTTP。Demo 使用独立配置和名称含 demo/test 的数据库。 +- 继续调用现有 `sense.exe migrate -c ...` 与 `sense.exe server -c ...`。在现有 Gin Engine 上增加可选同源 SPA fallback;未配置 `SENSE_WEB_ROOT` 时保持上游行为。MediaMTX 显式模式增加启动前就绪门禁,未配置模式保留既有惰性行为。 +- 管理员初始化无默认账户密码;密码经隐藏提示输入。停止脚本验证端口与可执行文件归属;备份密码只通过子进程环境;恢复要求目标库名称和二次短语确认。 +- 空白 PostgreSQL 17 已成功执行完整迁移,未复现 #67 曾记录的旧 `sys_config` 字段长度问题,因此未修改上游迁移。 ## 修改文件 -- `<文件>`:<改动说明> +- `Sense/scripts/build/**`、`Sense/tests/package/**`:固定工具链构建、包审计与配置/失败路径自动化。 +- `Sense/scripts/runtime/**`:Windows 配置、检查、迁移、启动停止、初始化、备份恢复入口。 +- `Sense/config/**`、`Sense/package/**`、`Sense/README-WINDOWS.md`:空值示例、MediaMTX 基线和现场说明。 +- `Sense/server/cmd/api/server.go`、`web.go`、`web_test.go`:现有 GoAdmin Gin 服务的可选 SPA 托管。 +- `Sense/server/app/sense/media/runtime.go`、`runtime_test.go`:显式 MediaMTX 模式的启动前就绪门禁。 +- `Sense/.gitignore`:忽略可重建交付产物并允许版本化构建脚本。 +- Wiki `Architecture-and-Code-Map`、`Local-Development-and-Verification`、`Delivery-Documentation-Guide` 及对应 `docs/` 镜像:运行链、构建验证和现场交付说明。 ## 验收结果 | 验收标准 | 结果 | |---|---| -| | 通过 / 未通过 | +| 干净环境按单一命令生成 Windows amd64 交付包 | 通过;固定工具链构建目录和 ZIP | +| 包不包含 node_modules、构建缓存、真实秘密或客户数据 | 通过;包审计及源码残留检查通过 | +| start 读取 config/sense.env,进程环境优先且不执行内容 | 通过;自动化覆盖优先级、特殊字符和注入非执行 | +| production 检查 PostgreSQL、迁移、端口和 MediaMTX | 通过;隔离 PostgreSQL 17 与 MediaMTX 包 smoke 通过 | +| demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test | +| 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 | + +最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,589,367 字节,SHA-256 `F0772175007641CA04434680B14BC0793CCD355DE75C8ADF82A573419170F592`。包内 `VERSION.txt` 记录源码提交 `6b79478414a31b28b855f4f36742dc24c9d0e76e`。 ## 测试 -- 执行命令:`<命令>` -- 结果: -- **未验证部分**:<!-- 必填;没有就写“无”。 --> +- `go test ./...`、`go vet ./...`、`go build ./...`:通过。 +- `go test -race ./app/sense/media ./cmd/api`:通过。 +- PowerShell 包测试:18 项断言通过,覆盖配置注入不执行、特殊字符、环境优先级、demo 数据库隔离、不支持字段拒绝及全部脚本语法。 +- `Sense/scripts/build/build-windows.ps1 -MediaMTXPath <已审核本机路径>`:通过;前端仅有冻结上游 6 条构建 warning。 +- `Sense/scripts/build/test-package.ps1 -PackageRoot Sense/dist/sense-windows-amd64`:通过。 +- 隔离 PostgreSQL 17:空库 8 个迁移通过;首页、SPA fallback、`/healthz`、MediaMTX Control API、包外目录启动停止通过;119,630 字节 custom-format 备份及恢复到另一数据库通过。 +- `git diff --check`:通过。 +- `python dev_scripts/check_harness.py --strict`:未通过,原因仅为既存 `docs/task/66`、`docs/task/67` 缺少当前模板要求的“修改文件/未验证”章节;#70 未修改这两个既有归档,也未发现 #70 新增问题。 +- **未验证部分**:尚未在全新客户 Windows 机器、客户生产 PostgreSQL 账号、目标浏览器和真实获准摄像机上验收;Windows 服务化不在本工单范围。 ## 遗留问题 -<!-- 没有就删除本节。 --> +- Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。 +- 客户环境验收需由实施人员使用脱敏测试账户和获准设备完成。 ## 相关提交 -- `<提交哈希>` <提交说明> +- `6b79478` 建立 Sense Windows 交付包。 +- `e4544a0` 记录 Sense Windows 交付流程。 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 05/44] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E6=97=A7?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E8=83=BD=E5=8A=9B=E5=AD=97=E6=AE=B5=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=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) + } +} From aee5f45d96a5e3065907c5750dcb2dc96eb75c05 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:16:32 +0800 Subject: [PATCH 06/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=83=BD=E5=8A=9B=E8=BF=81=E7=A7=BB=E6=8E=92=E9=94=99?= =?UTF-8?q?=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/06-troubleshooting.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 130cb14..49db000 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: 3523b7cd1f126ebc3ef77414cb6d658103f2491d -synchronized_at: 2026-08-15T01:13:11Z +wiki_revision: a169b2323323d9de6304e9b430ddbe9888ea1d25 +synchronized_at: 2026-08-15T07:16:25Z <!-- gitea-wiki-mirror:end --> # 故障排查 @@ -116,3 +116,21 @@ 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 --> From fe3badfe3e73588fc0c62b6229ebbdf009589ef4 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:17:28 +0800 Subject: [PATCH 07/44] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E6=97=A7?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=AE=8C=E6=95=B4=E8=BF=81=E7=A7=BB=E9=93=BE?= =?UTF-8?q?=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../version/2026081414000_device_test.go | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go b/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go index c4a74a4..b8cf7a3 100644 --- a/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go +++ b/Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go @@ -10,6 +10,8 @@ import ( "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) { @@ -126,6 +128,40 @@ func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) { 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) { From 3c4578c8045c6b95ac02fedbd87f36a548324e37 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:19:12 +0800 Subject: [PATCH 08/44] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#92=20=E5=BD=92=E6=A1=A3=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2-Sense旧设备能力JSONB兼容迁移.md | 50 +++++++++++++++++++ wiki-docs.json | 4 ++ 2 files changed, 54 insertions(+) create mode 100644 docs/task/92-Sense旧设备能力JSONB兼容迁移.md diff --git a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md new file mode 100644 index 0000000..8d5e172 --- /dev/null +++ b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md @@ -0,0 +1,50 @@ +<!-- 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: b2f12b3e5cf8a0db14b308a7adfe6caa4f05b537 +synchronized_at: 2026-08-15T07:18:09Z +<!-- gitea-wiki-mirror:end --> + +# 92 Sense旧设备能力JSONB兼容迁移 + +- 类型:需求 / 缺陷 / 重构 +- 所属 Epic:# +- 所属 MVP / 版本:# +- 状态:待验收 / 已完成 +- 日期:2026-08-15 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92 +- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +<!-- 原来有什么问题,这次达到什么结果。 --> + +## 最终方案 + +<!-- 说明实际实现。与建单方案不同之处必须写清原因。 --> + +## 修改文件 + +- `<文件>`:<改动说明> + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| | 通过 / 未通过 | + +## 测试 + +- 执行命令:`<命令>` +- 结果: +- **未验证部分**:<!-- 必填;没有就写“无”。 --> + +## 遗留问题 + +<!-- 没有就删除本节。 --> + +## 相关提交 + +- `<提交哈希>` <提交说明> diff --git a/wiki-docs.json b/wiki-docs.json index 9eab71e..cf70f4f 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -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" From 3a686a81518356af1303efe444870c3338b6cab2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:20:01 +0800 Subject: [PATCH 09/44] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#92=20=E5=BE=85=E9=AA=8C=E6=94=B6=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2-Sense旧设备能力JSONB兼容迁移.md | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md index 8d5e172..453fad3 100644 --- a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md +++ b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md @@ -2,16 +2,16 @@ 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: b2f12b3e5cf8a0db14b308a7adfe6caa4f05b537 -synchronized_at: 2026-08-15T07:18:09Z +wiki_revision: d10479f732597bd90dac4de03edc0cd4b39b6352 +synchronized_at: 2026-08-15T07:19:37Z <!-- gitea-wiki-mirror:end --> # 92 Sense旧设备能力JSONB兼容迁移 -- 类型:需求 / 缺陷 / 重构 -- 所属 Epic:# -- 所属 MVP / 版本:# -- 状态:待验收 / 已完成 +- 类型:缺陷 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:待验收 - 日期:2026-08-15 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92 - Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移 @@ -19,32 +19,56 @@ synchronized_at: 2026-08-15T07:18:09Z ## 背景与目标 -<!-- 原来有什么问题,这次达到什么结果。 --> +用户通过 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`。 ## 遗留问题 -<!-- 没有就删除本节。 --> +- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。 +- Harness strict 的 #66/#67 既有归档格式问题不属于本工单。 ## 相关提交 -- `<提交哈希>` <提交说明> +- `68b436a` 兼容旧设备能力字段迁移。 +- `fe3badf` 覆盖旧设备完整迁移链。 +- `aee5f45` 记录设备能力迁移排错。 From e82f15f1fb8f68fcdb8ee3f9e5b62eda4916dae1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:30:10 +0800 Subject: [PATCH 10/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#92=20=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/92-Sense旧设备能力JSONB兼容迁移.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md index 453fad3..cb0c8e8 100644 --- a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md +++ b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md @@ -2,8 +2,8 @@ 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: d10479f732597bd90dac4de03edc0cd4b39b6352 -synchronized_at: 2026-08-15T07:19:37Z +wiki_revision: 156684b5e2ae7487e4d6d87bd23c2e65c21d2f6e +synchronized_at: 2026-08-15T07:29:54Z <!-- gitea-wiki-mirror:end --> # 92 Sense旧设备能力JSONB兼容迁移 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-15T07:19:37Z - 类型:缺陷 - 所属 Epic:#7 - 所属 MVP / 版本:#8 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-15 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92 - Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移 @@ -62,6 +62,11 @@ synchronized_at: 2026-08-15T07:19:37Z - 受影响 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 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。 From 94721511039ec335e5b93d4b6e026589e1a7d80c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:31:14 +0800 Subject: [PATCH 11/44] =?UTF-8?q?docs:=20=E6=A0=A1=E6=AD=A3=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#92=20=E9=AA=8C=E6=94=B6=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/92-Sense旧设备能力JSONB兼容迁移.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md index cb0c8e8..ccb95ce 100644 --- a/docs/task/92-Sense旧设备能力JSONB兼容迁移.md +++ b/docs/task/92-Sense旧设备能力JSONB兼容迁移.md @@ -2,8 +2,8 @@ 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: 156684b5e2ae7487e4d6d87bd23c2e65c21d2f6e -synchronized_at: 2026-08-15T07:29:54Z +wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e +synchronized_at: 2026-08-15T07:30:56Z <!-- gitea-wiki-mirror:end --> # 92 Sense旧设备能力JSONB兼容迁移 @@ -67,6 +67,7 @@ synchronized_at: 2026-08-15T07:29:54Z - 用户于 2026-08-15 明确确认 `#92 验收通过`。 - 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。 - #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。 + ## 遗留问题 - 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。 From 3dd489066cf5e97ccc74a0125249db5f1eb6c989 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:48:10 +0800 Subject: [PATCH 12/44] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E6=97=A7?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E8=B7=AF=E7=94=B1=E7=BA=A6=E6=9D=9F=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/version/2026081419000_media.go | 124 ++++++++++++ .../version/2026081419000_media_test.go | 176 ++++++++++++++++-- 2 files changed, 284 insertions(+), 16 deletions(-) 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) +} From 6a8c763dec8c0b06f32bfa36d6a165a867aece88 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:56:21 +0800 Subject: [PATCH 13/44] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20#70=20?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=97=A7=E5=BA=93=E4=BA=A4=E4=BB=98=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../70-Sense-Windows配置启动与打包交付.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md index d86bf22..6d10942 100644 --- a/docs/task/70-Sense-Windows配置启动与打包交付.md +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-70-Sense-Windows配置启动与打包交付 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- -wiki_revision: a8e7e7eb355e1863bc4d0aaba02eb2131b2919df -synchronized_at: 2026-08-15T03:10:48Z +wiki_revision: cfb2aba30437659c03afe35a119e56d70beb623f +synchronized_at: 2026-08-15T07:56:07Z <!-- gitea-wiki-mirror:end --> # 70 Sense Windows配置启动与打包交付 @@ -51,7 +51,7 @@ synchronized_at: 2026-08-15T03:10:48Z | demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test | | 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 | -最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,589,367 字节,SHA-256 `F0772175007641CA04434680B14BC0793CCD355DE75C8ADF82A573419170F592`。包内 `VERSION.txt` 记录源码提交 `6b79478414a31b28b855f4f36742dc24c9d0e76e`。 +最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,595,209 字节,SHA-256 `7E35394F6377F9F4AB0046E8413F29B140D6F301383C54C87CE871B435EF253D`。包内 `VERSION.txt` 记录集成提交 `c088caf816422cb684c402bb8c85e18db1832186`,包含已验收 #92 与待验收 #95 的旧库兼容修复。 ## 测试 @@ -65,6 +65,13 @@ synchronized_at: 2026-08-15T03:10:48Z - `python dev_scripts/check_harness.py --strict`:未通过,原因仅为既存 `docs/task/66`、`docs/task/67` 缺少当前模板要求的“修改文件/未验证”章节;#70 未修改这两个既有归档,也未发现 #70 新增问题。 - **未验证部分**:尚未在全新客户 Windows 机器、客户生产 PostgreSQL 账号、目标浏览器和真实获准摄像机上验收;Windows 服务化不在本工单范围。 +## 旧库交付回归 + +- 真实迁移前已生成仓库外 PostgreSQL custom-format 备份与配置副本,备份通过 `pg_restore --list` 校验。 +- #92 成功把旧设备 `capabilities` 转为 JSONB;#95 继续兼容旧媒体路由 `path` 唯一约束和缺失运行态列。 +- 真实库 2 条媒体路由完整保留,运行态列无空值,`idx_sense_media_routes_path` 与设备/Profile 组合唯一索引均有效,媒体迁移版本已登记。 +- Web 首页、SPA、`/healthz`、MediaMTX Control API 均返回 200;停止脚本成功且 Sense/MediaMTX 监听端口全部清空。 +- PowerShell `Invoke-WebRequest` 在本机受代理环境影响而无法访问 loopback;使用明确绕过代理的本机 HTTP 客户端确认服务正常,该现象不属于 Sense 服务失败。 ## 遗留问题 - Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。 From 38549760aa6f01fdada0488c84cf40d639722ec9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:57:19 +0800 Subject: [PATCH 14/44] =?UTF-8?q?docs:=20=E6=A0=A1=E6=AD=A3=20#70=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/70-Sense-Windows配置启动与打包交付.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md index 6d10942..5655384 100644 --- a/docs/task/70-Sense-Windows配置启动与打包交付.md +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-70-Sense-Windows配置启动与打包交付 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- -wiki_revision: cfb2aba30437659c03afe35a119e56d70beb623f -synchronized_at: 2026-08-15T07:56:07Z +wiki_revision: e0a0ed3760720a7efc3def925c6d1e2313e1433a +synchronized_at: 2026-08-15T07:57:01Z <!-- gitea-wiki-mirror:end --> # 70 Sense Windows配置启动与打包交付 @@ -72,6 +72,7 @@ synchronized_at: 2026-08-15T07:56:07Z - 真实库 2 条媒体路由完整保留,运行态列无空值,`idx_sense_media_routes_path` 与设备/Profile 组合唯一索引均有效,媒体迁移版本已登记。 - Web 首页、SPA、`/healthz`、MediaMTX Control API 均返回 200;停止脚本成功且 Sense/MediaMTX 监听端口全部清空。 - PowerShell `Invoke-WebRequest` 在本机受代理环境影响而无法访问 loopback;使用明确绕过代理的本机 HTTP 客户端确认服务正常,该现象不属于 Sense 服务失败。 + ## 遗留问题 - Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。 From 6257859b83949d6219096e351247a9397bbcb554 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 16:09:40 +0800 Subject: [PATCH 15/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E6=97=A7?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E8=B7=AF=E7=94=B1=E8=BF=81=E7=A7=BB=E6=8E=92?= =?UTF-8?q?=E9=94=99=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/06-troubleshooting.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 <!-- gitea-wiki-mirror:end --> # 故障排查 @@ -134,3 +134,21 @@ synchronized_at: 2026-08-15T07:16:25Z 正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 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 --> From d0185caa0e0c96ba34f10486910a79f26af57623 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 16:43:41 +0800 Subject: [PATCH 16/44] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#95=20=E5=BE=85=E9=AA=8C=E6=94=B6=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...旧媒体路由唯一约束兼容迁移.md | 74 +++++++++++++++++++ wiki-docs.json | 4 + 2 files changed, 78 insertions(+) create mode 100644 docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md diff --git a/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md b/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md new file mode 100644 index 0000000..a82c784 --- /dev/null +++ b/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md @@ -0,0 +1,74 @@ +<!-- 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: 89f668bc6f3308edbc1ce874c9b0a189dd418d42 +synchronized_at: 2026-08-15T08:43:18Z +<!-- 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 包真实回归。 diff --git a/wiki-docs.json b/wiki-docs.json index cf70f4f..d18feb6 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -131,6 +131,10 @@ { "page": "Task-69-Sense多边形区域与方向警戒线配置", "path": "docs/task/69-Sense多边形区域与方向警戒线配置.md" + }, + { + "page": "Task-95-Sense旧媒体路由唯一约束兼容迁移", + "path": "docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md" } ] } From 4ca4abff7bbe8ade47b321b8db302004591a29e1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 17:07:54 +0800 Subject: [PATCH 17/44] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Sense=20Windo?= =?UTF-8?q?ws=20=E5=8C=85=E7=99=BD=E5=B1=8F=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/README-WINDOWS.md | 3 +- Sense/scripts/build/assert-web-assets.ps1 | 37 +++++++++++++++++++++++ Sense/scripts/build/test-package.ps1 | 1 + Sense/tests/package/run-tests.ps1 | 13 ++++++-- Sense/ui/vue.config.js | 15 +++++---- 5 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 Sense/scripts/build/assert-web-assets.ps1 diff --git a/Sense/README-WINDOWS.md b/Sense/README-WINDOWS.md index 1ece1fe..cb4295d 100644 --- a/Sense/README-WINDOWS.md +++ b/Sense/README-WINDOWS.md @@ -133,6 +133,7 @@ Demo 启动窗口按 `Ctrl+C` 停止;窗口不可用时执行 `stop-sense.bat - `External MediaMTX Control API is unreachable`:启动外部实例并确认 API 只监听回环地址。 - `migration failed`:不要跳过;先备份,保留错误输出,核对数据库账号权限和版本。 - 网页返回 404:检查 `web\index.html` 与 `SENSE_WEB_ROOT=web`,不要把源码目录或 `node_modules` 放进包。 +- 网页返回 200 但白屏:在浏览器开发者工具检查 JS/CSS 是否 404;正式包的构建审计会逐项核对 `web\index.html` 引用的本地资源,缺失时拒绝生成交付包。 ## 8. 构建交付包 @@ -153,4 +154,4 @@ Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe - `Sense\dist\sense-windows-amd64\` - `Sense\dist\sense-windows-amd64.zip` -构建末尾会审计包内容:拒绝 `node_modules`、嵌套 `dist`、Git/缓存目录、数据库/备份文件、非空秘密字段、常见默认密码和私钥标记。`dist` 为可重建产物,不提交 Git。 +构建末尾会审计包内容:逐项核对 `web\index.html` 引用的本地 JS/CSS,并拒绝 `node_modules`、嵌套 `dist`、Git/缓存目录、数据库/备份文件、非空秘密字段、常见默认密码和私钥标记。`dist` 为可重建产物,不提交 Git。 diff --git a/Sense/scripts/build/assert-web-assets.ps1 b/Sense/scripts/build/assert-web-assets.ps1 new file mode 100644 index 0000000..2a9b0b1 --- /dev/null +++ b/Sense/scripts/build/assert-web-assets.ps1 @@ -0,0 +1,37 @@ +param([Parameter(Mandatory = $true)][string]$WebRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($WebRoot) +$indexPath = Join-Path $root 'index.html' +if (-not (Test-Path -LiteralPath $indexPath -PathType Leaf)) { + throw "Web index not found: $indexPath" +} + +$rootPrefix = $root.TrimEnd('\') + '\' +$html = Get-Content -LiteralPath $indexPath -Raw +$references = [regex]::Matches($html, '(?i)(?:src|href)\s*=\s*["''](?<path>[^"'']+)["'']') +$checked = 0 +foreach ($match in $references) { + $assetReference = $match.Groups['path'].Value.Trim() + if (-not $assetReference -or $assetReference.StartsWith('//') -or $assetReference -match '^[a-z][a-z0-9+.-]*:') { + continue + } + $assetPath = ($assetReference -split '[?#]', 2)[0] + if ([IO.Path]::GetExtension($assetPath).ToLowerInvariant() -notin @('.js', '.css')) { + continue + } + $relative = [Uri]::UnescapeDataString($assetPath).TrimStart('/').Replace('/', '\') + if (-not $relative) { throw "Web index contains an empty local asset path: $assetReference" } + $resolved = [IO.Path]::GetFullPath((Join-Path $root $relative)) + if (-not $resolved.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Web index asset escapes the web root: $assetReference" + } + if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { + throw "Web index references missing local asset: $assetReference" + } + $checked++ +} + +if ($checked -eq 0) { throw 'Web index does not reference any local JavaScript or CSS assets.' } +Write-Host "Sense web asset audit passed: $checked local references." diff --git a/Sense/scripts/build/test-package.ps1 b/Sense/scripts/build/test-package.ps1 index 442fa91..2035141 100644 --- a/Sense/scripts/build/test-package.ps1 +++ b/Sense/scripts/build/test-package.ps1 @@ -15,6 +15,7 @@ $required = @( foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } } +& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web') $forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules', '.git', 'dist', '.cache') } if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" } $forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db', '.sqlite', '.sqlite3', '.dump', '.bak') } diff --git a/Sense/tests/package/run-tests.ps1 b/Sense/tests/package/run-tests.ps1 index 9195279..3b2c269 100644 --- a/Sense/tests/package/run-tests.ps1 +++ b/Sense/tests/package/run-tests.ps1 @@ -28,11 +28,20 @@ foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } try { - New-Item -ItemType Directory -Path (Join-Path $temporary 'config'), (Join-Path $temporary 'web'), (Join-Path $temporary 'bin') | Out-Null - [IO.File]::WriteAllText((Join-Path $temporary 'web\index.html'), 'sense', (New-Object Text.UTF8Encoding($false))) + New-Item -ItemType Directory -Path (Join-Path $temporary 'config'), (Join-Path $temporary 'web'), (Join-Path $temporary 'web\js'), (Join-Path $temporary 'bin') | Out-Null + $webIndex = '<div id="app"></div><script src="/js/runtime.fixture.js"></script>' + [IO.File]::WriteAllText((Join-Path $temporary 'web\index.html'), $webIndex, (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false))) [IO.File]::WriteAllText((Join-Path $temporary 'bin\mediamtx.exe'), 'fixture', (New-Object Text.UTF8Encoding($false))) [IO.File]::WriteAllText((Join-Path $temporary 'config\mediamtx.yml'), 'api: true', (New-Object Text.UTF8Encoding($false))) + $webAssetAudit = Join-Path $senseRoot 'scripts\build\assert-web-assets.ps1' + & $webAssetAudit -WebRoot (Join-Path $temporary 'web') + Assert-True $true 'web asset audit must accept existing local references' + Remove-Item -LiteralPath (Join-Path $temporary 'web\js\runtime.fixture.js') + Assert-Throws { & $webAssetAudit -WebRoot (Join-Path $temporary 'web') } 'missing local asset' 'web asset audit must reject missing runtime files' + [IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false))) + $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0) $listener.Start() $dbPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port diff --git a/Sense/ui/vue.config.js b/Sense/ui/vue.config.js index 6589e04..aa01667 100644 --- a/Sense/ui/vue.config.js +++ b/Sense/ui/vue.config.js @@ -107,14 +107,6 @@ module.exports = { config .when(process.env.NODE_ENV !== 'development', config => { - config - .plugin('ScriptExtHtmlWebpackPlugin') - .after('html') - .use('script-ext-html-webpack-plugin', [{ - // `runtime` must same as runtimeChunk name. default is `runtime` - inline: /runtime\..*\.js$/ - }]) - .end() config .optimization.splitChunks({ chunks: 'all', @@ -145,6 +137,13 @@ module.exports = { }, css: { loaderOptions: { + css: { + // Preserve GoAdmin's :export variables as JavaScript values with css-loader 6. + // ICSS mode does not rename ordinary global or component class selectors. + modules: { + mode: 'icss' + } + }, less: { modifyVars: { // less vars,customize ant design theme From 2c0e39bae872a17cbfac5034c8fd2191d9f0e2da Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 17:12:17 +0800 Subject: [PATCH 18/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20#70=20?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E4=BF=AE=E5=A4=8D=E9=AA=8C=E6=94=B6=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ense-Windows配置启动与打包交付.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md index 5655384..9919687 100644 --- a/docs/task/70-Sense-Windows配置启动与打包交付.md +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-70-Sense-Windows配置启动与打包交付 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- -wiki_revision: e0a0ed3760720a7efc3def925c6d1e2313e1433a -synchronized_at: 2026-08-15T07:57:01Z +wiki_revision: 412da1c81551d8fadbba3feb0d3b05868b83deee +synchronized_at: 2026-08-15T09:11:53Z <!-- gitea-wiki-mirror:end --> # 70 Sense Windows配置启动与打包交付 @@ -51,14 +51,14 @@ synchronized_at: 2026-08-15T07:57:01Z | demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test | | 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 | -最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,595,209 字节,SHA-256 `7E35394F6377F9F4AB0046E8413F29B140D6F301383C54C87CE871B435EF253D`。包内 `VERSION.txt` 记录集成提交 `c088caf816422cb684c402bb8c85e18db1832186`,包含已验收 #92 与待验收 #95 的旧库兼容修复。 +最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,597,422 字节,SHA-256 `217BEBADF77D34DBA314074FD56B43A2A5A66D73479F3D57BB7BBCEECFBAE114`。包内 `VERSION.txt` 记录修复提交 `4ca4abff7bbe8ade47b321b8db302004591a29e1`,包含 #92、#95 的旧库兼容修复及本次白屏修复。 ## 测试 - `go test ./...`、`go vet ./...`、`go build ./...`:通过。 - `go test -race ./app/sense/media ./cmd/api`:通过。 -- PowerShell 包测试:18 项断言通过,覆盖配置注入不执行、特殊字符、环境优先级、demo 数据库隔离、不支持字段拒绝及全部脚本语法。 -- `Sense/scripts/build/build-windows.ps1 -MediaMTXPath <已审核本机路径>`:通过;前端仅有冻结上游 6 条构建 warning。 +- PowerShell 包测试:21 项断言通过,覆盖配置注入不执行、特殊字符、环境优先级、demo 数据库隔离、不支持字段拒绝、HTML 本地资源正反例及全部脚本语法。 +- `Sense/scripts/build/build-windows.ps1 -MediaMTXPath <已审核本机路径>`:通过;前端剩余 4 条非阻塞构建 warning;导致启动失败的 runtime 与 SCSS 导出 warning 已消除。 - `Sense/scripts/build/test-package.ps1 -PackageRoot Sense/dist/sense-windows-amd64`:通过。 - 隔离 PostgreSQL 17:空库 8 个迁移通过;首页、SPA fallback、`/healthz`、MediaMTX Control API、包外目录启动停止通过;119,630 字节 custom-format 备份及恢复到另一数据库通过。 - `git diff --check`:通过。 @@ -73,6 +73,15 @@ synchronized_at: 2026-08-15T07:57:01Z - Web 首页、SPA、`/healthz`、MediaMTX Control API 均返回 200;停止脚本成功且 Sense/MediaMTX 监听端口全部清空。 - PowerShell `Invoke-WebRequest` 在本机受代理环境影响而无法访问 loopback;使用明确绕过代理的本机 HTTP 客户端确认服务正常,该现象不属于 Sense 服务失败。 + +## 白屏验收反馈修复 + +- 用户运行发布包后访问生产入口出现白屏。只读诊断确认首页 HTML 返回 200,但现代浏览器请求的 `runtime.daef9028.js` 不在包内并返回 404;修复 runtime 内联配置后,浏览器继续暴露 GoAdmin `:export` 主题变量在 css-loader 6 下没有 JavaScript 导出的启动错误。 +- 删除不可靠的 runtime 内联插件配置,使现代与 legacy runtime 都作为独立文件进入产物;为 css-loader 启用不改写普通类名的 ICSS mode,保留 GoAdmin 原有 SCSS `:export` 变量模式。 +- 新增 `assert-web-assets.ps1`,构建阶段逐项核对 `index.html` 引用的本地 JS/CSS;缺失 runtime 的反例会直接使包构建失败。 +- Chromium 最终打开 `http://127.0.0.1:18080/` 并进入账号登录页;首屏 7 个 JS/CSS 全部返回 200,白屏和阻止 Vue 挂载的错误消失。测试完成后停止 Sense 与 MediaMTX,18080 无监听。 +- 浏览器仍观察到不阻塞首屏的既有 `/api/v1/app-config` 404 和上游默认百度统计请求;不属于本次白屏修复范围,未混入当前提交。 + ## 遗留问题 - Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。 @@ -82,3 +91,4 @@ synchronized_at: 2026-08-15T07:57:01Z - `6b79478` 建立 Sense Windows 交付包。 - `e4544a0` 记录 Sense Windows 交付流程。 +- `4ca4abf` 修复 Windows 包白屏并增加静态资源闭环审计。 From 0bbdb27f6db4f9d08445ca02abae9061c81598b8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 18:02:10 +0800 Subject: [PATCH 19/44] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20Sense=20?= =?UTF-8?q?=E5=85=8D=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/common/middleware/handler/auth.go | 12 +-- .../server/common/middleware/handler/login.go | 2 - .../common/middleware/handler/login_test.go | 35 ++++++++ Sense/server/docs/admin/admin_docs.go | 10 +-- Sense/server/docs/admin/admin_swagger.json | 12 +-- Sense/server/docs/admin/admin_swagger.yaml | 6 -- Sense/ui/src/views/login/index.vue | 89 +------------------ Sense/ui/tests/unit/login/loginPage.spec.js | 15 ++++ 8 files changed, 58 insertions(+), 123 deletions(-) create mode 100644 Sense/server/common/middleware/handler/login_test.go create mode 100644 Sense/ui/tests/unit/login/loginPage.spec.js diff --git a/Sense/server/common/middleware/handler/auth.go b/Sense/server/common/middleware/handler/auth.go index c5e3020..c6651ae 100644 --- a/Sense/server/common/middleware/handler/auth.go +++ b/Sense/server/common/middleware/handler/auth.go @@ -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) diff --git a/Sense/server/common/middleware/handler/login.go b/Sense/server/common/middleware/handler/login.go index d17626c..3358b43 100644 --- a/Sense/server/common/middleware/handler/login.go +++ b/Sense/server/common/middleware/handler/login.go @@ -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) { diff --git a/Sense/server/common/middleware/handler/login_test.go b/Sense/server/common/middleware/handler/login_test.go new file mode 100644 index 0000000..74df91b --- /dev/null +++ b/Sense/server/common/middleware/handler/login_test.go @@ -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) + } + } +} diff --git a/Sense/server/docs/admin/admin_docs.go b/Sense/server/docs/admin/admin_docs.go index 688aa87..52d021e 100644 --- a/Sense/server/docs/admin/admin_docs.go +++ b/Sense/server/docs/admin/admin_docs.go @@ -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" } } }, diff --git a/Sense/server/docs/admin/admin_swagger.json b/Sense/server/docs/admin/admin_swagger.json index d3c32a1..db98d35 100644 --- a/Sense/server/docs/admin/admin_swagger.json +++ b/Sense/server/docs/admin/admin_swagger.json @@ -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" } } -} \ No newline at end of file +} diff --git a/Sense/server/docs/admin/admin_swagger.yaml b/Sense/server/docs/admin/admin_swagger.yaml index 412817e..2d92499 100644 --- a/Sense/server/docs/admin/admin_swagger.yaml +++ b/Sense/server/docs/admin/admin_swagger.yaml @@ -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: diff --git a/Sense/ui/src/views/login/index.vue b/Sense/ui/src/views/login/index.vue index 259f19b..9c19c1c 100644 --- a/Sense/ui/src/views/login/index.vue +++ b/Sense/ui/src/views/login/index.vue @@ -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%; diff --git a/Sense/ui/tests/unit/login/loginPage.spec.js b/Sense/ui/tests/unit/login/loginPage.spec.js new file mode 100644 index 0000000..197727d --- /dev/null +++ b/Sense/ui/tests/unit/login/loginPage.spec.js @@ -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) + }) +}) From bc1a01848d1769250b706f207d542b63fee2afa6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 18:02:16 +0800 Subject: [PATCH 20/44] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Sense=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04-local-development-and-verification.md | 39 ++++++++++++++++--- docs/09-product-requirements.md | 6 +-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 8c4709b..a45e4e2 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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 --> diff --git a/docs/09-product-requirements.md b/docs/09-product-requirements.md index 19192d5..594418d 100644 --- a/docs/09-product-requirements.md +++ b/docs/09-product-requirements.md @@ -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 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。 From a92e4da0434f5d965f578ca1964b66225d0536a1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 18:08:33 +0800 Subject: [PATCH 21/44] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=20Sense=20?= =?UTF-8?q?=E5=85=8D=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...验证码登录与管理员密码重置.md | 79 +++++++++++++++++++ wiki-docs.json | 4 + 2 files changed, 83 insertions(+) create mode 100644 docs/task/97-Sense免验证码登录与管理员密码重置.md diff --git a/docs/task/97-Sense免验证码登录与管理员密码重置.md b/docs/task/97-Sense免验证码登录与管理员密码重置.md new file mode 100644 index 0000000..2bc1326 --- /dev/null +++ b/docs/task/97-Sense免验证码登录与管理员密码重置.md @@ -0,0 +1,79 @@ +<!-- 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: a5c7ff4bb01b8d2e72ef1d94bec8b71b766b4d4d +synchronized_at: 2026-08-15T10:08:07Z +<!-- 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 登录安全边界镜像。 diff --git a/wiki-docs.json b/wiki-docs.json index cf70f4f..eeae211 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -131,6 +131,10 @@ { "page": "Task-69-Sense多边形区域与方向警戒线配置", "path": "docs/task/69-Sense多边形区域与方向警戒线配置.md" + }, + { + "page": "Task-97-Sense免验证码登录与管理员密码重置", + "path": "docs/task/97-Sense免验证码登录与管理员密码重置.md" } ] } From bdb9a5b4742b8ccafd990ce43de8f5a6c52db8d5 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:05:17 +0800 Subject: [PATCH 22/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#97=20=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ense免验证码登录与管理员密码重置.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/task/97-Sense免验证码登录与管理员密码重置.md b/docs/task/97-Sense免验证码登录与管理员密码重置.md index 2bc1326..5e5f0e4 100644 --- a/docs/task/97-Sense免验证码登录与管理员密码重置.md +++ b/docs/task/97-Sense免验证码登录与管理员密码重置.md @@ -2,8 +2,8 @@ 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: a5c7ff4bb01b8d2e72ef1d94bec8b71b766b4d4d -synchronized_at: 2026-08-15T10:08:07Z +wiki_revision: 486ebcca10e4cef0bd905df59b928c17006db17e +synchronized_at: 2026-08-16T11:04:57Z <!-- gitea-wiki-mirror:end --> # 97 Sense免验证码登录与管理员密码重置 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-15T10:08:07Z - 类型:安全行为调整 / 缺陷修复 - 所属 Epic:#7 - 所属 MVP / 版本:#8 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-15 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/97 - Wiki 页面:Task-97-Sense免验证码登录与管理员密码重置 @@ -77,3 +77,9 @@ GoAdmin 重建后的 Sense 登录页和生产后端重新启用了验证码, - `0bbdb27f6db4f9d08445ca02abae9061c81598b8` 恢复免验证码登录并补充测试。 - `bc1a01848d1769250b706f207d542b63fee2afa6` 更新 Sense 登录安全边界镜像。 + + +## 人工验收 + +- 2026-08-16:用户明确验收通过 #97。 +- 按工作流将 PR #98 合入 `dev`;`main` 保持不变。 From b66c39724c42b9e63891d06a41fb4a87c8c3f6c7 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:18:26 +0800 Subject: [PATCH 23/44] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E6=89=93?= =?UTF-8?q?=E5=8C=85=E6=B8=85=E5=8D=95=E6=A8=A1=E5=9D=97=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/scripts/build/build-windows.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sense/scripts/build/build-windows.ps1 b/Sense/scripts/build/build-windows.ps1 index b084199..a08194f 100644 --- a/Sense/scripts/build/build-windows.ps1 +++ b/Sense/scripts/build/build-windows.ps1 @@ -18,6 +18,17 @@ function Assert-ChildPath([string]$Parent, [string]$Child) { } } +function Get-SenseFileSha256([string]$Path) { + $sha256 = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '') + } finally { + $stream.Dispose() + $sha256.Dispose() + } +} + Assert-ChildPath $senseRoot $distRoot Assert-ChildPath $distRoot $target Assert-ChildPath $distRoot $archive @@ -89,7 +100,7 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Sense package audit failed.' } $manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { $relative = $file.FullName.Substring($staging.Length + 1).Replace('\', '/') - "$(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 | Select-Object -ExpandProperty Hash) $relative" + "$(Get-SenseFileSha256 -Path $file.FullName) $relative" } [IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'), $manifest, (New-Object Text.UTF8Encoding($false))) From 098d0fafee5705bec9327b170b03430000f5be0b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:27:38 +0800 Subject: [PATCH 24/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20#70=20?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E9=9B=86=E6=88=90=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0-Sense-Windows配置启动与打包交付.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md index 9919687..a006cd9 100644 --- a/docs/task/70-Sense-Windows配置启动与打包交付.md +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-70-Sense-Windows配置启动与打包交付 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- -wiki_revision: 412da1c81551d8fadbba3feb0d3b05868b83deee -synchronized_at: 2026-08-15T09:11:53Z +wiki_revision: f3c8e115375c6fa7bd1061a69d2b9368e934546a +synchronized_at: 2026-08-16T11:27:22Z <!-- gitea-wiki-mirror:end --> # 70 Sense Windows配置启动与打包交付 @@ -51,7 +51,7 @@ synchronized_at: 2026-08-15T09:11:53Z | demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test | | 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 | -最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,597,422 字节,SHA-256 `217BEBADF77D34DBA314074FD56B43A2A5A66D73479F3D57BB7BBCEECFBAE114`。包内 `VERSION.txt` 记录修复提交 `4ca4abff7bbe8ade47b321b8db302004591a29e1`,包含 #92、#95 的旧库兼容修复及本次白屏修复。 +最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,455,903 字节,SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。包内 `VERSION.txt` 记录实现提交 `b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`,包含 #92、#95 的旧库兼容修复、白屏修复及已验收 #97 的免验证码登录。 ## 测试 @@ -92,3 +92,13 @@ synchronized_at: 2026-08-15T09:11:53Z - `6b79478` 建立 Sense Windows 交付包。 - `e4544a0` 记录 Sense Windows 交付流程。 - `4ca4abf` 修复 Windows 包白屏并增加静态资源闭环审计。 + + +## #97 集成与最终重打包(2026-08-16) + +- 将 `dev@116318df748ff0d46d3fe5f8a4f41a6507567eec` 合入 #70 分支,发布包现已包含 #97 的账号密码直接登录;登录页和登录载荷不再包含验证码字段或请求,兼容 captcha API 保留。 +- Windows PowerShell 构建在生成清单时暴露 `Get-FileHash` 模块自动加载依赖;改用 .NET `SHA256` 流式计算,避免客户构建环境因模块加载差异失败。实现提交:`b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`。 +- 固定工具链构建通过;21 项包测试、Go 全量 test/vet、65 个 ZIP 清单文件逐项哈希、模板配置/无 node_modules 审计通过。 +- 使用仓库外配置备份完成真实 PostgreSQL 迁移、首页/SPA、MediaMTX、外部目录 start/stop smoke;测试后 18080/9997 无监听。本地解压目录恢复现场 `sense.env`,ZIP 内仍只含无秘密模板。 +- 首次在 PowerShell 7 下调用 smoke 的 `Invoke-WebRequest` 出现 loopback 超时;同一服务用 curl 返回 200,按交付目标的 Windows PowerShell 5.1 正式入口复测全部通过,确认不是 Sense 服务阻塞。 +- 新 ZIP:56,455,903 字节;SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。 From 15bd0008163704da6797214545d0cb94fc3d2e50 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:32:02 +0800 Subject: [PATCH 25/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#95=20=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...5-Sense旧媒体路由唯一约束兼容迁移.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md b/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md index a82c784..7ce7189 100644 --- a/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md +++ b/docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md @@ -2,8 +2,8 @@ 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: 89f668bc6f3308edbc1ce874c9b0a189dd418d42 -synchronized_at: 2026-08-15T08:43:18Z +wiki_revision: 5f3bdf3786f2a72dac5d29236ff466743e2912b5 +synchronized_at: 2026-08-16T11:31:47Z <!-- gitea-wiki-mirror:end --> # 95 Sense旧媒体路由唯一约束兼容迁移 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-15T08:43:18Z - 类型:缺陷 - 所属 Epic:#7 - 所属 MVP / 版本:#8 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-15 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/95 - Wiki 页面:Task-95-Sense旧媒体路由唯一约束兼容迁移 @@ -72,3 +72,9 @@ synchronized_at: 2026-08-15T08:43:18Z - `3dd4890` 兼容旧媒体路由约束和运行态列迁移。 - `6257859` 记录旧媒体路由迁移排错。 - `c088caf` #70 集成 #95 后用于 Windows 包真实回归。 + + +## 人工验收 + +- 2026-08-16:用户明确验收通过 #95。 +- 按依赖顺序先将 PR #96 合入 `dev`;`main` 保持不变。 From 5a67a17e17862f27b526c2e34905a260b1fa2dc4 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:33:44 +0800 Subject: [PATCH 26/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#70=20=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../70-Sense-Windows配置启动与打包交付.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md index a006cd9..562b99b 100644 --- a/docs/task/70-Sense-Windows配置启动与打包交付.md +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-70-Sense-Windows配置启动与打包交付 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- -wiki_revision: f3c8e115375c6fa7bd1061a69d2b9368e934546a -synchronized_at: 2026-08-16T11:27:22Z +wiki_revision: 9eb390dfd691acc089656a838dd3f12e24f97884 +synchronized_at: 2026-08-16T11:33:28Z <!-- gitea-wiki-mirror:end --> # 70 Sense Windows配置启动与打包交付 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-16T11:27:22Z - 类型:需求 - 所属 Epic:#7 - 所属 MVP / 版本:#8 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-15 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70 - Wiki 页面:Task-70-Sense-Windows配置启动与打包交付 @@ -102,3 +102,9 @@ synchronized_at: 2026-08-16T11:27:22Z - 使用仓库外配置备份完成真实 PostgreSQL 迁移、首页/SPA、MediaMTX、外部目录 start/stop smoke;测试后 18080/9997 无监听。本地解压目录恢复现场 `sense.env`,ZIP 内仍只含无秘密模板。 - 首次在 PowerShell 7 下调用 smoke 的 `Invoke-WebRequest` 出现 loopback 超时;同一服务用 curl 返回 200,按交付目标的 Windows PowerShell 5.1 正式入口复测全部通过,确认不是 Sense 服务阻塞。 - 新 ZIP:56,455,903 字节;SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。 + + +## 人工验收 + +- 2026-08-16:用户明确验收通过 #70。 +- #95 已先合入 `dev`,随后按依赖顺序合并 PR #89;`main` 保持不变。 From 713c9e4e3003088e29aafcf1fdd3bd87078ec394 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:40:29 +0800 Subject: [PATCH 27/44] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E5=89=8D=E7=AB=AF=E9=85=8D=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/server/app/admin/router/sys_router.go | 5 +++ .../app/admin/router/sys_router_test.go | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 Sense/server/app/admin/router/sys_router_test.go diff --git a/Sense/server/app/admin/router/sys_router.go b/Sense/server/app/admin/router/sys_router.go index ffab703..71ae8b7 100644 --- a/Sense/server/app/admin/router/sys_router.go +++ b/Sense/server/app/admin/router/sys_router.go @@ -80,6 +80,11 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { api := apis.SysMenu{} api2 := apis.SysDept{} + configAPI := apis.SysConfig{} + // The GoAdmin login shell reads frontend-only branding before a user is + // authenticated. Keep this single read route public without registering the + // disabled system-configuration CRUD and write routes. + v1.GET("/app-config", configAPI.Get2SysApp) v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) { v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect) diff --git a/Sense/server/app/admin/router/sys_router_test.go b/Sense/server/app/admin/router/sys_router_test.go new file mode 100644 index 0000000..d882d47 --- /dev/null +++ b/Sense/server/app/admin/router/sys_router_test.go @@ -0,0 +1,37 @@ +package router + +import ( + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func TestRegisterBaseRouterExposesOnlyFrontendAppConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + registerBaseRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{}) + + routes := make(map[string]struct{}) + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = struct{}{} + } + + if _, ok := routes["GET /api/v1/app-config"]; !ok { + t.Fatal("anonymous frontend app-config route is not registered") + } + for _, disabled := range []string{ + "GET /api/v1/config", + "POST /api/v1/config", + "GET /api/v1/config/:id", + "PUT /api/v1/config/:id", + "DELETE /api/v1/config", + "GET /api/v1/configKey/:configKey", + "GET /api/v1/set-config", + "PUT /api/v1/set-config", + } { + if _, ok := routes[disabled]; ok { + t.Fatalf("disabled system-configuration route was registered: %s", disabled) + } + } +} From 08b4b61f9020968aefc8e73a3c1715619da5214d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:46:07 +0800 Subject: [PATCH 28/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=A4=96=E5=A3=B3=E5=8F=AA=E8=AF=BB=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-architecture-and-code-map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5013571..0c95592 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: a95b7692bc0c6bf4d05f8c57088a83379e15a08f -synchronized_at: 2026-08-15T03:01:57Z +wiki_revision: 5cbaa178193811083519c5125febf8cc962c94ee +synchronized_at: 2026-08-16T11:45:49Z <!-- gitea-wiki-mirror:end --> # 架构与代码地图 @@ -87,7 +87,7 @@ Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入 工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。 -Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。 +Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。 工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。 From d067f0b98930801b95e6c5aacbe7ec34a8e0c2a3 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 19:49:19 +0800 Subject: [PATCH 29/44] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#99=20=E5=BE=85=E9=AA=8C=E6=94=B6=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../99-Sense登录页只读配置接口.md | 73 +++++++++++++++++++ wiki-docs.json | 4 + 2 files changed, 77 insertions(+) create mode 100644 docs/task/99-Sense登录页只读配置接口.md diff --git a/docs/task/99-Sense登录页只读配置接口.md b/docs/task/99-Sense登录页只读配置接口.md new file mode 100644 index 0000000..ae1af1a --- /dev/null +++ b/docs/task/99-Sense登录页只读配置接口.md @@ -0,0 +1,73 @@ +<!-- gitea-wiki-mirror:start --> +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-99-Sense登录页只读配置接口 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-99-Sense%E7%99%BB%E5%BD%95%E9%A1%B5%E5%8F%AA%E8%AF%BB%E9%85%8D%E7%BD%AE%E6%8E%A5%E5%8F%A3.- +wiki_revision: 26d3762828f6e4273a2002fca49336bebf7566fe +synchronized_at: 2026-08-16T11:48:58Z +<!-- gitea-wiki-mirror:end --> + +# 99 Sense登录页只读配置接口 + +- 类型:缺陷修复 / GoAdmin 路由精简回归 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:待验收 +- 日期:2026-08-16 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/99 +- Wiki 页面:Task-99-Sense登录页只读配置接口 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +用户启动 #70 Windows 发布包后,登录页弹出 `Request failed with status code 404`。只读诊断确认首页、runtime JS 和兼容 captcha 端点均为 200,唯一失败请求是登录页在 created 阶段发出的 `GET /api/v1/app-config`。 + +冻结 go-admin-ui 使用该匿名接口取得系统名称等登录外壳展示配置;Sense 后端保留了 `SysConfig.Get2SysApp` handler,但在最小化默认模块时没有注册此路由。目标是恢复这一个只读端点,同时继续禁用系统配置管理和写接口。 + +## 最终方案 + +- 在现有 `registerBaseRouter` 中单独注册匿名 `GET /api/v1/app-config`,直接复用 GoAdmin `SysConfig.Get2SysApp`。 +- 不调用上游完整 `registerSysConfigRouter`,因此 `/api/v1/config`、`/api/v1/configKey` 和 `/api/v1/set-config` 不会随之开放。 +- handler 继续只查询 `is_frontend=1` 的配置并返回标准响应;没有前端配置时返回空对象和业务码 200。 +- 保持 #97 的免验证码登录、JWT、RBAC、Cookie、密码策略和 captcha 兼容端点不变。 +- 基于实现提交重新生成 Windows 包,恢复仓库外备份的现场配置,并重新启动 Sense/MediaMTX。 + +## 修改文件 + +- `Sense/server/app/admin/router/sys_router.go`:恢复登录外壳必需的匿名只读 app-config 路由。 +- `Sense/server/app/admin/router/sys_router_test.go`:锁定公开端点和八个仍禁用的配置管理路由。 +- Wiki `Architecture-and-Code-Map` 及镜像:记录匿名只读例外与配置管理禁用边界。 +- `wiki-docs.json` 与本任务镜像:登记任务归档。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 匿名 app-config 返回 HTTP 200 和标准结构 | 通过:HTTP 200、业务码 200 | +| 登录页不再出现该 404 | 通过:真实 Edge 页面 app-config 200,错误消息数 0 | +| 配置 CRUD/configKey/set-config 未启用 | 通过:GET/PUT 定向 smoke 均为 404 | +| 免验证码登录与认证边界不变 | 通过:登录页仅账号和密码,无验证码文本;后端全量测试通过 | +| Go 与 Windows 包测试 | 通过 | +| ZIP 无秘密、node_modules 或客户数据 | 通过:构建内置包审计通过,ZIP 使用模板配置 | +| Wiki、归档与证据完整 | 通过 | + +## 测试 + +- `go test ./app/admin/router -run TestRegisterBaseRouterExposesOnlyFrontendAppConfig -count=1`:通过。 +- `go test ./...`、`go vet ./...`、`go build ./...`:通过。 +- Windows PowerShell 包测试:21 项断言通过。 +- 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与 11 个 HTML 本地资源审计通过;存在既有 4 条非阻塞构建 warning。 +- 真实运行:`GET /api/v1/app-config` 为 HTTP 200/业务码 200;配置 CRUD、configKey、set-config GET/PUT 均为 404。 +- Headless Edge:进入 `/#/login?redirect=/dashboard`;app-config 200;错误消息 0;仅账号、密码两个输入;无验证码文本、失败请求或页面异常。 +- 新 ZIP:56,457,323 字节;SHA-256 `A98EB70B709BBA546F4968723E4F89CE44DAF412FCC426869820FB7454CB3CC7`;包内 `source_commit=713c9e4e3003088e29aafcf1fdd3bd87078ec394`。 +- 测试后 Sense 与 MediaMTX 已使用恢复的现场配置重新启动,监听 18080/9997。 +- `git diff --check` 与 Wiki 定向同步检查:通过。 +- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和客户目标浏览器中验收;本轮使用当前机器 PostgreSQL、Microsoft Edge 和现有脱敏配置验证。 + +## 遗留问题 + +- 无本工单阻塞项。 + +## 相关提交 + +- `713c9e4e3003088e29aafcf1fdd3bd87078ec394` 恢复登录页只读配置接口并增加负向路由测试。 +- `08b4b61` 记录登录外壳只读配置边界。 diff --git a/wiki-docs.json b/wiki-docs.json index 3fb80b4..a14222e 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -143,6 +143,10 @@ { "page": "Task-97-Sense免验证码登录与管理员密码重置", "path": "docs/task/97-Sense免验证码登录与管理员密码重置.md" + }, + { + "page": "Task-99-Sense登录页只读配置接口", + "path": "docs/task/99-Sense登录页只读配置接口.md" } ] } From 4423d528b1dc6ebe8b6f7b8272ca5111032a1608 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 23:12:53 +0800 Subject: [PATCH 30/44] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20Sense=20GoAdm?= =?UTF-8?q?in=20=E5=BA=94=E7=94=A8=E5=A4=96=E5=A3=B3=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../version/2026081623100_sense_layout.go | 85 +++++++ .../2026081623100_sense_layout_test.go | 208 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go create mode 100644 Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go diff --git a/Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go b/Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go new file mode 100644 index 0000000..3b48a6a --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go @@ -0,0 +1,85 @@ +package version + +import ( + "fmt" + "runtime" + + "gorm.io/gorm" + + "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration" + migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models" + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" +) + +const senseLayoutMenuName = "SenseManage" + +var sensePageMenus = []migrationModels.SysMenu{ + {MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "devices", MenuType: "C", Permission: "sense:device:list", Component: "/sense/device/index", Sort: 1, Visible: "0", IsFrame: "1"}, + {MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 2, Visible: "0", IsFrame: "1"}, + {MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 3, Visible: "0", IsFrame: "1"}, + {MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 4, Visible: "0", IsFrame: "1"}, + {MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 5, Visible: "0", IsFrame: "1"}, +} + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLayout) +} + +func migrateSenseLayout(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := alignSenseLayout(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +func alignSenseLayout(tx *gorm.DB) error { + root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{ + MenuName: senseLayoutMenuName, + Title: "视频感知", + Icon: "video-camera", + Path: "/sense", + MenuType: "M", + ParentId: 0, + Component: "Layout", + Sort: 5, + Visible: "0", + IsFrame: "1", + }) + if err != nil { + return fmt.Errorf("ensure Sense layout menu: %w", err) + } + + for _, desired := range sensePageMenus { + desired.ParentId = root.MenuId + desired.Paths = fmt.Sprintf("/0/%d", root.MenuId) + if _, err = ensureDeviceMenu(tx, desired); err != nil { + return fmt.Errorf("align Sense page menu %s: %w", desired.MenuName, err) + } + } + + if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil { + return err + } + return nil +} + +func rebuildSenseMenuPaths(tx *gorm.DB, menuID int, parentPath string) error { + path := fmt.Sprintf("%s/%d", parentPath, menuID) + if err := tx.Model(&migrationModels.SysMenu{}).Where("menu_id = ?", menuID).Update("paths", path).Error; err != nil { + return fmt.Errorf("update menu %d paths: %w", menuID, err) + } + + var children []migrationModels.SysMenu + if err := tx.Where("parent_id = ?", menuID).Order("sort, menu_id").Find(&children).Error; err != nil { + return fmt.Errorf("list children of menu %d: %w", menuID, err) + } + for _, child := range children { + if err := rebuildSenseMenuPaths(tx, child.MenuId, path); err != nil { + return err + } + } + return nil +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go b/Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go new file mode 100644 index 0000000..3779a42 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go @@ -0,0 +1,208 @@ +package version + +import ( + "fmt" + "os" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models" + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" +) + +func TestAlignSenseLayoutPreservesShellAndExistingPages(t *testing.T) { + db := openSenseLayoutTestDB(t) + seedLegacySenseMenus(t, db) + associationsBefore := countRoleMenuAssociations(t, db) + + if err := alignSenseLayout(db); err != nil { + t.Fatal(err) + } + // The alignment helper is intentionally idempotent because partially upgraded + // deployments may rerun the repair before the migration version is recorded. + if err := alignSenseLayout(db); err != nil { + t.Fatalf("repeat alignment: %v", err) + } + + root := requireMenu(t, db, senseLayoutMenuName) + if root.ParentId != 0 || root.Component != "Layout" || root.Path != "/sense" || root.MenuType != "M" { + t.Fatalf("unexpected root menu: %#v", root) + } + if root.Paths != fmt.Sprintf("/0/%d", root.MenuId) { + t.Fatalf("root paths=%q", root.Paths) + } + + for _, desired := range sensePageMenus { + page := requireMenu(t, db, desired.MenuName) + if page.ParentId != root.MenuId || page.Path != desired.Path || page.Component != desired.Component || page.Permission != desired.Permission { + t.Errorf("unexpected page %s: %#v", desired.MenuName, page) + } + wantPagePaths := fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId) + if page.Paths != wantPagePaths { + t.Errorf("page %s paths=%q want %q", desired.MenuName, page.Paths, wantPagePaths) + } + + var buttons []migrationModels.SysMenu + if err := db.Where("parent_id = ? AND menu_type = ?", page.MenuId, "F").Find(&buttons).Error; err != nil { + t.Fatal(err) + } + if len(buttons) != 1 { + t.Fatalf("page %s button count=%d", desired.MenuName, len(buttons)) + } + wantButtonPaths := fmt.Sprintf("%s/%d", wantPagePaths, buttons[0].MenuId) + if buttons[0].Paths != wantButtonPaths { + t.Errorf("button %s paths=%q want %q", buttons[0].MenuName, buttons[0].Paths, wantButtonPaths) + } + } + + var rootCount int64 + if err := db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", senseLayoutMenuName).Count(&rootCount).Error; err != nil { + t.Fatal(err) + } + if rootCount != 1 { + t.Fatalf("Sense layout menu count=%d", rootCount) + } + if associationsAfter := countRoleMenuAssociations(t, db); associationsAfter != associationsBefore { + t.Fatalf("role-menu associations changed from %d to %d", associationsBefore, associationsAfter) + } +} + +func TestMigrateSenseLayoutRollsBackWhenRequiredPageIsMissing(t *testing.T) { + db := openSenseLayoutTestDB(t) + seedLegacySenseMenus(t, db) + if err := db.Where("menu_name = ?", "SenseArea").Delete(&migrationModels.SysMenu{}).Error; err != nil { + t.Fatal(err) + } + + // A missing page is recreated by the repair, so force a failure when the + // migration records its version and verify the menu transaction also rolls back. + version := "2026081623100" + if err := db.Create(&common.Migration{Version: version}).Error; err != nil { + t.Fatal(err) + } + if err := migrateSenseLayout(db, version); err == nil { + t.Fatal("expected duplicate migration version error") + } + + var rootCount, areaCount int64 + db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", senseLayoutMenuName).Count(&rootCount) + db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseArea").Count(&areaCount) + if rootCount != 0 || areaCount != 0 { + t.Fatalf("transaction left partial menus: root=%d area=%d", rootCount, areaCount) + } +} + +func openSenseLayoutTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &common.Migration{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestSenseLayoutMigrationOnPostgres(t *testing.T) { + dsn := os.Getenv("SENSE_LAYOUT_MIGRATION_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set SENSE_LAYOUT_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_layout_101_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) + } + if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &common.Migration{}); err != nil { + t.Fatal(err) + } + seedLegacySenseMenus(t, db) + + const version = "2026081623100" + if err = migrateSenseLayout(db, version); err != nil { + t.Fatal(err) + } + root := requireMenu(t, db, senseLayoutMenuName) + if root.Component != "Layout" || root.Path != "/sense" { + t.Fatalf("unexpected PostgreSQL root: %#v", root) + } + var pages, versions int64 + db.Model(&migrationModels.SysMenu{}).Where("parent_id = ? AND menu_type = ?", root.MenuId, "C").Count(&pages) + db.Model(&common.Migration{}).Where("version = ?", version).Count(&versions) + if pages != int64(len(sensePageMenus)) || versions != 1 { + t.Fatalf("pages=%d versions=%d", pages, versions) + } +} + +func seedLegacySenseMenus(t *testing.T, db *gorm.DB) { + t.Helper() + role := migrationModels.SysRole{RoleName: "site_admin", RoleKey: "site_admin", Status: "2"} + if err := db.Create(&role).Error; err != nil { + t.Fatal(err) + } + assigned := make([]migrationModels.SysMenu, 0, len(sensePageMenus)*2) + for index, desired := range sensePageMenus { + legacy := desired + legacy.Path = "/sense/" + desired.Path + legacy.ParentId = 0 + legacy.Sort = index + 5 + legacy.Paths = "" + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + button := migrationModels.SysMenu{ + MenuName: desired.MenuName + "Action", + Title: "测试操作", + MenuType: "F", + Permission: desired.Permission + ":action", + ParentId: legacy.MenuId, + Paths: fmt.Sprintf("/0/%d", legacy.MenuId), + Visible: "1", + IsFrame: "1", + } + if err := db.Create(&button).Error; err != nil { + t.Fatal(err) + } + assigned = append(assigned, legacy, button) + } + if err := db.Model(&role).Association("SysMenu").Append(assigned); err != nil { + t.Fatal(err) + } +} + +func countRoleMenuAssociations(t *testing.T, db *gorm.DB) int64 { + t.Helper() + var count int64 + if err := db.Table("sys_role_menu").Count(&count).Error; err != nil { + t.Fatal(err) + } + return count +} + +func requireMenu(t *testing.T, db *gorm.DB, name string) migrationModels.SysMenu { + t.Helper() + var menu migrationModels.SysMenu + if err := db.Where("menu_name = ?", name).First(&menu).Error; err != nil { + t.Fatal(err) + } + return menu +} From e6521097442d46868536e1b7872e8273eddc3876 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 23:26:55 +0800 Subject: [PATCH 31/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Sense=20Layo?= =?UTF-8?q?ut=20=E8=8F=9C=E5=8D=95=E7=BB=93=E6=9E=84=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-architecture-and-code-map.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5013571..d0938db 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: a95b7692bc0c6bf4d05f8c57088a83379e15a08f -synchronized_at: 2026-08-15T03:01:57Z +wiki_revision: 8608db441b21964aba7ba78e2c1581b4f5f910aa +synchronized_at: 2026-08-16T15:22:21Z <!-- gitea-wiki-mirror:end --> # 架构与代码地图 @@ -85,9 +85,11 @@ Sense/Brain 生成事件 Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。 +Sense 业务菜单必须遵循冻结 GoAdmin 的路由树:顶层目录使用 `MenuType=M`、`Component=Layout`,设备管理、视频接入、视频服务、实时监看、区域与警戒线使用相对路径作为 `MenuType=C` 子页面,操作权限继续作为页面下的 `MenuType=F` 节点。这样动态路由只替换 Layout 的右侧内容区,左侧导航、顶部栏和标签页始终保留;旧数据库由 `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go` 新增迁移修正父子关系和完整 `paths`,页面 URL 与权限标识不变。 + 工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。 -Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。 +Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。 工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。 From 9ff2f6339f173b0c6cf2fa8b82f34372a5ce73c3 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 23:32:42 +0800 Subject: [PATCH 32/44] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#101=20=E5=BE=85=E9=AA=8C=E6=94=B6=E5=BD=92?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/101-Sense-GoAdmin-应用外壳.md | 81 +++++++++++++++++++++ wiki-docs.json | 4 + 2 files changed, 85 insertions(+) create mode 100644 docs/task/101-Sense-GoAdmin-应用外壳.md diff --git a/docs/task/101-Sense-GoAdmin-应用外壳.md b/docs/task/101-Sense-GoAdmin-应用外壳.md new file mode 100644 index 0000000..2ed2b6b --- /dev/null +++ b/docs/task/101-Sense-GoAdmin-应用外壳.md @@ -0,0 +1,81 @@ +<!-- gitea-wiki-mirror:start --> +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-101-Sense-GoAdmin-应用外壳 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-101-Sense-GoAdmin-%E5%BA%94%E7%94%A8%E5%A4%96%E5%A3%B3.- +wiki_revision: 416668dca04772908eb66c5f74ef0cec7e79524c +synchronized_at: 2026-08-16T15:32:14Z +<!-- gitea-wiki-mirror:end --> + +# 101 Sense-GoAdmin-应用外壳 + +- 类型:缺陷修复 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 / Sense 首个独立纵切 +- 状态:待验收 +- 日期:2026-08-16 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/101 +- Wiki 页面:Task-101-Sense-GoAdmin-应用外壳 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只有在动态路由组件为 `Layout` 时才保留侧栏、顶部导航和标签页,因此点击设备管理等入口会用业务页面替换整个 GoAdmin 外壳。 + +本任务恢复冻结 GoAdmin 的标准父子菜单结构:应用外壳保持不变,模块切换只替换右侧内容区域。 + +## 最终方案 + +新增迁移 `2026081623100_sense_layout.go`,创建唯一的“视频感知”顶层目录: + +- 顶层目录:`MenuType=M`、`Path=/sense`、`Component=Layout`。 +- 设备管理、视频接入、视频服务、实时监看、区域与警戒线:作为相对路径的 `MenuType=C` 子页面。 +- 页面下既有 `MenuType=F` 操作权限保持不变。 +- 递归重算父目录、页面和按钮的完整 `paths`。 +- 页面 URL、组件、权限标识、角色关联和业务 API 均不改变。 +- 使用新增事务迁移兼容已经执行旧迁移的 PostgreSQL 数据库,不修改历史迁移。 + +与建单方案一致,没有修改 go-admin-ui 公共 Layout 或动态路由生成器。 + +## 修改文件 + +- `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go`:新增 GoAdmin Layout 菜单兼容迁移。 +- `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go`:覆盖菜单层级、相对路径、完整 paths、角色关联保持、重复对齐、事务回滚和 PostgreSQL。 +- `docs/02-architecture-and-code-map.md`:同步架构 Wiki 中的 Sense 动态菜单长期规则。 +- `wiki-docs.json`、`docs/task/101-Sense-GoAdmin-应用外壳.md`:任务归档映射与镜像。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 五个业务页位于 Layout 父路由下 | 通过 | +| 设备管理进入 `#/sense/devices`,标题与路径不串位 | 通过 | +| 切换和刷新后侧栏、顶部栏、标签页保留 | 通过 | +| 页面组件、权限标识和角色关联保持 | 通过 | +| 已有 PostgreSQL 数据库迁移无重复菜单 | 通过 | +| 重复对齐安全、失败事务回滚 | 通过 | +| 后端、前端和 Windows 包验证 | 通过 | +| Wiki、归档、PR 和证据 | 通过,等待用户验收 | + +## 测试 + +- `go test ./cmd/migrate/migration/version -run 'Test(AlignSenseLayout|MigrateSenseLayout|SenseLayout)' -count=1`:通过。 +- 真实 PostgreSQL 隔离 schema `TestSenseLayoutMigrationOnPostgres`:通过并清理测试 schema。 +- `go test ./...`:通过。 +- `go vet ./...`、后端构建:通过。 +- `pnpm lint`:0 error,32 条冻结上游/既有 warning。 +- `pnpm test:unit -- --runInBand`:17 suites、46 tests 全部通过。 +- `pnpm build:prod`:通过,4 条既有构建 warning。 +- Windows PowerShell 5.1 包测试:21 assertions 通过;包审计通过。 +- 本机生产数据库迁移及启动:通过。 +- Headless Edge:依次打开五个菜单及刷新区域页面,`.app-wrapper`、侧栏、navbar、TagsView 全程可见,URL 均正确。 +- Windows ZIP:`Sense/dist/sense-windows-amd64.zip`,SHA-256 `BAAA973F8502BB5B0BC080F11931E60419B25EFA8FF722A6B9F9B98364399642`,源码提交 `4423d528b1dc6ebe8b6f7b8272ca5111032a1608`。 +- **未验证部分**:客户全新 Windows 主机、客户目标浏览器及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;本机使用 admin 完成真实浏览器回归。 + +## 遗留问题 + +#99 仍处于待验收并使用独立分支;本任务代码不依赖 #99,但两项长期架构说明位于同一 Wiki 页面。PR 集成时建议先验收/合入 #99,再更新本分支到最新 dev,以消除镜像元数据的并行差异。#90 的项目档案 Wiki 更新未混入本任务提交。 + +## 相关提交 + +- `4423d528b1dc6ebe8b6f7b8272ca5111032a1608` 修复 Sense GoAdmin 应用外壳。 +- `e652109` 记录 Sense Layout 菜单长期架构。 diff --git a/wiki-docs.json b/wiki-docs.json index 3fb80b4..80c032e 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -143,6 +143,10 @@ { "page": "Task-97-Sense免验证码登录与管理员密码重置", "path": "docs/task/97-Sense免验证码登录与管理员密码重置.md" + }, + { + "page": "Task-101-Sense-GoAdmin-应用外壳", + "path": "docs/task/101-Sense-GoAdmin-应用外壳.md" } ] } From 3c668ccff6bdbf39110f5960332ab8bf344a1ab9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 23:41:26 +0800 Subject: [PATCH 33/44] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#99=20=E9=AA=8C=E6=94=B6=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/99-Sense登录页只读配置接口.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/task/99-Sense登录页只读配置接口.md b/docs/task/99-Sense登录页只读配置接口.md index ae1af1a..9c3d67b 100644 --- a/docs/task/99-Sense登录页只读配置接口.md +++ b/docs/task/99-Sense登录页只读配置接口.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-99-Sense登录页只读配置接口 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-99-Sense%E7%99%BB%E5%BD%95%E9%A1%B5%E5%8F%AA%E8%AF%BB%E9%85%8D%E7%BD%AE%E6%8E%A5%E5%8F%A3.- -wiki_revision: 26d3762828f6e4273a2002fca49336bebf7566fe -synchronized_at: 2026-08-16T11:48:58Z +wiki_revision: 7d0dd9b216e443b00a97f021d49678b848006db9 +synchronized_at: 2026-08-16T15:41:39Z <!-- gitea-wiki-mirror:end --> # 99 Sense登录页只读配置接口 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-16T11:48:58Z - 类型:缺陷修复 / GoAdmin 路由精简回归 - 所属 Epic:#7 - 所属 MVP / 版本:#8 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-16 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/99 - Wiki 页面:Task-99-Sense登录页只读配置接口 @@ -71,3 +71,9 @@ synchronized_at: 2026-08-16T11:48:58Z - `713c9e4e3003088e29aafcf1fdd3bd87078ec394` 恢复登录页只读配置接口并增加负向路由测试。 - `08b4b61` 记录登录外壳只读配置边界。 + +## 人工验收 + +- 用户于 2026-08-16 明确回复“#99 验收通过”。 +- PR #100 已合入 `dev`,合并提交:`34ee5ed619d122a8670dc50039578686a7a9ef66`。 +- 工单已按验收流程关闭;MVP #8 与 Epic #7 的子工单索引同步为完成。 From faa96a3bea9141e449435999072918ed6179cd87 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 16 Aug 2026 23:51:59 +0800 Subject: [PATCH 34/44] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#101=20=E7=BB=84=E5=90=88=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/101-Sense-GoAdmin-应用外壳.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/task/101-Sense-GoAdmin-应用外壳.md b/docs/task/101-Sense-GoAdmin-应用外壳.md index 2ed2b6b..6654805 100644 --- a/docs/task/101-Sense-GoAdmin-应用外壳.md +++ b/docs/task/101-Sense-GoAdmin-应用外壳.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-101-Sense-GoAdmin-应用外壳 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-101-Sense-GoAdmin-%E5%BA%94%E7%94%A8%E5%A4%96%E5%A3%B3.- -wiki_revision: 416668dca04772908eb66c5f74ef0cec7e79524c -synchronized_at: 2026-08-16T15:32:14Z +wiki_revision: b64071730850396e615a40ed29006ca9f669ccaa +synchronized_at: 2026-08-16T15:52:26Z <!-- gitea-wiki-mirror:end --> # 101 Sense-GoAdmin-应用外壳 @@ -79,3 +79,12 @@ Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只 - `4423d528b1dc6ebe8b6f7b8272ca5111032a1608` 修复 Sense GoAdmin 应用外壳。 - `e652109` 记录 Sense Layout 菜单长期架构。 + +## #99 合入后的组合验收包 + +- 用户验收 #99 后,PR #100 已合入 `dev`;本任务分支通过合并提交 `42dc77b1f904032131c51b3864184c4ccaea6b8f` 更新到该基线。 +- 冲突处理保留 Wiki 事实源中 #99 的匿名只读 `app-config` 边界、#101 的 GoAdmin Layout 菜单规则,以及两张任务归档映射。 +- 重新生成 `Sense/dist/sense-windows-amd64.zip`;包内 `source_commit=42dc77b1f904032131c51b3864184c4ccaea6b8f`。 +- 新 ZIP SHA-256:`65AF5DD1CFB5D4F8339DCCC53717354495259F13630FBC0C6A4AFC1ACD794B26`。 +- 固定工具链 production build、包审计、Windows PowerShell 5.1 包测试 21 项、真实 PostgreSQL 迁移/启动及 `TestRegisterBaseRouterExposesOnlyFrontendAppConfig` 均通过。 +- 现场配置在构建前备份、ZIP 生成后恢复;ZIP 仍只包含模板配置。Sense 当前监听 `127.0.0.1:18080`。 From 857ba45541bf8b8097cd9bdcb1433de87333151b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 17 Aug 2026 10:53:20 +0800 Subject: [PATCH 35/44] =?UTF-8?q?feat:=20=E5=B0=86=20Sense=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=9C=89=E6=95=88=E6=9C=9F=E8=B0=83=E6=95=B4=E4=B8=BA?= =?UTF-8?q?30=E5=A4=A9=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/scripts/runtime/sense-common.ps1 | 2 +- Sense/server/common/middleware/auth.go | 21 ++++++++------ Sense/server/common/middleware/auth_test.go | 26 +++++++++++++++++ Sense/server/config/READMEN.md | 2 +- Sense/server/config/settings.demo.yml | 2 +- Sense/server/config/settings.full.yml | 2 +- Sense/server/config/settings.sqlite.yml | 2 +- Sense/server/config/settings.yml | 2 +- Sense/tests/package/run-tests.ps1 | 1 + Sense/ui/src/utils/auth.js | 3 +- Sense/ui/tests/unit/utils/auth.spec.js | 32 +++++++++++++++++++++ 11 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 Sense/server/common/middleware/auth_test.go create mode 100644 Sense/ui/tests/unit/utils/auth.spec.js diff --git a/Sense/scripts/runtime/sense-common.ps1 b/Sense/scripts/runtime/sense-common.ps1 index e4cd351..03b32bc 100644 --- a/Sense/scripts/runtime/sense-common.ps1 +++ b/Sense/scripts/runtime/sense-common.ps1 @@ -228,7 +228,7 @@ function Initialize-SenseRuntime { ' enableddb: false', ' jwt:', " secret: $(ConvertTo-SenseYamlString $jwtSecret)", - ' timeout: 3600', + ' timeout: 2592000', ' database:', ' driver: postgres', " source: $(ConvertTo-SenseYamlString $databaseURL)", diff --git a/Sense/server/common/middleware/auth.go b/Sense/server/common/middleware/auth.go index 927743d..fdfbea6 100644 --- a/Sense/server/common/middleware/auth.go +++ b/Sense/server/common/middleware/auth.go @@ -8,16 +8,11 @@ import ( jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" ) +const defaultLoginValidity = 30 * 24 * time.Hour + // AuthInit jwt验证new func AuthInit() (*jwt.GinJWTMiddleware, error) { - timeout := time.Hour - if config.ApplicationConfig.Mode == "dev" { - timeout = time.Duration(876010) * time.Hour - } else { - if config.JwtConfig.Timeout != 0 { - timeout = time.Duration(config.JwtConfig.Timeout) * time.Second - } - } + timeout := resolveJWTTimeout(config.ApplicationConfig.Mode, config.JwtConfig.Timeout) return jwt.New(&jwt.GinJWTMiddleware{ Realm: "Sense", Key: []byte(config.JwtConfig.Secret), @@ -34,3 +29,13 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) { }) } + +func resolveJWTTimeout(mode string, configuredSeconds int64) time.Duration { + if mode == "dev" { + return time.Duration(876010) * time.Hour + } + if configuredSeconds > 0 { + return time.Duration(configuredSeconds) * time.Second + } + return defaultLoginValidity +} diff --git a/Sense/server/common/middleware/auth_test.go b/Sense/server/common/middleware/auth_test.go new file mode 100644 index 0000000..7a2e391 --- /dev/null +++ b/Sense/server/common/middleware/auth_test.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "testing" + "time" +) + +func TestResolveJWTTimeoutDefaultsToThirtyDays(t *testing.T) { + for _, mode := range []string{"prod", "test", "demo"} { + if got := resolveJWTTimeout(mode, 0); got != 30*24*time.Hour { + t.Errorf("mode %s timeout=%s, want 720h", mode, got) + } + } +} + +func TestResolveJWTTimeoutHonorsExplicitConfiguration(t *testing.T) { + if got := resolveJWTTimeout("prod", 3600); got != time.Hour { + t.Fatalf("timeout=%s, want 1h", got) + } +} + +func TestResolveJWTTimeoutKeepsUpstreamDevelopmentBehavior(t *testing.T) { + if got := resolveJWTTimeout("dev", 1); got != 876010*time.Hour { + t.Fatalf("development timeout=%s", got) + } +} diff --git a/Sense/server/config/READMEN.md b/Sense/server/config/READMEN.md index 7923904..bd03ad6 100644 --- a/Sense/server/config/READMEN.md +++ b/Sense/server/config/READMEN.md @@ -20,7 +20,7 @@ settings: # JWT加密字符串 secret: "" # 过期时间单位:秒 - timeout: 3600 + timeout: 2592000 database: # 数据库名称 name: dbname diff --git a/Sense/server/config/settings.demo.yml b/Sense/server/config/settings.demo.yml index 2750d18..9cc760c 100644 --- a/Sense/server/config/settings.demo.yml +++ b/Sense/server/config/settings.demo.yml @@ -16,7 +16,7 @@ settings: frontpath: ../ui/src jwt: secret: "" - timeout: 3600 + timeout: 2592000 logger: # 日志存放路径 path: temp/logs diff --git a/Sense/server/config/settings.full.yml b/Sense/server/config/settings.full.yml index 4383d4f..3b5aa2b 100644 --- a/Sense/server/config/settings.full.yml +++ b/Sense/server/config/settings.full.yml @@ -34,7 +34,7 @@ settings: # token 密钥,生产环境时及的修改 secret: "" # token 过期时间 单位:秒 - timeout: 3600 + timeout: 2592000 database: # Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。 driver: postgres diff --git a/Sense/server/config/settings.sqlite.yml b/Sense/server/config/settings.sqlite.yml index 714c374..84187f1 100644 --- a/Sense/server/config/settings.sqlite.yml +++ b/Sense/server/config/settings.sqlite.yml @@ -25,7 +25,7 @@ settings: # token 密钥,生产环境时及的修改 secret: "" # token 过期时间 单位:秒 - timeout: 3600 + timeout: 2592000 database: # 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。 driver: postgres diff --git a/Sense/server/config/settings.yml b/Sense/server/config/settings.yml index b429001..e721a41 100644 --- a/Sense/server/config/settings.yml +++ b/Sense/server/config/settings.yml @@ -25,7 +25,7 @@ settings: # 必填。生产环境至少 32 个字符;不得提交真实值。 secret: "" # token 过期时间 单位:秒 - timeout: 3600 + timeout: 2592000 database: # 数据库类型 mysql, sqlite3, postgres, sqlserver # sqlserver: sqlserver://用户名:密码@地址?database=数据库名 diff --git a/Sense/tests/package/run-tests.ps1 b/Sense/tests/package/run-tests.ps1 index 3b2c269..814d07f 100644 --- a/Sense/tests/package/run-tests.ps1 +++ b/Sense/tests/package/run-tests.ps1 @@ -65,6 +65,7 @@ try { Assert-Equal 18071 $state.Port 'non-empty process environment must override sense.env' Assert-True (-not (Test-Path -LiteralPath $marker)) 'sense.env content must never execute' $generatedSettings = Get-Content -LiteralPath $state.SettingsPath -Raw + Assert-True $generatedSettings.Contains('timeout: 2592000') 'generated runtime settings must keep login valid for 30 days' Assert-True ((Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL').Contains('p#&;=x')) 'special characters must survive env parsing' Assert-True ($generatedSettings.Contains('password=p#\u0026;=x')) 'special characters must be safely JSON-escaped in YAML' Assert-True (-not ((Get-SenseDatabaseInfo (Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL')).Sanitized.Contains('password='))) 'PostgreSQL tool arguments must not contain password' diff --git a/Sense/ui/src/utils/auth.js b/Sense/ui/src/utils/auth.js index cbea20b..7ee3723 100644 --- a/Sense/ui/src/utils/auth.js +++ b/Sense/ui/src/utils/auth.js @@ -1,13 +1,14 @@ import Cookies from 'js-cookie' const TokenKey = 'Sense-Admin-Token' +const LoginValidityDays = 30 export function getToken() { return Cookies.get(TokenKey) } export function setToken(token) { - return Cookies.set(TokenKey, token) + return Cookies.set(TokenKey, token, { expires: LoginValidityDays }) } export function removeToken() { diff --git a/Sense/ui/tests/unit/utils/auth.spec.js b/Sense/ui/tests/unit/utils/auth.spec.js new file mode 100644 index 0000000..011106d --- /dev/null +++ b/Sense/ui/tests/unit/utils/auth.spec.js @@ -0,0 +1,32 @@ +import Cookies from 'js-cookie' +import { getToken, removeToken, setToken } from '@/utils/auth' + +jest.mock('js-cookie', () => ({ + get: jest.fn(), + set: jest.fn(), + remove: jest.fn() +})) + +describe('Sense login token cookie', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('persists the token for 30 days', () => { + setToken('test-token') + + expect(Cookies.set).toHaveBeenCalledWith( + 'Sense-Admin-Token', + 'test-token', + { expires: 30 } + ) + }) + + it('reads and removes the same product-specific cookie', () => { + getToken() + removeToken() + + expect(Cookies.get).toHaveBeenCalledWith('Sense-Admin-Token') + expect(Cookies.remove).toHaveBeenCalledWith('Sense-Admin-Token') + }) +}) From a8431216e3ac083e35eae9ab4800f50d87c1baf7 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 17 Aug 2026 10:55:21 +0800 Subject: [PATCH 36/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Sense=2030?= =?UTF-8?q?=E5=A4=A9=E7=99=BB=E5=BD=95=E6=9C=89=E6=95=88=E6=9C=9F=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-architecture-and-code-map.md | 8 +++++--- docs/04-local-development-and-verification.md | 6 ++++-- docs/09-product-requirements.md | 6 +++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 0c95592..78b4974 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 5cbaa178193811083519c5125febf8cc962c94ee -synchronized_at: 2026-08-16T11:45:49Z +wiki_revision: 8df12aa3e136b1faee9c2dffb58ca39b045bc49f +synchronized_at: 2026-08-17T02:54:44Z <!-- gitea-wiki-mirror:end --> # 架构与代码地图 @@ -85,9 +85,11 @@ Sense/Brain 生成事件 Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。 +Sense 业务菜单必须遵循冻结 GoAdmin 的路由树:顶层目录使用 `MenuType=M`、`Component=Layout`,设备管理、视频接入、视频服务、实时监看、区域与警戒线使用相对路径作为 `MenuType=C` 子页面,操作权限继续作为页面下的 `MenuType=F` 节点。这样动态路由只替换 Layout 的右侧内容区,左侧导航、顶部栏和标签页始终保留;旧数据库由 `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go` 新增迁移修正父子关系和完整 `paths`,页面 URL 与权限标识不变。 + 工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。 -Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。 +Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。Sense 默认登录有效期为固定 30 天:后端 JWT 使用 2,592,000 秒,前端 Token Cookie 使用 30 天持久化期限;不做滑动续期、Refresh Token 或服务端单 Token 撤销,退出只删除本机 Cookie。部署新版本后已有 Token 不会自动延长,用户必须重新登录取得新有效期。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。 工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index a45e4e2..372052d 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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: ee4819a84dd40846bc8fe1865e08590e1cf9cb81 -synchronized_at: 2026-08-15T09:58:37Z +wiki_revision: 84a086640dc3618e43f834c488080f1e0cb5b063 +synchronized_at: 2026-08-17T02:54:49Z <!-- gitea-wiki-mirror:end --> # 本地开发与验证 @@ -148,6 +148,8 @@ Remove-Variable bootstrapToken, bootstrapBody 初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。Sense 在 production、test、dev 模式均只提交账号和密码,不显示、不请求也不校验验证码;`/api/v1/captcha` 暂时保留作上游兼容接口,但登录页和登录 API 不依赖它。登录成功、错误密码和未认证拒绝仍必须写入脱敏身份审计,密码继续执行 6–72 字节策略。 +Sense 默认登录有效期为固定 30 天。仓库配置和 Windows 运行脚本生成的 `jwt.timeout` 均为 `2592000` 秒,前端 `Sense-Admin-Token` Cookie 使用 30 天持久化期限。更新该版本后必须重新登录,已有 Token 不会自动延长。该有效期不是滑动续期;退出登录会删除本机 Cookie,但当前无状态 JWT 架构不提供服务端单 Token 撤销。如需立即使全部已签发 Token 失效,应在受控维护窗口轮换仓库外 JWT secret,并明确通知所有用户重新登录。 + 身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。 详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。 diff --git a/docs/09-product-requirements.md b/docs/09-product-requirements.md index 594418d..28a678b 100644 --- a/docs/09-product-requirements.md +++ b/docs/09-product-requirements.md @@ -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: 221fd392c943caab13ee097c19a3d19d4410b65a -synchronized_at: 2026-08-15T09:59:15Z +wiki_revision: e6c6e0d7f658a7c1040fe569026e7d0ec581701a +synchronized_at: 2026-08-17T02:54:53Z <!-- gitea-wiki-mirror:end --> # 产品需求 @@ -48,7 +48,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险 ### P0 -- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。 +- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。默认登录有效期为固定 30 天,后端 JWT 与浏览器持久 Cookie 必须一致;版本更新后已有 Token 不自动延长,不提供滑动续期或服务端单 Token 撤销。 - **SEN-002 设备台账**:以 Device 为根实体,通过 `modality` 和 `capabilities` 表达 video/radar/contact/button/wearable/other;首期只完整实现 video,未实现适配器显示 `adapter_not_ready`。 - **SEN-003 ONVIF/RTSP 接入**:支持发现或手工添加、Profiles、StreamUri、主/子码流、校时、认证失败、重新探测和凭据更新。 - **SEN-004 批量开通**:默认 16 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。 From adc227f110bf0c456d1523eac151ed1d8360cc83 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 17 Aug 2026 10:56:26 +0800 Subject: [PATCH 37/44] =?UTF-8?q?test:=20=E9=AA=8C=E8=AF=81=20Sense=2030?= =?UTF-8?q?=E5=A4=A9=20JWT=20=E5=88=B0=E6=9C=9F=E6=97=B6=E9=97=B4=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/server/common/middleware/auth_test.go | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Sense/server/common/middleware/auth_test.go b/Sense/server/common/middleware/auth_test.go index 7a2e391..0d26b7f 100644 --- a/Sense/server/common/middleware/auth_test.go +++ b/Sense/server/common/middleware/auth_test.go @@ -3,6 +3,8 @@ package middleware import ( "testing" "time" + + "github.com/go-admin-team/go-admin-core/sdk/config" ) func TestResolveJWTTimeoutDefaultsToThirtyDays(t *testing.T) { @@ -24,3 +26,32 @@ func TestResolveJWTTimeoutKeepsUpstreamDevelopmentBehavior(t *testing.T) { t.Fatalf("development timeout=%s", got) } } + +func TestAuthInitSignsTokenExpiringAfterThirtyDays(t *testing.T) { + oldMode := config.ApplicationConfig.Mode + oldTimeout := config.JwtConfig.Timeout + oldSecret := config.JwtConfig.Secret + t.Cleanup(func() { + config.ApplicationConfig.Mode = oldMode + config.JwtConfig.Timeout = oldTimeout + config.JwtConfig.Secret = oldSecret + }) + + config.ApplicationConfig.Mode = "prod" + config.JwtConfig.Timeout = 0 + config.JwtConfig.Secret = "task-104-test-secret-not-for-production" + + auth, err := AuthInit() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC) + auth.TimeFunc = func() time.Time { return now } + _, expiresAt, err := auth.TokenGenerator(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if got := expiresAt.Sub(now); got != 30*24*time.Hour { + t.Fatalf("signed token validity=%s, want 720h", got) + } +} From 4adf2d60aa62b097b33c638733ca7b4003ab3bdd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 17 Aug 2026 11:09:23 +0800 Subject: [PATCH 38/44] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#104=20=E5=BE=85=E9=AA=8C=E6=94=B6=E5=BD=92?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/104-Sense-30天登录有效期.md | 74 +++++++++++++++++++++ wiki-docs.json | 4 ++ 2 files changed, 78 insertions(+) create mode 100644 docs/task/104-Sense-30天登录有效期.md diff --git a/docs/task/104-Sense-30天登录有效期.md b/docs/task/104-Sense-30天登录有效期.md new file mode 100644 index 0000000..a1aff17 --- /dev/null +++ b/docs/task/104-Sense-30天登录有效期.md @@ -0,0 +1,74 @@ +<!-- gitea-wiki-mirror:start --> +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-104-Sense-30天登录有效期 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-104-Sense-30%E5%A4%A9%E7%99%BB%E5%BD%95%E6%9C%89%E6%95%88%E6%9C%9F.- +wiki_revision: 985be106d605ffec01de071d1f9271d33c708325 +synchronized_at: 2026-08-17T03:09:07Z +<!-- gitea-wiki-mirror:end --> + +# 104 Sense-30天登录有效期 + +- 类型:需求 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:待验收 +- 日期:2026-08-17 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/104 +- Wiki 页面:Task-104-Sense-30天登录有效期 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +Sense 原生产、demo、SQLite 和完整配置默认 JWT 有效期为 3600 秒,前端 Token 使用浏览器会话 Cookie。按用户确认,将默认登录有效期统一调整为固定 30 天,使浏览器重启后在 Token 未过期时仍可保持登录。 + +## 最终方案 + +- 保留 go-admin JWT 中间件和 go-admin-ui `js-cookie` 封装;非开发模式未显式配置时默认使用 30 天,合法显式配置仍优先,开发模式保持上游行为。 +- 生产、demo、SQLite、full 与 Windows 运行时生成配置统一使用 2,592,000 秒。 +- `Sense-Admin-Token` Cookie 使用 `expires: 30`;退出登录仍删除 Cookie。 +- 不是滑动续期,不增加 Refresh Token、服务端会话表或 Token 黑名单。已有 Token 不自动延长,部署后需重新登录。 +- Token 泄露窗口扩大到 30 天;紧急失效需要轮换 JWT secret,这会使全部现有登录失效。 + +## 修改文件 + +- `Sense/server/common/middleware/auth.go`、`auth_test.go`:实现并验证默认 30 天 JWT。 +- `Sense/server/config/settings*.yml`、`READMEN.md`:统一配置值和示例。 +- `Sense/scripts/runtime/sense-common.ps1`、`Sense/tests/package/run-tests.ps1`:统一并验证 Windows 运行时配置。 +- `Sense/ui/src/utils/auth.js`、`Sense/ui/tests/unit/utils/auth.spec.js`:持久化 Cookie 30 天并验证设置、读取和删除。 +- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/09-product-requirements.md`:由 Wiki 同步的长期文档镜像。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 非开发模式签发 JWT 的到期时间为签发后 30 天 | 通过 | +| Token Cookie 使用 30 天持久化期限,退出仍删除 | 通过 | +| 所有运行配置及 Windows 生成值为 2,592,000 秒 | 通过 | +| Go、Vue、Windows 包测试与 PostgreSQL 启动回归 | 通过 | +| ZIP 仅含模板配置,不包含现场秘密或数据 | 通过 | +| 已有 Token 不自动延长并记录重新登录要求 | 通过 | + +## 测试 + +- `go test ./...`:通过。 +- `go vet ./...`:通过。 +- `go build ./...`:通过。 +- `pnpm run lint`:0 error,32 条上游既有 warning。 +- `pnpm exec vue-cli-service test:unit --runInBand`:18 suites、48 tests 通过。 +- `Sense/tests/package/run-tests.bat -PackageRoot Sense/dist/sense-windows-amd64`:22 assertions 通过。 +- Windows amd64 生产构建、资源审计、包审计:通过;4 条既有构建 warning。 +- 独立 #104 包使用现场 PostgreSQL 完成迁移和启动,`127.0.0.1:18080` 监听成功,生成配置确认 `timeout: 2592000`。 +- 包:`Sense/dist/sense-windows-amd64-104.zip`,56,457,546 bytes,SHA-256 `BCE3EDD3F5BD79C75A953CB7673D1C9867CE3BB89755D6A188C7CA1226D05CE3`,来源提交 `adc227f110bf0c456d1523eac151ed1d8360cc83`。 +- 一次错误的 Vue 测试参数把 `--runInBand` 识别为模式而未找到测试,已改用正确命令并通过;一次临时运行数据恢复把日志目录复制成同名文件,修正验证环境后从失败点重试并通过,均非产品代码缺陷。 +- **未验证部分**:无法用真实等待 30 天验证自然到期;以固定时钟 JWT 测试和 Cookie 参数单测覆盖。实际浏览器关闭/重开由人工验收确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。 + +## 遗留问题 + +- 当前服务已恢复为用户原有的 #101 验证包,避免应用外壳回退;#104 独立包保留为具名 ZIP,未覆盖当前标准包。 +- 当前架构没有服务端单 Token 撤销能力,这是确认范围内的安全限制。 + +## 相关提交 + +- `857ba45` feat: 将 Sense 登录有效期调整为30天 (#104) +- `a843121` docs: 记录 Sense 30天登录有效期 (#104) +- `adc227f` test: 验证 Sense 30天 JWT 到期时间 (#104) diff --git a/wiki-docs.json b/wiki-docs.json index a14222e..8a8e8d1 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -147,6 +147,10 @@ { "page": "Task-99-Sense登录页只读配置接口", "path": "docs/task/99-Sense登录页只读配置接口.md" + }, + { + "page": "Task-104-Sense-30天登录有效期", + "path": "docs/task/104-Sense-30天登录有效期.md" } ] } From a53afb219c0cf4fa5ac25484de7ecefb7190f33a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 17 Aug 2026 11:46:31 +0800 Subject: [PATCH 39/44] =?UTF-8?q?docs:=20=E6=A0=87=E8=AE=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#104=20=E9=AA=8C=E6=94=B6=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/104-Sense-30天登录有效期.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/task/104-Sense-30天登录有效期.md b/docs/task/104-Sense-30天登录有效期.md index a1aff17..591637e 100644 --- a/docs/task/104-Sense-30天登录有效期.md +++ b/docs/task/104-Sense-30天登录有效期.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-104-Sense-30天登录有效期 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-104-Sense-30%E5%A4%A9%E7%99%BB%E5%BD%95%E6%9C%89%E6%95%88%E6%9C%9F.- -wiki_revision: 985be106d605ffec01de071d1f9271d33c708325 -synchronized_at: 2026-08-17T03:09:07Z +wiki_revision: 2b4df23ec321de868840da61d87d8d6fce8537a7 +synchronized_at: 2026-08-17T03:46:13Z <!-- gitea-wiki-mirror:end --> # 104 Sense-30天登录有效期 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-17T03:09:07Z - 类型:需求 - 所属 Epic:#7 - 所属 MVP / 版本:#8 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-17 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/104 - Wiki 页面:Task-104-Sense-30天登录有效期 @@ -43,6 +43,7 @@ Sense 原生产、demo、SQLite 和完整配置默认 JWT 有效期为 3600 秒 |---|---| | 非开发模式签发 JWT 的到期时间为签发后 30 天 | 通过 | | Token Cookie 使用 30 天持久化期限,退出仍删除 | 通过 | +| 浏览器关闭后重新打开仍保持登录 | 通过(用户验收) | | 所有运行配置及 Windows 生成值为 2,592,000 秒 | 通过 | | Go、Vue、Windows 包测试与 PostgreSQL 启动回归 | 通过 | | ZIP 仅含模板配置,不包含现场秘密或数据 | 通过 | @@ -60,13 +61,18 @@ Sense 原生产、demo、SQLite 和完整配置默认 JWT 有效期为 3600 秒 - 独立 #104 包使用现场 PostgreSQL 完成迁移和启动,`127.0.0.1:18080` 监听成功,生成配置确认 `timeout: 2592000`。 - 包:`Sense/dist/sense-windows-amd64-104.zip`,56,457,546 bytes,SHA-256 `BCE3EDD3F5BD79C75A953CB7673D1C9867CE3BB89755D6A188C7CA1226D05CE3`,来源提交 `adc227f110bf0c456d1523eac151ed1d8360cc83`。 - 一次错误的 Vue 测试参数把 `--runInBand` 识别为模式而未找到测试,已改用正确命令并通过;一次临时运行数据恢复把日志目录复制成同名文件,修正验证环境后从失败点重试并通过,均非产品代码缺陷。 -- **未验证部分**:无法用真实等待 30 天验证自然到期;以固定时钟 JWT 测试和 Cookie 参数单测覆盖。实际浏览器关闭/重开由人工验收确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。 +- **未验证部分**:无法用真实等待 30 天验证自然到期;以固定时钟 JWT 测试和 Cookie 参数单测覆盖。用户已于 2026-08-17 明确验收通过,浏览器关闭/重开行为由人工确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。 ## 遗留问题 - 当前服务已恢复为用户原有的 #101 验证包,避免应用外壳回退;#104 独立包保留为具名 ZIP,未覆盖当前标准包。 - 当前架构没有服务端单 Token 撤销能力,这是确认范围内的安全限制。 +## 验收确认 + +- 用户于 2026-08-17 明确回复“#104 验收通过”。 +- PR #105 按仓库门禁合入 `dev`;`main` 不变。 + ## 相关提交 - `857ba45` feat: 将 Sense 登录有效期调整为30天 (#104) From 54f10d894a3823ddfbac23001f6b299c10523b4c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 15:34:22 +0800 Subject: [PATCH 40/44] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20#101=20=E7=BB=84=E5=90=88=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/101-Sense-GoAdmin-应用外壳.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/task/101-Sense-GoAdmin-应用外壳.md b/docs/task/101-Sense-GoAdmin-应用外壳.md index 6654805..28789a0 100644 --- a/docs/task/101-Sense-GoAdmin-应用外壳.md +++ b/docs/task/101-Sense-GoAdmin-应用外壳.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-101-Sense-GoAdmin-应用外壳 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-101-Sense-GoAdmin-%E5%BA%94%E7%94%A8%E5%A4%96%E5%A3%B3.- -wiki_revision: b64071730850396e615a40ed29006ca9f669ccaa -synchronized_at: 2026-08-16T15:52:26Z +wiki_revision: f48d7f7d70cfaa00dd40ca3adcd655dd7cd105fb +synchronized_at: 2026-08-27T07:34:00Z <!-- gitea-wiki-mirror:end --> # 101 Sense-GoAdmin-应用外壳 @@ -73,7 +73,7 @@ Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只 ## 遗留问题 -#99 仍处于待验收并使用独立分支;本任务代码不依赖 #99,但两项长期架构说明位于同一 Wiki 页面。PR 集成时建议先验收/合入 #99,再更新本分支到最新 dev,以消除镜像元数据的并行差异。#90 的项目档案 Wiki 更新未混入本任务提交。 +#99 与 #104 均已验收并合入 `dev`;#101 已在最新 `dev` 上完成组合回归,仍等待用户验收。#90 的项目档案 Wiki 更新未混入本任务提交,客户全新 Windows 主机及三个非 admin 岗位的人工视觉验收仍未覆盖。 ## 相关提交 @@ -88,3 +88,14 @@ Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只 - 新 ZIP SHA-256:`65AF5DD1CFB5D4F8339DCCC53717354495259F13630FBC0C6A4AFC1ACD794B26`。 - 固定工具链 production build、包审计、Windows PowerShell 5.1 包测试 21 项、真实 PostgreSQL 迁移/启动及 `TestRegisterBaseRouterExposesOnlyFrontendAppConfig` 均通过。 - 现场配置在构建前备份、ZIP 生成后恢复;ZIP 仍只包含模板配置。Sense 当前监听 `127.0.0.1:18080`。 +## #104 合入后的最终组合验收包 + +- #104 已由用户验收并经 PR #105 合入 `dev@46c3232da40528d031367225b09d4f28c85a8312`;#101 分支通过合并提交 `8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0` 更新到该基线。 +- 唯一合并冲突是 `Architecture-and-Code-Map` 镜像头;读取 Wiki revision `8df12aa3e136b1faee9c2dffb58ca39b045bc49f` 确认页面同时包含 #101 Layout 菜单规则和 #104 30 天登录规则后,以该事实源解决。业务代码无冲突。 +- `go test ./...`、`go vet ./...`、`go build ./...` 全部通过;前端 lint 0 error(32 条既有 warning),18 suites / 48 tests 通过,production build 通过(4 条既有 warning)。 +- Windows 包资源审计、包审计和 22 assertions 通过;使用现场 PostgreSQL 完成幂等迁移与生产启动,`data/runtime/settings.yml` 确认 `timeout: 2592000`。 +- Chrome 真实回归依次验证设备管理、视频接入、视频服务、实时监看、区域与警戒线五个入口;URL 分别为 `#/sense/devices`、`#/sense/admission`、`#/sense/media`、`#/sense/liveview`、`#/sense/area`,侧栏、Navbar、TagsView 全程可见。直接刷新区域页面后外壳与选中标签保持,控制台无错误。 +- 本机直连验证 `/health`、`/api/v1/app-config` 和 `/` 均返回 200;首次命令行请求受开发机 HTTP 代理影响,明确绕过代理后通过,不属于 Sense 服务错误。 +- 组合 ZIP:`Sense/dist/sense-windows-amd64-101-104.zip`,56,461,005 bytes,SHA-256 `46B2B5654EE3F61788CA4E51F5CB66C1A7C7D1088EFFD39C0281E86E84F964A2`,包内 `source_commit=8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0`。标准 ZIP 内容相同且仅含模板配置;现场配置只恢复到解压目录。 +- 当前组合服务监听 `127.0.0.1:18080`。临时现场配置备份已删除,源码目录 `node_modules` 已清理。 +- **未验证部分**:客户全新 Windows 主机、客户目标浏览器及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收。 From 83012ad37f2ea790a2e73980cbba99fbfb8b6d1d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 15:53:08 +0800 Subject: [PATCH 41/44] =?UTF-8?q?docs:=20=E6=A0=87=E8=AE=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#101=20=E9=AA=8C=E6=94=B6=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/101-Sense-GoAdmin-应用外壳.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/task/101-Sense-GoAdmin-应用外壳.md b/docs/task/101-Sense-GoAdmin-应用外壳.md index 28789a0..2f0d3fe 100644 --- a/docs/task/101-Sense-GoAdmin-应用外壳.md +++ b/docs/task/101-Sense-GoAdmin-应用外壳.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-101-Sense-GoAdmin-应用外壳 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-101-Sense-GoAdmin-%E5%BA%94%E7%94%A8%E5%A4%96%E5%A3%B3.- -wiki_revision: f48d7f7d70cfaa00dd40ca3adcd655dd7cd105fb -synchronized_at: 2026-08-27T07:34:00Z +wiki_revision: a32fd29a7b8c4fa42396e9f249bcdd99d0c09e74 +synchronized_at: 2026-08-27T07:52:48Z <!-- gitea-wiki-mirror:end --> # 101 Sense-GoAdmin-应用外壳 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-27T07:34:00Z - 类型:缺陷修复 - 所属 Epic:#7 - 所属 MVP / 版本:#8 / Sense 首个独立纵切 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-16 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/101 - Wiki 页面:Task-101-Sense-GoAdmin-应用外壳 @@ -54,7 +54,7 @@ Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只 | 已有 PostgreSQL 数据库迁移无重复菜单 | 通过 | | 重复对齐安全、失败事务回滚 | 通过 | | 后端、前端和 Windows 包验证 | 通过 | -| Wiki、归档、PR 和证据 | 通过,等待用户验收 | +| Wiki、归档、PR 和证据 | 通过,用户已验收 | ## 测试 @@ -69,11 +69,11 @@ Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只 - 本机生产数据库迁移及启动:通过。 - Headless Edge:依次打开五个菜单及刷新区域页面,`.app-wrapper`、侧栏、navbar、TagsView 全程可见,URL 均正确。 - Windows ZIP:`Sense/dist/sense-windows-amd64.zip`,SHA-256 `BAAA973F8502BB5B0BC080F11931E60419B25EFA8FF722A6B9F9B98364399642`,源码提交 `4423d528b1dc6ebe8b6f7b8272ca5111032a1608`。 -- **未验证部分**:客户全新 Windows 主机、客户目标浏览器及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;本机使用 admin 完成真实浏览器回归。 +- **未验证部分**:客户全新 Windows 主机及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;本机使用 admin 完成真实浏览器回归,用户已确认验收通过。 ## 遗留问题 -#99 与 #104 均已验收并合入 `dev`;#101 已在最新 `dev` 上完成组合回归,仍等待用户验收。#90 的项目档案 Wiki 更新未混入本任务提交,客户全新 Windows 主机及三个非 admin 岗位的人工视觉验收仍未覆盖。 +#99、#104 与 #101 均已由用户验收;#101 经 PR #102 合入 `dev`,`main` 保持不变。#90 的项目档案 Wiki 更新未混入本任务提交;客户全新 Windows 主机及三个非 admin 岗位的人工视觉验收仍未覆盖。 ## 相关提交 @@ -98,4 +98,10 @@ Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只 - 本机直连验证 `/health`、`/api/v1/app-config` 和 `/` 均返回 200;首次命令行请求受开发机 HTTP 代理影响,明确绕过代理后通过,不属于 Sense 服务错误。 - 组合 ZIP:`Sense/dist/sense-windows-amd64-101-104.zip`,56,461,005 bytes,SHA-256 `46B2B5654EE3F61788CA4E51F5CB66C1A7C7D1088EFFD39C0281E86E84F964A2`,包内 `source_commit=8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0`。标准 ZIP 内容相同且仅含模板配置;现场配置只恢复到解压目录。 - 当前组合服务监听 `127.0.0.1:18080`。临时现场配置备份已删除,源码目录 `node_modules` 已清理。 -- **未验证部分**:客户全新 Windows 主机、客户目标浏览器及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收。 +- **未验证部分**:客户全新 Windows 主机及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;用户已确认当前交付验收通过。 + +## 验收确认 + +- 2026-08-27,用户明确确认“#101通过验收”。 +- PR #102 按规定合入 `dev`,`main` 未变更。 +- 工单 #101 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。 From 0646f5effd53536ae3f608c5e5dffe7fe950cb8f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 16:08:23 +0800 Subject: [PATCH 42/44] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Sense=20Supe?= =?UTF-8?q?rvisor=20=E6=89=98=E7=AE=A1=E6=96=B9=E5=BC=8F=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04-local-development-and-verification.md | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 372052d..6f19378 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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: 84a086640dc3618e43f834c488080f1e0cb5b063 -synchronized_at: 2026-08-17T02:54:49Z +wiki_revision: 925a16a72bd5840697b5c61489a97018134b3930 +synchronized_at: 2026-08-27T08:07:04Z <!-- gitea-wiki-mirror:end --> # 本地开发与验证 @@ -313,3 +313,29 @@ corepack pnpm@9.15.1 build:prod 浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。 <!-- sense-area:end --> + +<!-- sense-supervisor:start --> +## Sense 本机 Supervisor 托管 + +本机开发/演示环境可由 `D:\supervisor` 托管已经构建的 Sense Windows 交付包。实例配置位于仓库外的 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`;工作目录固定为 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。 + +Supervisor 配置只调用包内 `scripts\runtime\start-sense.ps1`,运行参数继续从包内 `config\sense.env` 读取。不得把数据库连接、JWT 密钥、摄像头凭据或其他秘密复制到 Supervisor 配置或工单。 + +常用命令: + +```powershell +D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf status yovision-sense +D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf restart yovision-sense +D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf stop yovision-sense +D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf start yovision-sense +Get-Content D:\supervisor\logs\yovision-sense.log -Tail 100 +``` + +新增或修改 `programs/*.conf` 后执行: + +```powershell +D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf reload +``` + +当前 Go Supervisor 的 `reload` 会重新读取独立配置;实际受影响实例必须以命令输出和 reload 前后 PID 为准。切换托管前先停止占用 Sense 端口的非 Supervisor 实例,防止自动启动进入 Backoff。验证至少包含 Supervisor 状态为 Running、`http://127.0.0.1:18080/health` 与首页返回 200,以及受控重启后 Sense 和受管 MediaMTX PID 均更新。 +<!-- sense-supervisor:end --> From 356f891c9e7e15235646da9347ba4b454b95318d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 16:10:28 +0800 Subject: [PATCH 43/44] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#106=20=E5=BE=85=E9=AA=8C=E6=94=B6=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../106-Sense-本机-Supervisor-实例.md | 71 +++++++++++++++++++ wiki-docs.json | 4 ++ 2 files changed, 75 insertions(+) create mode 100644 docs/task/106-Sense-本机-Supervisor-实例.md diff --git a/docs/task/106-Sense-本机-Supervisor-实例.md b/docs/task/106-Sense-本机-Supervisor-实例.md new file mode 100644 index 0000000..a4a74b1 --- /dev/null +++ b/docs/task/106-Sense-本机-Supervisor-实例.md @@ -0,0 +1,71 @@ +<!-- gitea-wiki-mirror:start --> +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-106-Sense-本机-Supervisor-实例 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-106-Sense-%E6%9C%AC%E6%9C%BA-Supervisor-%E5%AE%9E%E4%BE%8B.- +wiki_revision: 3d83e907d1c8bfddf472ff0517721796a85ceda3 +synchronized_at: 2026-08-27T08:10:00Z +<!-- gitea-wiki-mirror:end --> + +# 106 Sense-本机-Supervisor-实例 + +- 类型:运维配置 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 / Sense 首个独立纵切 +- 状态:待验收 +- 日期:2026-08-27 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/106 +- Wiki 页面:Task-106-Sense-本机-Supervisor-实例 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +本机 Sense 原由独立终端启动,不受 `D:\supervisor` 管理。目标是在不复制现场秘密、不修改 Sense 业务代码的前提下,让 Supervisor 托管现有 Windows 交付包,并提供自动启动、异常重启、进程组停止和独立日志。 + +当前 Brain、Bell 只有规则文件,没有可运行交付物,因此本任务只创建 `yovision-sense`,不创建空实例。 + +## 最终方案 + +- 在仓库外新增 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`。 +- 工作目录使用 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。 +- Supervisor 直接调用包内 `scripts/runtime/start-sense.ps1`,配置仍由 `config/sense.env` 读取。 +- 配置启用 autostart、autorestart、启动重试、进程组停止和 50 MB × 5 的日志轮转。 +- 日志写入 `D:\supervisor\logs\yovision-sense.log`;Supervisor 配置不含 environment、数据库连接、令牌、密码或摄像头凭据。 +- 使用包内停止脚本核对并停止原 Sense PID 28440,释放 18080 后执行 Supervisor reload。 + +原计划按最坏情况说明 reload 会重启全部实例;实际命令返回 `Added Groups: yovision-sense`。dsh/goauto PID 未变化,原先处于 Backoff 的三个 Chorus 实例在 reload 后恢复 Running,因此没有观察到既有 Running 实例被重启。 + +## 修改文件 + +- `D:\supervisor\programs\yovision.conf`:新增本机 Supervisor 实例配置;该文件位于仓库外。 +- `Local-Development-and-Verification` Wiki:记录实例位置、常用命令、秘密边界和验证方式。 +- `docs/04-local-development-and-verification.md`:上述 Wiki 的只读镜像。 +- `wiki-docs.json`、`docs/task/106-Sense-本机-Supervisor-实例.md`:任务归档登记与镜像。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| `yovision-sense` 稳定为 Running | 通过 | +| Sense 监听 18080,健康检查与首页返回 200 | 通过 | +| Sense 管理的 MediaMTX 随单实例重启更新 PID | 通过 | +| Supervisor 配置不包含现场秘密 | 通过 | +| dsh/goauto 保持运行,Chorus 从既有 Backoff 恢复 | 通过 | +| Wiki、归档、提交、PR 和证据 | 进行中,等待用户验收 | + +## 测试 + +- `supervisord.exe ctl /c supervisord.conf reload`:返回新增 `yovision-sense` 配置组。 +- `supervisord.exe ctl /c supervisord.conf status`:七个实例均为 Running;`yovision-sense` Supervisor PID 28000。 +- `GET /health`、`GET /healthz`、`GET /`:均返回 200。 +- 受控执行 `restart yovision-sense`:Sense PID 从 38408 更新为 43916,MediaMTX PID 从 27880 更新为 27212,重启后健康检查与首页仍为 200。 +- 外部配置 SHA-256:`7C5BDDD574384ED6038F0C2DAA8CE364FB1EA996B256831595ABF4E22EDA4769`。 +- 配置敏感字段扫描:未发现 password、token、secret、`SENSE_DATABASE_URL` 或 `environment=`。 +- **未验证部分**:未通过重启 Windows 验证开机后的整体 Supervisor 自启动;未故意崩溃 Sense 验证异常自动重启,已用 Supervisor 受控重启验证停止与拉起链路。 + +## 遗留问题 + +Supervisor 管理端口当前监听 `0.0.0.0:9009` 且未配置认证,这是任务开始前已存在的安全风险,本任务按确认范围未修改,建议另建安全工单处理。 + +## 相关提交 + +- `0646f5effd53536ae3f608c5e5dffe7fe950cb8f` 记录 Sense Supervisor 托管方式。 diff --git a/wiki-docs.json b/wiki-docs.json index 9fcd78f..c619f58 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -155,6 +155,10 @@ { "page": "Task-104-Sense-30天登录有效期", "path": "docs/task/104-Sense-30天登录有效期.md" + }, + { + "page": "Task-106-Sense-本机-Supervisor-实例", + "path": "docs/task/106-Sense-本机-Supervisor-实例.md" } ] } From 120a9efee4be084e2f4af0d12708232a45a0369a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 16:17:36 +0800 Subject: [PATCH 44/44] =?UTF-8?q?docs:=20=E6=A0=87=E8=AE=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20#106=20=E9=AA=8C=E6=94=B6=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/task/106-Sense-本机-Supervisor-实例.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/task/106-Sense-本机-Supervisor-实例.md b/docs/task/106-Sense-本机-Supervisor-实例.md index a4a74b1..dd71531 100644 --- a/docs/task/106-Sense-本机-Supervisor-实例.md +++ b/docs/task/106-Sense-本机-Supervisor-实例.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-106-Sense-本机-Supervisor-实例 wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-106-Sense-%E6%9C%AC%E6%9C%BA-Supervisor-%E5%AE%9E%E4%BE%8B.- -wiki_revision: 3d83e907d1c8bfddf472ff0517721796a85ceda3 -synchronized_at: 2026-08-27T08:10:00Z +wiki_revision: fcff45a700cba3db939819cfc5deb1cc97b8155b +synchronized_at: 2026-08-27T08:17:20Z <!-- gitea-wiki-mirror:end --> # 106 Sense-本机-Supervisor-实例 @@ -11,7 +11,7 @@ synchronized_at: 2026-08-27T08:10:00Z - 类型:运维配置 - 所属 Epic:#7 - 所属 MVP / 版本:#8 / Sense 首个独立纵切 -- 状态:待验收 +- 状态:已完成 - 日期:2026-08-27 - Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/106 - Wiki 页面:Task-106-Sense-本机-Supervisor-实例 @@ -50,7 +50,7 @@ synchronized_at: 2026-08-27T08:10:00Z | Sense 管理的 MediaMTX 随单实例重启更新 PID | 通过 | | Supervisor 配置不包含现场秘密 | 通过 | | dsh/goauto 保持运行,Chorus 从既有 Backoff 恢复 | 通过 | -| Wiki、归档、提交、PR 和证据 | 进行中,等待用户验收 | +| Wiki、归档、提交、PR 和证据 | 通过,用户已验收 | ## 测试 @@ -69,3 +69,9 @@ Supervisor 管理端口当前监听 `0.0.0.0:9009` 且未配置认证,这是 ## 相关提交 - `0646f5effd53536ae3f608c5e5dffe7fe950cb8f` 记录 Sense Supervisor 托管方式。 + +## 验收确认 + +- 2026-08-27,用户明确确认“#106 验收通过”。 +- PR #107 按规定合入 `dev`,`main` 未变更。 +- 工单 #106 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。