Compare 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 | ||
|
|
17c1afd195 | ||
|
|
c83c181a12 | ||
|
|
a279a1ec0d | ||
|
|
c63c623df5 | ||
|
|
b1bdb91fdb | ||
|
|
35b9c7b8a4 | ||
|
|
263a68c98d | ||
|
|
4149cc4426 | ||
|
|
19f9bfa1a0 | ||
|
|
24cf4bbd65 |
@@ -3,5 +3,8 @@ server/*.exe
|
|||||||
server/*.db
|
server/*.db
|
||||||
ui/node_modules/
|
ui/node_modules/
|
||||||
ui/dist/
|
ui/dist/
|
||||||
|
dist/
|
||||||
|
!scripts/build/
|
||||||
|
!scripts/build/**
|
||||||
.env
|
.env
|
||||||
*.local.yml
|
*.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
|
```powershell
|
||||||
@@ -44,4 +53,6 @@ go run . server -c C:\secure-path\sense-settings.yml
|
|||||||
|
|
||||||
使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
|
使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
|
||||||
|
|
||||||
|
MediaMTX 保持独立二进制。配置 `SENSE_MEDIAMTX_BINARY`、`SENSE_MEDIAMTX_CONFIG` 和只允许回环地址的 `SENSE_MEDIAMTX_API`。Sense 只生成无摄像头凭据的基础配置;路径和凭据在运行时通过回环 Control API 下发。模板见 `server/config/mediamtx/mediamtx.yml.example`。
|
||||||
|
|
||||||
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
|
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/liveview"
|
||||||
|
"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, registerSenseLiveviewRouter)
|
||||||
|
routerNoCheckRole = append(routerNoCheckRole, registerSenseLiveviewPlayerRouter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSenseLiveviewRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||||
|
api := &liveview.API{}
|
||||||
|
r := v1.Group("/liveview").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||||
|
r.GET("/routes", api.List)
|
||||||
|
r.POST("/sessions", api.Create)
|
||||||
|
r.GET("/sessions/:id", api.Get)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSenseLiveviewPlayerRouter(v1 *gin.RouterGroup) {
|
||||||
|
api := &liveview.API{}
|
||||||
|
v1.GET("/liveview/player/:id", api.Player)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
"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, registerSenseMediaRouter) }
|
||||||
|
|
||||||
|
func registerSenseMediaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||||
|
api := &media.API{}
|
||||||
|
r := v1.Group("/media").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||||
|
r.GET("/routes", api.List)
|
||||||
|
r.GET("/process", api.Process)
|
||||||
|
r.POST("/reconcile", api.ReconcileAll)
|
||||||
|
r.POST("/routes/:id/reconcile", api.Reconcile)
|
||||||
|
r.POST("/routes/:id/stop", api.Stop)
|
||||||
|
}
|
||||||
@@ -80,6 +80,11 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
|||||||
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||||
api := apis.SysMenu{}
|
api := apis.SysMenu{}
|
||||||
api2 := apis.SysDept{}
|
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 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||||
{
|
{
|
||||||
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
|
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 (
|
|||||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||||
|
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +54,9 @@ func (e *API) Probe(c *gin.Context) {
|
|||||||
e.writeError(err)
|
e.writeError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Media route intent is best-effort and credential-free. A MediaMTX
|
||||||
|
// failure must never roll back the verified device/Profile transaction.
|
||||||
|
_ = media.EnsureDeviceRoutes(c.Request.Context(), service.Orm, request.DeviceID)
|
||||||
e.OK(result, "探测完成")
|
e.OK(result, "探测完成")
|
||||||
}
|
}
|
||||||
func (e *API) Get(c *gin.Context) {
|
func (e *API) Get(c *gin.Context) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
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/onvif"
|
||||||
@@ -131,7 +132,16 @@ func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if replaceProfiles && len(result.Profiles) > 0 {
|
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,18 +10,25 @@ import (
|
|||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
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/onvif"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
|
"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) {
|
func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"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), 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"), 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 struct {
|
||||||
|
RouteID string `json:"routeId"`
|
||||||
|
}
|
||||||
|
if err = decodeJSON(c, &request); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(session, "播放会话已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Get(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(session, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
|
||||||
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
|
||||||
|
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
|
||||||
|
|
||||||
|
func (e *API) Player(c *gin.Context) {
|
||||||
|
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.String(http.StatusGone, "播放会话已过期,请重新连接")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsed := template.URL(target)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Header("Referrer-Policy", "no-referrer")
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||||
|
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) writeError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidRequest):
|
||||||
|
e.Error(http.StatusBadRequest, err, err.Error())
|
||||||
|
case errors.Is(err, ErrRouteNotFound):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, ErrSessionExpired):
|
||||||
|
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
|
||||||
|
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
|
||||||
|
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
|
||||||
|
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, 16<<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,41 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
store := NewStore()
|
||||||
|
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||||
|
store.now = func() time.Time { return now }
|
||||||
|
previous := defaultStore
|
||||||
|
defaultStore = store
|
||||||
|
t.Cleanup(func() { defaultStore = previous })
|
||||||
|
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
api := &API{}
|
||||||
|
router.GET("/api/v1/liveview/player/:id", api.Player)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
|
||||||
|
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
|
||||||
|
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
now = now.Add(sessionTTL)
|
||||||
|
expired := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||||
|
if expired.Code != http.StatusGone {
|
||||||
|
t.Fatalf("expired capability status=%d", expired.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
|
||||||
|
ErrRouteNotFound = errors.New("可监看的视频不存在")
|
||||||
|
ErrSessionExpired = errors.New("播放会话已过期")
|
||||||
|
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||||
|
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 2 * time.Minute
|
||||||
|
|
||||||
|
type PageRequest struct {
|
||||||
|
Keyword string
|
||||||
|
PageIndex int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
DeviceLocation string `json:"deviceLocation"`
|
||||||
|
ProfileToken string `json:"profileToken"`
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
ProfileKind string `json:"profileKind"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
Actual string `json:"actual"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Readers int `json:"readers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RouteID string `json:"routeId"`
|
||||||
|
PlayerURL string `json:"playerUrl"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeRecord struct {
|
||||||
|
RouteResponse
|
||||||
|
Path string `gorm:"column:path"`
|
||||||
|
Desired string `gorm:"column:desired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionRecord struct {
|
||||||
|
ID string
|
||||||
|
OwnerID int
|
||||||
|
RouteID string
|
||||||
|
TargetURL string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
DeviceName string
|
||||||
|
ProfileName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]sessionRecord
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultStore = NewStore()
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, store *Store) *Service {
|
||||||
|
if store == nil {
|
||||||
|
store = defaultStore
|
||||||
|
}
|
||||||
|
return &Service{db: db, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
|
||||||
|
if request.PageIndex < 1 {
|
||||||
|
request.PageIndex = 1
|
||||||
|
}
|
||||||
|
if request.PageSize < 1 {
|
||||||
|
request.PageSize = 10
|
||||||
|
}
|
||||||
|
if request.PageSize > 50 {
|
||||||
|
request.PageSize = 50
|
||||||
|
}
|
||||||
|
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
|
||||||
|
keyword := strings.TrimSpace(request.Keyword)
|
||||||
|
if len([]rune(keyword)) > 128 {
|
||||||
|
return nil, 0, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
pattern := "%" + escapeLike(keyword) + "%"
|
||||||
|
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var records []routeRecord
|
||||||
|
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]RouteResponse, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
items = append(items, record.RouteResponse)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
|
||||||
|
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
|
||||||
|
return SessionResponse{}, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
record, err := s.route(ctx, routeID)
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
|
||||||
|
return SessionResponse{}, ErrRouteNotFound
|
||||||
|
}
|
||||||
|
base, err := playbackBase(request)
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
target := *base
|
||||||
|
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
|
||||||
|
target.RawQuery = "controls=true&muted=true&autoplay=true"
|
||||||
|
id, err := randomID()
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
now := s.store.now().UTC()
|
||||||
|
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
|
||||||
|
s.store.put(session)
|
||||||
|
return responseFrom(session, record), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
|
||||||
|
session, err := s.store.get(id, ownerID, true)
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
record, err := s.route(ctx, session.RouteID)
|
||||||
|
if err != nil || record.Desired != "running" {
|
||||||
|
return SessionResponse{}, ErrRouteNotFound
|
||||||
|
}
|
||||||
|
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
|
||||||
|
s.store.put(session)
|
||||||
|
return responseFrom(session, record), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PlayerTarget(id string) (string, error) {
|
||||||
|
session, err := s.store.get(id, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return session.TargetURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
|
||||||
|
var record routeRecord
|
||||||
|
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
|
||||||
|
return routeRecord{}, err
|
||||||
|
}
|
||||||
|
if record.ID == "" {
|
||||||
|
return routeRecord{}, ErrRouteNotFound
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
|
||||||
|
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
|
||||||
|
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
|
||||||
|
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("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
|
||||||
|
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func playbackStatus(actual string) string {
|
||||||
|
switch actual {
|
||||||
|
case "ready", "waiting", "stopped":
|
||||||
|
return actual
|
||||||
|
case "credential_unavailable", "profile_unavailable":
|
||||||
|
return "authentication_failed"
|
||||||
|
case "path_missing", "apply_failed", "status_unavailable":
|
||||||
|
return "stream_not_found"
|
||||||
|
case "process_unavailable":
|
||||||
|
return "service_unavailable"
|
||||||
|
default:
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) put(session sessionRecord) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
now := s.now()
|
||||||
|
for id, item := range s.sessions {
|
||||||
|
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
|
||||||
|
delete(s.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sessions[session.ID] = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
session, ok := s.sessions[id]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
|
||||||
|
return sessionRecord{}, ErrSessionExpired
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playbackBase(request *http.Request) (*url.URL, error) {
|
||||||
|
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
|
||||||
|
if configured != "" {
|
||||||
|
return validatePlaybackBase(configured)
|
||||||
|
}
|
||||||
|
if request == nil || request.Host == "" {
|
||||||
|
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
|
||||||
|
}
|
||||||
|
host := request.Host
|
||||||
|
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
|
||||||
|
host = parsedHost
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
|
||||||
|
return nil, errors.New("无效的请求主机")
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if request.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePlaybackBase(value string) (*url.URL, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimRight(value, "/"))
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||||
|
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomID() (string, error) {
|
||||||
|
value := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "view_" + hex.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeLike(value string) string {
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||||
|
return strings.ReplaceAll(value, `_`, `\_`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDB(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)
|
||||||
|
}
|
||||||
|
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, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
|
||||||
|
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
|
||||||
|
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||||
|
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
|
||||||
|
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err = db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
|
||||||
|
service := NewService(testDB(t), NewStore())
|
||||||
|
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
|
||||||
|
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
|
||||||
|
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(items)
|
||||||
|
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
|
||||||
|
t.Fatalf("response leaked private media data: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
|
||||||
|
store := NewStore()
|
||||||
|
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||||
|
store.now = func() time.Time { return now }
|
||||||
|
service := NewService(testDB(t), store)
|
||||||
|
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
|
||||||
|
session, err := service.Create(context.Background(), 7, "device-1:main", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
|
||||||
|
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
|
||||||
|
}
|
||||||
|
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||||
|
t.Fatalf("another owner accessed session: %v", err)
|
||||||
|
}
|
||||||
|
target, err := service.PlayerTarget(session.ID)
|
||||||
|
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
|
||||||
|
t.Fatalf("target=%q err=%v", target, err)
|
||||||
|
}
|
||||||
|
now = now.Add(sessionTTL)
|
||||||
|
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||||
|
t.Fatalf("expired session remained valid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
|
||||||
|
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
|
||||||
|
t.Fatal("expected credential-bearing base URL to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
|
||||||
|
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
|
||||||
|
if got := playbackStatus(input); got != want {
|
||||||
|
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIs(err, target error) bool { return err == target }
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||||
|
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 serviceFor(base.Orm), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) List(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := service.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(gin.H{"list": items, "total": len(items)}, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Process(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(service.ProcessState(), "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) ReconcileAll(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = service.EnsureAllVerified(c.Request.Context()); err == nil {
|
||||||
|
err = service.ReconcileDue(c.Request.Context())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(nil, "对账完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Reconcile(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := service.Reconcile(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil && item.ID == "" {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(item, "对账完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Stop(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := service.StopRoute(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(item, "媒体路径已停止")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) writeError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, ErrRuntimeUnavailable), errors.Is(err, ErrUnsafeControlAPI):
|
||||||
|
e.Error(http.StatusServiceUnavailable, err, "视频服务运行配置不可用")
|
||||||
|
default:
|
||||||
|
e.Error(http.StatusInternalServerError, err, "视频服务操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnsafeControlAPI = errors.New("MediaMTX Control API 必须使用本机回环地址")
|
||||||
|
|
||||||
|
type RuntimeConfig struct {
|
||||||
|
Binary string
|
||||||
|
ConfigPath string
|
||||||
|
APIBase string
|
||||||
|
PollInterval time.Duration
|
||||||
|
StartTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromEnvironment() (RuntimeConfig, error) {
|
||||||
|
c := RuntimeConfig{
|
||||||
|
Binary: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_BINARY")), ConfigPath: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_CONFIG")),
|
||||||
|
APIBase: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_API")), PollInterval: 5 * time.Second, StartTimeout: 8 * time.Second,
|
||||||
|
}
|
||||||
|
if c.APIBase == "" {
|
||||||
|
c.APIBase = "http://127.0.0.1:9997"
|
||||||
|
}
|
||||||
|
if err := validateControlAPI(c.APIBase); err != nil {
|
||||||
|
return RuntimeConfig{}, err
|
||||||
|
}
|
||||||
|
if c.Binary != "" && c.ConfigPath == "" {
|
||||||
|
return RuntimeConfig{}, errors.New("SENSE_MEDIAMTX_CONFIG is required when SENSE_MEDIAMTX_BINARY is configured")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateControlAPI(value string) error {
|
||||||
|
u, err := url.Parse(value)
|
||||||
|
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||||
|
return ErrUnsafeControlAPI
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||||
|
return ErrUnsafeControlAPI
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderBaseConfig(w io.Writer, apiBase string) error {
|
||||||
|
if err := validateControlAPI(apiBase); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, _ := url.Parse(apiBase)
|
||||||
|
_, err := fmt.Fprintf(w, "logLevel: info\napi: true\napiAddress: %s\nmetrics: false\npaths: {}\n", u.Host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureBaseConfig(path, apiBase string) error {
|
||||||
|
if path == "" {
|
||||||
|
return errors.New("MediaMTX config path is empty")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporary := path + ".tmp"
|
||||||
|
f, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeErr := RenderBaseConfig(f, apiBase)
|
||||||
|
closeErr := f.Close()
|
||||||
|
if writeErr != nil || closeErr != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return errors.Join(writeErr, closeErr)
|
||||||
|
}
|
||||||
|
if err = os.Rename(temporary, path); err != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
Path, URI, Username, Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathStatus struct {
|
||||||
|
Exists, Ready bool
|
||||||
|
Readers int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Controller interface {
|
||||||
|
Health(context.Context) error
|
||||||
|
Apply(context.Context, Source) error
|
||||||
|
Delete(context.Context, string) error
|
||||||
|
Status(context.Context, string) (PathStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPController struct {
|
||||||
|
base string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPController(base string) (*HTTPController, error) {
|
||||||
|
if err := validateControlAPI(base); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = nil
|
||||||
|
return &HTTPController{base: strings.TrimRight(base, "/"), client: &http.Client{
|
||||||
|
Timeout: 5 * time.Second, Transport: transport,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return errors.New("MediaMTX Control API redirect rejected")
|
||||||
|
},
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Health(ctx context.Context) error {
|
||||||
|
return c.request(ctx, http.MethodGet, "/v3/config/global/get", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Apply(ctx context.Context, source Source) error {
|
||||||
|
if !validPath.MatchString(source.Path) {
|
||||||
|
return errors.New("invalid MediaMTX path")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(source.URI)
|
||||||
|
if err != nil || u.User != nil || u.Host == "" || (u.Scheme != "rtsp" && u.Scheme != "rtsps") {
|
||||||
|
return errors.New("invalid credential-free RTSP source")
|
||||||
|
}
|
||||||
|
payload := map[string]any{"source": u.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
|
||||||
|
if source.Username != "" {
|
||||||
|
// MediaMTX v1.19.3 has no sourceUser/sourcePass fields. Credentials
|
||||||
|
// are assembled only for this loopback request and are never stored,
|
||||||
|
// logged, or returned by a Sense endpoint.
|
||||||
|
u.User = url.UserPassword(source.Username, source.Password)
|
||||||
|
payload["source"] = u.String()
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configured, err := c.configured(ctx, source.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
method, action := http.MethodPost, "add"
|
||||||
|
if configured {
|
||||||
|
method, action = http.MethodPatch, "patch"
|
||||||
|
}
|
||||||
|
return c.request(ctx, method, "/v3/config/paths/"+action+"/"+url.PathEscape(source.Path), bytes.NewReader(data), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) configured(ctx context.Context, path string) (bool, error) {
|
||||||
|
status := 0
|
||||||
|
err := c.request(ctx, http.MethodGet, "/v3/config/paths/get/"+url.PathEscape(path), nil, &status)
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Delete(ctx context.Context, path string) error {
|
||||||
|
if !validPath.MatchString(path) {
|
||||||
|
return errors.New("invalid MediaMTX path")
|
||||||
|
}
|
||||||
|
status := 0
|
||||||
|
err := c.request(ctx, http.MethodDelete, "/v3/config/paths/delete/"+url.PathEscape(path), nil, &status)
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
|
||||||
|
statusCode := 0
|
||||||
|
var raw struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Readers []any `json:"readers"`
|
||||||
|
}
|
||||||
|
err := c.requestJSON(ctx, http.MethodGet, "/v3/paths/get/"+url.PathEscape(path), &statusCode, &raw)
|
||||||
|
if statusCode == http.StatusNotFound {
|
||||||
|
return PathStatus{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PathStatus{}, err
|
||||||
|
}
|
||||||
|
return PathStatus{Exists: true, Ready: raw.Ready, Readers: len(raw.Readers)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) request(ctx context.Context, method, path string, body io.Reader, statusOut *int) error {
|
||||||
|
return c.requestJSON(ctx, method, path, body, statusOut, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) requestJSON(ctx context.Context, method, path string, args ...any) error {
|
||||||
|
var body io.Reader
|
||||||
|
var statusOut *int
|
||||||
|
var target any
|
||||||
|
for _, arg := range args {
|
||||||
|
switch value := arg.(type) {
|
||||||
|
case io.Reader:
|
||||||
|
body = value
|
||||||
|
case *int:
|
||||||
|
statusOut = value
|
||||||
|
default:
|
||||||
|
target = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
res, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MediaMTX Control API unavailable: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if statusOut != nil {
|
||||||
|
*statusOut = res.StatusCode
|
||||||
|
}
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||||
|
return fmt.Errorf("MediaMTX Control API returned %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControllerUsesCredentialsOnlyAtLoopbackBoundary(t *testing.T) {
|
||||||
|
var payload map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v3/config/paths/get/sense_test":
|
||||||
|
http.NotFound(w, r)
|
||||||
|
case r.URL.Path == "/v3/config/paths/add/sense_test":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
controller, err := NewHTTPController(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := Source{Path: "sense_test", URI: "rtsp://192.0.2.1/live", Username: "synthetic-user", Password: "synthetic-pass"}
|
||||||
|
if err = controller.Apply(context.Background(), source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["source"] != "rtsp://synthetic-user:synthetic-pass@192.0.2.1/live" {
|
||||||
|
t.Fatalf("unexpected MediaMTX payload: %#v", payload)
|
||||||
|
}
|
||||||
|
if source.URI != "rtsp://192.0.2.1/live" || strings.Contains(source.URI, "synthetic") {
|
||||||
|
t.Fatalf("caller source was mutated: %#v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlAPIAndGeneratedConfigAreLoopbackOnly(t *testing.T) {
|
||||||
|
if _, err := NewHTTPController("http://192.0.2.1:9997"); err != ErrUnsafeControlAPI {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := RenderBaseConfig(&output, "http://127.0.0.1:9997"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(output.String()), "password") || !strings.Contains(output.String(), "paths: {}") {
|
||||||
|
t.Fatalf("unsafe config: %s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalProcessUsesOrphanSafetyGate(t *testing.T) {
|
||||||
|
supervisor := NewSupervisor("", "")
|
||||||
|
supervisor.MarkExternal()
|
||||||
|
if err := supervisor.Stop(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := supervisor.State()
|
||||||
|
if !state.External || state.Owned || state.Phase != "running" {
|
||||||
|
t.Fatalf("unexpected state: %#v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type RouteResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
ProfileToken string `json:"profileToken"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Desired string `json:"desired"`
|
||||||
|
Actual string `json:"actual"`
|
||||||
|
Readers int `json:"readers"`
|
||||||
|
SourceReady bool `json:"sourceReady"`
|
||||||
|
FailureCount int `json:"failureCount"`
|
||||||
|
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
|
||||||
|
LastErrorCode string `json:"lastErrorCode,omitempty"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeResponse(r Route) RouteResponse {
|
||||||
|
return RouteResponse{ID: r.ID, DeviceID: r.DeviceID, ProfileToken: r.ProfileToken, Path: r.Path, Desired: r.Desired, Actual: r.Actual, Readers: r.Readers, SourceReady: r.SourceReady, FailureCount: r.FailureCount, NextRetryAt: r.NextRetryAt, LastErrorCode: r.LastErrorCode, Detail: r.Detail, Version: r.Version, UpdatedAt: r.UpdatedAt}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DesiredRunning = "running"
|
||||||
|
DesiredStopped = "stopped"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
ID string `gorm:"size:96;primaryKey"`
|
||||||
|
DeviceID string `gorm:"size:36;not null;uniqueIndex:media_device_profile"`
|
||||||
|
ProfileToken string `gorm:"size:255;not null;uniqueIndex:media_device_profile"`
|
||||||
|
Path string `gorm:"size:96;not null;uniqueIndex"`
|
||||||
|
Desired string `gorm:"size:16;not null;index"`
|
||||||
|
Actual string `gorm:"size:32;not null;index"`
|
||||||
|
Readers int `gorm:"not null"`
|
||||||
|
SourceReady bool `gorm:"not null"`
|
||||||
|
FailureCount int `gorm:"not null"`
|
||||||
|
NextRetryAt *time.Time `gorm:"index"`
|
||||||
|
LastErrorCode string `gorm:"size:64;not null"`
|
||||||
|
Detail string `gorm:"size:512;not null"`
|
||||||
|
Version int64 `gorm:"not null"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Route) TableName() string { return "sense_media_routes" }
|
||||||
|
|
||||||
|
// admissionProfile intentionally maps only non-secret fields from #66.
|
||||||
|
type admissionProfile struct {
|
||||||
|
DeviceID string `gorm:"column:device_id"`
|
||||||
|
Token string `gorm:"column:token"`
|
||||||
|
StreamURI string `gorm:"column:stream_uri"`
|
||||||
|
VerificationStatus string `gorm:"column:verification_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (admissionProfile) TableName() string { return "sense_admission_profiles" }
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runtimeState struct {
|
||||||
|
sync.RWMutex
|
||||||
|
service *Service
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
config, err := ConfigFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
controller, err := NewHTTPController(config.APIBase)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
runtimeState.service, runtimeState.cancel = service, cancel
|
||||||
|
runtimeState.Unlock()
|
||||||
|
go service.Run(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShutdownRuntime(ctx context.Context) error {
|
||||||
|
runtimeState.Lock()
|
||||||
|
service, cancel := runtimeState.service, runtimeState.cancel
|
||||||
|
runtimeState.service, runtimeState.cancel = nil, nil
|
||||||
|
runtimeState.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if service != nil && service.process != nil {
|
||||||
|
return service.process.Stop(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceFor(db *gorm.DB) *Service {
|
||||||
|
runtimeState.RLock()
|
||||||
|
service := runtimeState.service
|
||||||
|
runtimeState.RUnlock()
|
||||||
|
if service != nil {
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
return NewService(db, nil, nil, RuntimeConfig{PollInterval: 5 * time.Second})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDeviceRoutes is an internal post-admission port. It persists only
|
||||||
|
// credential-free route intent and deliberately does not fail device probing.
|
||||||
|
func EnsureDeviceRoutes(ctx context.Context, db *gorm.DB, deviceID string) error {
|
||||||
|
return serviceFor(db).EnsureDevice(ctx, deviceID)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("媒体路由不存在")
|
||||||
|
ErrRuntimeUnavailable = errors.New("视频服务运行配置不可用")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
controller Controller
|
||||||
|
process *Supervisor
|
||||||
|
config RuntimeConfig
|
||||||
|
now func() time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, controller Controller, process *Supervisor, config RuntimeConfig) *Service {
|
||||||
|
return &Service{db: db, controller: controller, process: process, config: config, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureAllVerified(ctx context.Context) error {
|
||||||
|
var ids []string
|
||||||
|
if err := s.db.WithContext(ctx).Model(&admissionProfile{}).Where("verification_status = ?", "ready").Distinct().Pluck("device_id", &ids).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := s.ensureDevice(ctx, id, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureDevice(ctx context.Context, deviceID string) error {
|
||||||
|
return s.ensureDevice(ctx, deviceID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureDevice(ctx context.Context, deviceID string, reactivate bool) error {
|
||||||
|
if strings.TrimSpace(deviceID) == "" {
|
||||||
|
return errors.New("device id is required")
|
||||||
|
}
|
||||||
|
var profiles []admissionProfile
|
||||||
|
if err := s.db.WithContext(ctx).Where("device_id = ? AND verification_status = ?", deviceID, "ready").Find(&profiles).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := s.now().UTC()
|
||||||
|
for _, profile := range profiles {
|
||||||
|
id := profile.DeviceID + ":" + profile.Token
|
||||||
|
digest := sha256.Sum256([]byte(id))
|
||||||
|
route := Route{ID: id, DeviceID: profile.DeviceID, ProfileToken: profile.Token, Path: fmt.Sprintf("sense_%x", digest[:12]), Desired: DesiredRunning, Actual: "pending", Detail: "等待视频服务对账", Version: 1, UpdatedAt: now}
|
||||||
|
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&route).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var existing Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&existing, "id = ?", id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reactivate && existing.Desired != DesiredRunning {
|
||||||
|
if err := s.db.WithContext(ctx).Model(&existing).Updates(map[string]any{"desired": DesiredRunning, "actual": "pending", "detail": "等待视频服务对账", "next_retry_at": nil, "version": existing.Version + 1, "updated_at": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Run(ctx context.Context) {
|
||||||
|
_ = s.EnsureAllVerified(ctx)
|
||||||
|
_ = s.ReconcileDue(ctx)
|
||||||
|
interval := s.config.PollInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = s.ReconcileDue(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReconcileDue(ctx context.Context) error {
|
||||||
|
now := s.now().UTC()
|
||||||
|
var routes []Route
|
||||||
|
if err := s.db.WithContext(ctx).Where("desired = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", DesiredRunning, now).Order("updated_at ASC").Limit(128).Find(&routes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var result error
|
||||||
|
for _, route := range routes {
|
||||||
|
var err error
|
||||||
|
if route.FailureCount == 0 && (route.Actual == "waiting" || route.Actual == "ready") {
|
||||||
|
_, err = s.Refresh(ctx, route.ID)
|
||||||
|
} else {
|
||||||
|
_, err = s.Reconcile(ctx, route.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Refresh(ctx context.Context, id string) (RouteResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var route Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||||
|
return RouteResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err := s.ensureControl(ctx); err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||||
|
}
|
||||||
|
status, err := s.controller.Status(ctx, route.Path)
|
||||||
|
if err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||||
|
}
|
||||||
|
if !status.Exists {
|
||||||
|
// A cold MediaMTX start begins with paths: {}; re-apply the route
|
||||||
|
// after releasing the service lock.
|
||||||
|
s.mu.Unlock()
|
||||||
|
response, reconcileErr := s.Reconcile(ctx, id)
|
||||||
|
s.mu.Lock()
|
||||||
|
return response, reconcileErr
|
||||||
|
}
|
||||||
|
if status.Ready {
|
||||||
|
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
|
||||||
|
}
|
||||||
|
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var route Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return RouteResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return RouteResponse{}, err
|
||||||
|
}
|
||||||
|
if route.Desired == DesiredStopped {
|
||||||
|
if s.controller != nil {
|
||||||
|
_ = s.controller.Delete(ctx, route.Path)
|
||||||
|
}
|
||||||
|
return s.saveSuccess(ctx, route, "stopped", false, 0, "媒体路径已停止")
|
||||||
|
}
|
||||||
|
if err := s.ensureControl(ctx); err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||||
|
}
|
||||||
|
var profile admissionProfile
|
||||||
|
if err := s.db.WithContext(ctx).Where("device_id = ? AND token = ? AND verification_status = ?", route.DeviceID, route.ProfileToken, "ready").First(&profile).Error; err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "profile_unavailable", "已验证 Profile 不可用", err)
|
||||||
|
}
|
||||||
|
value, err := credential.Read(s.db.WithContext(ctx), route.DeviceID, credential.PurposeRTSP)
|
||||||
|
if err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "credential_unavailable", "RTSP 凭据不可用", err)
|
||||||
|
}
|
||||||
|
if err = s.controller.Apply(ctx, Source{Path: route.Path, URI: profile.StreamURI, Username: value.Username, Password: value.Password}); err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "apply_failed", "媒体路径配置失败", err)
|
||||||
|
}
|
||||||
|
status, err := s.controller.Status(ctx, route.Path)
|
||||||
|
if err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||||
|
}
|
||||||
|
if !status.Exists {
|
||||||
|
return s.saveFailure(ctx, route, "path_missing", "媒体路径不存在", errors.New("MediaMTX path missing after apply"))
|
||||||
|
}
|
||||||
|
if status.Ready {
|
||||||
|
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
|
||||||
|
}
|
||||||
|
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureControl(ctx context.Context) error {
|
||||||
|
if s.controller == nil {
|
||||||
|
return ErrRuntimeUnavailable
|
||||||
|
}
|
||||||
|
if err := s.controller.Health(ctx); err == nil {
|
||||||
|
if s.process != nil && !s.process.State().Owned {
|
||||||
|
s.process.MarkExternal()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.process == nil {
|
||||||
|
return ErrRuntimeUnavailable
|
||||||
|
}
|
||||||
|
if err := EnsureBaseConfig(s.config.ConfigPath, s.config.APIBase); err != nil {
|
||||||
|
s.process.MarkFailed("MediaMTX 基础配置不可用")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.process.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deadline := s.now().Add(s.config.StartTimeout)
|
||||||
|
for s.now().Before(deadline) {
|
||||||
|
if err := s.controller.Health(ctx); err == nil {
|
||||||
|
s.process.MarkReady()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.process.MarkFailed("MediaMTX Control API 就绪超时")
|
||||||
|
return errors.New("MediaMTX readiness timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveSuccess(ctx context.Context, route Route, actual string, sourceReady bool, readers int, detail string) (RouteResponse, error) {
|
||||||
|
if route.Actual == actual && route.SourceReady == sourceReady && route.Readers == readers && route.FailureCount == 0 && route.NextRetryAt == nil && route.LastErrorCode == "" && route.Detail == detail {
|
||||||
|
return routeResponse(route), nil
|
||||||
|
}
|
||||||
|
now := s.now().UTC()
|
||||||
|
updates := map[string]any{"actual": actual, "source_ready": sourceReady, "readers": readers, "failure_count": 0, "next_retry_at": nil, "last_error_code": "", "detail": detail, "version": route.Version + 1, "updated_at": now}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
|
||||||
|
return RouteResponse{}, err
|
||||||
|
}
|
||||||
|
for key, value := range updates {
|
||||||
|
switch key {
|
||||||
|
case "actual":
|
||||||
|
route.Actual = value.(string)
|
||||||
|
case "source_ready":
|
||||||
|
route.SourceReady = value.(bool)
|
||||||
|
case "readers":
|
||||||
|
route.Readers = value.(int)
|
||||||
|
case "failure_count":
|
||||||
|
route.FailureCount = value.(int)
|
||||||
|
case "last_error_code":
|
||||||
|
route.LastErrorCode = value.(string)
|
||||||
|
case "detail":
|
||||||
|
route.Detail = value.(string)
|
||||||
|
case "version":
|
||||||
|
route.Version = value.(int64)
|
||||||
|
case "updated_at":
|
||||||
|
route.UpdatedAt = value.(time.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route.NextRetryAt = nil
|
||||||
|
return routeResponse(route), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveFailure(ctx context.Context, route Route, code, detail string, cause error) (RouteResponse, error) {
|
||||||
|
now := s.now().UTC()
|
||||||
|
failures := route.FailureCount + 1
|
||||||
|
next := now.Add(reconcile.Backoff(failures))
|
||||||
|
updates := map[string]any{"actual": code, "source_ready": false, "readers": 0, "failure_count": failures, "next_retry_at": &next, "last_error_code": code, "detail": detail, "version": route.Version + 1, "updated_at": now}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
|
||||||
|
return RouteResponse{}, errors.Join(cause, err)
|
||||||
|
}
|
||||||
|
route.Actual, route.SourceReady, route.Readers, route.FailureCount, route.NextRetryAt, route.LastErrorCode, route.Detail = code, false, 0, failures, &next, code, detail
|
||||||
|
route.Version, route.UpdatedAt = route.Version+1, now
|
||||||
|
return routeResponse(route), cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) StopRoute(ctx context.Context, id string) (RouteResponse, error) {
|
||||||
|
var route Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||||
|
return RouteResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
route.Desired = DesiredStopped
|
||||||
|
if err := s.db.WithContext(ctx).Model(&route).Updates(map[string]any{"desired": DesiredStopped, "next_retry_at": nil, "version": route.Version + 1, "updated_at": s.now().UTC()}).Error; err != nil {
|
||||||
|
return RouteResponse{}, err
|
||||||
|
}
|
||||||
|
return s.Reconcile(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]RouteResponse, error) {
|
||||||
|
var routes []Route
|
||||||
|
if err := s.db.WithContext(ctx).Order("updated_at DESC").Find(&routes).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]RouteResponse, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
items = append(items, routeResponse(route))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ProcessState() ProcessState {
|
||||||
|
if s.process == nil {
|
||||||
|
return ProcessState{Phase: "configuration_failed", Detail: "视频服务运行配置不可用", LastChanged: s.now().UTC()}
|
||||||
|
}
|
||||||
|
return s.process.State()
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeController struct {
|
||||||
|
healthErr, applyErr, statusErr error
|
||||||
|
applies int
|
||||||
|
status PathStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeController) Health(context.Context) error { return f.healthErr }
|
||||||
|
func (f *fakeController) Apply(context.Context, Source) error { f.applies++; return f.applyErr }
|
||||||
|
func (f *fakeController) Delete(context.Context, string) error { return nil }
|
||||||
|
func (f *fakeController) Status(context.Context, string) (PathStatus, error) {
|
||||||
|
return f.status, f.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaTestService(t *testing.T, controller Controller) (*Service, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&Route{}, &admissionProfile{}, &credential.DeviceCredential{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||||
|
vault, _ := credential.NewVault(key)
|
||||||
|
ciphertext, err := vault.Encrypt("device-1", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-1", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&admissionProfile{DeviceID: "device-1", Token: "main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewService(db, controller, nil, RuntimeConfig{})
|
||||||
|
service.now = func() time.Time { return time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) }
|
||||||
|
return service, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAndReconcileAreIdempotent(t *testing.T) {
|
||||||
|
controller := &fakeController{status: PathStatus{Exists: true, Ready: true, Readers: 2}}
|
||||||
|
service, db := mediaTestService(t, controller)
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&Route{}).Count(&count).Error; err != nil || count != 1 {
|
||||||
|
t.Fatalf("count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
item, err := service.Reconcile(context.Background(), "device-1:main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item.Actual != "ready" || item.Readers != 2 || controller.applies != 1 {
|
||||||
|
t.Fatalf("item=%#v applies=%d", item, controller.applies)
|
||||||
|
}
|
||||||
|
if err = service.ReconcileDue(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := service.List(context.Background())
|
||||||
|
if err != nil || controller.applies != 1 || items[0].Version != item.Version {
|
||||||
|
t.Fatalf("steady route was rewritten: items=%#v applies=%d err=%v", items, controller.applies, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColdStartDoesNotReactivateStoppedRoute(t *testing.T) {
|
||||||
|
controller := &fakeController{status: PathStatus{Exists: true}}
|
||||||
|
service, _ := mediaTestService(t, controller)
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := service.StopRoute(context.Background(), "device-1:main"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.EnsureAllVerified(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := service.List(context.Background())
|
||||||
|
if err != nil || len(items) != 1 || items[0].Desired != DesiredStopped {
|
||||||
|
t.Fatalf("stopped route was reactivated: %#v err=%v", items, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailurePersistsBackoffWithoutChangingProfile(t *testing.T) {
|
||||||
|
controller := &fakeController{applyErr: errors.New("synthetic apply failure")}
|
||||||
|
service, db := mediaTestService(t, controller)
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item, err := service.Reconcile(context.Background(), "device-1:main")
|
||||||
|
if err == nil || item.Actual != "apply_failed" || item.FailureCount != 1 || item.NextRetryAt == nil {
|
||||||
|
t.Fatalf("item=%#v err=%v", item, err)
|
||||||
|
}
|
||||||
|
var profile admissionProfile
|
||||||
|
if err = db.First(&profile, "device_id = ? AND token = ?", "device-1", "main").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if profile.VerificationStatus != "ready" {
|
||||||
|
t.Fatalf("profile changed: %#v", profile)
|
||||||
|
}
|
||||||
|
if value := os.Getenv(credential.EnvironmentKey); value == "" {
|
||||||
|
t.Fatal("test key unexpectedly missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessState struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
PID int `json:"pid,omitempty"`
|
||||||
|
Owned bool `json:"owned"`
|
||||||
|
External bool `json:"external"`
|
||||||
|
Restarts int `json:"restarts"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
LastChanged time.Time `json:"lastChanged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Process interface {
|
||||||
|
Start(context.Context) error
|
||||||
|
Stop(context.Context) error
|
||||||
|
State() ProcessState
|
||||||
|
}
|
||||||
|
|
||||||
|
type Supervisor struct {
|
||||||
|
binary, config string
|
||||||
|
mu sync.Mutex
|
||||||
|
command *exec.Cmd
|
||||||
|
state ProcessState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSupervisor(binary, config string) *Supervisor {
|
||||||
|
phase, detail := "stopped", "MediaMTX 尚未启动"
|
||||||
|
if binary == "" {
|
||||||
|
phase, detail = "not_configured", "未配置 MediaMTX 二进制;可连接外部已启动实例"
|
||||||
|
}
|
||||||
|
return &Supervisor{binary: binary, config: config, state: ProcessState{Phase: phase, Detail: detail, LastChanged: time.Now().UTC()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) MarkExternal() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Owned {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.state.Phase, s.state.External, s.state.Detail, s.state.LastChanged = "running", true, "检测到外部 MediaMTX;孤儿安全闸禁止 Sense 停止该进程", time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Start(_ context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Owned && s.command != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.binary == "" {
|
||||||
|
return errors.New("SENSE_MEDIAMTX_BINARY is not configured")
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
if s.config != "" {
|
||||||
|
args = append(args, s.config)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(s.binary, args...)
|
||||||
|
if s.config != "" {
|
||||||
|
cmd.Dir = filepath.Dir(s.config)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", "MediaMTX 进程启动失败", time.Now().UTC()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.state.Phase == "failed" {
|
||||||
|
s.state.Restarts++
|
||||||
|
}
|
||||||
|
s.command = cmd
|
||||||
|
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "starting", cmd.Process.Pid, true, false
|
||||||
|
s.state.Detail, s.state.LastChanged = "等待 MediaMTX Control API 就绪", time.Now().UTC()
|
||||||
|
go s.wait(cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) MarkReady() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Owned {
|
||||||
|
s.state.Phase, s.state.Detail, s.state.LastChanged = "running", "MediaMTX Control API 已就绪", time.Now().UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) MarkFailed(detail string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", detail, time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) wait(cmd *exec.Cmd) {
|
||||||
|
err := cmd.Wait()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.command != cmd {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.command = nil
|
||||||
|
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "failed", 0, false, false
|
||||||
|
s.state.Detail = "MediaMTX 进程已退出"
|
||||||
|
if err == nil {
|
||||||
|
s.state.Phase, s.state.Detail = "stopped", "MediaMTX 进程已停止"
|
||||||
|
}
|
||||||
|
s.state.LastChanged = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Stop(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
cmd := s.command
|
||||||
|
owned := s.state.Owned
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !owned || cmd == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := cmd.Process.Signal(os.Interrupt); err != nil {
|
||||||
|
if killErr := cmd.Process.Kill(); killErr != nil {
|
||||||
|
return errors.Join(err, killErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(50 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
s.mu.Lock()
|
||||||
|
finished := s.command != cmd
|
||||||
|
s.mu.Unlock()
|
||||||
|
if finished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) State() ProcessState {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.state
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package reconcile
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func Backoff(failures int) time.Duration {
|
||||||
|
if failures <= 1 {
|
||||||
|
return time.Second
|
||||||
|
}
|
||||||
|
d := time.Second
|
||||||
|
for i := 1; i < failures; i++ {
|
||||||
|
if d >= 30*time.Second {
|
||||||
|
return time.Minute
|
||||||
|
}
|
||||||
|
d *= 2
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package reconcile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackoffIsBounded(t *testing.T) {
|
||||||
|
if Backoff(1) != time.Second || Backoff(4) != 8*time.Second || Backoff(20) != time.Minute {
|
||||||
|
t.Fatal("unexpected retry backoff")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
|
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
|
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
|
||||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||||
@@ -85,6 +86,19 @@ func run() error {
|
|||||||
for _, f := range AppRouters {
|
for _, f := range AppRouters {
|
||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
runtimeCtx, runtimeCancel := context.WithCancel(context.Background())
|
||||||
|
defer runtimeCancel()
|
||||||
|
var runtimeDBFound bool
|
||||||
|
for _, db := range sdk.Runtime.GetDb() {
|
||||||
|
runtimeDBFound = true
|
||||||
|
if err := media.StartRuntime(runtimeCtx, db); err != nil {
|
||||||
|
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !runtimeDBFound {
|
||||||
|
log.Error("MediaMTX runtime unavailable: Sense database is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||||
@@ -139,6 +153,10 @@ func run() error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
log.Info("Shutdown Server ... ")
|
log.Info("Shutdown Server ... ")
|
||||||
|
runtimeCancel()
|
||||||
|
if err := media.ShutdownRuntime(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Errorf("Shutdown MediaMTX runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
log.Fatal("Server Shutdown:", err)
|
log.Fatal("Server Shutdown:", err)
|
||||||
@@ -178,5 +196,6 @@ func initRouter() {
|
|||||||
Use(api.SetRequestLogger)
|
Use(api.SetRequestLogger)
|
||||||
|
|
||||||
common.InitMiddleware(r)
|
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
|
package version
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -34,6 +37,9 @@ func init() {
|
|||||||
|
|
||||||
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||||
return db.Transaction(func(tx *gorm.DB) 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 {
|
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
|
||||||
return err
|
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) {
|
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
|
||||||
var menu migrationModels.SysMenu
|
var menu migrationModels.SysMenu
|
||||||
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
"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), migrateSenseMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "/sense/media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 7, Visible: "0", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reconcile, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaReconcile", Title: "立即对账", MenuType: "F", Action: "POST", Permission: "sense:media:reconcile", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stop, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaStop", Title: "停止路径", MenuType: "F", Action: "POST", Permission: "sense:media:stop", 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, reconcile, stop}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
read := [][2]string{{"/api/v1/media/routes", "GET"}, {"/api/v1/media/process", "GET"}}
|
||||||
|
write := [][2]string{{"/api/v1/media/reconcile", "POST"}, {"/api/v1/media/routes/:id/reconcile", "POST"}, {"/api/v1/media/routes/:id/stop", "POST"}}
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||||
|
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaMigrationOnPostgres(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set SENSE_MEDIA_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_media_95_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("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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,37 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"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), migrateSenseLiveview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateSenseLiveview(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "/sense/liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 8, Visible: "0", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||||
|
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, policy := range [][2]string{{"/api/v1/liveview/routes", "GET"}, {"/api/v1/liveview/sessions", "POST"}, {"/api/v1/liveview/sessions/:id", "GET"}} {
|
||||||
|
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,57 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"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 TestLiveviewMigrationOnPostgres(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set SENSE_LIVEVIEW_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_liveview_68_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 = migrateSenseLiveview(db, "2026081420000_liveview.go"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var menus, policies, applied int64
|
||||||
|
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseLiveview").Count(&menus)
|
||||||
|
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/liveview%").Count(&policies)
|
||||||
|
db.Model(&common.Migration{}).Where("version = ?", "2026081420000_liveview.go").Count(&applied)
|
||||||
|
if menus != 1 || policies != 9 || applied != 1 {
|
||||||
|
t.Fatalf("menus=%d policies=%d applied=%d", menus, policies, applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultLoginValidity = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// AuthInit jwt验证new
|
// AuthInit jwt验证new
|
||||||
func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||||
timeout := time.Hour
|
timeout := resolveJWTTimeout(config.ApplicationConfig.Mode, config.JwtConfig.Timeout)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return jwt.New(&jwt.GinJWTMiddleware{
|
return jwt.New(&jwt.GinJWTMiddleware{
|
||||||
Realm: "Sense",
|
Realm: "Sense",
|
||||||
Key: []byte(config.JwtConfig.Secret),
|
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/gin-gonic/gin"
|
||||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
"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"
|
||||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
|
||||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
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/jwtauth/user"
|
||||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
"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
|
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)
|
sysUser, role, e := loginVals.GetUser(db)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
username = loginVals.Username
|
username = loginVals.Username
|
||||||
|
|
||||||
return map[string]interface{}{"user": sysUser, "role": role}, nil
|
return map[string]interface{}{"user": sysUser, "role": role}, nil
|
||||||
} else {
|
} else {
|
||||||
|
username = loginVals.Username
|
||||||
msg = "登录失败"
|
msg = "登录失败"
|
||||||
status = "1"
|
status = "1"
|
||||||
log.Warnf("%s login failed!", loginVals.Username)
|
log.Warnf("%s login failed!", loginVals.Username)
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
type Login struct {
|
type Login struct {
|
||||||
Username string `form:"UserName" json:"username" binding:"required"`
|
Username string `form:"UserName" json:"username" binding:"required"`
|
||||||
Password string `form:"Password" json:"password" 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) {
|
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加密字符串
|
# JWT加密字符串
|
||||||
secret: ""
|
secret: ""
|
||||||
# 过期时间单位:秒
|
# 过期时间单位:秒
|
||||||
timeout: 3600
|
timeout: 2592000
|
||||||
database:
|
database:
|
||||||
# 数据库名称
|
# 数据库名称
|
||||||
name: dbname
|
name: dbname
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ SENSE_CREDENTIAL_KEY=
|
|||||||
# approved local interface/IP ranges; comma-separate multiple CIDRs.
|
# approved local interface/IP ranges; comma-separate multiple CIDRs.
|
||||||
SENSE_ONVIF_DISCOVERY_IP=
|
SENSE_ONVIF_DISCOVERY_IP=
|
||||||
SENSE_ONVIF_ALLOWED_CIDRS=
|
SENSE_ONVIF_ALLOWED_CIDRS=
|
||||||
|
SENSE_MEDIAMTX_BINARY=
|
||||||
|
SENSE_MEDIAMTX_CONFIG=
|
||||||
|
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Sense generates only this credential-free base configuration.
|
||||||
|
# The Control API must stay on loopback; camera paths are applied at runtime.
|
||||||
|
logLevel: info
|
||||||
|
api: true
|
||||||
|
apiAddress: 127.0.0.1:9997
|
||||||
|
metrics: false
|
||||||
|
paths: {}
|
||||||
@@ -16,7 +16,7 @@ settings:
|
|||||||
frontpath: ../ui/src
|
frontpath: ../ui/src
|
||||||
jwt:
|
jwt:
|
||||||
secret: ""
|
secret: ""
|
||||||
timeout: 3600
|
timeout: 2592000
|
||||||
logger:
|
logger:
|
||||||
# 日志存放路径
|
# 日志存放路径
|
||||||
path: temp/logs
|
path: temp/logs
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ settings:
|
|||||||
# token 密钥,生产环境时及的修改
|
# token 密钥,生产环境时及的修改
|
||||||
secret: ""
|
secret: ""
|
||||||
# token 过期时间 单位:秒
|
# token 过期时间 单位:秒
|
||||||
timeout: 3600
|
timeout: 2592000
|
||||||
database:
|
database:
|
||||||
# Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。
|
# Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。
|
||||||
driver: postgres
|
driver: postgres
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ settings:
|
|||||||
# token 密钥,生产环境时及的修改
|
# token 密钥,生产环境时及的修改
|
||||||
secret: ""
|
secret: ""
|
||||||
# token 过期时间 单位:秒
|
# token 过期时间 单位:秒
|
||||||
timeout: 3600
|
timeout: 2592000
|
||||||
database:
|
database:
|
||||||
# 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。
|
# 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。
|
||||||
driver: postgres
|
driver: postgres
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ settings:
|
|||||||
# 必填。生产环境至少 32 个字符;不得提交真实值。
|
# 必填。生产环境至少 32 个字符;不得提交真实值。
|
||||||
secret: ""
|
secret: ""
|
||||||
# token 过期时间 单位:秒
|
# token 过期时间 单位:秒
|
||||||
timeout: 3600
|
timeout: 2592000
|
||||||
database:
|
database:
|
||||||
# 数据库类型 mysql, sqlite3, postgres, sqlserver
|
# 数据库类型 mysql, sqlite3, postgres, sqlserver
|
||||||
# sqlserver: sqlserver://用户名:密码@地址?database=数据库名
|
# sqlserver: sqlserver://用户名:密码@地址?database=数据库名
|
||||||
|
|||||||
@@ -3798,23 +3798,15 @@ const docTemplateadmin = `{
|
|||||||
"handler.Login": {
|
"handler.Login": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"code",
|
|
||||||
"password",
|
"password",
|
||||||
"username",
|
"username"
|
||||||
"uuid"
|
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"uuid": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3789,23 +3789,15 @@
|
|||||||
"handler.Login": {
|
"handler.Login": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"code",
|
|
||||||
"password",
|
"password",
|
||||||
"username",
|
"username"
|
||||||
"uuid"
|
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"uuid": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4345,4 +4337,4 @@
|
|||||||
"in": "header"
|
"in": "header"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -685,19 +685,13 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
handler.Login:
|
handler.Login:
|
||||||
properties:
|
properties:
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
uuid:
|
|
||||||
type: string
|
|
||||||
required:
|
required:
|
||||||
- code
|
|
||||||
- password
|
- password
|
||||||
- username
|
- username
|
||||||
- uuid
|
|
||||||
type: object
|
type: object
|
||||||
models.SysApi:
|
models.SysApi:
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package media_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
func freeAddress(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := listener.Addr().String()
|
||||||
|
if err = listener.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealMediaMTXControlLifecycle(t *testing.T) {
|
||||||
|
binary := os.Getenv("SENSE_MEDIAMTX_TEST_BINARY")
|
||||||
|
if binary == "" {
|
||||||
|
t.Skip("set SENSE_MEDIAMTX_TEST_BINARY to run the real MediaMTX integration")
|
||||||
|
}
|
||||||
|
apiAddress, rtspAddress := freeAddress(t), freeAddress(t)
|
||||||
|
configPath := filepath.Join(t.TempDir(), "mediamtx.yml")
|
||||||
|
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
|
||||||
|
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
controller, err := media.NewHTTPController("http://" + apiAddress)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
supervisor := media.NewSupervisor(binary, configPath)
|
||||||
|
if err = supervisor.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = supervisor.Stop(ctx)
|
||||||
|
})
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
for {
|
||||||
|
err = controller.Health(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("MediaMTX did not become ready: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
source := media.Source{Path: "sense_integration", URI: "rtsp://127.0.0.1:65530/test", Username: "synthetic-user", Password: "synthetic-password"}
|
||||||
|
if err = controller.Apply(context.Background(), source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = controller.Apply(context.Background(), source); err != nil {
|
||||||
|
t.Fatalf("replace must be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
if err = controller.Delete(context.Background(), source.Path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package media_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
type readyController struct{}
|
||||||
|
|
||||||
|
func (readyController) Health(context.Context) error { return nil }
|
||||||
|
func (readyController) Apply(context.Context, media.Source) error { return nil }
|
||||||
|
func (readyController) Delete(context.Context, string) error { return nil }
|
||||||
|
func (readyController) Status(context.Context, string) (media.PathStatus, error) {
|
||||||
|
return media.PathStatus{Exists: true, Ready: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresColdStartRestoresDesiredRoute(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SENSE_MEDIA_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set SENSE_MEDIA_TEST_DATABASE_URL to run PostgreSQL media recovery")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&media.Route{}, &admission.Profile{}, &credential.DeviceCredential{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sense_admission_profiles, sense_device_credentials")
|
||||||
|
})
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||||
|
vault, _ := credential.NewVault(key)
|
||||||
|
ciphertext, err := vault.Encrypt("device-pg", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-pg", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&admission.Profile{DeviceID: "device-pg", Token: "main", Name: "Main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready", VerificationDetail: "synthetic"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := media.NewService(db, readyController{}, nil, media.RuntimeConfig{})
|
||||||
|
if err = first.EnsureDevice(context.Background(), "device-pg"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := media.NewService(db.Session(&gorm.Session{NewDB: true}), readyController{}, nil, media.RuntimeConfig{})
|
||||||
|
if err = second.ReconcileDue(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := second.List(context.Background())
|
||||||
|
if err != nil || len(items) != 1 || items[0].Actual != "ready" {
|
||||||
|
t.Fatalf("items=%#v err=%v", items, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,13 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listLiveviewRoutes(query) {
|
||||||
|
return request({ url: '/api/v1/liveview/routes', method: 'get', params: query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveviewSession(routeId) {
|
||||||
|
return request({ url: '/api/v1/liveview/sessions', method: 'post', data: { routeId }})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLiveviewSession(id) {
|
||||||
|
return request({ url: `/api/v1/liveview/sessions/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listMediaRoutes() { return request({ url: '/api/v1/media/routes', method: 'get' }) }
|
||||||
|
export function getMediaProcess() { return request({ url: '/api/v1/media/process', method: 'get' }) }
|
||||||
|
export function reconcileAllMedia() { return request({ url: '/api/v1/media/reconcile', method: 'post' }) }
|
||||||
|
export function reconcileMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/reconcile`, method: 'post' }) }
|
||||||
|
export function stopMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/stop`, method: 'post' }) }
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sense-video-player" :aria-busy="busy ? 'true' : 'false'">
|
||||||
|
<iframe
|
||||||
|
v-if="playerUrl && playable"
|
||||||
|
:key="playerUrl"
|
||||||
|
class="sense-video-player__frame"
|
||||||
|
:src="playerUrl"
|
||||||
|
title="Sense 单路实时视频"
|
||||||
|
allow="autoplay; fullscreen"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
@load="$emit('loaded')"
|
||||||
|
/>
|
||||||
|
<div v-if="state !== 'ready'" class="sense-video-player__state" :class="{ 'is-overlay': playerUrl && playable }" aria-live="polite">
|
||||||
|
<el-icon v-if="busy" class="is-loading sense-video-player__icon" aria-hidden="true"><Loading /></el-icon>
|
||||||
|
<el-icon v-else class="sense-video-player__icon" aria-hidden="true"><WarningFilled /></el-icon>
|
||||||
|
<strong>{{ title }}</strong>
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
<el-button v-if="recoverable" type="primary" :loading="retrying" @click="$emit('retry')">重新连接</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { playbackStatusDetail, playbackStatusLabel } from '@/views/sense/liveview/playbackStatus'
|
||||||
|
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SenseVideoPlayer',
|
||||||
|
components: { Loading, WarningFilled },
|
||||||
|
props: {
|
||||||
|
state: { type: String, default: 'loading' },
|
||||||
|
detail: { type: String, default: '' },
|
||||||
|
playerUrl: { type: String, default: '' },
|
||||||
|
retrying: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
emits: ['retry', 'loaded'],
|
||||||
|
computed: {
|
||||||
|
busy() {
|
||||||
|
return this.state === 'loading' || this.state === 'waiting'
|
||||||
|
},
|
||||||
|
playable() {
|
||||||
|
return ['loading', 'waiting', 'ready'].includes(this.state)
|
||||||
|
},
|
||||||
|
recoverable() {
|
||||||
|
return !this.busy && this.state !== 'ready'
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return playbackStatusLabel(this.state)
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
return playbackStatusDetail(this.state, this.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sense-video-player {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
min-height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
background: #101419;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__state {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
color: #dcdfe6;
|
||||||
|
text-align: center;
|
||||||
|
background: #101419;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__state.is-overlay {
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgb(16 20 25 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__state strong {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__state span {
|
||||||
|
max-width: 640px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__state .el-button {
|
||||||
|
pointer-events: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-video-player__icon {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sense-video-player {
|
||||||
|
min-height: 210px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sense-video-player__icon {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user