Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ab023557 | ||
|
|
dc426774ac | ||
|
|
404e25584e | ||
|
|
1514215729 | ||
|
|
120a9efee4 | ||
|
|
356f891c9e | ||
|
|
0646f5effd | ||
|
|
d264758dee | ||
|
|
83012ad37f | ||
|
|
54f10d894a | ||
|
|
8cdd3f61cb | ||
|
|
46c3232da4 | ||
|
|
a53afb219c | ||
|
|
4adf2d60aa | ||
|
|
adc227f110 | ||
|
|
a8431216e3 | ||
|
|
857ba45541 | ||
|
|
faa96a3bea | ||
|
|
42dc77b1f9 | ||
|
|
4b135b852b | ||
|
|
3c668ccff6 | ||
|
|
34ee5ed619 | ||
|
|
9ff2f6339f | ||
|
|
e652109744 | ||
|
|
4423d528b1 | ||
|
|
d067f0b989 | ||
|
|
08b4b61f90 | ||
|
|
713c9e4e30 | ||
|
|
863b232a73 | ||
|
|
5a67a17e17 | ||
|
|
ce0793505f | ||
|
|
12e1bff8b4 | ||
|
|
15bd000816 | ||
|
|
7f13e595b6 | ||
|
|
098d0fafee | ||
|
|
b66c39724c | ||
|
|
ae0c26434e | ||
|
|
116318df74 | ||
|
|
bdb9a5b474 | ||
|
|
a92e4da043 | ||
|
|
bc1a01848d | ||
|
|
0bbdb27f6d | ||
|
|
2c0e39bae8 | ||
|
|
4ca4abff7b | ||
|
|
d0185caa0e | ||
|
|
6257859b83 | ||
|
|
38549760aa | ||
|
|
6a8c763dec | ||
|
|
c088caf816 | ||
|
|
3dd489066c | ||
|
|
b9824a8312 | ||
|
|
40409707cc | ||
|
|
9472151103 | ||
|
|
e82f15f1fb | ||
|
|
eaa6ae0815 | ||
|
|
3a686a8151 | ||
|
|
3c4578c804 | ||
|
|
fe3badfe3e | ||
|
|
aee5f45d96 | ||
|
|
68b436ac62 | ||
|
|
23bfc04884 | ||
|
|
7112840362 | ||
|
|
06d982d220 | ||
|
|
a865dda1c3 | ||
|
|
2e76aafd0d | ||
|
|
23d3cdbf84 | ||
|
|
e4544a011f | ||
|
|
6b79478414 | ||
|
|
cf50c285f4 | ||
|
|
0bc113af04 | ||
|
|
61b79db9f7 | ||
|
|
55be48069f | ||
|
|
afd4dab567 | ||
|
|
f82dd51d95 | ||
|
|
17bd383229 | ||
|
|
020bf3fe5a |
@@ -3,5 +3,8 @@ server/*.exe
|
||||
server/*.db
|
||||
ui/node_modules/
|
||||
ui/dist/
|
||||
dist/
|
||||
!scripts/build/
|
||||
!scripts/build/**
|
||||
.env
|
||||
*.local.yml
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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` 放进包。
|
||||
- 网页返回 200 但白屏:在浏览器开发者工具检查 JS/CSS 是否 404;正式包的构建审计会逐项核对 `web\index.html` 引用的本地资源,缺失时拒绝生成交付包。
|
||||
|
||||
## 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`
|
||||
|
||||
构建末尾会审计包内容:逐项核对 `web\index.html` 引用的本地 JS/CSS,并拒绝 `node_modules`、嵌套 `dist`、Git/缓存目录、数据库/备份文件、非空秘密字段、常见默认密码和私钥标记。`dist` 为可重建产物,不提交 Git。
|
||||
@@ -13,6 +13,15 @@ Sense 是从项目冻结的 GoAdmin 后端与 go-admin-ui 前端源码独立派
|
||||
|
||||
## 本地验证入口
|
||||
|
||||
已使用 Windows 打包脚本生成 `Sense\dist\sense-windows-amd64` 后,可从 Sense 项目目录直接启动交付包:
|
||||
|
||||
```bat
|
||||
start_sense.bat
|
||||
start_sense.bat demo
|
||||
```
|
||||
|
||||
该入口只负责定位并调用包内 `start-sense.bat`;生产配置、迁移和 MediaMTX 编排仍由交付包处理。交付包不存在时,入口会提示先运行 `scripts\build\build-windows.bat`,不会自动构建或启动开发服务。
|
||||
|
||||
后端:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
logLevel: info
|
||||
api: true
|
||||
apiAddress: 127.0.0.1:9997
|
||||
metrics: false
|
||||
paths: {}
|
||||
@@ -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=
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,117 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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-SenseFileSha256 -Path $file.FullName) $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 }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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" }
|
||||
}
|
||||
& (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') }
|
||||
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"
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\backup-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\check-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\initialize-admin.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\migrate-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\restore-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\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: 2592000',
|
||||
' 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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\start-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\stop-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseAreaRouter) }
|
||||
|
||||
func registerSenseAreaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &area.API{}
|
||||
r := v1.Group("/area").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("/configurations", api.List)
|
||||
r.POST("/configurations", api.Create)
|
||||
r.PUT("/configurations/:id", api.Update)
|
||||
r.GET("/configurations/:id/versions", api.Versions)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
||||
@@ -131,7 +132,16 @@ func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool
|
||||
}
|
||||
}
|
||||
if replaceProfiles && len(result.Profiles) > 0 {
|
||||
return tx.Create(&result.Profiles).Error
|
||||
if err := tx.Create(&result.Profiles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if replaceProfiles {
|
||||
snapshots := make([]area.ProfileSnapshot, 0, len(result.Profiles))
|
||||
for _, profile := range result.Profiles {
|
||||
snapshots = append(snapshots, area.ProfileSnapshot{Token: profile.Token, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding})
|
||||
}
|
||||
return area.MarkProfilesReplaced(tx, request.DeviceID, snapshots)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -10,18 +10,25 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
|
||||
)
|
||||
|
||||
type fakeONVIF struct{ err error }
|
||||
type fakeONVIF struct {
|
||||
err error
|
||||
profiles []onvif.Profile
|
||||
}
|
||||
|
||||
func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.profiles != nil {
|
||||
return f.profiles, nil
|
||||
}
|
||||
return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil
|
||||
}
|
||||
|
||||
@@ -105,3 +112,27 @@ func TestProbeErrorsHaveActionableStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileResolutionChangeMarksAreaForRecalibration(t *testing.T) {
|
||||
service := admissionService(t)
|
||||
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Orm.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := area.Definition{ID: "area-1", Name: "东门警戒线", Kind: area.KindDirectionLine, DeviceID: "device-1", ProfileToken: "main", ProfileWidth: 1920, ProfileHeight: 1080, ProfileEncoding: "H264", CurrentVersion: 1, CurrentVersionID: "version-1", Enabled: true, CreatedBy: 7, UpdatedBy: 7}
|
||||
if err := service.Orm.Create(&definition).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.ONVIF = fakeONVIF{profiles: []onvif.Profile{{Token: "main", Name: "主码流", Width: 1280, Height: 720, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}}}
|
||||
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Orm.First(&definition, "id = ?", "area-1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !definition.NeedsRecalibration {
|
||||
t.Fatal("profile resolution change did not mark the bound area")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
base := coreService.Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewService(base.Orm), nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||
return
|
||||
}
|
||||
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), Kind: c.Query("kind"), RecalibrationState: c.Query("recalibrationState"), PageIndex: pageIndex, PageSize: pageSize})
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Create(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||
return
|
||||
}
|
||||
var request UpsertRequest
|
||||
if err = decodeJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
request.UpdateBy = user.GetUserId(c)
|
||||
item, err := service.Create(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(item, "区域配置已创建")
|
||||
}
|
||||
|
||||
func (e *API) Update(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||
return
|
||||
}
|
||||
var request UpsertRequest
|
||||
if err = decodeJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
request.UpdateBy = user.GetUserId(c)
|
||||
item, err := service.Update(c.Request.Context(), c.Param("id"), request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(item, "已保存新版本")
|
||||
}
|
||||
|
||||
func (e *API) Versions(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||
return
|
||||
}
|
||||
items, err := service.Versions(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(items, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidRequest), errors.Is(err, ErrInvalidGeometry):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound), errors.Is(err, ErrProfileMissing):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "区域配置操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(c *gin.Context, target any) error {
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||
return errors.New("content type must be application/json")
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("request body must contain one JSON object")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package area
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
KindPolygon = "polygon"
|
||||
KindDirectionLine = "direction_line"
|
||||
DirectionForward = "forward"
|
||||
DirectionReverse = "reverse"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type PageRequest struct {
|
||||
Keyword string
|
||||
Kind string
|
||||
RecalibrationState string
|
||||
PageIndex int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
RouteID string `json:"routeId"`
|
||||
Points []Point `json:"points"`
|
||||
Direction string `json:"direction"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ExpectedVersion int64 `json:"expectedVersion"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
DeviceLocation string `json:"deviceLocation"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
ProfileName string `json:"profileName"`
|
||||
ProfileWidth int `json:"profileWidth"`
|
||||
ProfileHeight int `json:"profileHeight"`
|
||||
ProfileEncoding string `json:"profileEncoding"`
|
||||
RouteID string `json:"routeId"`
|
||||
Version int64 `json:"version"`
|
||||
Points []Point `json:"points"`
|
||||
Direction string `json:"direction"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NeedsRecalibration bool `json:"needsRecalibration"`
|
||||
UpdatedBy int `json:"updatedBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type VersionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
ProfileWidth int `json:"profileWidth"`
|
||||
ProfileHeight int `json:"profileHeight"`
|
||||
ProfileEncoding string `json:"profileEncoding"`
|
||||
Points []Point `json:"points"`
|
||||
Direction string `json:"direction"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SupersedesID string `json:"supersedesId"`
|
||||
CreatedBy int `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ProfileSnapshot struct {
|
||||
Token string
|
||||
Width int
|
||||
Height int
|
||||
Encoding string
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
var ErrInvalidGeometry = errors.New("区域几何不符合要求")
|
||||
|
||||
func validateGeometry(kind, direction string, points []Point) error {
|
||||
if kind != KindPolygon && kind != KindDirectionLine {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
if (kind == KindPolygon && (len(points) < 3 || len(points) > 64)) || (kind == KindDirectionLine && len(points) != 2) {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
if kind == KindDirectionLine && direction != DirectionForward && direction != DirectionReverse {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
if kind == KindPolygon && direction != "" {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
for i, point := range points {
|
||||
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) || point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
if i > 0 && samePoint(point, points[i-1]) {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
}
|
||||
if kind == KindDirectionLine {
|
||||
if samePoint(points[0], points[1]) {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if samePoint(points[0], points[len(points)-1]) || math.Abs(polygonArea(points)) < 0.000001 || polygonSelfIntersects(points) {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func samePoint(a, b Point) bool {
|
||||
return math.Abs(a.X-b.X) < 0.0000001 && math.Abs(a.Y-b.Y) < 0.0000001
|
||||
}
|
||||
|
||||
func polygonArea(points []Point) float64 {
|
||||
area := 0.0
|
||||
for i := range points {
|
||||
next := points[(i+1)%len(points)]
|
||||
area += points[i].X*next.Y - next.X*points[i].Y
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
func polygonSelfIntersects(points []Point) bool {
|
||||
for i := range points {
|
||||
a1, a2 := points[i], points[(i+1)%len(points)]
|
||||
for j := i + 1; j < len(points); j++ {
|
||||
if j == i || j == (i+1)%len(points) || i == (j+1)%len(points) {
|
||||
continue
|
||||
}
|
||||
b1, b2 := points[j], points[(j+1)%len(points)]
|
||||
if segmentsIntersect(a1, a2, b1, b2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func segmentsIntersect(a, b, c, d Point) bool {
|
||||
orientation := func(p, q, r Point) float64 {
|
||||
return (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y)
|
||||
}
|
||||
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
|
||||
if ((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0)) {
|
||||
return true
|
||||
}
|
||||
onSegment := func(p, q, r Point) bool {
|
||||
return q.X <= math.Max(p.X, r.X)+0.0000001 && q.X >= math.Min(p.X, r.X)-0.0000001 && q.Y <= math.Max(p.Y, r.Y)+0.0000001 && q.Y >= math.Min(p.Y, r.Y)-0.0000001
|
||||
}
|
||||
return (math.Abs(o1) < 0.0000001 && onSegment(a, c, b)) || (math.Abs(o2) < 0.0000001 && onSegment(a, d, b)) || (math.Abs(o3) < 0.0000001 && onSegment(c, a, d)) || (math.Abs(o4) < 0.0000001 && onSegment(c, b, d))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package area
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGeometryValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
direction string
|
||||
points []Point
|
||||
valid bool
|
||||
}{
|
||||
{"polygon", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, true},
|
||||
{"self intersecting", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.8}, {0.8, 0.1}, {0.1, 0.8}}, false},
|
||||
{"outside", KindPolygon, "", []Point{{-0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, false},
|
||||
{"line", KindDirectionLine, DirectionForward, []Point{{0.2, 0.5}, {0.8, 0.5}}, true},
|
||||
{"line missing direction", KindDirectionLine, "", []Point{{0.2, 0.5}, {0.8, 0.5}}, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateGeometry(test.kind, test.direction, test.points)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("valid=%v err=%v", test.valid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package area
|
||||
|
||||
import "time"
|
||||
|
||||
type Definition struct {
|
||||
ID string `gorm:"size:36;primaryKey"`
|
||||
Name string `gorm:"size:128;not null;index"`
|
||||
Kind string `gorm:"size:32;not null;index"`
|
||||
DeviceID string `gorm:"size:36;not null;index"`
|
||||
ProfileToken string `gorm:"size:255;not null"`
|
||||
ProfileWidth int `gorm:"not null"`
|
||||
ProfileHeight int `gorm:"not null"`
|
||||
ProfileEncoding string `gorm:"size:32;not null"`
|
||||
CurrentVersion int64 `gorm:"not null"`
|
||||
CurrentVersionID string `gorm:"size:36;not null;uniqueIndex"`
|
||||
Enabled bool `gorm:"not null"`
|
||||
NeedsRecalibration bool `gorm:"not null;index"`
|
||||
CreatedBy int `gorm:"not null"`
|
||||
UpdatedBy int `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (Definition) TableName() string { return "sense_area_definitions" }
|
||||
|
||||
type Version struct {
|
||||
ID string `gorm:"size:36;primaryKey"`
|
||||
DefinitionID string `gorm:"size:36;not null;uniqueIndex:ux_sense_area_version,priority:1;index"`
|
||||
Version int64 `gorm:"not null;uniqueIndex:ux_sense_area_version,priority:2"`
|
||||
Name string `gorm:"size:128;not null"`
|
||||
Kind string `gorm:"size:32;not null"`
|
||||
DeviceID string `gorm:"size:36;not null"`
|
||||
ProfileToken string `gorm:"size:255;not null"`
|
||||
ProfileWidth int `gorm:"not null"`
|
||||
ProfileHeight int `gorm:"not null"`
|
||||
ProfileEncoding string `gorm:"size:32;not null"`
|
||||
GeometryJSON string `gorm:"type:text;not null"`
|
||||
Direction string `gorm:"size:16;not null"`
|
||||
Enabled bool `gorm:"not null"`
|
||||
SupersedesID string `gorm:"size:36"`
|
||||
CreatedBy int `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (Version) TableName() string { return "sense_area_versions" }
|
||||
@@ -0,0 +1,95 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestConcurrentUpdateOnPostgresReturnsConflict(t *testing.T) {
|
||||
baseDSN := os.Getenv("SENSE_AREA_TEST_DATABASE_URL")
|
||||
if baseDSN == "" {
|
||||
t.Skip("set SENSE_AREA_TEST_DATABASE_URL to run the PostgreSQL concurrency test")
|
||||
}
|
||||
admin, err := gorm.Open(postgres.Open(baseDSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_area_69_concurrency"
|
||||
if err = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||
separator := "?"
|
||||
if strings.Contains(baseDSN, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(baseDSN+separator+"search_path="+schema), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
|
||||
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
|
||||
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
|
||||
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), triangleRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
errorsChannel := make(chan error, 2)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 2; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
request := triangleRequest()
|
||||
request.ExpectedVersion = created.Version
|
||||
_, updateErr := service.Update(context.Background(), created.ID, request)
|
||||
errorsChannel <- updateErr
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(errorsChannel)
|
||||
successes, conflicts := 0, 0
|
||||
for updateErr := range errorsChannel {
|
||||
switch {
|
||||
case updateErr == nil:
|
||||
successes++
|
||||
case errors.Is(updateErr, ErrConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("unexpected concurrent update error: %v", updateErr)
|
||||
}
|
||||
}
|
||||
if successes != 1 || conflicts != 1 {
|
||||
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
|
||||
}
|
||||
versions, err := service.Versions(context.Background(), created.ID)
|
||||
if err != nil || len(versions) != 2 {
|
||||
t.Fatalf("versions=%d err=%v", len(versions), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("区域配置请求不符合要求")
|
||||
ErrNotFound = errors.New("区域配置不存在")
|
||||
ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试")
|
||||
ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入")
|
||||
)
|
||||
|
||||
type Service struct{ db *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
|
||||
|
||||
type routeSnapshot struct {
|
||||
RouteID string `gorm:"column:route_id"`
|
||||
DeviceID string `gorm:"column:device_id"`
|
||||
DeviceName string `gorm:"column:device_name"`
|
||||
DeviceLocation string `gorm:"column:device_location"`
|
||||
ProfileToken string `gorm:"column:profile_token"`
|
||||
ProfileName string `gorm:"column:profile_name"`
|
||||
Width int `gorm:"column:width"`
|
||||
Height int `gorm:"column:height"`
|
||||
Encoding string `gorm:"column:encoding"`
|
||||
Verification string `gorm:"column:verification_status"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) {
|
||||
request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize)
|
||||
if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) {
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&Definition{})
|
||||
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
|
||||
pattern := "%" + escapeLike(keyword) + "%"
|
||||
query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern)
|
||||
}
|
||||
if request.Kind != "" {
|
||||
query = query.Where("kind = ?", request.Kind)
|
||||
}
|
||||
switch request.RecalibrationState {
|
||||
case "", "all":
|
||||
case "needed":
|
||||
query = query.Where("needs_recalibration = ?", true)
|
||||
case "ready":
|
||||
query = query.Where("needs_recalibration = ?", false)
|
||||
default:
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var definitions []Definition
|
||||
if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]Response, 0, len(definitions))
|
||||
for i := range definitions {
|
||||
item, err := s.response(ctx, &definitions[i])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) {
|
||||
if request.ExpectedVersion != 0 {
|
||||
return Response{}, ErrInvalidRequest
|
||||
}
|
||||
name, err := validateRequest(request)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
definitionID := uuid.NewString()
|
||||
versionID := uuid.NewString()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
route, routeErr := loadRoute(tx, request.RouteID)
|
||||
if routeErr != nil {
|
||||
return routeErr
|
||||
}
|
||||
geometry, marshalErr := json.Marshal(request.Points)
|
||||
if marshalErr != nil {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now}
|
||||
version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now}
|
||||
if err = tx.Create(&definition).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&version).Error
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return s.Get(ctx, definitionID)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) {
|
||||
if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 {
|
||||
return Response{}, ErrInvalidRequest
|
||||
}
|
||||
name, err := validateRequest(request)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current Definition
|
||||
if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", id).Error; readErr != nil {
|
||||
if errors.Is(readErr, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
if current.CurrentVersion != request.ExpectedVersion {
|
||||
return ErrConflict
|
||||
}
|
||||
route, routeErr := loadRoute(tx, request.RouteID)
|
||||
if routeErr != nil {
|
||||
return routeErr
|
||||
}
|
||||
geometry, marshalErr := json.Marshal(request.Points)
|
||||
if marshalErr != nil {
|
||||
return ErrInvalidGeometry
|
||||
}
|
||||
versionID := uuid.NewString()
|
||||
version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now}
|
||||
if createErr := tx.Create(&version).Error; createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now}
|
||||
result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id string) (Response, error) {
|
||||
var definition Definition
|
||||
if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Response{}, ErrNotFound
|
||||
}
|
||||
return Response{}, err
|
||||
}
|
||||
return s.response(ctx, &definition)
|
||||
}
|
||||
|
||||
func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var versions []Version
|
||||
if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]VersionResponse, 0, len(versions))
|
||||
for _, version := range versions {
|
||||
points, err := decodePoints(version.GeometryJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode area version %d: %w", version.Version, err)
|
||||
}
|
||||
out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) {
|
||||
var version Version
|
||||
if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
points, err := decodePoints(version.GeometryJSON)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
var route routeSnapshot
|
||||
query := s.db.WithContext(ctx).Table("sense_devices AS d").
|
||||
Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status").
|
||||
Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken).
|
||||
Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken).
|
||||
Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route)
|
||||
if query.Error != nil {
|
||||
return Response{}, query.Error
|
||||
}
|
||||
recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding)
|
||||
if recalibration && !definition.NeedsRecalibration {
|
||||
if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
definition.NeedsRecalibration = true
|
||||
}
|
||||
return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil
|
||||
}
|
||||
|
||||
func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error {
|
||||
if tx == nil || !tx.Migrator().HasTable(&Definition{}) {
|
||||
return nil
|
||||
}
|
||||
available := make(map[string]ProfileSnapshot, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
available[profile.Token] = profile
|
||||
}
|
||||
var definitions []Definition
|
||||
if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
profile, ok := available[definition.ProfileToken]
|
||||
if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) {
|
||||
if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) {
|
||||
if strings.TrimSpace(routeID) == "" {
|
||||
return routeSnapshot{}, ErrProfileMissing
|
||||
}
|
||||
var route routeSnapshot
|
||||
err := db.Table("sense_media_routes AS r").
|
||||
Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status").
|
||||
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
|
||||
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
|
||||
Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error
|
||||
if err != nil {
|
||||
return routeSnapshot{}, err
|
||||
}
|
||||
if route.RouteID == "" || route.Width < 1 || route.Height < 1 {
|
||||
return routeSnapshot{}, ErrProfileMissing
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func validateRequest(request UpsertRequest) (string, error) {
|
||||
name := strings.TrimSpace(request.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" {
|
||||
return "", ErrInvalidRequest
|
||||
}
|
||||
if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func decodePoints(value string) ([]Point, error) {
|
||||
var points []Point
|
||||
if err := json.Unmarshal([]byte(value), &points); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func normalizePage(index, size int) (int, int) {
|
||||
if index < 1 {
|
||||
index = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 10
|
||||
}
|
||||
if size > 50 {
|
||||
size = 50
|
||||
}
|
||||
return index, size
|
||||
}
|
||||
|
||||
func escapeLike(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||
return strings.ReplaceAll(value, `_`, `\_`)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func areaTestDB(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(&Definition{}, &Version{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
|
||||
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
|
||||
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
|
||||
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func triangleRequest() UpsertRequest {
|
||||
return UpsertRequest{Name: "操场危险区域", Kind: KindPolygon, RouteID: "device-1:main", Points: []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, Enabled: true, UpdateBy: 7}
|
||||
}
|
||||
|
||||
func TestVersionsAreAppendOnlyAndUseOptimisticConcurrency(t *testing.T) {
|
||||
service := NewService(areaTestDB(t))
|
||||
created, err := service.Create(context.Background(), triangleRequest())
|
||||
if err != nil || created.Version != 1 || created.ProfileWidth != 1920 {
|
||||
t.Fatalf("created=%+v err=%v", created, err)
|
||||
}
|
||||
request := triangleRequest()
|
||||
request.Name = "操场危险区域(校准)"
|
||||
request.ExpectedVersion = 1
|
||||
updated, err := service.Update(context.Background(), created.ID, request)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("updated=%+v err=%v", updated, err)
|
||||
}
|
||||
if _, err = service.Update(context.Background(), created.ID, request); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale update err=%v", err)
|
||||
}
|
||||
versions, err := service.Versions(context.Background(), created.ID)
|
||||
if err != nil || len(versions) != 2 || versions[0].Version != 2 || versions[1].Version != 1 || versions[0].SupersedesID != versions[1].ID {
|
||||
t.Fatalf("versions=%+v err=%v", versions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileReplacementMarksOnlyChangedBindings(t *testing.T) {
|
||||
db := areaTestDB(t)
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), triangleRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1920, Height: 1080, Encoding: "H264"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, _ := service.Get(context.Background(), created.ID)
|
||||
if item.NeedsRecalibration {
|
||||
t.Fatal("unchanged profile was marked for recalibration")
|
||||
}
|
||||
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1280, Height: 720, Encoding: "H264"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, _ = service.Get(context.Background(), created.ID)
|
||||
if !item.NeedsRecalibration {
|
||||
t.Fatal("resolution change did not mark recalibration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIsPaginatedAndDoesNotExposeMediaSecrets(t *testing.T) {
|
||||
service := NewService(areaTestDB(t))
|
||||
if _, err := service.Create(context.Background(), triangleRequest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, err := service.List(context.Background(), PageRequest{Keyword: "操场", PageIndex: 1, PageSize: 10})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].RouteID != "device-1:main" {
|
||||
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -34,6 +37,9 @@ func init() {
|
||||
|
||||
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := prepareLegacyDeviceCapabilities(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -89,6 +95,104 @@ func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||
})
|
||||
}
|
||||
|
||||
var supportedLegacyCapabilities = map[string]struct{}{
|
||||
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
|
||||
}
|
||||
|
||||
type legacyDeviceCapabilitiesRow struct {
|
||||
ID string
|
||||
Capabilities sql.NullString
|
||||
}
|
||||
|
||||
// prepareLegacyDeviceCapabilities upgrades the pre-GoAdmin text column before
|
||||
// GORM sees it. PostgreSQL cannot cast the old empty-string default to jsonb,
|
||||
// and old rows stored a single capability token rather than a JSON array.
|
||||
func prepareLegacyDeviceCapabilities(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
type columnMetadata struct {
|
||||
DataType string
|
||||
}
|
||||
var column columnMetadata
|
||||
result := tx.Raw(`SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_devices'
|
||||
AND column_name = 'capabilities'`).Scan(&column)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("inspect sense_devices.capabilities: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 || column.DataType == "json" || column.DataType == "jsonb" {
|
||||
return nil
|
||||
}
|
||||
if column.DataType != "text" && column.DataType != "character varying" {
|
||||
return fmt.Errorf("sense_devices.capabilities has unsupported legacy type %q", column.DataType)
|
||||
}
|
||||
if err := tx.Exec(`LOCK TABLE "sense_devices" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
|
||||
return fmt.Errorf("lock sense_devices for capabilities migration: %w", err)
|
||||
}
|
||||
var rows []legacyDeviceCapabilitiesRow
|
||||
if err := tx.Raw(`SELECT id, capabilities FROM "sense_devices" ORDER BY id`).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("read legacy device capabilities: %w", err)
|
||||
}
|
||||
canonical := make(map[string]string, len(rows))
|
||||
invalid := 0
|
||||
for _, row := range rows {
|
||||
value, err := canonicalLegacyCapabilities(row.Capabilities)
|
||||
if err != nil {
|
||||
invalid++
|
||||
continue
|
||||
}
|
||||
canonical[row.ID] = value
|
||||
}
|
||||
if invalid > 0 {
|
||||
return fmt.Errorf("sense_devices.capabilities contains unsupported legacy data in %d row(s); migration rolled back", invalid)
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" DROP DEFAULT`).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy capabilities default: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := tx.Exec(`UPDATE "sense_devices" SET "capabilities" = ? WHERE "id" = ?`, canonical[row.ID], row.ID).Error; err != nil {
|
||||
return fmt.Errorf("normalize legacy device capabilities: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" TYPE jsonb USING "capabilities"::jsonb`).Error; err != nil {
|
||||
return fmt.Errorf("convert capabilities to jsonb: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" SET DEFAULT '[]'::jsonb`).Error; err != nil {
|
||||
return fmt.Errorf("set jsonb capabilities default: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalLegacyCapabilities(value sql.NullString) (string, error) {
|
||||
if !value.Valid || strings.TrimSpace(value.String) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(value.String)
|
||||
if _, ok := supportedLegacyCapabilities[trimmed]; ok {
|
||||
encoded, _ := json.Marshal([]string{trimmed})
|
||||
return string(encoded), nil
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "[") {
|
||||
return "", fmt.Errorf("legacy capability value is not an array")
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &values); err != nil || values == nil || len(values) > 16 {
|
||||
return "", fmt.Errorf("legacy capability array is invalid")
|
||||
}
|
||||
for index, item := range values {
|
||||
item = strings.TrimSpace(item)
|
||||
if _, ok := supportedLegacyCapabilities[item]; !ok {
|
||||
return "", fmt.Errorf("legacy capability array contains an unsupported value")
|
||||
}
|
||||
values[index] = item
|
||||
}
|
||||
encoded, _ := json.Marshal(values)
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
|
||||
var menu migrationModels.SysMenu
|
||||
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestCanonicalLegacyCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input sql.NullString
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "null", input: sql.NullString{}, want: "[]"},
|
||||
{name: "blank", input: sql.NullString{String: " ", Valid: true}, want: "[]"},
|
||||
{name: "single", input: sql.NullString{String: " video ", Valid: true}, want: `["video"]`},
|
||||
{name: "array", input: sql.NullString{String: `["radar", "contact"]`, Valid: true}, want: `["radar","contact"]`},
|
||||
{name: "empty array", input: sql.NullString{String: `[]`, Valid: true}, want: `[]`},
|
||||
{name: "unknown", input: sql.NullString{String: "unknown", Valid: true}, wantErr: true},
|
||||
{name: "object", input: sql.NullString{String: `{"video":true}`, Valid: true}, wantErr: true},
|
||||
{name: "unknown array item", input: sql.NullString{String: `["video","unknown"]`, Valid: true}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := canonicalLegacyCapabilities(test.input)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("got %q, %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_device_92_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("converts legacy values and remains idempotent", func(t *testing.T) {
|
||||
resetLegacyDeviceTable(t, db)
|
||||
for _, row := range [][2]string{{"single", "video"}, {"blank", ""}, {"array", `["radar","contact"]`}} {
|
||||
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES (?, ?)`, row[0], row[1]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&deviceModels.Device{}); err != nil {
|
||||
t.Fatalf("AutoMigrate after compatibility conversion: %v", err)
|
||||
}
|
||||
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
|
||||
var values []struct{ ID, Capabilities string }
|
||||
if err = db.Raw(`SELECT id, capabilities::text AS capabilities FROM sense_devices ORDER BY id`).Scan(&values).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, value := range values {
|
||||
got[value.ID] = strings.ReplaceAll(value.Capabilities, " ", "")
|
||||
}
|
||||
if got["single"] != `["video"]` || got["blank"] != `[]` || got["array"] != `["radar","contact"]` {
|
||||
t.Fatalf("unexpected converted values: %#v", got)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
|
||||
t.Fatalf("repeat migration: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown values without partial conversion", func(t *testing.T) {
|
||||
resetLegacyDeviceTable(t, db)
|
||||
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('valid', 'video'), ('invalid', 'unknown')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err == nil {
|
||||
t.Fatal("expected unsupported legacy data error")
|
||||
}
|
||||
assertCapabilitiesColumn(t, db, "text", "''::text")
|
||||
var value string
|
||||
if err = db.Raw(`SELECT capabilities FROM sense_devices WHERE id = 'valid'`).Scan(&value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "video" {
|
||||
t.Fatalf("transaction left partial data: %q", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does nothing when the table is absent", func(t *testing.T) {
|
||||
if err = db.Exec(`DROP TABLE sense_devices`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("completes the device migration and records its version", func(t *testing.T) {
|
||||
resetLegacyDeviceTable(t, db)
|
||||
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('legacy', 'video')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const migrationVersion = "2026081414000_device.go"
|
||||
if err = migrateSenseDeviceLedger(db, migrationVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
|
||||
var migratedValue string
|
||||
if err = db.Raw(`SELECT capabilities::text FROM sense_devices WHERE id = 'legacy'`).Scan(&migratedValue).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ReplaceAll(migratedValue, " ", "") != `["video"]` {
|
||||
t.Fatalf("unexpected full-migration value: %q", migratedValue)
|
||||
}
|
||||
var applied int64
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("migration record count=%d", applied)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func resetLegacyDeviceTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_devices`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE sense_devices (
|
||||
id text PRIMARY KEY,
|
||||
name varchar(128) NOT NULL DEFAULT '',
|
||||
location varchar(255) NOT NULL DEFAULT '',
|
||||
modality varchar(32) NOT NULL DEFAULT 'video',
|
||||
capabilities text NOT NULL DEFAULT '',
|
||||
status varchar(32) NOT NULL DEFAULT 'active',
|
||||
adapter_status varchar(32) NOT NULL DEFAULT 'ready',
|
||||
rtsp_credential_same_as_onvif boolean NOT NULL DEFAULT true,
|
||||
credential_updated_at timestamptz,
|
||||
retry_requested_at timestamptz,
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
create_by bigint NOT NULL DEFAULT 0,
|
||||
update_by bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
deleted_at timestamptz
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCapabilitiesColumn(t *testing.T, db *gorm.DB, wantType, wantDefault string) {
|
||||
t.Helper()
|
||||
var column struct{ DataType, ColumnDefault string }
|
||||
if err := db.Raw(`SELECT data_type, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_devices'
|
||||
AND column_name = 'capabilities'`).Scan(&column).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if column.DataType != wantType || column.ColumnDefault != wantDefault {
|
||||
t.Fatalf("type/default=%q/%q; want %q/%q", column.DataType, column.ColumnDefault, wantType, wantDefault)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -19,6 +21,9 @@ func init() {
|
||||
|
||||
func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := prepareLegacyMediaRouteSchema(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&media.Route{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
type mediaPathConstraint struct {
|
||||
Name string
|
||||
Columns string
|
||||
}
|
||||
|
||||
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
|
||||
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
|
||||
// the runtime-state columns now required by the Route model.
|
||||
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
var tableCount int64
|
||||
if err := tx.Raw(`SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
|
||||
return fmt.Errorf("inspect legacy media route table: %w", err)
|
||||
}
|
||||
if tableCount == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
|
||||
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
|
||||
}
|
||||
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
|
||||
var constraints []mediaPathConstraint
|
||||
if err := tx.Raw(`SELECT c.conname AS name,
|
||||
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'sense_media_routes'
|
||||
AND c.contype = 'u'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(c.conkey) AS key(attnum)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
|
||||
WHERE a.attname = 'path'
|
||||
)
|
||||
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
|
||||
return fmt.Errorf("inspect legacy media path constraints: %w", err)
|
||||
}
|
||||
if len(constraints) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(constraints) != 1 || constraints[0].Columns != "path" {
|
||||
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
|
||||
}
|
||||
const expectedName = "uni_sense_media_routes_path"
|
||||
if constraints[0].Name == expectedName {
|
||||
return nil
|
||||
}
|
||||
var conflictingNameCount int64
|
||||
if err := tx.Raw(`SELECT COUNT(*)
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'sense_media_routes'
|
||||
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
|
||||
return fmt.Errorf("inspect target media path constraint name: %w", err)
|
||||
}
|
||||
if conflictingNameCount != 0 {
|
||||
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
|
||||
}
|
||||
rename := fmt.Sprintf(
|
||||
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
|
||||
quotePostgresIdentifier(constraints[0].Name),
|
||||
quotePostgresIdentifier(expectedName),
|
||||
)
|
||||
if err := tx.Exec(rename).Error; err != nil {
|
||||
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
|
||||
for _, statement := range []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
|
||||
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
|
||||
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
|
||||
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
|
||||
SET "source_ready" = COALESCE("source_ready", false),
|
||||
"failure_count" = COALESCE("failure_count", 0),
|
||||
"last_error_code" = COALESCE("last_error_code", '')
|
||||
WHERE "source_ready" IS NULL
|
||||
OR "failure_count" IS NULL
|
||||
OR "last_error_code" IS NULL`},
|
||||
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
|
||||
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
|
||||
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
|
||||
} {
|
||||
if err := tx.Exec(statement.sql).Error; err != nil {
|
||||
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quotePostgresIdentifier(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
const schema = "sense_media_95_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
|
||||
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
|
||||
if err = db.Exec(`INSERT INTO sense_media_routes
|
||||
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
|
||||
VALUES
|
||||
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
|
||||
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const migrationVersion = "2026081419000_media.go"
|
||||
if err = migrateSenseMedia(db, migrationVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes, menus, policies, applied int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
|
||||
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||
}
|
||||
assertMediaPathUniqueIndex(t, db)
|
||||
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
|
||||
t.Fatal("expected path uniqueness violation")
|
||||
}
|
||||
var runtimeState struct {
|
||||
SourceReady bool
|
||||
FailureCount int64
|
||||
LastError string
|
||||
}
|
||||
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
|
||||
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
|
||||
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
|
||||
t.Fatalf("repeat compatibility migration: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does nothing when the table is absent", func(t *testing.T) {
|
||||
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates a fresh media table", func(t *testing.T) {
|
||||
resetEmptyMediaMigration(t, db)
|
||||
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 0 {
|
||||
t.Fatalf("fresh route count=%d", routes)
|
||||
}
|
||||
assertMediaPathUniqueIndex(t, db)
|
||||
})
|
||||
|
||||
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
|
||||
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
|
||||
t.Fatalf("expected unsupported uniqueness error, got %v", err)
|
||||
}
|
||||
var constraints int64
|
||||
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if constraints != 1 {
|
||||
t.Fatalf("constraint rollback count=%d", constraints)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
|
||||
}
|
||||
|
||||
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes, menus, policies, applied int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
createRouteTable := `CREATE TABLE sense_media_routes (
|
||||
id text PRIMARY KEY,
|
||||
device_id text NOT NULL,
|
||||
profile_token text NOT NULL,
|
||||
path text NOT NULL,
|
||||
desired text NOT NULL,
|
||||
actual text NOT NULL,
|
||||
readers integer NOT NULL DEFAULT 0,
|
||||
detail text NOT NULL DEFAULT '',
|
||||
version bigint NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
` + pathConstraint + `
|
||||
)`
|
||||
if err := db.Exec(createRouteTable).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
|
||||
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var indexes []struct {
|
||||
IndexName string
|
||||
IndexDef string
|
||||
}
|
||||
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'sense_media_routes'
|
||||
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, index := range indexes {
|
||||
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing unique path index: %#v", indexes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||
"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"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseArea)
|
||||
}
|
||||
|
||||
func migrateSenseArea(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "/sense/area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 9, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
create, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaCreate", Title: "新增配置", MenuType: "F", Action: "POST", Permission: "sense:area:create", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaUpdate", Title: "编辑配置", MenuType: "F", Action: "PUT", Permission: "sense:area:update", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, create, update}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||
return err
|
||||
}
|
||||
read := [][2]string{{"/api/v1/area/configurations", "GET"}, {"/api/v1/area/configurations/:id/versions", "GET"}}
|
||||
write := [][2]string{{"/api/v1/area/configurations", "POST"}, {"/api/v1/area/configurations/:id", "PUT"}}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
policies := append([][2]string{}, read...)
|
||||
if role != "viewer" {
|
||||
policies = append(policies, write...)
|
||||
}
|
||||
for _, policy := range policies {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestAreaMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_AREA_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_AREA_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_area_69_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{}, &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)
|
||||
}
|
||||
}
|
||||
if err = migrateSenseArea(db, "2026081509000_area.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies, definitions, versions, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseArea%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/area%").Count(&policies)
|
||||
db.Model(&area.Definition{}).Count(&definitions)
|
||||
db.Model(&area.Version{}).Count(&versions)
|
||||
db.Model(&common.Migration{}).Where("version = ?", "2026081509000_area.go").Count(&applied)
|
||||
if menus != 3 || policies != 10 || definitions != 0 || versions != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d policies=%d definitions=%d versions=%d applied=%d", menus, policies, definitions, versions, applied)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
@@ -82,21 +80,13 @@ func Authenticator(c *gin.Context) (interface{}, error) {
|
||||
|
||||
return nil, jwt.ErrMissingLoginValues
|
||||
}
|
||||
if config.ApplicationConfig.Mode != "dev" {
|
||||
if !captcha.Verify(loginVals.UUID, loginVals.Code, true) {
|
||||
username = loginVals.Username
|
||||
msg = "验证码错误"
|
||||
status = "1"
|
||||
|
||||
return nil, jwt.ErrInvalidVerificationode
|
||||
}
|
||||
}
|
||||
sysUser, role, e := loginVals.GetUser(db)
|
||||
if e == nil {
|
||||
username = loginVals.Username
|
||||
|
||||
return map[string]interface{}{"user": sysUser, "role": role}, nil
|
||||
} else {
|
||||
username = loginVals.Username
|
||||
msg = "登录失败"
|
||||
status = "1"
|
||||
log.Warnf("%s login failed!", loginVals.Username)
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
type Login struct {
|
||||
Username string `form:"UserName" json:"username" binding:"required"`
|
||||
Password string `form:"Password" json:"password" binding:"required"`
|
||||
Code string `form:"Code" json:"code" binding:"required"`
|
||||
UUID string `form:"UUID" json:"uuid" binding:"required"`
|
||||
}
|
||||
|
||||
func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLoginAcceptsCredentialsWithoutCaptcha(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", strings.NewReader(`{"username":"operator","password":"valid-password"}`))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var login Login
|
||||
if err := ctx.ShouldBindJSON(&login); err != nil {
|
||||
t.Fatalf("bind credentials-only login: %v", err)
|
||||
}
|
||||
if login.Username != "operator" || login.Password != "valid-password" {
|
||||
t.Fatalf("unexpected login payload: username=%q", login.Username)
|
||||
}
|
||||
|
||||
typeOfLogin := reflect.TypeOf(login)
|
||||
if typeOfLogin.NumField() != 2 {
|
||||
t.Fatalf("login payload must only expose username and password, got %d fields", typeOfLogin.NumField())
|
||||
}
|
||||
for _, removed := range []string{"Code", "UUID"} {
|
||||
if _, ok := typeOfLogin.FieldByName(removed); ok {
|
||||
t.Fatalf("captcha field %s must not be part of the login payload", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ settings:
|
||||
# JWT加密字符串
|
||||
secret: ""
|
||||
# 过期时间单位:秒
|
||||
timeout: 3600
|
||||
timeout: 2592000
|
||||
database:
|
||||
# 数据库名称
|
||||
name: dbname
|
||||
|
||||
@@ -16,7 +16,7 @@ settings:
|
||||
frontpath: ../ui/src
|
||||
jwt:
|
||||
secret: ""
|
||||
timeout: 3600
|
||||
timeout: 2592000
|
||||
logger:
|
||||
# 日志存放路径
|
||||
path: temp/logs
|
||||
|
||||
@@ -34,7 +34,7 @@ settings:
|
||||
# token 密钥,生产环境时及的修改
|
||||
secret: ""
|
||||
# token 过期时间 单位:秒
|
||||
timeout: 3600
|
||||
timeout: 2592000
|
||||
database:
|
||||
# Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。
|
||||
driver: postgres
|
||||
|
||||
@@ -25,7 +25,7 @@ settings:
|
||||
# token 密钥,生产环境时及的修改
|
||||
secret: ""
|
||||
# token 过期时间 单位:秒
|
||||
timeout: 3600
|
||||
timeout: 2592000
|
||||
database:
|
||||
# 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。
|
||||
driver: postgres
|
||||
|
||||
@@ -25,7 +25,7 @@ settings:
|
||||
# 必填。生产环境至少 32 个字符;不得提交真实值。
|
||||
secret: ""
|
||||
# token 过期时间 单位:秒
|
||||
timeout: 3600
|
||||
timeout: 2592000
|
||||
database:
|
||||
# 数据库类型 mysql, sqlite3, postgres, sqlserver
|
||||
# sqlserver: sqlserver://用户名:密码@地址?database=数据库名
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3789,23 +3789,15 @@
|
||||
"handler.Login": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"password",
|
||||
"username",
|
||||
"uuid"
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4345,4 +4337,4 @@
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,19 +685,13 @@ definitions:
|
||||
type: object
|
||||
handler.Login:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
uuid:
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- password
|
||||
- username
|
||||
- uuid
|
||||
type: object
|
||||
models.SysApi:
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "PACKAGE_LAUNCHER=%~dp0dist\sense-windows-amd64\start-sense.bat"
|
||||
|
||||
if not exist "%PACKAGE_LAUNCHER%" (
|
||||
echo [ERROR] Sense Windows delivery package was not found.
|
||||
echo Expected launcher: "%PACKAGE_LAUNCHER%"
|
||||
echo Build it first with: "%~dp0scripts\build\build-windows.bat"
|
||||
endlocal
|
||||
exit /b 2
|
||||
)
|
||||
|
||||
call "%PACKAGE_LAUNCHER%" %*
|
||||
set "SENSE_EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
endlocal & exit /b %SENSE_EXIT_CODE%
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-tests.ps1"
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,98 @@
|
||||
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 '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
|
||||
$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 $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'
|
||||
|
||||
[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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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('<title>')) { 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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listAreaConfigurations(query) {
|
||||
return request({ url: '/api/v1/area/configurations', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function createAreaConfiguration(data) {
|
||||
return request({ url: '/api/v1/area/configurations', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateAreaConfiguration(id, data) {
|
||||
return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function listAreaVersions(id) {
|
||||
return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export const POLYGON = 'polygon'
|
||||
export const DIRECTION_LINE = 'direction_line'
|
||||
|
||||
export function clamp(value) {
|
||||
return Math.min(1, Math.max(0, Number(value)))
|
||||
}
|
||||
|
||||
export function geometryError(kind, points, direction = '') {
|
||||
if (!Array.isArray(points)) return '请在画面中添加坐标点'
|
||||
if (kind === POLYGON && (points.length < 3 || points.length > 64)) return '多边形需要 3 到 64 个点'
|
||||
if (kind === DIRECTION_LINE && points.length !== 2) return '方向警戒线需要恰好 2 个点'
|
||||
if (kind === DIRECTION_LINE && !['forward', 'reverse'].includes(direction)) return '请选择警戒方向'
|
||||
if (![POLYGON, DIRECTION_LINE].includes(kind)) return '请选择配置类型'
|
||||
if (points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1)) return '坐标必须位于画面范围内'
|
||||
if (kind === POLYGON && selfIntersects(points)) return '多边形边线不能交叉,请调整顶点'
|
||||
if (kind === POLYGON && Math.abs(polygonArea(points)) < 0.000001) return '多边形面积过小,请重新绘制'
|
||||
if (kind === DIRECTION_LINE && samePoint(points[0], points[1])) return '警戒线起点和终点不能重合'
|
||||
return ''
|
||||
}
|
||||
|
||||
function samePoint(a, b) {
|
||||
return Math.abs(a.x - b.x) < 0.0000001 && Math.abs(a.y - b.y) < 0.0000001
|
||||
}
|
||||
|
||||
function polygonArea(points) {
|
||||
return points.reduce((total, point, index) => {
|
||||
const next = points[(index + 1) % points.length]
|
||||
return total + point.x * next.y - next.x * point.y
|
||||
}, 0) / 2
|
||||
}
|
||||
|
||||
function selfIntersects(points) {
|
||||
for (let first = 0; first < points.length; first += 1) {
|
||||
const a = points[first]
|
||||
const b = points[(first + 1) % points.length]
|
||||
for (let second = first + 1; second < points.length; second += 1) {
|
||||
if (second === first || second === (first + 1) % points.length || first === (second + 1) % points.length) continue
|
||||
const c = points[second]
|
||||
const d = points[(second + 1) % points.length]
|
||||
if (segmentsIntersect(a, b, c, d)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function segmentsIntersect(a, b, c, d) {
|
||||
const orientation = (p, q, r) => (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
|
||||
const values = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)]
|
||||
if (((values[0] > 0 && values[1] < 0) || (values[0] < 0 && values[1] > 0)) && ((values[2] > 0 && values[3] < 0) || (values[2] < 0 && values[3] > 0))) return true
|
||||
const onSegment = (p, q, r) => q.x <= Math.max(p.x, r.x) + 0.0000001 && q.x >= Math.min(p.x, r.x) - 0.0000001 && q.y <= Math.max(p.y, r.y) + 0.0000001 && q.y >= Math.min(p.y, r.y) - 0.0000001
|
||||
return (Math.abs(values[0]) < 0.0000001 && onSegment(a, c, b)) ||
|
||||
(Math.abs(values[1]) < 0.0000001 && onSegment(a, d, b)) ||
|
||||
(Math.abs(values[2]) < 0.0000001 && onSegment(c, a, d)) ||
|
||||
(Math.abs(values[3]) < 0.0000001 && onSegment(c, b, d))
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<section class="geometry-editor" aria-labelledby="geometry-editor-title">
|
||||
<div class="editor-toolbar">
|
||||
<div>
|
||||
<strong id="geometry-editor-title">画面坐标</strong>
|
||||
<span>{{ instruction }}</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<el-button :disabled="disabled || !canAdd" @click="addCenterPoint">添加中心点</el-button>
|
||||
<el-button :disabled="disabled || undoStack.length === 0" @click="undo">撤销</el-button>
|
||||
<el-button :disabled="disabled || points.length === 0" @click="clear">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-shell" :class="{ 'is-disabled': disabled }">
|
||||
<iframe
|
||||
v-if="playerUrl"
|
||||
:src="playerUrl"
|
||||
title="区域校准实时视频"
|
||||
allow="autoplay; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div v-else class="canvas-placeholder">选择可用视频后,可在实时画面上绘制</div>
|
||||
<svg
|
||||
ref="canvas"
|
||||
class="geometry-canvas"
|
||||
viewBox="0 0 1000 562.5"
|
||||
role="application"
|
||||
:aria-label="instruction"
|
||||
tabindex="0"
|
||||
@click="addFromPointer"
|
||||
@pointermove="dragPoint"
|
||||
@pointerup="stopDrag"
|
||||
@pointercancel="stopDrag"
|
||||
@keydown.enter.prevent="addCenterPoint"
|
||||
@keydown.space.prevent="addCenterPoint"
|
||||
>
|
||||
<defs>
|
||||
<marker id="sense-area-arrow" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,8 L10,4 z" class="arrow-head" />
|
||||
</marker>
|
||||
</defs>
|
||||
<polygon v-if="kind === 'polygon' && points.length >= 2" :points="svgPoints" class="area-shape" />
|
||||
<line
|
||||
v-if="kind === 'direction_line' && points.length === 2"
|
||||
:x1="scaledPoints[0].x"
|
||||
:y1="scaledPoints[0].y"
|
||||
:x2="scaledPoints[1].x"
|
||||
:y2="scaledPoints[1].y"
|
||||
class="direction-line"
|
||||
:marker-start="direction === 'reverse' ? 'url(#sense-area-arrow)' : undefined"
|
||||
:marker-end="direction === 'forward' ? 'url(#sense-area-arrow)' : undefined"
|
||||
/>
|
||||
<g
|
||||
v-for="(point, index) in scaledPoints"
|
||||
:key="index"
|
||||
class="point-control"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`坐标点 ${index + 1},横向 ${Math.round(points[index].x * 100)}%,纵向 ${Math.round(points[index].y * 100)}%`"
|
||||
@click.stop
|
||||
@pointerdown.stop.prevent="startDrag(index, $event)"
|
||||
@keydown="movePointByKeyboard(index, $event)"
|
||||
>
|
||||
<circle :cx="point.x" :cy="point.y" r="12" />
|
||||
<text :x="point.x" :y="point.y + 4" text-anchor="middle">{{ index + 1 }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="point-summary" aria-live="polite">
|
||||
<span>已添加 {{ points.length }} 个点</span>
|
||||
<span>键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。</span>
|
||||
</div>
|
||||
<p v-if="error" class="geometry-error" role="alert">{{ error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { clamp, geometryError } from './geometry'
|
||||
|
||||
export default {
|
||||
name: 'SenseGeometryEditor',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
kind: { type: String, required: true },
|
||||
direction: { type: String, default: '' },
|
||||
playerUrl: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { undoStack: [], draggingIndex: -1 }
|
||||
},
|
||||
computed: {
|
||||
points() {
|
||||
return this.modelValue || []
|
||||
},
|
||||
scaledPoints() {
|
||||
return this.points.map(point => ({ x: point.x * 1000, y: point.y * 562.5 }))
|
||||
},
|
||||
svgPoints() {
|
||||
return this.scaledPoints.map(point => `${point.x},${point.y}`).join(' ')
|
||||
},
|
||||
canAdd() {
|
||||
return this.kind === 'polygon' ? this.points.length < 64 : this.points.length < 2
|
||||
},
|
||||
instruction() {
|
||||
return this.kind === 'polygon' ? '点击画面添加顶点,拖动顶点调整危险区域' : '依次添加起点和终点,箭头表示警戒方向'
|
||||
},
|
||||
error() {
|
||||
if (this.points.length === 0) return ''
|
||||
return geometryError(this.kind, this.points, this.direction)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
commit(next, remember = true) {
|
||||
if (this.disabled) return
|
||||
if (remember) this.undoStack.push(this.points.map(point => ({ ...point })))
|
||||
this.$emit('update:modelValue', next.map(point => ({ x: clamp(point.x), y: clamp(point.y) })))
|
||||
},
|
||||
addFromPointer(event) {
|
||||
if (this.disabled || !this.canAdd || event.target.closest('.point-control')) return
|
||||
this.commit([...this.points, this.eventPoint(event)])
|
||||
},
|
||||
addCenterPoint() {
|
||||
if (!this.disabled && this.canAdd) this.commit([...this.points, { x: 0.5, y: 0.5 }])
|
||||
},
|
||||
clear() {
|
||||
if (this.points.length) this.commit([])
|
||||
},
|
||||
undo() {
|
||||
if (!this.undoStack.length || this.disabled) return
|
||||
const previous = this.undoStack.pop()
|
||||
this.commit(previous, false)
|
||||
},
|
||||
startDrag(index, event) {
|
||||
if (this.disabled) return
|
||||
this.undoStack.push(this.points.map(point => ({ ...point })))
|
||||
this.draggingIndex = index
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
},
|
||||
dragPoint(event) {
|
||||
if (this.draggingIndex < 0 || this.disabled) return
|
||||
const next = this.points.map(point => ({ ...point }))
|
||||
next[this.draggingIndex] = this.eventPoint(event)
|
||||
this.commit(next, false)
|
||||
},
|
||||
stopDrag() {
|
||||
this.draggingIndex = -1
|
||||
},
|
||||
movePointByKeyboard(index, event) {
|
||||
if (this.disabled) return
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
this.commit(this.points.filter((_, pointIndex) => pointIndex !== index))
|
||||
return
|
||||
}
|
||||
const movement = { ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0], ArrowUp: [0, -0.01], ArrowDown: [0, 0.01] }[event.key]
|
||||
if (!movement) return
|
||||
event.preventDefault()
|
||||
const next = this.points.map(point => ({ ...point }))
|
||||
next[index] = { x: next[index].x + movement[0], y: next[index].y + movement[1] }
|
||||
this.commit(next)
|
||||
},
|
||||
eventPoint(event) {
|
||||
const rect = this.$refs.canvas.getBoundingClientRect()
|
||||
return { x: clamp((event.clientX - rect.left) / rect.width), y: clamp((event.clientY - rect.top) / rect.height) }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.geometry-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-toolbar,
|
||||
.point-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-toolbar span,
|
||||
.point-summary {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-darker);
|
||||
}
|
||||
|
||||
.canvas-shell iframe,
|
||||
.geometry-canvas,
|
||||
.canvas-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.geometry-canvas {
|
||||
cursor: crosshair;
|
||||
outline-offset: -3px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.geometry-canvas:focus-visible {
|
||||
outline: 3px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.area-shape {
|
||||
fill: color-mix(in srgb, var(--el-color-warning) 24%, transparent);
|
||||
stroke: var(--el-color-warning-dark-2);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.direction-line {
|
||||
stroke: var(--el-color-danger);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.arrow-head {
|
||||
fill: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.point-control {
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.point-control circle {
|
||||
fill: var(--el-color-primary);
|
||||
stroke: var(--el-color-white);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.point-control text {
|
||||
fill: var(--el-color-white);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.point-control:focus-visible circle {
|
||||
stroke: var(--el-color-warning);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.point-summary {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.geometry-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--el-color-danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.is-disabled .geometry-canvas {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.editor-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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() {
|
||||
|
||||
@@ -102,29 +102,6 @@
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="code">
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
v-model="loginForm.code"
|
||||
placeholder="请输入验证码"
|
||||
name="code"
|
||||
type="text"
|
||||
tabindex="3"
|
||||
maxlength="5"
|
||||
autocomplete="off"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<div class="captcha-wrap" title="点击刷新" @click="getCode">
|
||||
<img v-if="codeUrl" :src="codeUrl" class="captcha-img" alt="验证码">
|
||||
<div v-else class="captcha-placeholder">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
:loading="loading"
|
||||
type="primary"
|
||||
@@ -142,27 +119,22 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCodeImg } from '@/api/login'
|
||||
import { User, Lock, Key, View, Hide, Monitor, Loading } from '@element-plus/icons-vue'
|
||||
import { User, Lock, View, Hide, Monitor } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'LoginPage',
|
||||
setup() {
|
||||
return { User, Lock, Key, View, Hide, Monitor, Loading }
|
||||
return { User, Lock, View, Hide, Monitor }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
codeUrl: '',
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: '',
|
||||
code: '',
|
||||
uuid: ''
|
||||
password: ''
|
||||
},
|
||||
loginRules: {
|
||||
username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
|
||||
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }],
|
||||
code: [{ required: true, trigger: 'change', message: '验证码不能为空' }]
|
||||
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
|
||||
},
|
||||
passwordType: 'password',
|
||||
capsTooltip: false,
|
||||
@@ -185,7 +157,6 @@ export default {
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getCode()
|
||||
this.getSystemSetting()
|
||||
},
|
||||
mounted() {
|
||||
@@ -202,15 +173,6 @@ export default {
|
||||
document.title = ret.sys_app_name
|
||||
})
|
||||
},
|
||||
getCode() {
|
||||
this.codeUrl = ''
|
||||
getCodeImg().then((res) => {
|
||||
if (res !== undefined) {
|
||||
this.codeUrl = res.data
|
||||
this.loginForm.uuid = res.id
|
||||
}
|
||||
})
|
||||
},
|
||||
checkCapslock({ shiftKey, key } = {}) {
|
||||
if (key && key.length === 1) {
|
||||
if ((shiftKey && key >= 'a' && key <= 'z') || (!shiftKey && key >= 'A' && key <= 'Z')) {
|
||||
@@ -238,7 +200,6 @@ export default {
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false
|
||||
this.getCode()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -562,48 +523,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 验证码 ── */
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-wrap {
|
||||
width: 110px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.captcha-placeholder {
|
||||
color: #c1c7d0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ── 登录按钮 ── */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DIRECTION_LINE, geometryError } from '@/components/sense/geometry-editor/geometry'
|
||||
|
||||
export function buildAreaPayload(form) {
|
||||
return {
|
||||
name: String(form.name || '').trim(),
|
||||
kind: form.kind,
|
||||
routeId: form.routeId,
|
||||
points: (form.points || []).map(point => ({ x: Number(point.x), y: Number(point.y) })),
|
||||
direction: form.kind === DIRECTION_LINE ? form.direction : '',
|
||||
enabled: Boolean(form.enabled),
|
||||
expectedVersion: Number(form.version || 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAreaForm(form) {
|
||||
if (!String(form.name || '').trim()) return '请填写配置名称'
|
||||
if (!form.routeId) return '请选择设备与视频码流'
|
||||
const payload = buildAreaPayload(form)
|
||||
return geometryError(payload.kind, payload.points, payload.direction)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>区域与警戒线</h3>
|
||||
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
|
||||
</div>
|
||||
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
|
||||
</div>
|
||||
|
||||
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
|
||||
<el-form-item label="名称" prop="keyword">
|
||||
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="kind">
|
||||
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
|
||||
<el-option label="危险区域" value="polygon" />
|
||||
<el-option label="方向警戒线" value="direction_line" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="校准状态" prop="recalibrationState">
|
||||
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
|
||||
<el-option label="需要重新校准" value="needed" />
|
||||
<el-option label="已校准" value="ready" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!loading && total === 0"
|
||||
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="empty-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loading" :data="items" border stripe>
|
||||
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="130">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备与码流" min-width="220">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
|
||||
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="90">
|
||||
<template #default="scope">v{{ scope.row.version }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="校准状态" width="150">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
|
||||
<el-tag v-else type="success" size="small">已校准</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
|
||||
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:page="queryParams.pageIndex"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
|
||||
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
|
||||
</div>
|
||||
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="form.needsRecalibration"
|
||||
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="dialog-alert"
|
||||
/>
|
||||
|
||||
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="配置名称" prop="name">
|
||||
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置类型" prop="kind">
|
||||
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
|
||||
<el-option label="危险区域" value="polygon" />
|
||||
<el-option label="方向警戒线" value="direction_line" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备与码流" prop="routeId" class="route-field">
|
||||
<el-select
|
||||
v-model="form.routeId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchRoutes"
|
||||
:loading="routesLoading"
|
||||
placeholder="输入设备、位置或码流名称"
|
||||
@change="handleRouteChange"
|
||||
>
|
||||
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
|
||||
<el-select v-model="form.direction" @change="dirty = true">
|
||||
<el-option label="从起点到终点" value="forward" />
|
||||
<el-option label="从终点到起点" value="reverse" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用配置">
|
||||
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="绑定规格" class="profile-summary">
|
||||
<span>{{ selectedProfileSummary }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="绘制区域" prop="points">
|
||||
<SenseGeometryEditor
|
||||
:model-value="form.points"
|
||||
:kind="form.kind"
|
||||
:direction="form.direction"
|
||||
:player-url="preview.playerUrl"
|
||||
:disabled="!form.routeId"
|
||||
@update:model-value="handlePointsChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="requestEditorClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
|
||||
<el-table v-loading="versionsLoading" :data="versions" border>
|
||||
<el-table-column label="版本" width="80">
|
||||
<template #default="scope">v{{ scope.row.version }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="180" />
|
||||
<el-table-column label="绑定规格" min-width="190">
|
||||
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="坐标点" width="90">
|
||||
<template #default="scope">{{ scope.row.points.length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
|
||||
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
import SenseGeometryEditor from '@/components/sense/geometry-editor'
|
||||
import { buildAreaPayload, validateAreaForm } from './areaPayload'
|
||||
|
||||
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
|
||||
|
||||
export default {
|
||||
name: 'SenseArea',
|
||||
components: { SenseGeometryEditor },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
items: [],
|
||||
total: 0,
|
||||
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
|
||||
editorOpen: false,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
form: emptyForm(),
|
||||
formError: '',
|
||||
rules: {
|
||||
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
|
||||
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
|
||||
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
|
||||
},
|
||||
routeOptions: [],
|
||||
routesLoading: false,
|
||||
preview: {},
|
||||
versionsOpen: false,
|
||||
versionsLoading: false,
|
||||
versions: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedRoute() {
|
||||
return this.routeOptions.find(route => route.id === this.form.routeId) || null
|
||||
},
|
||||
selectedProfileSummary() {
|
||||
if (!this.selectedRoute) return '尚未选择视频码流'
|
||||
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listAreaConfigurations(this.queryParams)
|
||||
this.items = response.data.list || []
|
||||
this.total = response.data.count || 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.$refs.queryForm.resetFields()
|
||||
this.handleQuery()
|
||||
},
|
||||
kindLabel(kind) {
|
||||
return kind === 'polygon' ? '危险区域' : '方向警戒线'
|
||||
},
|
||||
profileLabel(item) {
|
||||
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
|
||||
},
|
||||
routeLabel(route) {
|
||||
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
|
||||
},
|
||||
async searchRoutes(keyword = '') {
|
||||
this.routesLoading = true
|
||||
try {
|
||||
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
|
||||
const routes = response.data.list || []
|
||||
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
|
||||
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
|
||||
} finally {
|
||||
this.routesLoading = false
|
||||
}
|
||||
},
|
||||
async openCreate() {
|
||||
this.form = emptyForm()
|
||||
this.formError = ''
|
||||
this.preview = {}
|
||||
this.dirty = false
|
||||
this.editorOpen = true
|
||||
await this.searchRoutes('')
|
||||
},
|
||||
async openEdit(item) {
|
||||
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
|
||||
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
|
||||
this.formError = ''
|
||||
this.preview = {}
|
||||
this.dirty = false
|
||||
this.editorOpen = true
|
||||
if (item.routeId) await this.openPreview()
|
||||
},
|
||||
handleKindChange() {
|
||||
this.form.points = []
|
||||
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
},
|
||||
async handleRouteChange() {
|
||||
this.form.points = []
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
await this.openPreview()
|
||||
},
|
||||
handlePointsChange(points) {
|
||||
this.form.points = points
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
},
|
||||
async openPreview() {
|
||||
this.preview = {}
|
||||
if (!this.form.routeId) return
|
||||
try {
|
||||
const response = await createLiveviewSession(this.form.routeId)
|
||||
this.preview = response.data || {}
|
||||
} catch (error) {
|
||||
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
this.formError = validateAreaForm(this.form)
|
||||
if (this.formError) return
|
||||
try {
|
||||
await this.$refs.editorForm.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
this.saving = true
|
||||
try {
|
||||
const payload = buildAreaPayload(this.form)
|
||||
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
|
||||
else await createAreaConfiguration(payload)
|
||||
this.dirty = false
|
||||
this.editorOpen = false
|
||||
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
|
||||
await this.getList()
|
||||
} catch (error) {
|
||||
this.formError = error.message || '保存失败,请刷新后重试'
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
requestEditorClose() {
|
||||
this.beforeEditorClose(() => { this.editorOpen = false })
|
||||
},
|
||||
beforeEditorClose(done) {
|
||||
if (!this.dirty || this.saving) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
|
||||
},
|
||||
async openVersions(item) {
|
||||
this.versionsOpen = true
|
||||
this.versionsLoading = true
|
||||
this.versions = []
|
||||
try {
|
||||
const response = await listAreaVersions(item.id)
|
||||
this.versions = response.data || []
|
||||
} finally {
|
||||
this.versionsLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.dialog-header span,
|
||||
.muted-text,
|
||||
.profile-summary span {
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.empty-alert,
|
||||
.dialog-alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-input) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-select) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 20px;
|
||||
}
|
||||
|
||||
.route-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.route-field :deep(.el-select),
|
||||
.form-grid :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0 110px;
|
||||
color: var(--el-color-danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.route-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import GeometryEditor from '@/components/sense/geometry-editor/index.vue'
|
||||
|
||||
describe('Sense geometry editor keyboard operations', () => {
|
||||
it('adds, moves and removes a point without a mouse', () => {
|
||||
const context = {
|
||||
disabled: false,
|
||||
kind: 'polygon',
|
||||
points: [],
|
||||
undoStack: [],
|
||||
canAdd: true,
|
||||
$emit(event, points) { this.points = points }
|
||||
}
|
||||
context.commit = GeometryEditor.methods.commit.bind(context)
|
||||
GeometryEditor.methods.addCenterPoint.call(context)
|
||||
expect(context.points).toEqual([{ x: 0.5, y: 0.5 }])
|
||||
|
||||
const moveEvent = { key: 'ArrowRight', preventDefault: jest.fn() }
|
||||
GeometryEditor.methods.movePointByKeyboard.call(context, 0, moveEvent)
|
||||
expect(context.points[0].x).toBeCloseTo(0.51)
|
||||
expect(moveEvent.preventDefault).toHaveBeenCalled()
|
||||
|
||||
GeometryEditor.methods.movePointByKeyboard.call(context, 0, { key: 'Delete', preventDefault: jest.fn() })
|
||||
expect(context.points).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DIRECTION_LINE, POLYGON, clamp, geometryError } from '@/components/sense/geometry-editor/geometry'
|
||||
|
||||
describe('Sense area geometry', () => {
|
||||
it('validates polygon and direction-line constraints', () => {
|
||||
expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.1 }, { x: 0.5, y: 0.8 }])).toBe('')
|
||||
expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.8 }, { x: 0.8, y: 0.1 }, { x: 0.1, y: 0.8 }])).toContain('不能交叉')
|
||||
expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], 'forward')).toBe('')
|
||||
expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }], 'forward')).toContain('恰好 2 个点')
|
||||
})
|
||||
|
||||
it('clamps pointer coordinates to the normalized frame', () => {
|
||||
expect(clamp(-0.2)).toBe(0)
|
||||
expect(clamp(0.4)).toBe(0.4)
|
||||
expect(clamp(1.2)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { buildAreaPayload, validateAreaForm } from '@/views/sense/area/areaPayload'
|
||||
|
||||
describe('Sense area payload', () => {
|
||||
it('keeps the optimistic version and normalized geometry', () => {
|
||||
const form = { name: ' 东门警戒线 ', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: '0.2', y: '0.5' }, { x: '0.8', y: '0.5' }], direction: 'reverse', enabled: true, version: 4 }
|
||||
expect(validateAreaForm(form)).toBe('')
|
||||
expect(buildAreaPayload(form)).toEqual({ name: '东门警戒线', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], direction: 'reverse', enabled: true, expectedVersion: 4 })
|
||||
})
|
||||
|
||||
it('rejects incomplete geometry before sending', () => {
|
||||
expect(validateAreaForm({ name: '危险区域', kind: 'polygon', routeId: 'route-1', points: [] })).toContain('3 到 64')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
|
||||
wiki_revision: d32f00a86bb3127485b8ad4da8436bf2117352e0
|
||||
synchronized_at: 2026-08-14T01:06:22Z
|
||||
wiki_revision: 84cb91d5fb87fcb711c04c1f94b5c09ebe8c22a1
|
||||
synchronized_at: 2026-08-15T06:45:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -112,3 +112,11 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
|
||||
- `main`:用户审核通过的最小/发布基线;禁止直接开发和未经用户明确审核的合并。
|
||||
- `dev`:集成开发与测试分支;功能分支从 `dev` 派生,并通过 PR 合回 `dev`。
|
||||
- 只有用户明确审核通过,才能把 `dev` 合入 `main`。
|
||||
|
||||
<!-- sense-root-launcher:start -->
|
||||
## Sense Windows 项目目录启动入口
|
||||
|
||||
工单 #90 在 `Sense/start_sense.bat` 提供项目目录快捷入口。它只使用脚本自身路径定位 `Sense/dist/sense-windows-amd64/start-sense.bat`,透传 production、demo 和其他包内启动参数,并保留包内脚本退出码;不读取配置、不自动构建,也不直接启动 Go 或 Node 开发服务。
|
||||
|
||||
使用前必须先按 Windows 交付流程生成 `Sense/dist/sense-windows-amd64`。从仓库根目录可运行 `Sense\start_sense.bat`,进入 Sense 目录后可运行 `start_sense.bat` 或 `start_sense.bat demo`。交付包不存在时脚本返回非零并提示执行 `Sense\scripts\build\build-windows.bat`。
|
||||
<!-- sense-root-launcher:end -->
|
||||
|
||||
@@ -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: 0a15795aa69c5a0279a42ff1b48b44ddf68a9fad
|
||||
synchronized_at: 2026-08-14T10:45:46Z
|
||||
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、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
|
||||
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 只返回是否已配置,不提供凭据读取接口。
|
||||
|
||||
@@ -118,3 +120,25 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
|
||||
|
||||
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
|
||||
<!-- bell-mvp:end -->
|
||||
|
||||
<!-- sense-area:start -->
|
||||
## Sense 区域与警戒线代码入口
|
||||
|
||||
工单 #69 在 `Sense/server/app/sense/area/` 建立区域配置业务层,GoAdmin 路由位于 `Sense/server/app/admin/router/sense_area.go`,前端页面位于 `Sense/ui/src/views/sense/area/index.vue`。页面继续复用 BasicLayout、Axios、Element Plus 表单/表格/分页/Dialog/Tag/Alert 和权限指令;`Sense/ui/src/components/sense/geometry-editor/` 是唯一新增的业务专用绘制组件。
|
||||
|
||||
数据采用两层结构:`sense_area_definitions` 保存当前版本指针和当前校准状态,`sense_area_versions` 保存每次创建、编辑、启停或重校准形成的不可变快照。更新请求携带 `expectedVersion`,服务在 PostgreSQL 事务中锁定当前定义;并发保存只有一个成功,其余返回冲突。历史版本不覆盖、不删除。
|
||||
|
||||
几何坐标使用画面内 0–1 归一化值,同时每个版本固化 Device、Profile Token、分辨率和编码。视频接入成功替换 Profile 后,`admission` 服务比较 Token、分辨率和编码并主动标记当前定义 `needs_recalibration`;读取时也会对缺失或失效 Profile 进行保守校验。系统不自动重投影旧坐标,只有用户重新确认画面并保存新版本后才清除重校准状态。
|
||||
|
||||
认证 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 -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: e80685e19115e26384d58ddee8a15e5d80b03bb4
|
||||
synchronized_at: 2026-08-14T10:45:50Z
|
||||
wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18
|
||||
synchronized_at: 2026-08-15T01:13:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -134,3 +134,15 @@ synchronized_at: 2026-08-14T10:45:50Z
|
||||
- Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。
|
||||
- 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
<!-- sense-area:start -->
|
||||
## Sense 区域与方向警戒线规则
|
||||
|
||||
- 区域配置必须绑定已验证的 Device、Profile Token、分辨率和编码;客户端只提交媒体路由引用,最终绑定规格由后端重新查询确认。
|
||||
- 多边形使用 3–64 个画面内坐标点,必须有非零面积,边线不得自交或重叠;方向警戒线恰好两个不同点,并明确“起点到终点”或“终点到起点”。
|
||||
- 坐标以 0–1 归一化值保存,同时固化当时分辨率和编码。Profile 删除、Token 替换、分辨率或编码变化必须设置 `needs_recalibration`,不得静默缩放或重投影旧几何。
|
||||
- 新建为 v1;编辑、启停和重新校准都追加不可变版本。请求使用 `expectedVersion` 乐观并发,过期版本返回 409,旧版本保留用于审计。
|
||||
- `admin`、`implementation_operator`、`site_admin` 可创建和保存新版本;`viewer` 只读。页面状态必须同时使用文字和 Tag,不能只靠颜色表达。
|
||||
- 鼠标可点击/拖动顶点;键盘必须能添加、移动和删除顶点。错误在绘制区域附近以可被辅助技术感知的文字给出,并提供撤销、清空和未保存关闭确认。
|
||||
- 区域配置是 Sense 内部事实;#69 不发布 Brain 契约。后续 Sense→Brain 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。
|
||||
<!-- sense-area:end -->
|
||||
|
||||
@@ -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: 95be7e2aa152205f524a185897dff5eb2c7e7e04
|
||||
synchronized_at: 2026-08-14T10:45:55Z
|
||||
wiki_revision: 925a16a72bd5840697b5c61489a97018134b3930
|
||||
synchronized_at: 2026-08-27T08:07:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -146,7 +146,9 @@ 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 字节策略。
|
||||
|
||||
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,不依赖通用操作日志数据库开关。
|
||||
|
||||
@@ -230,9 +232,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 -->
|
||||
|
||||
|
||||
@@ -245,3 +276,66 @@ corepack pnpm@9.15.1 build:prod
|
||||
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
|
||||
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
|
||||
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
|
||||
|
||||
<!-- sense-area:start -->
|
||||
### Sense 区域与警戒线验证
|
||||
|
||||
后端定向与全量验证:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
go test ./app/sense/area ./app/sense/admission
|
||||
go test -race ./app/sense/area ./app/sense/admission
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
使用隔离 PostgreSQL 验证迁移和真实并发;连接值只放当前进程环境,不写入仓库或日志:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_AREA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./cmd/migrate/migration/version -run TestAreaMigrationOnPostgres -count=1 -v
|
||||
|
||||
$env:SENSE_AREA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./app/sense/area -run TestConcurrentUpdateOnPostgresReturnsConflict -count=1 -v
|
||||
```
|
||||
|
||||
前端验证:
|
||||
|
||||
```powershell
|
||||
cd Sense/ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit
|
||||
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 -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: 331755cb39ac9d63c188395c5caafe0815d64975
|
||||
synchronized_at: 2026-08-14T10:46:04Z
|
||||
wiki_revision: 1a452e9aafdfe01580f37f9179584b89516cf992
|
||||
synchronized_at: 2026-08-15T07:59:38Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -101,3 +101,54 @@ synchronized_at: 2026-08-14T10:46:04Z
|
||||
<!-- sense-media:end -->
|
||||
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
<!-- sense-area:start -->
|
||||
## Sense 区域与警戒线排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 显示“需要重新校准” | 绑定 Profile 已删除、验证失效,或 Token、分辨率、编码发生变化;打开“编辑/校准”,选择当前可用码流,在实际画面确认坐标后保存新版本。不要手工清状态或复制旧坐标冒充校准。 |
|
||||
| 保存提示配置已被其他用户更新 | 当前页面的 `expectedVersion` 已过期;刷新列表,查看最新版本后重新编辑。系统会保留已成功写入的版本,不覆盖对方结果。 |
|
||||
| 画面可见但无法添加更多顶点 | 方向警戒线最多两个点,多边形最多 64 个点;检查配置类型,必要时撤销或清空后重画。 |
|
||||
| 提示边线交叉或面积过小 | 顶点顺序形成自交、重叠或退化多边形;拖动顶点消除交叉,确保至少三个不同且围成有效面积的点。 |
|
||||
| 实时画面不可用 | 先到“实时监看”确认该 Profile 可播放,再检查 MediaMTX/WebRTC 公开地址。区域 API 不返回或要求填写 RTSP URI。 |
|
||||
| Profile 已恢复但仍显示重校准 | 这是保守安全状态;恢复相同规格不会自动认可旧坐标。必须由有权限用户打开实际画面确认并保存新版本。 |
|
||||
| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 |
|
||||
| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 |
|
||||
<!-- sense-area:end -->
|
||||
|
||||
<!-- sense-capabilities-jsonb:start -->
|
||||
## Sense 旧设备能力字段迁移排错
|
||||
|
||||
启动迁移出现 `字段 "capabilities" 的默认值不能转换成类型 jsonb (SQLSTATE 42804)`,表示数据库仍保留旧版 `sense_devices.capabilities text DEFAULT ''`,而当前 GoAdmin 派生模型要求 JSONB。不要跳过迁移、删除设备记录或只手工删除默认值;旧单值数据仍可能在下一步转换失败。
|
||||
|
||||
工单 #92 的兼容迁移会在同一事务内锁定设备表并先验证全部旧值:空值转为 `[]`,`video`、`radar`、`contact`、`button`、`wearable`、`other` 等旧单值转为 JSON 数组,合法 JSON 数组保持数组。未知值或非数组 JSON 会拒绝迁移并整体回滚,不输出具体业务值。
|
||||
|
||||
处理步骤:
|
||||
|
||||
1. 停止所有连接该 Sense 数据库的服务实例。
|
||||
2. 使用 `backup-sense.bat` 创建 PostgreSQL custom-format 备份,并确认备份文件可读取。
|
||||
3. 部署包含 #92 的新 `sense.exe` 后重新运行 `migrate-sense.bat` 或正常启动。
|
||||
4. 若提示“unsupported legacy data”,不要直接改表;保留错误、恢复测试副本并由维护人员确认旧能力语义。
|
||||
5. 迁移成功后确认设备仍存在、能力标签正确,再启动其他实例。
|
||||
|
||||
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
|
||||
<!-- sense-capabilities-jsonb:end -->
|
||||
|
||||
<!-- sense-media-path-constraint:start -->
|
||||
## Sense 旧媒体路由迁移排错
|
||||
|
||||
旧库迁移出现 `约束 "uni_sense_media_routes_path" 不存在 (SQLSTATE 42704)`,表示旧 `sense_media_routes.path` 由 PostgreSQL 自动命名的唯一约束保护,而新 GORM 模型准备改用唯一索引;GORM 按推导名称删除旧约束时找不到实际名称。修正该名称后若继续出现 `source_ready ... contains null values (SQLSTATE 23502)`,表示非空旧表还缺少当前模型要求的运行态列。
|
||||
|
||||
工单 #95 的兼容迁移只在 PostgreSQL 旧表存在时执行:取得 ACCESS EXCLUSIVE 表锁,确认只有一个单列 `UNIQUE(path)` 约束,将实际约束名规范为 GORM 可识别名称;同时为旧路由初始化保守运行态 `source_ready=false`、`failure_count=0`、`last_error_code=''`,再继续 AutoMigrate。迁移不会把旧路由伪装成已就绪,服务启动后仍由对账恢复真实状态。复合约束、多重 path 约束或其他无法确认的唯一性结构会拒绝迁移并整体回滚。
|
||||
|
||||
处理步骤:
|
||||
|
||||
1. 停止连接该数据库的全部 Sense 实例,并确认 Sense 与 MediaMTX 相关端口已释放。
|
||||
2. 使用 `backup-sense.bat` 生成 PostgreSQL custom-format 备份;非标准 PostgreSQL 安装目录需通过 `SENSE_POSTGRES_BIN` 指向包含 `pg_dump.exe`、`pg_restore.exe` 的目录。
|
||||
3. 使用 `pg_restore --list <备份文件>` 确认备份可读取,再部署包含 #95 的 Windows 包。
|
||||
4. 先运行 `migrate-sense.bat`;成功后确认旧路由数量不变、运行态列无空值、`path` 仍有唯一索引。
|
||||
5. 再启动 Sense,检查首页、`/healthz`、MediaMTX Control API 和视频服务对账;验证完成后使用 `stop-sense.bat` 停止。
|
||||
|
||||
如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。
|
||||
<!-- sense-media-path-constraint:end -->
|
||||
|
||||
@@ -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: e6c6e0d7f658a7c1040fe569026e7d0ec581701a
|
||||
synchronized_at: 2026-08-17T02:54:53Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -48,7 +48,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
|
||||
|
||||
### P0
|
||||
|
||||
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。
|
||||
- **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 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。
|
||||
|
||||
+11
-7
@@ -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 -->
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<!-- 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: a32fd29a7b8c4fa42396e9f249bcdd99d0c09e74
|
||||
synchronized_at: 2026-08-27T07:52:48Z
|
||||
<!-- 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、#104 与 #101 均已由用户验收;#101 经 PR #102 合入 `dev`,`main` 保持不变。#90 的项目档案 Wiki 更新未混入本任务提交;客户全新 Windows 主机及三个非 admin 岗位的人工视觉验收仍未覆盖。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `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`。
|
||||
## #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 三种岗位的人工视觉验收;用户已确认当前交付验收通过。
|
||||
|
||||
## 验收确认
|
||||
|
||||
- 2026-08-27,用户明确确认“#101通过验收”。
|
||||
- PR #102 按规定合入 `dev`,`main` 未变更。
|
||||
- 工单 #101 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- 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: 2b4df23ec321de868840da61d87d8d6fce8537a7
|
||||
synchronized_at: 2026-08-17T03:46:13Z
|
||||
<!-- 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 参数单测覆盖。用户已于 2026-08-17 明确验收通过,浏览器关闭/重开行为由人工确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 当前服务已恢复为用户原有的 #101 验证包,避免应用外壳回退;#104 独立包保留为具名 ZIP,未覆盖当前标准包。
|
||||
- 当前架构没有服务端单 Token 撤销能力,这是确认范围内的安全限制。
|
||||
|
||||
## 验收确认
|
||||
|
||||
- 用户于 2026-08-17 明确回复“#104 验收通过”。
|
||||
- PR #105 按仓库门禁合入 `dev`;`main` 不变。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `857ba45` feat: 将 Sense 登录有效期调整为30天 (#104)
|
||||
- `a843121` docs: 记录 Sense 30天登录有效期 (#104)
|
||||
- `adc227f` test: 验证 Sense 30天 JWT 到期时间 (#104)
|
||||
@@ -0,0 +1,77 @@
|
||||
<!-- 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: fcff45a700cba3db939819cfc5deb1cc97b8155b
|
||||
synchronized_at: 2026-08-27T08:17:20Z
|
||||
<!-- 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 托管方式。
|
||||
|
||||
## 验收确认
|
||||
|
||||
- 2026-08-27,用户明确确认“#106 验收通过”。
|
||||
- PR #107 按规定合入 `dev`,`main` 未变更。
|
||||
- 工单 #106 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
|
||||
@@ -0,0 +1,84 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-69-Sense多边形区域与方向警戒线配置
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-69-Sense%E5%A4%9A%E8%BE%B9%E5%BD%A2%E5%8C%BA%E5%9F%9F%E4%B8%8E%E6%96%B9%E5%90%91%E8%AD%A6%E6%88%92%E7%BA%BF%E9%85%8D%E7%BD%AE.-
|
||||
wiki_revision: c679c3c2b41a190480732cd28cc52083c76843e5
|
||||
synchronized_at: 2026-08-15T01:36:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 69 Sense 多边形区域与方向警戒线配置
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/69
|
||||
- 评审 PR:https://git.ilapage.cn/ila/yovision/pulls/87
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #61 冻结的 GoAdmin/go-admin-ui 源码派生基线上,为网管和非技术实施人员提供绑定 Device/Profile/分辨率的危险区域与方向警戒线配置。配置必须可追溯、能识别视频 Profile 变化,并且 Brain、Bell 不启动时可独立管理和预览。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 新增当前区域定义与不可变版本表;创建为 v1,编辑、启停和重新校准均追加版本,不覆盖历史。
|
||||
- 服务层在 PostgreSQL 事务内锁定当前定义并校验 `expectedVersion`;并发保存只允许一方成功,另一方返回稳定 409 冲突。
|
||||
- 服务端根据监看路由解析 Device/Profile,保存归一化坐标和冻结的 Profile token、分辨率、编码等非敏感快照;不信任客户端提交的绑定元数据。
|
||||
- 多边形支持 3–64 个点并拒绝越界、零面积、自交和重叠边;方向线固定两个点并记录正向/反向。
|
||||
- admission 完成 Profile 替换后按 token、分辨率和编码比较,主动标记 `needs_recalibration`;读取时也保守识别 Profile 缺失、未验证或发生变化。
|
||||
- 前端复用 go-admin-ui 的 BasicLayout、Axios、Element Plus 搜索、表格、分页、表单、Dialog、Tag、Alert 和权限按钮,仅新增视频坐标几何编辑器。
|
||||
- 编辑器支持鼠标点选/拖动,以及键盘 Enter/Space 加点、方向键移动、Delete/Backspace 删除;提供可见焦点、文字错误、撤销、清空和未保存关闭确认。
|
||||
- 权限沿用 GoAdmin Casbin:viewer 只读,implementation_operator/site_admin 可写;页面预览复用 #68 的实时监看会话。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/area/**`:模型、DTO、几何校验、服务、API 与测试。
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081509000_area*`:表、菜单、权限迁移及测试。
|
||||
- `Sense/server/app/admin/router/sense_area.go`:区域 API 路由。
|
||||
- `Sense/server/app/sense/admission/service*.go`:Profile 替换触发重新校准。
|
||||
- `Sense/ui/src/views/sense/area/**`、`Sense/ui/src/api/sense/area.js`:区域管理页面、请求与载荷。
|
||||
- `Sense/ui/src/components/sense/geometry-editor/**`:业务专用几何编辑器。
|
||||
- `Sense/ui/tests/unit/sense/area*.spec.js`:载荷、几何和页面行为单测。
|
||||
- Wiki 的 Architecture、Business Rules、Local Development、Troubleshooting:记录架构、规则、验证和排错;`docs/` 为同步镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 多边形与方向线几何校验、版本并发和审计成立 | 通过 |
|
||||
| 配置绑定明确的 Device/Profile/分辨率 | 通过 |
|
||||
| Profile、分辨率或能力变化触发 needs_recalibration | 通过 |
|
||||
| 旧版本可追溯且不能被静默覆盖 | 通过 |
|
||||
| 画布键盘/鼠标操作、错误反馈和视觉风格一致 | 通过 |
|
||||
| Brain/Bell 未启动时可独立保存、读取和预览配置 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `go vet ./...`:通过。
|
||||
- `go build ./...`:通过。
|
||||
- `go test -race ./app/sense/area ./app/sense/admission`:通过。
|
||||
- PostgreSQL 17 实库:迁移、3 个菜单、10 条 Casbin 策略及并发更新测试通过;并发结果为一次成功、一次冲突、两个版本。
|
||||
- `pnpm lint`:通过,0 error;保留 32 个冻结上游 warning。
|
||||
- `pnpm test:unit`:16 suites / 45 tests 通过。
|
||||
- `pnpm build:prod`:通过,保留 6 个冻结上游 warning。
|
||||
- Edge/Playwright 浏览器烟测:鼠标绘制、键盘移动/删除和 ARIA 错误提示通过。
|
||||
- Brain、Bell 未启动,独立后端与前端验证通过。
|
||||
- **未验证部分**:客户现场授权摄像头、实际部署反向代理及目标客户浏览器上的端到端人工操作尚未验证,留给本工单人工验收或后续集成验收。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- Sense→Brain 的版本化区域配置发布属于后续共享契约工单,本工单未写入 `contracts/`,不阻塞 Sense 独立配置能力。
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 用户于 2026-08-15 明确确认“#69 验收通过”。
|
||||
- PR #87 已合并到 `dev`,合并提交:`61b79db9f72db3ca8b7187898b34763d9cab806c`。
|
||||
- 工单在归档与父工单同步完成后关闭;本次未合入 `main`。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `17bd383` feat: 重建 Sense 区域与警戒线配置 (#69)
|
||||
- `f82dd51` docs: 记录 Sense 区域配置架构 (#69)
|
||||
- `61b79db9` Merge pull request '#87' from feature/69-sense-area into dev
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- 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: 9eb390dfd691acc089656a838dd3f12e24f97884
|
||||
synchronized_at: 2026-08-16T11:33:28Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 70 Sense Windows配置启动与打包交付
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70
|
||||
- Wiki 页面:Task-70-Sense-Windows配置启动与打包交付
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #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,455,903 字节,SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。包内 `VERSION.txt` 记录实现提交 `b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`,包含 #92、#95 的旧库兼容修复、白屏修复及已验收 #97 的免验证码登录。
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./app/sense/media ./cmd/api`:通过。
|
||||
- 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`:通过。
|
||||
- `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 服务失败。
|
||||
|
||||
|
||||
## 白屏验收反馈修复
|
||||
|
||||
- 用户运行发布包后访问生产入口出现白屏。只读诊断确认首页 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 产品代码和交付包验证。
|
||||
- 客户环境验收需由实施人员使用脱敏测试账户和获准设备完成。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `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`。
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 2026-08-16:用户明确验收通过 #70。
|
||||
- #95 已先合入 `dev`,随后按依赖顺序合并 PR #89;`main` 保持不变。
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-90-Sense项目根目录Windows启动脚本
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-90-Sense%E9%A1%B9%E7%9B%AE%E6%A0%B9%E7%9B%AE%E5%BD%95Windows%E5%90%AF%E5%8A%A8%E8%84%9A%E6%9C%AC.-
|
||||
wiki_revision: cdfab02efe085b8e40cc19b0941e5c3d3c1ae7aa
|
||||
synchronized_at: 2026-08-27T08:40:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 90 Sense项目根目录Windows启动脚本
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/90
|
||||
- Wiki 页面:Task-90-Sense项目根目录Windows启动脚本
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
#70 建立的 Windows 交付包入口位于 `Sense/dist/sense-windows-amd64/start-sense.bat`,用户从项目目录启动时需要进入多层目录。工单 #90 增加项目根目录快捷入口,同时保持包内脚本为配置、迁移和服务编排的唯一事实源。
|
||||
|
||||
## 最终方案
|
||||
|
||||
新增 `Sense/start_sense.bat`。脚本使用 `%~dp0` 定位同一项目下的交付包,不依赖调用者当前工作目录;通过 `call ... %*` 原样透传 production、demo 和开关参数,并保存子脚本退出码。交付包入口不存在时输出预期路径和构建命令,返回退出码 2。
|
||||
|
||||
脚本不读取 `sense.env`、不处理密码或 token、不自动构建,也不直接启动 Go/Node 开发服务。项目 README 与 Wiki Project-Profile 记录快捷命令;#70 的包内说明和运行脚本保持不变。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/start_sense.bat`:项目根目录 Windows 启动入口。
|
||||
- `Sense/README.md`:增加 production/demo 快捷启动说明与职责边界。
|
||||
- Wiki `Project-Profile`、`docs/00-project-profile.md`:记录长期启动入口。
|
||||
- `wiki-docs.json`、`docs/task/90-Sense项目根目录Windows启动脚本.md`:登记任务归档镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 从任意工作目录定位包内入口 | 通过;从 `C:\Windows` 使用隔离夹具和当前本地交付包验证 |
|
||||
| 参数和退出码原样传递 | 通过;`demo -SkipMigration "two words"` 原样到达假包内脚本,退出码 17 保持 |
|
||||
| 缺失交付包时明确失败 | 通过;输出预期路径和构建命令,退出码 2 |
|
||||
| 不包含秘密、不解析配置、不复制启动实现 | 通过;脚本仅 17 行定位、检查、调用和退出码逻辑 |
|
||||
| 项目说明和 Wiki 镜像一致 | 通过;受影响页面定向同步与检查通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 隔离缺失包夹具:从 `C:\Windows` 调用,错误信息可行动,退出码 2。
|
||||
- 隔离假包内脚本:参数输出为 `demo -SkipMigration "two words"`,子脚本退出码 17 被根入口保留。
|
||||
- 当前 #70 本地交付包:从 `C:\Windows` 分别调用包内入口和根入口并传入安全的无效模式,两者均返回参数校验退出码 1,证明真实路径连接和退出码一致。
|
||||
- 敏感关键词扫描:脚本不含 password、token、secret、database URL 或 credential。
|
||||
- `git diff --check`:通过。
|
||||
- Wiki 受影响页面定向 `sync_wiki_docs.py --check --config .tmp-wiki-90.json`:通过。
|
||||
- **未验证部分**:未通过根入口实际启动 production/demo 服务,以避免在本工单重复操作数据库和服务进程;真实启动链已由 #70 验证。完整 Wiki 全量检查暂受待验收 PR #89 已更新但尚未合入 `dev` 的三个 #70 镜像影响,#90 只对不冲突页面做定向一致性检查。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- `dev` 合入 #70 后才包含根入口所调用的版本化包内构建和启动脚本;在此之前根入口会按设计提示先构建且返回 2。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `a865dda` 增加 Sense 根目录启动脚本。
|
||||
- `06d982d` 记录 Sense 根目录启动方式。
|
||||
|
||||
## 最新 dev 组合回归(2026-08-27)
|
||||
|
||||
- 分支合并 `dev@15142157299936231fc4dda9aa6624bba34aeed3`,合并提交为 `404e25584e41e4c3ba4e8850b4aa07452ae3f7af`。
|
||||
- 唯一冲突位于 `wiki-docs.json`:#90 与后续 #70/#95/#97/#99/#101/#104/#106 同时新增任务归档映射;解决时保留全部映射。Sense 业务代码和启动脚本无冲突。
|
||||
- 在仓库外隔离夹具中从 `D:\` 调用根脚本,`demo -SkipMigration "two words"` 原样到达包内脚本,退出码 17 保持。
|
||||
- 缺失交付包时输出预期路径与构建命令并返回 2;当前真实交付包入口存在于约定路径。
|
||||
- 根脚本敏感关键词扫描、`git diff --check`、Harness 31 项单测通过;全量 Wiki 镜像检查通过。
|
||||
- `check_harness.py --strict` 仍仅被既有 #66/#67 归档缺少模板章节的 4 项问题阻断,与 #90 无关。
|
||||
- 为避免与当前 Supervisor 托管的生产实例争用 18080,本轮没有通过根入口重复启动服务;`yovision-sense` 保持 Running,健康检查返回 200。
|
||||
|
||||
## 验收确认
|
||||
|
||||
- 2026-08-27,用户明确确认“#90 验收通过”。
|
||||
- PR #91 按规定合入 `dev`,`main` 未变更。
|
||||
- 工单 #90 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-92-Sense旧设备能力JSONB兼容迁移
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-92-Sense%E6%97%A7%E8%AE%BE%E5%A4%87%E8%83%BD%E5%8A%9BJSONB%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
|
||||
wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e
|
||||
synchronized_at: 2026-08-15T07:30:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 92 Sense旧设备能力JSONB兼容迁移
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92
|
||||
- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
用户通过 Windows 包启动 Sense 时,设备迁移报 PostgreSQL `SQLSTATE 42804`。只读诊断确认旧数据库的 `sense_devices.capabilities` 为 `text NOT NULL DEFAULT ''::text`,一条旧记录保存受支持的单值能力但不是 JSON;新模型要求 JSONB,GORM AutoMigrate 无法直接转换旧默认值和数据。
|
||||
|
||||
目标是在不删除设备、不跳过迁移、不修改当前用户数据库的前提下,为旧 schema 提供确定性、可回滚的 JSONB 兼容迁移。
|
||||
|
||||
## 最终方案
|
||||
|
||||
在现有 `2026081414000` 设备迁移事务开头执行 PostgreSQL 专用兼容步骤。受影响数据库尚未登记该迁移版本,后置新版本无法越过失败点,因此兼容逻辑必须放在原失败迁移内。
|
||||
|
||||
兼容步骤只处理既有 text/varchar 列:取得 ACCESS EXCLUSIVE 表锁,读取并验证全部旧值后才开始改变默认值和数据。空值转为 `[]`;六种受支持旧单值转为单元素 JSON 数组;合法字符串数组规范化后保持语义。未知单值、对象、非字符串数组、未知数组元素和超过 16 项的数组会返回不含业务值的错误,整个事务回滚。
|
||||
|
||||
全部验证通过后删除旧 text 默认值,参数化更新规范 JSON,使用显式 `USING capabilities::jsonb` 转型并设置 `'[]'::jsonb` 默认值,再继续原有 GORM AutoMigrate、菜单、权限和迁移版本登记。非 PostgreSQL、表/列不存在以及已是 json/jsonb 时不执行旧值转换。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:事务内旧 text 能力验证、转换、锁表和 JSONB 默认值处理。
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go`:纯函数和隔离 PostgreSQL 17 回归,包括完整设备迁移与版本登记。
|
||||
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:备份、重试、未知旧值和回退说明。
|
||||
- `wiki-docs.json`、`docs/task/92-Sense旧设备能力JSONB兼容迁移.md`:任务归档登记和镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 旧 text 默认值不再触发 SQLSTATE 42804 | 通过;隔离 PostgreSQL 重现结构成功转为 jsonb |
|
||||
| 旧单值无损转换为 JSONB 数组 | 通过;`video` 转为单元素数组 |
|
||||
| 空值与合法数组正确处理 | 通过;空字符串转空数组,合法数组保持顺序与值 |
|
||||
| 未知值拒绝且无半成品 | 通过;类型、默认值和已验证行均保持旧状态 |
|
||||
| 新库、jsonb 和重复执行兼容 | 通过;无表跳过、jsonb 重复调用无操作 |
|
||||
| 全量 Go 和迁移回归通过 | 通过 |
|
||||
| 当前用户数据库未被修改 | 通过;仅执行只读诊断,写测试使用独立数据库并在结束后删除 |
|
||||
| Wiki 与归档一致 | 受影响页面定向检查通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1 -v`:通过。
|
||||
- 隔离 PostgreSQL 17 `TestDeviceCapabilitiesMigrationOnPostgres`:旧单值/空值/合法数组、未知值事务回滚、无表、重复调用、完整设备迁移和 `sys_migration` 登记全部通过。
|
||||
- 隔离数据库 `sense92_migration_test` 测试前确认不存在,测试后确认计数为 0。
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- 受影响 Wiki 定向同步与检查:通过。
|
||||
- **未验证部分**:按工单安全边界未在当前用户 `sense` 数据库执行写迁移,也未替换当前 `Sense/dist` 中的待验收 #70 二进制;需先备份,再部署包含 #92 的新包进行最终启动验收。全量 Wiki 检查仍会先发现待验收 PR #89 的 #70 镜像尚未合入 `dev`。
|
||||
|
||||
## 用户验收
|
||||
|
||||
- 用户于 2026-08-15 明确确认 `#92 验收通过`。
|
||||
- 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。
|
||||
- #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。
|
||||
- Harness strict 的 #66/#67 既有归档格式问题不属于本工单。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `68b436a` 兼容旧设备能力字段迁移。
|
||||
- `fe3badf` 覆盖旧设备完整迁移链。
|
||||
- `aee5f45` 记录设备能力迁移排错。
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-95-Sense旧媒体路由唯一约束兼容迁移
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-95-Sense%E6%97%A7%E5%AA%92%E4%BD%93%E8%B7%AF%E7%94%B1%E5%94%AF%E4%B8%80%E7%BA%A6%E6%9D%9F%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
|
||||
wiki_revision: 5f3bdf3786f2a72dac5d29236ff466743e2912b5
|
||||
synchronized_at: 2026-08-16T11:31:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 95 Sense旧媒体路由唯一约束兼容迁移
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/95
|
||||
- Wiki 页面:Task-95-Sense旧媒体路由唯一约束兼容迁移
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
#92 修复进入真实旧库后,设备能力字段已成功转换为 JSONB;下一条媒体迁移因 GORM 尝试删除不存在的推导约束名 `uni_sense_media_routes_path` 而报 SQLSTATE 42704。只读核对确认旧 `sense_media_routes.path` 实际由 PostgreSQL 自动命名约束 `sense_media_routes_path_key` 保证唯一,且表中已有 2 条路由。
|
||||
|
||||
隔离回归越过约束错误后进一步确认,旧非空表缺少当前模型要求的运行态列,直接新增 `source_ready NOT NULL` 会报 SQLSTATE 23502。目标是在不删除路由、不削弱 path 唯一性、不伪造媒体已就绪的前提下完成旧表迁移。
|
||||
|
||||
## 最终方案
|
||||
|
||||
在现有 `2026081419000` 媒体迁移事务开头执行 PostgreSQL 专用兼容步骤。仅当旧表存在时取得 ACCESS EXCLUSIVE 锁,从 pg_catalog 读取包含 path 的唯一约束;只接受唯一的单列 `UNIQUE(path)`,复合、多重或冲突结构拒绝迁移并整体回滚。
|
||||
|
||||
确认结构后,把数据库实际约束名规范为 GORM 能识别和移除的名称,让 AutoMigrate 转换为模型的 `idx_sense_media_routes_path` 唯一索引。锁在整个迁移事务提交前持续有效,因此约束切换期间没有并发写入窗口。
|
||||
|
||||
兼容步骤同时为旧路由添加并回填当前模型要求的运行态列:`source_ready=false`、`failure_count=0`、`last_error_code=''`,随后设为 NOT NULL。保守初值表示服务启动后必须重新对账,不把旧路由冒充为已经就绪;`next_retry_at` 保持可空并由 AutoMigrate 建立。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081419000_media.go`:旧约束识别、规范化、锁表和运行态列兼容。
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go`:隔离 PostgreSQL 旧表、非空路由、空库、无表、重复执行、唯一性和不安全结构回滚测试。
|
||||
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:错误含义、备份、迁移、验证和回退步骤。
|
||||
- `wiki-docs.json`、`docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md`:任务归档登记和镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 旧约束名不再触发 SQLSTATE 42704 | 通过;隔离和真实 PostgreSQL 均完成媒体迁移 |
|
||||
| 既有媒体路由完整保留 | 通过;真实库迁移前后均为 2 条 |
|
||||
| path 始终具有唯一性保护 | 通过;迁移后 `idx_sense_media_routes_path` 唯一索引有效,重复 path 写入被拒绝 |
|
||||
| 无表、新库、已迁移库和重复兼容 | 通过 |
|
||||
| 不安全结构拒绝并回滚 | 通过;复合 path 约束夹具未发生部分变更 |
|
||||
| Go 全量与隔离 PostgreSQL 回归 | 通过 |
|
||||
| #70 Windows 包真实迁移与启动 smoke | 通过;Web/SPA/health/MediaMTX 200,停止后端口清空 |
|
||||
| Wiki 镜像与任务归档一致 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 隔离 PostgreSQL 17 `TestMediaMigrationOnPostgres`:旧 2 路由、旧约束、运行态回填、空库、无表、重复兼容、唯一性冲突和复合约束回滚全部通过;独立测试数据库每次运行后删除。
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -count=1`:使用隔离 PostgreSQL 通过。
|
||||
- #70 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与包审计通过。
|
||||
- 迁移前 PostgreSQL custom-format 备份通过 `pg_restore --list`;真实迁移完成,2 条旧路由保留、运行态列无空值、媒体迁移版本登记、唯一索引有效。
|
||||
- Web 首页、SPA、`/healthz` 与 MediaMTX Control API 均返回 200;`stop-sense.bat` 后相关端口无监听。
|
||||
- `git diff --check`:通过。
|
||||
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和真实获准摄像机上验收;当前回归使用本机 PostgreSQL、脱敏业务计数和已配置测试摄像机环境。Harness strict 仍只受既存 #66/#67 归档格式影响。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响,访问 loopback 时失败;明确绕过代理的本机 HTTP 请求验证服务正常,不属于 Sense 服务端故障。
|
||||
- #95 需先经用户验收并合入 `dev`,随后 #70 才能按依赖顺序完成合并。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `3dd4890` 兼容旧媒体路由约束和运行态列迁移。
|
||||
- `6257859` 记录旧媒体路由迁移排错。
|
||||
- `c088caf` #70 集成 #95 后用于 Windows 包真实回归。
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 2026-08-16:用户明确验收通过 #95。
|
||||
- 按依赖顺序先将 PR #96 合入 `dev`;`main` 保持不变。
|
||||
@@ -0,0 +1,85 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-97-Sense免验证码登录与管理员密码重置
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-97-Sense%E5%85%8D%E9%AA%8C%E8%AF%81%E7%A0%81%E7%99%BB%E5%BD%95%E4%B8%8E%E7%AE%A1%E7%90%86%E5%91%98%E5%AF%86%E7%A0%81%E9%87%8D%E7%BD%AE.-
|
||||
wiki_revision: 486ebcca10e4cef0bd905df59b928c17006db17e
|
||||
synchronized_at: 2026-08-16T11:04:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 97 Sense免验证码登录与管理员密码重置
|
||||
|
||||
- 类型:安全行为调整 / 缺陷修复
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/97
|
||||
- Wiki 页面:Task-97-Sense免验证码登录与管理员密码重置
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
GoAdmin 重建后的 Sense 登录页和生产后端重新启用了验证码,与用户确认的账号密码直接登录流程不一致。用户要求所有运行模式恢复免验证码登录,并把当前本地 PostgreSQL 的管理员账号设置为用户指定、满足现行 6–72 字节策略的密码;密码明文不得进入仓库、工单、Wiki 或日志。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 保留冻结 GoAdmin 的 Gin/GORM/JWT/Casbin `Authenticator`、登录路由、Vuex 登录动作、Element Plus 表单和同步身份审计。
|
||||
- 登录 DTO 只保留 `username/password`;production、test、dev 均不再校验验证码。
|
||||
- 登录页移除验证码字段、规则、图标、接口请求、失败刷新和样式;兼容保留 `/api/v1/captcha` 端点及上游存储初始化,便于回退。
|
||||
- Swagger 登录载荷同步为只要求账号和密码。
|
||||
- 修复失败认证分支未把用户名写入审计的问题,使错误密码尝试可按账号追踪。
|
||||
- 目标数据库起初没有任何用户,因此没有执行不安全的直接插入;使用一次性高熵进程令牌走既有 `/api/v1/bootstrap` 安全初始化路径创建 `admin`,密码只在进程内传递。随后验证正确密码成功、错误密码拒绝和成功/失败审计。
|
||||
- 密码最少 6 位、最多 72 字节且不强制字符复杂度的既有策略保持不变;JWT、RBAC、Cookie 和会话有效期未修改。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/common/middleware/handler/auth.go`:移除验证码校验并补齐失败登录用户名审计。
|
||||
- `Sense/server/common/middleware/handler/login.go`:登录载荷只保留账号和密码。
|
||||
- `Sense/server/common/middleware/handler/login_test.go`:覆盖无验证码登录载荷。
|
||||
- `Sense/server/docs/admin/admin_docs.go`、`admin_swagger.json`、`admin_swagger.yaml`:同步登录接口模型。
|
||||
- `Sense/ui/src/views/login/index.vue`:移除验证码 UI、请求和状态。
|
||||
- `Sense/ui/tests/unit/login/loginPage.spec.js`:覆盖登录页只使用账号密码。
|
||||
- Wiki `Product-Requirements`、`Local-Development-and-Verification` 及镜像:记录免验证码登录、安全审计和密码策略边界。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 登录页不显示验证码且不请求验证码接口 | 通过:组件状态、规则与方法均只含账号密码;生产构建通过 |
|
||||
| 所有模式接受仅账号密码的登录载荷 | 通过:后端不再按模式进入 captcha 校验,DTO 定向测试通过 |
|
||||
| 正确密码成功、错误密码拒绝并有脱敏审计 | 通过:本地 production/PostgreSQL smoke 成功;成功与失败审计均可按 admin 查询 |
|
||||
| JWT、RBAC、未登录拒绝不变 | 通过:未认证管理路由返回 401,全量后端测试通过 |
|
||||
| 密码策略仍为 6–72 字节 | 通过:现有密码策略测试通过,相关代码未修改 |
|
||||
| 管理员密码安全设置且仓库无明文 | 通过:空用户库经一次性 bootstrap 创建,跟踪差异秘密扫描无泄漏 |
|
||||
| 前后端测试和 production build | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `go vet ./...`:通过。
|
||||
- `go build ./...`:通过。
|
||||
- `corepack pnpm@9.15.1 exec eslint src/views/login/index.vue tests/unit/login/loginPage.spec.js`:通过。
|
||||
- 前端全量单测:17 个 suite、46 个 test 通过。
|
||||
- `corepack pnpm@9.15.1 run build:prod`:通过;存在既有 Sass、SCSS export、代码生成器和体积 warning,未由本工单引入。
|
||||
- production/PostgreSQL/MediaMTX smoke:安全初始化成功、免验证码登录成功、错误密码拒绝、未认证接口返回 401;身份审计包含首个管理员创建、登录成功和登录失败记录。
|
||||
- `python -m unittest discover -s tests -v`:31 项通过。
|
||||
- `python dev_scripts/check_harness.py --strict`:只因既有 #66/#67 归档缺少当前模板章节失败 4 项,与本工单修改无关。
|
||||
- `git diff --check` 与跟踪差异密码扫描:通过。
|
||||
- 测试结束后 Sense、MediaMTX 均已停止,18080/9997 无监听;`Sense/ui/node_modules` 与 `Sense/ui/dist` 已清理。
|
||||
- **未验证部分**:尚未在 #70 最终 Windows ZIP、客户全新 Windows 主机和客户目标浏览器中重新打包验收;#70 与 #97 合入 `dev` 后需重新生成发布包。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 验证码 API 为上游兼容而保留但登录链不使用;若未来永久删除,需单独清理工单核对依赖和回退。
|
||||
- 取消验证码降低自动化暴力尝试阻力;本工单按用户确认保留失败登录审计,但未引入未确认的限流或锁定体系。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `0bbdb27f6db4f9d08445ca02abae9061c81598b8` 恢复免验证码登录并补充测试。
|
||||
- `bc1a01848d1769250b706f207d542b63fee2afa6` 更新 Sense 登录安全边界镜像。
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 2026-08-16:用户明确验收通过 #97。
|
||||
- 按工作流将 PR #98 合入 `dev`;`main` 保持不变。
|
||||
@@ -0,0 +1,79 @@
|
||||
<!-- 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: 7d0dd9b216e443b00a97f021d49678b848006db9
|
||||
synchronized_at: 2026-08-16T15:41:39Z
|
||||
<!-- 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` 记录登录外壳只读配置边界。
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 用户于 2026-08-16 明确回复“#99 验收通过”。
|
||||
- PR #100 已合入 `dev`,合并提交:`34ee5ed619d122a8670dc50039578686a7a9ef66`。
|
||||
- 工单已按验收流程关闭;MVP #8 与 Epic #7 的子工单索引同步为完成。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user