Compare commits

..
Author SHA1 Message Date
QiuSW e0ab023557 docs: 标记任务 #90 验收完成 2026-08-27 16:40:40 +08:00
QiuSW dc426774ac docs: 更新任务 #90 最新 dev 回归证据 2026-08-27 16:30:41 +08:00
QiuSW 404e25584e merge: 更新 #90 到最新 dev
# Conflicts:
#	wiki-docs.json
2026-08-27 16:22:04 +08:00
ila 1514215729 merge: 完成本机 Supervisor Sense 实例 (#106)
用户已于 2026-08-27 验收通过;将 #106 文档与归档合入 dev,外部 Supervisor 实例保持运行,main 不变。
2026-08-27 16:17:51 +08:00
QiuSW 120a9efee4 docs: 标记任务 #106 验收完成 2026-08-27 16:17:36 +08:00
QiuSW 356f891c9e docs: 归档任务 #106 待验收证据 2026-08-27 16:10:28 +08:00
QiuSW 0646f5effd docs: 记录 Sense Supervisor 托管方式 (#106) 2026-08-27 16:08:23 +08:00
ila d264758dee merge: 完成 Sense GoAdmin 应用外壳 (#101)
用户已于 2026-08-27 验收通过;将 #101 合入 dev,main 保持不变。
2026-08-27 15:53:22 +08:00
QiuSW 83012ad37f docs: 标记任务 #101 验收完成 2026-08-27 15:53:08 +08:00
QiuSW 54f10d894a docs: 更新工单 #101 组合验收证据 2026-08-27 15:34:22 +08:00
QiuSW 8cdd3f61cb Merge remote-tracking branch 'origin/dev' into fix/101-sense-layout-shell
# Conflicts:
#	docs/02-architecture-and-code-map.md
2026-08-27 15:23:19 +08:00
ila 46c3232da4 merge: 完成 Sense 30天登录有效期 (#104)
用户已验收通过 #104;将 PR #105 合入 dev,main 保持不变。
2026-08-17 11:46:42 +08:00
QiuSW a53afb219c docs: 标记任务 #104 验收完成 2026-08-17 11:46:31 +08:00
QiuSW 4adf2d60aa docs: 完成任务 #104 待验收归档 2026-08-17 11:09:23 +08:00
QiuSW adc227f110 test: 验证 Sense 30天 JWT 到期时间 (#104) 2026-08-17 10:56:26 +08:00
QiuSW a8431216e3 docs: 记录 Sense 30天登录有效期 (#104) 2026-08-17 10:55:21 +08:00
QiuSW 857ba45541 feat: 将 Sense 登录有效期调整为30天 (#104) 2026-08-17 10:53:20 +08:00
QiuSW faa96a3bea docs: 更新工单 #101 组合验收包 2026-08-16 23:52:42 +08:00
QiuSW 42dc77b1f9 Merge branch 'dev' into fix/101-sense-layout-shell
# Conflicts:
#	docs/02-architecture-and-code-map.md
#	wiki-docs.json
2026-08-16 23:43:52 +08:00
ila 4b135b852b Merge pull request '#103' from docs/99-acceptance into dev
归档工单 #99 验收结果
2026-08-16 23:43:01 +08:00
QiuSW 3c668ccff6 docs: 归档工单 #99 验收结果 2026-08-16 23:41:53 +08:00
ila 34ee5ed619 Merge pull request '#100' from feature/99-sense-app-config into dev
[SEN] 恢复登录页匿名只读 app-config 接口(#99)

用户已于 2026-08-16 明确验收通过。
2026-08-16 23:38:40 +08:00
QiuSW 9ff2f6339f docs: 完成任务 #101 待验收归档 2026-08-16 23:32:42 +08:00
QiuSW e652109744 docs: 记录 Sense Layout 菜单结构 (#101) 2026-08-16 23:26:55 +08:00
QiuSW 4423d528b1 fix: 保留 Sense GoAdmin 应用外壳 (#101) 2026-08-16 23:12:53 +08:00
QiuSW d067f0b989 docs: 完成任务 #99 待验收归档 2026-08-16 19:49:19 +08:00
QiuSW 08b4b61f90 docs: 记录登录外壳只读配置边界 (#99) 2026-08-16 19:46:07 +08:00
QiuSW 713c9e4e30 fix: 恢复登录页前端配置接口 (#99) 2026-08-16 19:40:29 +08:00
ila 863b232a73 Merge pull request '#89' from feature/70-sense-windows-delivery into dev
[SEN] 建立 Windows 配置、启动与打包交付(#70)
2026-08-16 19:33:55 +08:00
QiuSW 5a67a17e17 docs: 记录工单 #70 验收通过 2026-08-16 19:33:44 +08:00
QiuSW ce0793505f Merge remote-tracking branch 'origin/dev' into feature/70-sense-windows-delivery
# Conflicts:
#	wiki-docs.json
2026-08-16 19:33:09 +08:00
ila 12e1bff8b4 Merge pull request '#96' from fix/95-sense-media-path-constraint into dev
[SEN] 兼容旧媒体路由 path 唯一约束迁移(#95)
2026-08-16 19:32:12 +08:00
QiuSW 15bd000816 docs: 记录工单 #95 验收通过 2026-08-16 19:32:02 +08:00
QiuSW 7f13e595b6 Merge remote-tracking branch 'origin/dev' into fix/95-sense-media-path-constraint
# Conflicts:
#	wiki-docs.json
2026-08-16 19:31:00 +08:00
QiuSW 098d0fafee docs: 记录 #70 最终集成回归 2026-08-16 19:27:38 +08:00
QiuSW b66c39724c fix: 移除打包清单模块依赖 (#70) 2026-08-16 19:18:26 +08:00
QiuSW ae0c26434e Merge remote-tracking branch 'origin/dev' into feature/70-sense-windows-delivery
# Conflicts:
#	docs/04-local-development-and-verification.md
#	wiki-docs.json
2026-08-16 19:15:00 +08:00
ila 116318df74 Merge pull request '#98' from feature/97-sense-password-login into dev
[SEN] 恢复免验证码登录并安全设置管理员密码(#97)
2026-08-16 19:05:35 +08:00
QiuSW bdb9a5b474 docs: 记录工单 #97 验收通过 2026-08-16 19:05:17 +08:00
QiuSW a92e4da043 docs: 归档 Sense 免验证码登录任务 (#97) 2026-08-15 18:08:33 +08:00
QiuSW bc1a01848d docs: 更新 Sense 登录安全边界 (#97) 2026-08-15 18:02:16 +08:00
QiuSW 0bbdb27f6d fix: 恢复 Sense 免验证码登录 (#97) 2026-08-15 18:02:10 +08:00
QiuSW 2c0e39bae8 docs: 记录 #70 白屏修复验收证据 2026-08-15 17:12:17 +08:00
QiuSW 4ca4abff7b fix: 修复 Sense Windows 包白屏 (#70) 2026-08-15 17:07:54 +08:00
QiuSW d0185caa0e docs: 完成任务 #95 待验收归档 2026-08-15 16:43:41 +08:00
QiuSW 6257859b83 docs: 记录旧媒体路由迁移排错 (#95) 2026-08-15 16:09:40 +08:00
QiuSW 38549760aa docs: 校正 #70 回归归档 2026-08-15 15:57:19 +08:00
QiuSW 6a8c763dec docs: 更新 #70 真实旧库交付回归 2026-08-15 15:56:21 +08:00
QiuSW c088caf816 merge: 集成媒体旧库迁移修复 (#70 #95) 2026-08-15 15:49:18 +08:00
QiuSW 3dd489066c fix: 兼容旧媒体路由约束迁移 (#95) 2026-08-15 15:48:10 +08:00
QiuSW b9824a8312 merge: 纳入已验收的设备迁移修复 (#70) 2026-08-15 15:32:15 +08:00
ila 40409707cc Merge pull request '#94' from docs/92-acceptance into dev
记录工单 #92 验收通过
2026-08-15 15:31:29 +08:00
QiuSW 9472151103 docs: 校正工单 #92 验收归档 2026-08-15 15:31:14 +08:00
QiuSW e82f15f1fb docs: 记录工单 #92 验收通过 2026-08-15 15:30:10 +08:00
ila eaa6ae0815 Merge pull request '#93' from fix/92-sense-capabilities-jsonb into dev
[SEN] 修复旧设备 capabilities 迁移到 JSONB(#92)
2026-08-15 15:28:03 +08:00
QiuSW 3a686a8151 docs: 完成任务 #92 待验收归档 2026-08-15 15:20:01 +08:00
QiuSW 3c4578c804 docs: 登记任务 #92 归档镜像 2026-08-15 15:19:12 +08:00
QiuSW fe3badfe3e test: 覆盖旧设备完整迁移链 (#92) 2026-08-15 15:17:28 +08:00
QiuSW aee5f45d96 docs: 记录设备能力迁移排错 (#92) 2026-08-15 15:16:32 +08:00
QiuSW 68b436ac62 fix: 兼容旧设备能力字段迁移 (#92) 2026-08-15 15:15:02 +08:00
QiuSW 23bfc04884 docs: 完成任务 #90 待验收归档 2026-08-15 14:48:43 +08:00
QiuSW 7112840362 docs: 登记任务 #90 归档镜像 2026-08-15 14:47:48 +08:00
QiuSW 06d982d220 docs: 记录 Sense 根目录启动方式 (#90) 2026-08-15 14:45:23 +08:00
QiuSW a865dda1c3 feat: 增加 Sense 根目录启动脚本 (#90) 2026-08-15 14:42:34 +08:00
QiuSW 2e76aafd0d docs: 完成任务 #70 待验收归档 2026-08-15 11:10:53 +08:00
QiuSW 23d3cdbf84 docs: 登记任务 #70 归档镜像 2026-08-15 11:08:16 +08:00
QiuSW e4544a011f docs: 记录 Sense Windows 交付流程 (#70) 2026-08-15 11:04:15 +08:00
QiuSW 6b79478414 feat: 建立 Sense Windows 交付包 (#70) 2026-08-15 10:56:47 +08:00
ila cf50c285f4 Merge pull request '#88' from docs/69-acceptance into dev
记录工单 #69 验收通过。
2026-08-15 09:37:08 +08:00
QiuSW 0bc113af04 docs: 记录工单 #69 验收通过 2026-08-15 09:36:34 +08:00
ila 61b79db9f7 Merge pull request '#87' from feature/69-sense-area into dev
[SEN] 重建多边形区域与方向警戒线配置(#69)

用户已明确验收通过。
2026-08-15 09:33:32 +08:00
QiuSW 55be48069f docs: 同步 Sense 区域配置归档 (#69) 2026-08-15 09:24:42 +08:00
QiuSW afd4dab567 docs: 归档 Sense 区域配置工单 (#69) 2026-08-15 09:19:40 +08:00
QiuSW f82dd51d95 docs: 记录 Sense 区域配置架构 (#69) 2026-08-15 09:15:11 +08:00
QiuSW 17bd383229 feat: 重建 Sense 区域与警戒线配置 (#69) 2026-08-15 09:10:43 +08:00
ila 020bf3fe5a Merge pull request '#86' from feature/68-sense-liveview into dev
[SEN] 重建单路实时监看与播放状态反馈(#68)

用户于 2026-08-15 验收通过。
2026-08-15 08:42:04 +08:00
101 changed files with 5416 additions and 188 deletions
+3
View File
@@ -3,5 +3,8 @@ server/*.exe
server/*.db
ui/node_modules/
ui/dist/
dist/
!scripts/build/
!scripts/build/**
.env
*.local.yml
+157
View File
@@ -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。
+9
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
logLevel: info
api: true
apiAddress: 127.0.0.1:9997
metrics: false
paths: {}
+18
View File
@@ -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=
+18
View File
@@ -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=
+5
View File
@@ -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.
+37
View File
@@ -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."
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %*
exit /b %errorlevel%
+117
View File
@@ -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 }
}
+37
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\backup-sense.ps1" %*
exit /b %errorlevel%
+22
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\check-sense.ps1" %*
exit /b %errorlevel%
+19
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\migrate-sense.ps1" %*
exit /b %errorlevel%
+19
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\restore-sense.ps1" %*
exit /b %errorlevel%
+27
View File
@@ -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
}
+281
View File
@@ -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')
}
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\start-sense.ps1" %*
exit /b %errorlevel%
+31
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\stop-sense.ps1" %*
exit /b %errorlevel%
+24
View File
@@ -0,0 +1,24 @@
param([ValidateSet('production', 'demo')][string]$Mode = 'production')
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' }
$config = Join-Path $root "config\$configName"
Import-SenseEnvironment -Path $config
$port = [int](Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080')
$connection = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $connection) { Write-Host "Sense is not listening on port $port."; exit 0 }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $($connection.OwningProcess)"
$expected = [IO.Path]::GetFullPath((Join-Path $root 'sense.exe'))
if (-not $process -or [IO.Path]::GetFullPath($process.ExecutablePath) -ne $expected) {
throw "Port $port belongs to another process; it was not stopped."
}
& taskkill.exe /PID $process.ProcessId /T /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Sense process tree.' }
Write-Host 'Sense and its managed child processes were stopped.'
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
@@ -0,0 +1,20 @@
package router
import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseAreaRouter) }
func registerSenseAreaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &area.API{}
r := v1.Group("/area").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("/configurations", api.List)
r.POST("/configurations", api.Create)
r.PUT("/configurations/:id", api.Update)
r.GET("/configurations/:id/versions", api.Versions)
}
@@ -80,6 +80,11 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysMenu{}
api2 := apis.SysDept{}
configAPI := apis.SysConfig{}
// The GoAdmin login shell reads frontend-only branding before a user is
// authenticated. Keep this single read route public without registering the
// disabled system-configuration CRUD and write routes.
v1.GET("/app-config", configAPI.Get2SysApp)
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
@@ -0,0 +1,37 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestRegisterBaseRouterExposesOnlyFrontendAppConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerBaseRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
routes := make(map[string]struct{})
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = struct{}{}
}
if _, ok := routes["GET /api/v1/app-config"]; !ok {
t.Fatal("anonymous frontend app-config route is not registered")
}
for _, disabled := range []string{
"GET /api/v1/config",
"POST /api/v1/config",
"GET /api/v1/config/:id",
"PUT /api/v1/config/:id",
"DELETE /api/v1/config",
"GET /api/v1/configKey/:configKey",
"GET /api/v1/set-config",
"PUT /api/v1/set-config",
} {
if _, ok := routes[disabled]; ok {
t.Fatalf("disabled system-configuration route was registered: %s", disabled)
}
}
}
+11 -1
View File
@@ -12,6 +12,7 @@ import (
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
@@ -131,7 +132,16 @@ func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool
}
}
if replaceProfiles && len(result.Profiles) > 0 {
return tx.Create(&result.Profiles).Error
if err := tx.Create(&result.Profiles).Error; err != nil {
return err
}
}
if replaceProfiles {
snapshots := make([]area.ProfileSnapshot, 0, len(result.Profiles))
for _, profile := range result.Profiles {
snapshots = append(snapshots, area.ProfileSnapshot{Token: profile.Token, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding})
}
return area.MarkProfilesReplaced(tx, request.DeviceID, snapshots)
}
return nil
})
@@ -10,18 +10,25 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
)
type fakeONVIF struct{ err error }
type fakeONVIF struct {
err error
profiles []onvif.Profile
}
func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
if f.err != nil {
return nil, f.err
}
if f.profiles != nil {
return f.profiles, nil
}
return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil
}
@@ -105,3 +112,27 @@ func TestProbeErrorsHaveActionableStates(t *testing.T) {
}
}
}
func TestProfileResolutionChangeMarksAreaForRecalibration(t *testing.T) {
service := admissionService(t)
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
t.Fatal(err)
}
if err := service.Orm.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
t.Fatal(err)
}
definition := area.Definition{ID: "area-1", Name: "东门警戒线", Kind: area.KindDirectionLine, DeviceID: "device-1", ProfileToken: "main", ProfileWidth: 1920, ProfileHeight: 1080, ProfileEncoding: "H264", CurrentVersion: 1, CurrentVersionID: "version-1", Enabled: true, CreatedBy: 7, UpdatedBy: 7}
if err := service.Orm.Create(&definition).Error; err != nil {
t.Fatal(err)
}
service.ONVIF = fakeONVIF{profiles: []onvif.Profile{{Token: "main", Name: "主码流", Width: 1280, Height: 720, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}}}
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); err != nil {
t.Fatal(err)
}
if err := service.Orm.First(&definition, "id = ?", "area-1").Error; err != nil {
t.Fatal(err)
}
if !definition.NeedsRecalibration {
t.Fatal("profile resolution change did not mark the bound area")
}
}
+126
View File
@@ -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
}
+81
View File
@@ -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
}
+84
View File
@@ -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)
}
})
}
}
+45
View File
@@ -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)
}
}
+304
View File
@@ -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(&current, "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)
}
}
+12
View File
@@ -3,6 +3,8 @@ package media
import (
"context"
"errors"
"os"
"strings"
"sync"
"time"
@@ -16,6 +18,10 @@ var runtimeState struct {
}
func StartRuntime(parent context.Context, db *gorm.DB) error {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_MODE")))
if mode == "disabled" {
return nil
}
if db == nil {
return errors.New("Sense database is unavailable for MediaMTX runtime")
}
@@ -29,6 +35,12 @@ func StartRuntime(parent context.Context, db *gorm.DB) error {
}
service := NewService(db, controller, NewSupervisor(config.Binary, config.ConfigPath), config)
ctx, cancel := context.WithCancel(parent)
if mode == "managed" || mode == "external" {
if err = service.ensureControl(ctx); err != nil {
cancel()
return err
}
}
runtimeState.Lock()
if runtimeState.cancel != nil {
runtimeState.cancel()
@@ -0,0 +1,22 @@
package media
import (
"context"
"strings"
"testing"
)
func TestStartRuntimeAllowsExplicitDemoDisable(t *testing.T) {
t.Setenv("SENSE_MEDIAMTX_MODE", "disabled")
if err := StartRuntime(context.Background(), nil); err != nil {
t.Fatalf("disabled demo runtime must not require MediaMTX or a database: %v", err)
}
}
func TestStartRuntimeManagedModeRequiresDatabase(t *testing.T) {
t.Setenv("SENSE_MEDIAMTX_MODE", "managed")
err := StartRuntime(context.Background(), nil)
if err == nil || !strings.Contains(err.Error(), "database") {
t.Fatalf("managed runtime must fail before HTTP startup without a database: %v", err)
}
}
+2 -1
View File
@@ -92,7 +92,7 @@ func run() error {
for _, db := range sdk.Runtime.GetDb() {
runtimeDBFound = true
if err := media.StartRuntime(runtimeCtx, db); err != nil {
log.Errorf("MediaMTX runtime unavailable: %v", err)
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
}
break
}
@@ -196,5 +196,6 @@ func initRouter() {
Use(api.SetRequestLogger)
common.InitMiddleware(r)
configureWebUI(r)
}
+70
View File
@@ -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
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestConfigureWebUIServesAssetsAndSPAFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("sense-index"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "js"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "js", "app.js"), []byte("sense-app"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("SENSE_WEB_ROOT", root)
r := gin.New()
configureWebUI(r)
for _, tc := range []struct {
path string
code int
body string
}{
{path: "/", code: http.StatusOK, body: "sense-index"},
{path: "/device/list", code: http.StatusOK, body: "sense-index"},
{path: "/js/app.js", code: http.StatusOK, body: "sense-app"},
{path: "/js/missing.js", code: http.StatusNotFound},
{path: "/api/v1/missing", code: http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
if res.Code != tc.code || (tc.body != "" && res.Body.String() != tc.body) {
t.Fatalf("%s: got %d %q", tc.path, res.Code, res.Body.String())
}
}
}
func TestWithinRootRejectsTraversal(t *testing.T) {
root := t.TempDir()
if withinRoot(root, filepath.Join(root, "..", "secret.txt")) {
t.Fatal("path traversal must be rejected")
}
}
@@ -1,8 +1,11 @@
package version
import (
"database/sql"
"encoding/json"
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -34,6 +37,9 @@ func init() {
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := prepareLegacyDeviceCapabilities(tx); err != nil {
return err
}
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
return err
}
@@ -89,6 +95,104 @@ func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
})
}
var supportedLegacyCapabilities = map[string]struct{}{
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
}
type legacyDeviceCapabilitiesRow struct {
ID string
Capabilities sql.NullString
}
// prepareLegacyDeviceCapabilities upgrades the pre-GoAdmin text column before
// GORM sees it. PostgreSQL cannot cast the old empty-string default to jsonb,
// and old rows stored a single capability token rather than a JSON array.
func prepareLegacyDeviceCapabilities(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
type columnMetadata struct {
DataType string
}
var column columnMetadata
result := tx.Raw(`SELECT data_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sense_devices'
AND column_name = 'capabilities'`).Scan(&column)
if result.Error != nil {
return fmt.Errorf("inspect sense_devices.capabilities: %w", result.Error)
}
if result.RowsAffected == 0 || column.DataType == "json" || column.DataType == "jsonb" {
return nil
}
if column.DataType != "text" && column.DataType != "character varying" {
return fmt.Errorf("sense_devices.capabilities has unsupported legacy type %q", column.DataType)
}
if err := tx.Exec(`LOCK TABLE "sense_devices" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_devices for capabilities migration: %w", err)
}
var rows []legacyDeviceCapabilitiesRow
if err := tx.Raw(`SELECT id, capabilities FROM "sense_devices" ORDER BY id`).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy device capabilities: %w", err)
}
canonical := make(map[string]string, len(rows))
invalid := 0
for _, row := range rows {
value, err := canonicalLegacyCapabilities(row.Capabilities)
if err != nil {
invalid++
continue
}
canonical[row.ID] = value
}
if invalid > 0 {
return fmt.Errorf("sense_devices.capabilities contains unsupported legacy data in %d row(s); migration rolled back", invalid)
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" DROP DEFAULT`).Error; err != nil {
return fmt.Errorf("drop legacy capabilities default: %w", err)
}
for _, row := range rows {
if err := tx.Exec(`UPDATE "sense_devices" SET "capabilities" = ? WHERE "id" = ?`, canonical[row.ID], row.ID).Error; err != nil {
return fmt.Errorf("normalize legacy device capabilities: %w", err)
}
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" TYPE jsonb USING "capabilities"::jsonb`).Error; err != nil {
return fmt.Errorf("convert capabilities to jsonb: %w", err)
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" SET DEFAULT '[]'::jsonb`).Error; err != nil {
return fmt.Errorf("set jsonb capabilities default: %w", err)
}
return nil
}
func canonicalLegacyCapabilities(value sql.NullString) (string, error) {
if !value.Valid || strings.TrimSpace(value.String) == "" {
return "[]", nil
}
trimmed := strings.TrimSpace(value.String)
if _, ok := supportedLegacyCapabilities[trimmed]; ok {
encoded, _ := json.Marshal([]string{trimmed})
return string(encoded), nil
}
if !strings.HasPrefix(trimmed, "[") {
return "", fmt.Errorf("legacy capability value is not an array")
}
var values []string
if err := json.Unmarshal([]byte(trimmed), &values); err != nil || values == nil || len(values) > 16 {
return "", fmt.Errorf("legacy capability array is invalid")
}
for index, item := range values {
item = strings.TrimSpace(item)
if _, ok := supportedLegacyCapabilities[item]; !ok {
return "", fmt.Errorf("legacy capability array contains an unsupported value")
}
values[index] = item
}
encoded, _ := json.Marshal(values)
return string(encoded), nil
}
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
var menu migrationModels.SysMenu
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
@@ -0,0 +1,207 @@
package version
import (
"database/sql"
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestCanonicalLegacyCapabilities(t *testing.T) {
tests := []struct {
name string
input sql.NullString
want string
wantErr bool
}{
{name: "null", input: sql.NullString{}, want: "[]"},
{name: "blank", input: sql.NullString{String: " ", Valid: true}, want: "[]"},
{name: "single", input: sql.NullString{String: " video ", Valid: true}, want: `["video"]`},
{name: "array", input: sql.NullString{String: `["radar", "contact"]`, Valid: true}, want: `["radar","contact"]`},
{name: "empty array", input: sql.NullString{String: `[]`, Valid: true}, want: `[]`},
{name: "unknown", input: sql.NullString{String: "unknown", Valid: true}, wantErr: true},
{name: "object", input: sql.NullString{String: `{"video":true}`, Valid: true}, wantErr: true},
{name: "unknown array item", input: sql.NullString{String: `["video","unknown"]`, Valid: true}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := canonicalLegacyCapabilities(test.input)
if test.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil || got != test.want {
t.Fatalf("got %q, %v; want %q", got, err, test.want)
}
})
}
}
func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_device_92_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
t.Run("converts legacy values and remains idempotent", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
for _, row := range [][2]string{{"single", "video"}, {"blank", ""}, {"array", `["radar","contact"]`}} {
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES (?, ?)`, row[0], row[1]).Error; err != nil {
t.Fatal(err)
}
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&deviceModels.Device{}); err != nil {
t.Fatalf("AutoMigrate after compatibility conversion: %v", err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var values []struct{ ID, Capabilities string }
if err = db.Raw(`SELECT id, capabilities::text AS capabilities FROM sense_devices ORDER BY id`).Scan(&values).Error; err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, value := range values {
got[value.ID] = strings.ReplaceAll(value.Capabilities, " ", "")
}
if got["single"] != `["video"]` || got["blank"] != `[]` || got["array"] != `["radar","contact"]` {
t.Fatalf("unexpected converted values: %#v", got)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatalf("repeat migration: %v", err)
}
})
t.Run("rejects unknown values without partial conversion", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('valid', 'video'), ('invalid', 'unknown')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err == nil {
t.Fatal("expected unsupported legacy data error")
}
assertCapabilitiesColumn(t, db, "text", "''::text")
var value string
if err = db.Raw(`SELECT capabilities FROM sense_devices WHERE id = 'valid'`).Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "video" {
t.Fatalf("transaction left partial data: %q", value)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE sense_devices`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatal(err)
}
})
t.Run("completes the device migration and records its version", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('legacy', 'video')`).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
const migrationVersion = "2026081414000_device.go"
if err = migrateSenseDeviceLedger(db, migrationVersion); err != nil {
t.Fatal(err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var migratedValue string
if err = db.Raw(`SELECT capabilities::text FROM sense_devices WHERE id = 'legacy'`).Scan(&migratedValue).Error; err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(migratedValue, " ", "") != `["video"]` {
t.Fatalf("unexpected full-migration value: %q", migratedValue)
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration record count=%d", applied)
}
})
}
func resetLegacyDeviceTable(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_devices`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TABLE sense_devices (
id text PRIMARY KEY,
name varchar(128) NOT NULL DEFAULT '',
location varchar(255) NOT NULL DEFAULT '',
modality varchar(32) NOT NULL DEFAULT 'video',
capabilities text NOT NULL DEFAULT '',
status varchar(32) NOT NULL DEFAULT 'active',
adapter_status varchar(32) NOT NULL DEFAULT 'ready',
rtsp_credential_same_as_onvif boolean NOT NULL DEFAULT true,
credential_updated_at timestamptz,
retry_requested_at timestamptz,
version bigint NOT NULL DEFAULT 1,
create_by bigint NOT NULL DEFAULT 0,
update_by bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT current_timestamp,
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
deleted_at timestamptz
)`).Error; err != nil {
t.Fatal(err)
}
}
func assertCapabilitiesColumn(t *testing.T, db *gorm.DB, wantType, wantDefault string) {
t.Helper()
var column struct{ DataType, ColumnDefault string }
if err := db.Raw(`SELECT data_type, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sense_devices'
AND column_name = 'capabilities'`).Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.DataType != wantType || column.ColumnDefault != wantDefault {
t.Fatalf("type/default=%q/%q; want %q/%q", column.DataType, column.ColumnDefault, wantType, wantDefault)
}
}
@@ -1,7 +1,9 @@
package version
import (
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -19,6 +21,9 @@ func init() {
func migrateSenseMedia(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := prepareLegacyMediaRouteSchema(tx); err != nil {
return err
}
if err := tx.AutoMigrate(&media.Route{}); err != nil {
return err
}
@@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error {
return tx.Create(&common.Migration{Version: version}).Error
})
}
type mediaPathConstraint struct {
Name string
Columns string
}
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
// the runtime-state columns now required by the Route model.
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
var tableCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
return fmt.Errorf("inspect legacy media route table: %w", err)
}
if tableCount == 0 {
return nil
}
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
}
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
return err
}
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
return err
}
return nil
}
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
var constraints []mediaPathConstraint
if err := tx.Raw(`SELECT c.conname AS name,
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.contype = 'u'
AND EXISTS (
SELECT 1
FROM unnest(c.conkey) AS key(attnum)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
WHERE a.attname = 'path'
)
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
return fmt.Errorf("inspect legacy media path constraints: %w", err)
}
if len(constraints) == 0 {
return nil
}
if len(constraints) != 1 || constraints[0].Columns != "path" {
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
}
const expectedName = "uni_sense_media_routes_path"
if constraints[0].Name == expectedName {
return nil
}
var conflictingNameCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
return fmt.Errorf("inspect target media path constraint name: %w", err)
}
if conflictingNameCount != 0 {
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
}
rename := fmt.Sprintf(
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
quotePostgresIdentifier(constraints[0].Name),
quotePostgresIdentifier(expectedName),
)
if err := tx.Exec(rename).Error; err != nil {
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
}
return nil
}
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
for _, statement := range []struct {
name string
sql string
}{
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
SET "source_ready" = COALESCE("source_ready", false),
"failure_count" = COALESCE("failure_count", 0),
"last_error_code" = COALESCE("last_error_code", '')
WHERE "source_ready" IS NULL
OR "failure_count" IS NULL
OR "last_error_code" IS NULL`},
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
} {
if err := tx.Exec(statement.sql).Error; err != nil {
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
}
}
return nil
}
func quotePostgresIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
@@ -2,6 +2,7 @@ package version
import (
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
@@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
const schema = "sense_media_95_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
if err = db.Exec(`INSERT INTO sense_media_routes
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
VALUES
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
t.Fatal(err)
}
const migrationVersion = "2026081419000_media.go"
if err = migrateSenseMedia(db, migrationVersion); err != nil {
t.Fatal(err)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
}
assertMediaPathUniqueIndex(t, db)
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
t.Fatal("expected path uniqueness violation")
}
var runtimeState struct {
SourceReady bool
FailureCount int64
LastError string
}
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
t.Fatal(err)
}
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatalf("repeat compatibility migration: %v", err)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatal(err)
}
})
t.Run("creates a fresh media table", func(t *testing.T) {
resetEmptyMediaMigration(t, db)
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
t.Fatal(err)
}
var routes int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if routes != 0 {
t.Fatalf("fresh route count=%d", routes)
}
assertMediaPathUniqueIndex(t, db)
})
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
t.Fatalf("expected unsupported uniqueness error, got %v", err)
}
var constraints int64
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
t.Fatal(err)
}
if constraints != 1 {
t.Fatalf("constraint rollback count=%d", constraints)
}
})
}
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
}
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
t.Fatal(err)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
createRouteTable := `CREATE TABLE sense_media_routes (
id text PRIMARY KEY,
device_id text NOT NULL,
profile_token text NOT NULL,
path text NOT NULL,
desired text NOT NULL,
actual text NOT NULL,
readers integer NOT NULL DEFAULT 0,
detail text NOT NULL DEFAULT '',
version bigint NOT NULL,
updated_at timestamptz NOT NULL,
` + pathConstraint + `
)`
if err := db.Exec(createRouteTable).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
}
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
t.Helper()
var indexes []struct {
IndexName string
IndexDef string
}
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'sense_media_routes'
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
for _, index := range indexes {
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
return
}
}
t.Fatalf("missing unique path index: %#v", indexes)
}
@@ -0,0 +1,60 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseArea)
}
func migrateSenseArea(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "/sense/area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 9, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
create, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaCreate", Title: "新增配置", MenuType: "F", Action: "POST", Permission: "sense:area:create", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaUpdate", Title: "编辑配置", MenuType: "F", Action: "PUT", Permission: "sense:area:update", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, create, update}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/area/configurations", "GET"}, {"/api/v1/area/configurations/:id/versions", "GET"}}
write := [][2]string{{"/api/v1/area/configurations", "POST"}, {"/api/v1/area/configurations/:id", "PUT"}}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
policies := append([][2]string{}, read...)
if role != "viewer" {
policies = append(policies, write...)
}
for _, policy := range policies {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,60 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestAreaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_AREA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_AREA_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_area_69_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseArea(db, "2026081509000_area.go"); err != nil {
t.Fatal(err)
}
var menus, policies, definitions, versions, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseArea%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/area%").Count(&policies)
db.Model(&area.Definition{}).Count(&definitions)
db.Model(&area.Version{}).Count(&versions)
db.Model(&common.Migration{}).Where("version = ?", "2026081509000_area.go").Count(&applied)
if menus != 3 || policies != 10 || definitions != 0 || versions != 0 || applied != 1 {
t.Fatalf("menus=%d policies=%d definitions=%d versions=%d applied=%d", menus, policies, definitions, versions, applied)
}
}
@@ -0,0 +1,85 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
const senseLayoutMenuName = "SenseManage"
var sensePageMenus = []migrationModels.SysMenu{
{MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "devices", MenuType: "C", Permission: "sense:device:list", Component: "/sense/device/index", Sort: 1, Visible: "0", IsFrame: "1"},
{MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 2, Visible: "0", IsFrame: "1"},
{MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 3, Visible: "0", IsFrame: "1"},
{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 4, Visible: "0", IsFrame: "1"},
{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 5, Visible: "0", IsFrame: "1"},
}
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLayout)
}
func migrateSenseLayout(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := alignSenseLayout(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func alignSenseLayout(tx *gorm.DB) error {
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: senseLayoutMenuName,
Title: "视频感知",
Icon: "video-camera",
Path: "/sense",
MenuType: "M",
ParentId: 0,
Component: "Layout",
Sort: 5,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return fmt.Errorf("ensure Sense layout menu: %w", err)
}
for _, desired := range sensePageMenus {
desired.ParentId = root.MenuId
desired.Paths = fmt.Sprintf("/0/%d", root.MenuId)
if _, err = ensureDeviceMenu(tx, desired); err != nil {
return fmt.Errorf("align Sense page menu %s: %w", desired.MenuName, err)
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return nil
}
func rebuildSenseMenuPaths(tx *gorm.DB, menuID int, parentPath string) error {
path := fmt.Sprintf("%s/%d", parentPath, menuID)
if err := tx.Model(&migrationModels.SysMenu{}).Where("menu_id = ?", menuID).Update("paths", path).Error; err != nil {
return fmt.Errorf("update menu %d paths: %w", menuID, err)
}
var children []migrationModels.SysMenu
if err := tx.Where("parent_id = ?", menuID).Order("sort, menu_id").Find(&children).Error; err != nil {
return fmt.Errorf("list children of menu %d: %w", menuID, err)
}
for _, child := range children {
if err := rebuildSenseMenuPaths(tx, child.MenuId, path); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,208 @@
package version
import (
"fmt"
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestAlignSenseLayoutPreservesShellAndExistingPages(t *testing.T) {
db := openSenseLayoutTestDB(t)
seedLegacySenseMenus(t, db)
associationsBefore := countRoleMenuAssociations(t, db)
if err := alignSenseLayout(db); err != nil {
t.Fatal(err)
}
// The alignment helper is intentionally idempotent because partially upgraded
// deployments may rerun the repair before the migration version is recorded.
if err := alignSenseLayout(db); err != nil {
t.Fatalf("repeat alignment: %v", err)
}
root := requireMenu(t, db, senseLayoutMenuName)
if root.ParentId != 0 || root.Component != "Layout" || root.Path != "/sense" || root.MenuType != "M" {
t.Fatalf("unexpected root menu: %#v", root)
}
if root.Paths != fmt.Sprintf("/0/%d", root.MenuId) {
t.Fatalf("root paths=%q", root.Paths)
}
for _, desired := range sensePageMenus {
page := requireMenu(t, db, desired.MenuName)
if page.ParentId != root.MenuId || page.Path != desired.Path || page.Component != desired.Component || page.Permission != desired.Permission {
t.Errorf("unexpected page %s: %#v", desired.MenuName, page)
}
wantPagePaths := fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId)
if page.Paths != wantPagePaths {
t.Errorf("page %s paths=%q want %q", desired.MenuName, page.Paths, wantPagePaths)
}
var buttons []migrationModels.SysMenu
if err := db.Where("parent_id = ? AND menu_type = ?", page.MenuId, "F").Find(&buttons).Error; err != nil {
t.Fatal(err)
}
if len(buttons) != 1 {
t.Fatalf("page %s button count=%d", desired.MenuName, len(buttons))
}
wantButtonPaths := fmt.Sprintf("%s/%d", wantPagePaths, buttons[0].MenuId)
if buttons[0].Paths != wantButtonPaths {
t.Errorf("button %s paths=%q want %q", buttons[0].MenuName, buttons[0].Paths, wantButtonPaths)
}
}
var rootCount int64
if err := db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", senseLayoutMenuName).Count(&rootCount).Error; err != nil {
t.Fatal(err)
}
if rootCount != 1 {
t.Fatalf("Sense layout menu count=%d", rootCount)
}
if associationsAfter := countRoleMenuAssociations(t, db); associationsAfter != associationsBefore {
t.Fatalf("role-menu associations changed from %d to %d", associationsBefore, associationsAfter)
}
}
func TestMigrateSenseLayoutRollsBackWhenRequiredPageIsMissing(t *testing.T) {
db := openSenseLayoutTestDB(t)
seedLegacySenseMenus(t, db)
if err := db.Where("menu_name = ?", "SenseArea").Delete(&migrationModels.SysMenu{}).Error; err != nil {
t.Fatal(err)
}
// A missing page is recreated by the repair, so force a failure when the
// migration records its version and verify the menu transaction also rolls back.
version := "2026081623100"
if err := db.Create(&common.Migration{Version: version}).Error; err != nil {
t.Fatal(err)
}
if err := migrateSenseLayout(db, version); err == nil {
t.Fatal("expected duplicate migration version error")
}
var rootCount, areaCount int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", senseLayoutMenuName).Count(&rootCount)
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseArea").Count(&areaCount)
if rootCount != 0 || areaCount != 0 {
t.Fatalf("transaction left partial menus: root=%d area=%d", rootCount, areaCount)
}
}
func openSenseLayoutTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
return db
}
func TestSenseLayoutMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_LAYOUT_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_LAYOUT_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_layout_101_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
seedLegacySenseMenus(t, db)
const version = "2026081623100"
if err = migrateSenseLayout(db, version); err != nil {
t.Fatal(err)
}
root := requireMenu(t, db, senseLayoutMenuName)
if root.Component != "Layout" || root.Path != "/sense" {
t.Fatalf("unexpected PostgreSQL root: %#v", root)
}
var pages, versions int64
db.Model(&migrationModels.SysMenu{}).Where("parent_id = ? AND menu_type = ?", root.MenuId, "C").Count(&pages)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&versions)
if pages != int64(len(sensePageMenus)) || versions != 1 {
t.Fatalf("pages=%d versions=%d", pages, versions)
}
}
func seedLegacySenseMenus(t *testing.T, db *gorm.DB) {
t.Helper()
role := migrationModels.SysRole{RoleName: "site_admin", RoleKey: "site_admin", Status: "2"}
if err := db.Create(&role).Error; err != nil {
t.Fatal(err)
}
assigned := make([]migrationModels.SysMenu, 0, len(sensePageMenus)*2)
for index, desired := range sensePageMenus {
legacy := desired
legacy.Path = "/sense/" + desired.Path
legacy.ParentId = 0
legacy.Sort = index + 5
legacy.Paths = ""
if err := db.Create(&legacy).Error; err != nil {
t.Fatal(err)
}
button := migrationModels.SysMenu{
MenuName: desired.MenuName + "Action",
Title: "测试操作",
MenuType: "F",
Permission: desired.Permission + ":action",
ParentId: legacy.MenuId,
Paths: fmt.Sprintf("/0/%d", legacy.MenuId),
Visible: "1",
IsFrame: "1",
}
if err := db.Create(&button).Error; err != nil {
t.Fatal(err)
}
assigned = append(assigned, legacy, button)
}
if err := db.Model(&role).Association("SysMenu").Append(assigned); err != nil {
t.Fatal(err)
}
}
func countRoleMenuAssociations(t *testing.T, db *gorm.DB) int64 {
t.Helper()
var count int64
if err := db.Table("sys_role_menu").Count(&count).Error; err != nil {
t.Fatal(err)
}
return count
}
func requireMenu(t *testing.T, db *gorm.DB, name string) migrationModels.SysMenu {
t.Helper()
var menu migrationModels.SysMenu
if err := db.Where("menu_name = ?", name).First(&menu).Error; err != nil {
t.Fatal(err)
}
return menu
}
+13 -8
View File
@@ -8,16 +8,11 @@ import (
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
const defaultLoginValidity = 30 * 24 * time.Hour
// AuthInit jwt验证new
func AuthInit() (*jwt.GinJWTMiddleware, error) {
timeout := time.Hour
if config.ApplicationConfig.Mode == "dev" {
timeout = time.Duration(876010) * time.Hour
} else {
if config.JwtConfig.Timeout != 0 {
timeout = time.Duration(config.JwtConfig.Timeout) * time.Second
}
}
timeout := resolveJWTTimeout(config.ApplicationConfig.Mode, config.JwtConfig.Timeout)
return jwt.New(&jwt.GinJWTMiddleware{
Realm: "Sense",
Key: []byte(config.JwtConfig.Secret),
@@ -34,3 +29,13 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
})
}
func resolveJWTTimeout(mode string, configuredSeconds int64) time.Duration {
if mode == "dev" {
return time.Duration(876010) * time.Hour
}
if configuredSeconds > 0 {
return time.Duration(configuredSeconds) * time.Second
}
return defaultLoginValidity
}
@@ -0,0 +1,57 @@
package middleware
import (
"testing"
"time"
"github.com/go-admin-team/go-admin-core/sdk/config"
)
func TestResolveJWTTimeoutDefaultsToThirtyDays(t *testing.T) {
for _, mode := range []string{"prod", "test", "demo"} {
if got := resolveJWTTimeout(mode, 0); got != 30*24*time.Hour {
t.Errorf("mode %s timeout=%s, want 720h", mode, got)
}
}
}
func TestResolveJWTTimeoutHonorsExplicitConfiguration(t *testing.T) {
if got := resolveJWTTimeout("prod", 3600); got != time.Hour {
t.Fatalf("timeout=%s, want 1h", got)
}
}
func TestResolveJWTTimeoutKeepsUpstreamDevelopmentBehavior(t *testing.T) {
if got := resolveJWTTimeout("dev", 1); got != 876010*time.Hour {
t.Fatalf("development timeout=%s", got)
}
}
func TestAuthInitSignsTokenExpiringAfterThirtyDays(t *testing.T) {
oldMode := config.ApplicationConfig.Mode
oldTimeout := config.JwtConfig.Timeout
oldSecret := config.JwtConfig.Secret
t.Cleanup(func() {
config.ApplicationConfig.Mode = oldMode
config.JwtConfig.Timeout = oldTimeout
config.JwtConfig.Secret = oldSecret
})
config.ApplicationConfig.Mode = "prod"
config.JwtConfig.Timeout = 0
config.JwtConfig.Secret = "task-104-test-secret-not-for-production"
auth, err := AuthInit()
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
auth.TimeFunc = func() time.Time { return now }
_, expiresAt, err := auth.TokenGenerator(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if got := expiresAt.Sub(now); got != 30*24*time.Hour {
t.Fatalf("signed token validity=%s, want 720h", got)
}
}
+1 -11
View File
@@ -7,9 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
@@ -82,21 +80,13 @@ func Authenticator(c *gin.Context) (interface{}, error) {
return nil, jwt.ErrMissingLoginValues
}
if config.ApplicationConfig.Mode != "dev" {
if !captcha.Verify(loginVals.UUID, loginVals.Code, true) {
username = loginVals.Username
msg = "验证码错误"
status = "1"
return nil, jwt.ErrInvalidVerificationode
}
}
sysUser, role, e := loginVals.GetUser(db)
if e == nil {
username = loginVals.Username
return map[string]interface{}{"user": sysUser, "role": role}, nil
} else {
username = loginVals.Username
msg = "登录失败"
status = "1"
log.Warnf("%s login failed!", loginVals.Username)
@@ -9,8 +9,6 @@ import (
type Login struct {
Username string `form:"UserName" json:"username" binding:"required"`
Password string `form:"Password" json:"password" binding:"required"`
Code string `form:"Code" json:"code" binding:"required"`
UUID string `form:"UUID" json:"uuid" binding:"required"`
}
func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {
@@ -0,0 +1,35 @@
package handler
import (
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoginAcceptsCredentialsWithoutCaptcha(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", strings.NewReader(`{"username":"operator","password":"valid-password"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
var login Login
if err := ctx.ShouldBindJSON(&login); err != nil {
t.Fatalf("bind credentials-only login: %v", err)
}
if login.Username != "operator" || login.Password != "valid-password" {
t.Fatalf("unexpected login payload: username=%q", login.Username)
}
typeOfLogin := reflect.TypeOf(login)
if typeOfLogin.NumField() != 2 {
t.Fatalf("login payload must only expose username and password, got %d fields", typeOfLogin.NumField())
}
for _, removed := range []string{"Code", "UUID"} {
if _, ok := typeOfLogin.FieldByName(removed); ok {
t.Fatalf("captcha field %s must not be part of the login payload", removed)
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ settings:
# JWT加密字符串
secret: ""
# 过期时间单位:秒
timeout: 3600
timeout: 2592000
database:
# 数据库名称
name: dbname
+1 -1
View File
@@ -16,7 +16,7 @@ settings:
frontpath: ../ui/src
jwt:
secret: ""
timeout: 3600
timeout: 2592000
logger:
# 日志存放路径
path: temp/logs
+1 -1
View File
@@ -34,7 +34,7 @@ settings:
# token 密钥,生产环境时及的修改
secret: ""
# token 过期时间 单位:秒
timeout: 3600
timeout: 2592000
database:
# Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。
driver: postgres
+1 -1
View File
@@ -25,7 +25,7 @@ settings:
# token 密钥,生产环境时及的修改
secret: ""
# token 过期时间 单位:秒
timeout: 3600
timeout: 2592000
database:
# 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。
driver: postgres
+1 -1
View File
@@ -25,7 +25,7 @@ settings:
# 必填。生产环境至少 32 个字符;不得提交真实值。
secret: ""
# token 过期时间 单位:秒
timeout: 3600
timeout: 2592000
database:
# 数据库类型 mysql, sqlite3, postgres, sqlserver
# sqlserver: sqlserver://用户名:密码@地址?database=数据库名
+1 -9
View File
@@ -3798,23 +3798,15 @@ const docTemplateadmin = `{
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
+2 -10
View File
@@ -3789,23 +3789,15 @@
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
@@ -4345,4 +4337,4 @@
"in": "header"
}
}
}
}
@@ -685,19 +685,13 @@ definitions:
type: object
handler.Login:
properties:
code:
type: string
password:
type: string
username:
type: string
uuid:
type: string
required:
- code
- password
- username
- uuid
type: object
models.SysApi:
properties:
+17
View File
@@ -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%
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-tests.ps1"
exit /b %errorlevel%
+98
View File
@@ -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
}
}
+32
View File
@@ -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
}
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listAreaConfigurations(query) {
return request({ url: '/api/v1/area/configurations', method: 'get', params: query })
}
export function createAreaConfiguration(data) {
return request({ url: '/api/v1/area/configurations', method: 'post', data })
}
export function updateAreaConfiguration(id, data) {
return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data })
}
export function listAreaVersions(id) {
return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' })
}
@@ -0,0 +1,55 @@
export const POLYGON = 'polygon'
export const DIRECTION_LINE = 'direction_line'
export function clamp(value) {
return Math.min(1, Math.max(0, Number(value)))
}
export function geometryError(kind, points, direction = '') {
if (!Array.isArray(points)) return '请在画面中添加坐标点'
if (kind === POLYGON && (points.length < 3 || points.length > 64)) return '多边形需要 3 到 64 个点'
if (kind === DIRECTION_LINE && points.length !== 2) return '方向警戒线需要恰好 2 个点'
if (kind === DIRECTION_LINE && !['forward', 'reverse'].includes(direction)) return '请选择警戒方向'
if (![POLYGON, DIRECTION_LINE].includes(kind)) return '请选择配置类型'
if (points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1)) return '坐标必须位于画面范围内'
if (kind === POLYGON && selfIntersects(points)) return '多边形边线不能交叉,请调整顶点'
if (kind === POLYGON && Math.abs(polygonArea(points)) < 0.000001) return '多边形面积过小,请重新绘制'
if (kind === DIRECTION_LINE && samePoint(points[0], points[1])) return '警戒线起点和终点不能重合'
return ''
}
function samePoint(a, b) {
return Math.abs(a.x - b.x) < 0.0000001 && Math.abs(a.y - b.y) < 0.0000001
}
function polygonArea(points) {
return points.reduce((total, point, index) => {
const next = points[(index + 1) % points.length]
return total + point.x * next.y - next.x * point.y
}, 0) / 2
}
function selfIntersects(points) {
for (let first = 0; first < points.length; first += 1) {
const a = points[first]
const b = points[(first + 1) % points.length]
for (let second = first + 1; second < points.length; second += 1) {
if (second === first || second === (first + 1) % points.length || first === (second + 1) % points.length) continue
const c = points[second]
const d = points[(second + 1) % points.length]
if (segmentsIntersect(a, b, c, d)) return true
}
}
return false
}
function segmentsIntersect(a, b, c, d) {
const orientation = (p, q, r) => (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
const values = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)]
if (((values[0] > 0 && values[1] < 0) || (values[0] < 0 && values[1] > 0)) && ((values[2] > 0 && values[3] < 0) || (values[2] < 0 && values[3] > 0))) return true
const onSegment = (p, q, r) => q.x <= Math.max(p.x, r.x) + 0.0000001 && q.x >= Math.min(p.x, r.x) - 0.0000001 && q.y <= Math.max(p.y, r.y) + 0.0000001 && q.y >= Math.min(p.y, r.y) - 0.0000001
return (Math.abs(values[0]) < 0.0000001 && onSegment(a, c, b)) ||
(Math.abs(values[1]) < 0.0000001 && onSegment(a, d, b)) ||
(Math.abs(values[2]) < 0.0000001 && onSegment(c, a, d)) ||
(Math.abs(values[3]) < 0.0000001 && onSegment(c, b, d))
}
@@ -0,0 +1,307 @@
<template>
<section class="geometry-editor" aria-labelledby="geometry-editor-title">
<div class="editor-toolbar">
<div>
<strong id="geometry-editor-title">画面坐标</strong>
<span>{{ instruction }}</span>
</div>
<div class="editor-actions">
<el-button :disabled="disabled || !canAdd" @click="addCenterPoint">添加中心点</el-button>
<el-button :disabled="disabled || undoStack.length === 0" @click="undo">撤销</el-button>
<el-button :disabled="disabled || points.length === 0" @click="clear">清空</el-button>
</div>
</div>
<div class="canvas-shell" :class="{ 'is-disabled': disabled }">
<iframe
v-if="playerUrl"
:src="playerUrl"
title="区域校准实时视频"
allow="autoplay; fullscreen"
referrerpolicy="no-referrer"
tabindex="-1"
/>
<div v-else class="canvas-placeholder">选择可用视频后,可在实时画面上绘制</div>
<svg
ref="canvas"
class="geometry-canvas"
viewBox="0 0 1000 562.5"
role="application"
:aria-label="instruction"
tabindex="0"
@click="addFromPointer"
@pointermove="dragPoint"
@pointerup="stopDrag"
@pointercancel="stopDrag"
@keydown.enter.prevent="addCenterPoint"
@keydown.space.prevent="addCenterPoint"
>
<defs>
<marker id="sense-area-arrow" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,8 L10,4 z" class="arrow-head" />
</marker>
</defs>
<polygon v-if="kind === 'polygon' && points.length >= 2" :points="svgPoints" class="area-shape" />
<line
v-if="kind === 'direction_line' && points.length === 2"
:x1="scaledPoints[0].x"
:y1="scaledPoints[0].y"
:x2="scaledPoints[1].x"
:y2="scaledPoints[1].y"
class="direction-line"
:marker-start="direction === 'reverse' ? 'url(#sense-area-arrow)' : undefined"
:marker-end="direction === 'forward' ? 'url(#sense-area-arrow)' : undefined"
/>
<g
v-for="(point, index) in scaledPoints"
:key="index"
class="point-control"
role="button"
tabindex="0"
:aria-label="`坐标点 ${index + 1},横向 ${Math.round(points[index].x * 100)}%,纵向 ${Math.round(points[index].y * 100)}%`"
@click.stop
@pointerdown.stop.prevent="startDrag(index, $event)"
@keydown="movePointByKeyboard(index, $event)"
>
<circle :cx="point.x" :cy="point.y" r="12" />
<text :x="point.x" :y="point.y + 4" text-anchor="middle">{{ index + 1 }}</text>
</g>
</svg>
</div>
<div class="point-summary" aria-live="polite">
<span>已添加 {{ points.length }} 个点</span>
<span>键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。</span>
</div>
<p v-if="error" class="geometry-error" role="alert">{{ error }}</p>
</section>
</template>
<script>
import { clamp, geometryError } from './geometry'
export default {
name: 'SenseGeometryEditor',
props: {
modelValue: { type: Array, default: () => [] },
kind: { type: String, required: true },
direction: { type: String, default: '' },
playerUrl: { type: String, default: '' },
disabled: { type: Boolean, default: false }
},
emits: ['update:modelValue'],
data() {
return { undoStack: [], draggingIndex: -1 }
},
computed: {
points() {
return this.modelValue || []
},
scaledPoints() {
return this.points.map(point => ({ x: point.x * 1000, y: point.y * 562.5 }))
},
svgPoints() {
return this.scaledPoints.map(point => `${point.x},${point.y}`).join(' ')
},
canAdd() {
return this.kind === 'polygon' ? this.points.length < 64 : this.points.length < 2
},
instruction() {
return this.kind === 'polygon' ? '点击画面添加顶点,拖动顶点调整危险区域' : '依次添加起点和终点,箭头表示警戒方向'
},
error() {
if (this.points.length === 0) return ''
return geometryError(this.kind, this.points, this.direction)
}
},
methods: {
commit(next, remember = true) {
if (this.disabled) return
if (remember) this.undoStack.push(this.points.map(point => ({ ...point })))
this.$emit('update:modelValue', next.map(point => ({ x: clamp(point.x), y: clamp(point.y) })))
},
addFromPointer(event) {
if (this.disabled || !this.canAdd || event.target.closest('.point-control')) return
this.commit([...this.points, this.eventPoint(event)])
},
addCenterPoint() {
if (!this.disabled && this.canAdd) this.commit([...this.points, { x: 0.5, y: 0.5 }])
},
clear() {
if (this.points.length) this.commit([])
},
undo() {
if (!this.undoStack.length || this.disabled) return
const previous = this.undoStack.pop()
this.commit(previous, false)
},
startDrag(index, event) {
if (this.disabled) return
this.undoStack.push(this.points.map(point => ({ ...point })))
this.draggingIndex = index
event.currentTarget.setPointerCapture?.(event.pointerId)
},
dragPoint(event) {
if (this.draggingIndex < 0 || this.disabled) return
const next = this.points.map(point => ({ ...point }))
next[this.draggingIndex] = this.eventPoint(event)
this.commit(next, false)
},
stopDrag() {
this.draggingIndex = -1
},
movePointByKeyboard(index, event) {
if (this.disabled) return
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
this.commit(this.points.filter((_, pointIndex) => pointIndex !== index))
return
}
const movement = { ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0], ArrowUp: [0, -0.01], ArrowDown: [0, 0.01] }[event.key]
if (!movement) return
event.preventDefault()
const next = this.points.map(point => ({ ...point }))
next[index] = { x: next[index].x + movement[0], y: next[index].y + movement[1] }
this.commit(next)
},
eventPoint(event) {
const rect = this.$refs.canvas.getBoundingClientRect()
return { x: clamp((event.clientX - rect.left) / rect.width), y: clamp((event.clientY - rect.top) / rect.height) }
}
}
}
</script>
<style scoped>
.geometry-editor {
width: 100%;
}
.editor-toolbar,
.point-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.editor-toolbar > div:first-child {
display: flex;
flex-direction: column;
gap: 4px;
}
.editor-toolbar span,
.point-summary {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.editor-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editor-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.canvas-shell {
position: relative;
width: 100%;
margin-top: 12px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-darker);
}
.canvas-shell iframe,
.geometry-canvas,
.canvas-placeholder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.canvas-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.geometry-canvas {
cursor: crosshair;
outline-offset: -3px;
touch-action: none;
}
.geometry-canvas:focus-visible {
outline: 3px solid var(--el-color-primary);
}
.area-shape {
fill: color-mix(in srgb, var(--el-color-warning) 24%, transparent);
stroke: var(--el-color-warning-dark-2);
stroke-width: 4;
}
.direction-line {
stroke: var(--el-color-danger);
stroke-width: 6;
}
.arrow-head {
fill: var(--el-color-danger);
}
.point-control {
cursor: grab;
outline: none;
}
.point-control circle {
fill: var(--el-color-primary);
stroke: var(--el-color-white);
stroke-width: 3;
}
.point-control text {
fill: var(--el-color-white);
font-size: 12px;
pointer-events: none;
}
.point-control:focus-visible circle {
stroke: var(--el-color-warning);
stroke-width: 6;
}
.point-summary {
margin-top: 8px;
flex-wrap: wrap;
}
.geometry-error {
margin: 8px 0 0;
color: var(--el-color-danger);
line-height: 1.5;
}
.is-disabled .geometry-canvas {
cursor: not-allowed;
}
@media (max-width: 767px) {
.editor-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+2 -1
View File
@@ -1,13 +1,14 @@
import Cookies from 'js-cookie'
const TokenKey = 'Sense-Admin-Token'
const LoginValidityDays = 30
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
return Cookies.set(TokenKey, token, { expires: LoginValidityDays })
}
export function removeToken() {
+4 -85
View File
@@ -102,29 +102,6 @@
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="code">
<div class="captcha-row">
<el-input
v-model="loginForm.code"
placeholder="请输入验证码"
name="code"
type="text"
tabindex="3"
maxlength="5"
autocomplete="off"
size="large"
:prefix-icon="Key"
@keyup.enter="handleLogin"
/>
<div class="captcha-wrap" title="点击刷新" @click="getCode">
<img v-if="codeUrl" :src="codeUrl" class="captcha-img" alt="验证码">
<div v-else class="captcha-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
</el-form-item>
<el-button
:loading="loading"
type="primary"
@@ -142,27 +119,22 @@
</template>
<script>
import { getCodeImg } from '@/api/login'
import { User, Lock, Key, View, Hide, Monitor, Loading } from '@element-plus/icons-vue'
import { User, Lock, View, Hide, Monitor } from '@element-plus/icons-vue'
export default {
name: 'LoginPage',
setup() {
return { User, Lock, Key, View, Hide, Monitor, Loading }
return { User, Lock, View, Hide, Monitor }
},
data() {
return {
codeUrl: '',
loginForm: {
username: '',
password: '',
code: '',
uuid: ''
password: ''
},
loginRules: {
username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }],
code: [{ required: true, trigger: 'change', message: '验证码不能为空' }]
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
},
passwordType: 'password',
capsTooltip: false,
@@ -185,7 +157,6 @@ export default {
}
},
created() {
this.getCode()
this.getSystemSetting()
},
mounted() {
@@ -202,15 +173,6 @@ export default {
document.title = ret.sys_app_name
})
},
getCode() {
this.codeUrl = ''
getCodeImg().then((res) => {
if (res !== undefined) {
this.codeUrl = res.data
this.loginForm.uuid = res.id
}
})
},
checkCapslock({ shiftKey, key } = {}) {
if (key && key.length === 1) {
if ((shiftKey && key >= 'a' && key <= 'z') || (!shiftKey && key >= 'A' && key <= 'Z')) {
@@ -238,7 +200,6 @@ export default {
})
.catch(() => {
this.loading = false
this.getCode()
})
}
})
@@ -562,48 +523,6 @@ export default {
}
}
/* ── 验证码 ── */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
.el-input {
flex: 1;
}
}
.captcha-wrap {
width: 110px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
transition: border-color 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.captcha-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
color: #c1c7d0;
font-size: 18px;
}
/* ── 登录按钮 ── */
.submit-btn {
width: 100%;
@@ -0,0 +1,20 @@
import { DIRECTION_LINE, geometryError } from '@/components/sense/geometry-editor/geometry'
export function buildAreaPayload(form) {
return {
name: String(form.name || '').trim(),
kind: form.kind,
routeId: form.routeId,
points: (form.points || []).map(point => ({ x: Number(point.x), y: Number(point.y) })),
direction: form.kind === DIRECTION_LINE ? form.direction : '',
enabled: Boolean(form.enabled),
expectedVersion: Number(form.version || 0)
}
}
export function validateAreaForm(form) {
if (!String(form.name || '').trim()) return '请填写配置名称'
if (!form.routeId) return '请选择设备与视频码流'
const payload = buildAreaPayload(form)
return geometryError(payload.kind, payload.points, payload.direction)
}
+455
View File
@@ -0,0 +1,455 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>区域与警戒线</h3>
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
</div>
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
</div>
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
<el-form-item label="名称" prop="keyword">
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="类型" prop="kind">
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
<el-option label="危险区域" value="polygon" />
<el-option label="方向警戒线" value="direction_line" />
</el-select>
</el-form-item>
<el-form-item label="校准状态" prop="recalibrationState">
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
<el-option label="需要重新校准" value="needed" />
<el-option label="已校准" value="ready" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="!loading && total === 0"
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
type="info"
:closable="false"
show-icon
class="empty-alert"
/>
<el-table v-loading="loading" :data="items" border stripe>
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="130">
<template #default="scope">
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="设备与码流" min-width="220">
<template #default="scope">
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
</template>
</el-table-column>
<el-table-column label="版本" width="90">
<template #default="scope">v{{ scope.row.version }}</template>
</el-table-column>
<el-table-column label="校准状态" width="150">
<template #default="scope">
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
<el-tag v-else type="success" size="small">已校准</el-tag>
</template>
</el-table-column>
<el-table-column label="启用状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
v-model:page="queryParams.pageIndex"
v-model:limit="queryParams.pageSize"
:total="total"
@pagination="getList"
/>
</el-card>
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
<template #header>
<div class="dialog-header">
<div>
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
</div>
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
</div>
</template>
<el-alert
v-if="form.needsRecalibration"
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
type="warning"
:closable="false"
show-icon
class="dialog-alert"
/>
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
<div class="form-grid">
<el-form-item label="配置名称" prop="name">
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
</el-form-item>
<el-form-item label="配置类型" prop="kind">
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
<el-option label="危险区域" value="polygon" />
<el-option label="方向警戒线" value="direction_line" />
</el-select>
</el-form-item>
<el-form-item label="设备与码流" prop="routeId" class="route-field">
<el-select
v-model="form.routeId"
filterable
remote
:remote-method="searchRoutes"
:loading="routesLoading"
placeholder="输入设备、位置或码流名称"
@change="handleRouteChange"
>
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
</el-select>
</el-form-item>
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
<el-select v-model="form.direction" @change="dirty = true">
<el-option label="从起点到终点" value="forward" />
<el-option label="从终点到起点" value="reverse" />
</el-select>
</el-form-item>
<el-form-item label="启用配置">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
</el-form-item>
</div>
<el-form-item label="绑定规格" class="profile-summary">
<span>{{ selectedProfileSummary }}</span>
</el-form-item>
<el-form-item label="绘制区域" prop="points">
<SenseGeometryEditor
:model-value="form.points"
:kind="form.kind"
:direction="form.direction"
:player-url="preview.playerUrl"
:disabled="!form.routeId"
@update:model-value="handlePointsChange"
/>
</el-form-item>
</el-form>
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
<template #footer>
<el-button @click="requestEditorClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
</template>
</el-dialog>
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
<el-table v-loading="versionsLoading" :data="versions" border>
<el-table-column label="版本" width="80">
<template #default="scope">v{{ scope.row.version }}</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="绑定规格" min-width="190">
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
</el-table-column>
<el-table-column label="坐标点" width="90">
<template #default="scope">{{ scope.row.points.length }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
import SenseGeometryEditor from '@/components/sense/geometry-editor'
import { buildAreaPayload, validateAreaForm } from './areaPayload'
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
export default {
name: 'SenseArea',
components: { SenseGeometryEditor },
data() {
return {
loading: false,
items: [],
total: 0,
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
editorOpen: false,
saving: false,
dirty: false,
form: emptyForm(),
formError: '',
rules: {
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
},
routeOptions: [],
routesLoading: false,
preview: {},
versionsOpen: false,
versionsLoading: false,
versions: []
}
},
computed: {
selectedRoute() {
return this.routeOptions.find(route => route.id === this.form.routeId) || null
},
selectedProfileSummary() {
if (!this.selectedRoute) return '尚未选择视频码流'
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
}
},
created() {
this.getList()
},
methods: {
async getList() {
this.loading = true
try {
const response = await listAreaConfigurations(this.queryParams)
this.items = response.data.list || []
this.total = response.data.count || 0
} finally {
this.loading = false
}
},
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
resetQuery() {
this.$refs.queryForm.resetFields()
this.handleQuery()
},
kindLabel(kind) {
return kind === 'polygon' ? '危险区域' : '方向警戒线'
},
profileLabel(item) {
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
},
routeLabel(route) {
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
},
async searchRoutes(keyword = '') {
this.routesLoading = true
try {
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
const routes = response.data.list || []
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
} finally {
this.routesLoading = false
}
},
async openCreate() {
this.form = emptyForm()
this.formError = ''
this.preview = {}
this.dirty = false
this.editorOpen = true
await this.searchRoutes('')
},
async openEdit(item) {
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
this.formError = ''
this.preview = {}
this.dirty = false
this.editorOpen = true
if (item.routeId) await this.openPreview()
},
handleKindChange() {
this.form.points = []
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
this.dirty = true
this.formError = ''
},
async handleRouteChange() {
this.form.points = []
this.dirty = true
this.formError = ''
await this.openPreview()
},
handlePointsChange(points) {
this.form.points = points
this.dirty = true
this.formError = ''
},
async openPreview() {
this.preview = {}
if (!this.form.routeId) return
try {
const response = await createLiveviewSession(this.form.routeId)
this.preview = response.data || {}
} catch (error) {
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
}
},
async save() {
this.formError = validateAreaForm(this.form)
if (this.formError) return
try {
await this.$refs.editorForm.validate()
} catch (error) {
return
}
this.saving = true
try {
const payload = buildAreaPayload(this.form)
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
else await createAreaConfiguration(payload)
this.dirty = false
this.editorOpen = false
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
await this.getList()
} catch (error) {
this.formError = error.message || '保存失败,请刷新后重试'
} finally {
this.saving = false
}
},
requestEditorClose() {
this.beforeEditorClose(() => { this.editorOpen = false })
},
beforeEditorClose(done) {
if (!this.dirty || this.saving) {
done()
return
}
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
},
async openVersions(item) {
this.versionsOpen = true
this.versionsLoading = true
this.versions = []
try {
const response = await listAreaVersions(item.id)
this.versions = response.data || []
} finally {
this.versionsLoading = false
}
}
}
}
</script>
<style scoped>
.page-header,
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-header h3 {
margin: 0 0 8px;
}
.page-header p,
.dialog-header span,
.muted-text,
.profile-summary span {
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.dialog-header > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.search-form,
.empty-alert,
.dialog-alert {
margin-top: 16px;
}
.search-form :deep(.el-input) {
width: 240px;
}
.search-form :deep(.el-select) {
width: 180px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
}
.route-field {
grid-column: 1 / -1;
}
.route-field :deep(.el-select),
.form-grid :deep(.el-select) {
width: 100%;
}
.profile-summary {
margin-bottom: 12px;
}
.form-error {
margin: 12px 0 0 110px;
color: var(--el-color-danger);
line-height: 1.5;
}
@media (max-width: 767px) {
.page-header,
.dialog-header {
align-items: flex-start;
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
.route-field {
grid-column: auto;
}
.form-error {
margin-left: 0;
}
}
</style>
@@ -0,0 +1,15 @@
import LoginPage from '@/views/login/index.vue'
describe('Sense login page', () => {
it('uses username and password without a captcha challenge', () => {
const state = LoginPage.data()
const getSystemSetting = jest.fn()
LoginPage.created.call({ getSystemSetting })
expect(Object.keys(state.loginForm)).toEqual(['username', 'password'])
expect(Object.keys(state.loginRules)).toEqual(['username', 'password'])
expect(LoginPage.methods.getCode).toBeUndefined()
expect(getSystemSetting).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,25 @@
import GeometryEditor from '@/components/sense/geometry-editor/index.vue'
describe('Sense geometry editor keyboard operations', () => {
it('adds, moves and removes a point without a mouse', () => {
const context = {
disabled: false,
kind: 'polygon',
points: [],
undoStack: [],
canAdd: true,
$emit(event, points) { this.points = points }
}
context.commit = GeometryEditor.methods.commit.bind(context)
GeometryEditor.methods.addCenterPoint.call(context)
expect(context.points).toEqual([{ x: 0.5, y: 0.5 }])
const moveEvent = { key: 'ArrowRight', preventDefault: jest.fn() }
GeometryEditor.methods.movePointByKeyboard.call(context, 0, moveEvent)
expect(context.points[0].x).toBeCloseTo(0.51)
expect(moveEvent.preventDefault).toHaveBeenCalled()
GeometryEditor.methods.movePointByKeyboard.call(context, 0, { key: 'Delete', preventDefault: jest.fn() })
expect(context.points).toEqual([])
})
})
@@ -0,0 +1,16 @@
import { DIRECTION_LINE, POLYGON, clamp, geometryError } from '@/components/sense/geometry-editor/geometry'
describe('Sense area geometry', () => {
it('validates polygon and direction-line constraints', () => {
expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.1 }, { x: 0.5, y: 0.8 }])).toBe('')
expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.8 }, { x: 0.8, y: 0.1 }, { x: 0.1, y: 0.8 }])).toContain('不能交叉')
expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], 'forward')).toBe('')
expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }], 'forward')).toContain('恰好 2 个点')
})
it('clamps pointer coordinates to the normalized frame', () => {
expect(clamp(-0.2)).toBe(0)
expect(clamp(0.4)).toBe(0.4)
expect(clamp(1.2)).toBe(1)
})
})
@@ -0,0 +1,13 @@
import { buildAreaPayload, validateAreaForm } from '@/views/sense/area/areaPayload'
describe('Sense area payload', () => {
it('keeps the optimistic version and normalized geometry', () => {
const form = { name: ' 东门警戒线 ', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: '0.2', y: '0.5' }, { x: '0.8', y: '0.5' }], direction: 'reverse', enabled: true, version: 4 }
expect(validateAreaForm(form)).toBe('')
expect(buildAreaPayload(form)).toEqual({ name: '东门警戒线', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], direction: 'reverse', enabled: true, expectedVersion: 4 })
})
it('rejects incomplete geometry before sending', () => {
expect(validateAreaForm({ name: '危险区域', kind: 'polygon', routeId: 'route-1', points: [] })).toContain('3 到 64')
})
})
+32
View File
@@ -0,0 +1,32 @@
import Cookies from 'js-cookie'
import { getToken, removeToken, setToken } from '@/utils/auth'
jest.mock('js-cookie', () => ({
get: jest.fn(),
set: jest.fn(),
remove: jest.fn()
}))
describe('Sense login token cookie', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('persists the token for 30 days', () => {
setToken('test-token')
expect(Cookies.set).toHaveBeenCalledWith(
'Sense-Admin-Token',
'test-token',
{ expires: 30 }
)
})
it('reads and removes the same product-specific cookie', () => {
getToken()
removeToken()
expect(Cookies.get).toHaveBeenCalledWith('Sense-Admin-Token')
expect(Cookies.remove).toHaveBeenCalledWith('Sense-Admin-Token')
})
})
+7 -8
View File
@@ -107,14 +107,6 @@ module.exports = {
config
.when(process.env.NODE_ENV !== 'development',
config => {
config
.plugin('ScriptExtHtmlWebpackPlugin')
.after('html')
.use('script-ext-html-webpack-plugin', [{
// `runtime` must same as runtimeChunk name. default is `runtime`
inline: /runtime\..*\.js$/
}])
.end()
config
.optimization.splitChunks({
chunks: 'all',
@@ -145,6 +137,13 @@ module.exports = {
},
css: {
loaderOptions: {
css: {
// Preserve GoAdmin's :export variables as JavaScript values with css-loader 6.
// ICSS mode does not rename ordinary global or component class selectors.
modules: {
mode: 'icss'
}
},
less: {
modifyVars: {
// less vars,customize ant design theme
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
wiki_revision: d32f00a86bb3127485b8ad4da8436bf2117352e0
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: 84cb91d5fb87fcb711c04c1f94b5c09ebe8c22a1
synchronized_at: 2026-08-15T06:45:18Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -112,3 +112,11 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
- `main`:用户审核通过的最小/发布基线;禁止直接开发和未经用户明确审核的合并。
- `dev`:集成开发与测试分支;功能分支从 `dev` 派生,并通过 PR 合回 `dev`。
- 只有用户明确审核通过,才能把 `dev` 合入 `main`。
<!-- sense-root-launcher:start -->
## Sense Windows 项目目录启动入口
工单 #90 在 `Sense/start_sense.bat` 提供项目目录快捷入口。它只使用脚本自身路径定位 `Sense/dist/sense-windows-amd64/start-sense.bat`,透传 production、demo 和其他包内启动参数,并保留包内脚本退出码;不读取配置、不自动构建,也不直接启动 Go 或 Node 开发服务。
使用前必须先按 Windows 交付流程生成 `Sense/dist/sense-windows-amd64`。从仓库根目录可运行 `Sense\start_sense.bat`,进入 Sense 目录后可运行 `start_sense.bat` 或 `start_sense.bat demo`。交付包不存在时脚本返回非零并提示执行 `Sense\scripts\build\build-windows.bat`。
<!-- sense-root-launcher:end -->
+27 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 0a15795aa69c5a0279a42ff1b48b44ddf68a9fad
synchronized_at: 2026-08-14T10:45:46Z
wiki_revision: 8df12aa3e136b1faee9c2dffb58ca39b045bc49f
synchronized_at: 2026-08-17T02:54:44Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -85,9 +85,11 @@ Sense/Brain 生成事件
Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。
Sense 业务菜单必须遵循冻结 GoAdmin 的路由树:顶层目录使用 `MenuType=M`、`Component=Layout`,设备管理、视频接入、视频服务、实时监看、区域与警戒线使用相对路径作为 `MenuType=C` 子页面,操作权限继续作为页面下的 `MenuType=F` 节点。这样动态路由只替换 Layout 的右侧内容区,左侧导航、顶部栏和标签页始终保留;旧数据库由 `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go` 新增迁移修正父子关系和完整 `paths`,页面 URL 与权限标识不变。
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。Sense 默认登录有效期为固定 30 天:后端 JWT 使用 2,592,000 秒,前端 Token Cookie 使用 30 天持久化期限;不做滑动续期、Refresh Token 或服务端单 Token 撤销,退出只删除本机 Cookie。部署新版本后已有 Token 不会自动延长,用户必须重新登录取得新有效期。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。
工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。
@@ -118,3 +120,25 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
<!-- bell-mvp:end -->
<!-- sense-area:start -->
## Sense 区域与警戒线代码入口
工单 #69 在 `Sense/server/app/sense/area/` 建立区域配置业务层,GoAdmin 路由位于 `Sense/server/app/admin/router/sense_area.go`,前端页面位于 `Sense/ui/src/views/sense/area/index.vue`。页面继续复用 BasicLayout、Axios、Element Plus 表单/表格/分页/Dialog/Tag/Alert 和权限指令;`Sense/ui/src/components/sense/geometry-editor/` 是唯一新增的业务专用绘制组件。
数据采用两层结构:`sense_area_definitions` 保存当前版本指针和当前校准状态,`sense_area_versions` 保存每次创建、编辑、启停或重校准形成的不可变快照。更新请求携带 `expectedVersion`,服务在 PostgreSQL 事务中锁定当前定义;并发保存只有一个成功,其余返回冲突。历史版本不覆盖、不删除。
几何坐标使用画面内 0–1 归一化值,同时每个版本固化 Device、Profile Token、分辨率和编码。视频接入成功替换 Profile 后,`admission` 服务比较 Token、分辨率和编码并主动标记当前定义 `needs_recalibration`;读取时也会对缺失或失效 Profile 进行保守校验。系统不自动重投影旧坐标,只有用户重新确认画面并保存新版本后才清除重校准状态。
认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。
<!-- sense-area:end -->
<!-- sense-windows-delivery:start -->
## Sense Windows 交付运行链
工单 #70 在 GoAdmin 派生入口上建立 Windows amd64 交付链。构建入口为 `Sense/scripts/build/build-windows.ps1`;运行包入口为 `start-sense.bat`,它先调用现有 `sense.exe migrate -c data\runtime\settings.yml`,迁移成功后再调用 `sense.exe server -c ...`。前端生产构建复制到包内 `web/`,后端只在显式配置 `SENSE_WEB_ROOT` 时提供同源静态资源和 SPA fallback,API 与健康检查不会被 fallback 覆盖。
运行脚本将 `config\sense.env` 当作数据解析,只接受白名单 `SENSE_*` 字段,不执行文件内容;同名非空进程环境变量优先。生成的 `data\runtime\settings.yml` 含运行秘密,只能留在部署目录。production 固定使用 PostgreSQL,要求至少 32 字符 JWT secret,且 MediaMTX 只能为 `managed` 或 `external`;`managed` 在 HTTP 启动前拉起包内二进制,`external` 在 HTTP 启动前确认回环 Control API 可达。Demo 使用独立配置和名称含 demo/test 的隔离数据库,默认禁用 MediaMTX,绝不回退 production 数据库。
交付包同时提供检查、迁移、管理员初始化、停止、备份和恢复入口。停止脚本只操作当前包且监听配置端口的 Sense 进程树;备份密码只进入子进程环境;恢复要求数据库名和二次短语确认。包不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。
<!-- sense-windows-delivery:end -->
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: e80685e19115e26384d58ddee8a15e5d80b03bb4
synchronized_at: 2026-08-14T10:45:50Z
wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18
synchronized_at: 2026-08-15T01:13:04Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -134,3 +134,15 @@ synchronized_at: 2026-08-14T10:45:50Z
- Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。
- 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。
<!-- sense-admission:end -->
<!-- sense-area:start -->
## Sense 区域与方向警戒线规则
- 区域配置必须绑定已验证的 Device、Profile Token、分辨率和编码;客户端只提交媒体路由引用,最终绑定规格由后端重新查询确认。
- 多边形使用 3–64 个画面内坐标点,必须有非零面积,边线不得自交或重叠;方向警戒线恰好两个不同点,并明确“起点到终点”或“终点到起点”。
- 坐标以 0–1 归一化值保存,同时固化当时分辨率和编码。Profile 删除、Token 替换、分辨率或编码变化必须设置 `needs_recalibration`,不得静默缩放或重投影旧几何。
- 新建为 v1;编辑、启停和重新校准都追加不可变版本。请求使用 `expectedVersion` 乐观并发,过期版本返回 409,旧版本保留用于审计。
- `admin`、`implementation_operator`、`site_admin` 可创建和保存新版本;`viewer` 只读。页面状态必须同时使用文字和 Tag,不能只靠颜色表达。
- 鼠标可点击/拖动顶点;键盘必须能添加、移动和删除顶点。错误在绘制区域附近以可被辅助技术感知的文字给出,并提供撤销、清空和未保存关闭确认。
- 区域配置是 Sense 内部事实;#69 不发布 Brain 契约。后续 Sense→Brain 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。
<!-- sense-area:end -->
+99 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: 95be7e2aa152205f524a185897dff5eb2c7e7e04
synchronized_at: 2026-08-14T10:45:55Z
wiki_revision: 925a16a72bd5840697b5c61489a97018134b3930
synchronized_at: 2026-08-27T08:07:04Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -146,7 +146,9 @@ Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:<端口>/api/v1/bootstrap"
Remove-Variable bootstrapToken, bootstrapBody
```
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。生产登录需要先调用验证码接口并提交验证码;自动化集成验证不得通过关闭生产安全约束来冒充生产结果。
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。Sense 在 production、test、dev 模式均只提交账号和密码,不显示、不请求也不校验验证码;`/api/v1/captcha` 暂时保留作上游兼容接口,但登录页和登录 API 不依赖它。登录成功、错误密码和未认证拒绝仍必须写入脱敏身份审计,密码继续执行 6–72 字节策略。
Sense 默认登录有效期为固定 30 天。仓库配置和 Windows 运行脚本生成的 `jwt.timeout` 均为 `2592000` 秒,前端 `Sense-Admin-Token` Cookie 使用 30 天持久化期限。更新该版本后必须重新登录,已有 Token 不会自动延长。该有效期不是滑动续期;退出登录会删除本机 Cookie,但当前无状态 JWT 架构不提供服务端单 Token 撤销。如需立即使全部已签发 Token 失效,应在受控维护窗口轮换仓库外 JWT secret,并明确通知所有用户重新登录。
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
@@ -230,9 +232,38 @@ corepack pnpm@9.15.1 build:prod
<!-- bell-runtime:end -->
<!-- sense-windows-package:start -->
## Sense Windows 打包状态
## Sense Windows 打包与验证
旧 Sense Windows 包脚本只属于 `explore` 快照。新的打包命令必须在 GoAdmin 派生骨架和业务迁移完成后由独立工单重新建立、验证和记录。
在仓库根目录使用冻结工具链构建:
```powershell
Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe
```
构建脚本严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,执行前端生产构建与 Windows 后端构建,并生成 `Sense\dist\sense-windows-amd64\` 和同名 ZIP。未传 `-MediaMTXPath` 时只生成占位说明,交付前必须另外提供已审核的 Windows amd64 MediaMTX。构建末尾会执行包审计,并清理源码目录的 `Sense/ui/node_modules` 与 `Sense/ui/dist`。
提交前验证:
```powershell
cd Sense\server
go test ./...
go vet ./...
go build ./...
go test -race ./app/sense/media ./cmd/api
cd ..\..
Sense\scripts\build\test-package.ps1 -PackageRoot Sense\dist\sense-windows-amd64
```
包内验证从解压目录执行:
```bat
check-sense.bat
start-sense.bat
stop-sense.bat
```
检查项至少覆盖配置解析与进程环境优先级、特殊字符不被执行、production/demo 数据库隔离、迁移失败不启动服务、首页 SPA fallback、`/healthz`、MediaMTX Control API、包外工作目录启动与停止、PostgreSQL custom-format 备份及恢复到独立数据库。真实摄像机、目标客户数据库账号、目标浏览器与干净客户机器仍须在授权交付环境验收。
<!-- sense-windows-package:end -->
@@ -245,3 +276,66 @@ corepack pnpm@9.15.1 build:prod
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
<!-- sense-area:start -->
### Sense 区域与警戒线验证
后端定向与全量验证:
```powershell
cd Sense/server
go test ./app/sense/area ./app/sense/admission
go test -race ./app/sense/area ./app/sense/admission
go test ./...
go vet ./...
go build ./...
```
使用隔离 PostgreSQL 验证迁移和真实并发;连接值只放当前进程环境,不写入仓库或日志:
```powershell
$env:SENSE_AREA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
go test ./cmd/migrate/migration/version -run TestAreaMigrationOnPostgres -count=1 -v
$env:SENSE_AREA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
go test ./app/sense/area -run TestConcurrentUpdateOnPostgresReturnsConflict -count=1 -v
```
前端验证:
```powershell
cd Sense/ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
corepack pnpm@9.15.1 test:unit
corepack pnpm@9.15.1 build:prod
```
浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。
<!-- sense-area:end -->
<!-- sense-supervisor:start -->
## Sense 本机 Supervisor 托管
本机开发/演示环境可由 `D:\supervisor` 托管已经构建的 Sense Windows 交付包。实例配置位于仓库外的 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`;工作目录固定为 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。
Supervisor 配置只调用包内 `scripts\runtime\start-sense.ps1`,运行参数继续从包内 `config\sense.env` 读取。不得把数据库连接、JWT 密钥、摄像头凭据或其他秘密复制到 Supervisor 配置或工单。
常用命令:
```powershell
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf status yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf restart yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf stop yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf start yovision-sense
Get-Content D:\supervisor\logs\yovision-sense.log -Tail 100
```
新增或修改 `programs/*.conf` 后执行:
```powershell
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf reload
```
当前 Go Supervisor 的 `reload` 会重新读取独立配置;实际受影响实例必须以命令输出和 reload 前后 PID 为准。切换托管前先停止占用 Sense 端口的非 Supervisor 实例,防止自动启动进入 Backoff。验证至少包含 Supervisor 状态为 Running、`http://127.0.0.1:18080/health` 与首页返回 200,以及受控重启后 Sense 和受管 MediaMTX PID 均更新。
<!-- sense-supervisor:end -->
+53 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: 331755cb39ac9d63c188395c5caafe0815d64975
synchronized_at: 2026-08-14T10:46:04Z
wiki_revision: 1a452e9aafdfe01580f37f9179584b89516cf992
synchronized_at: 2026-08-15T07:59:38Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -101,3 +101,54 @@ synchronized_at: 2026-08-14T10:46:04Z
<!-- sense-media:end -->
<!-- sense-admission:end -->
<!-- sense-area:start -->
## Sense 区域与警戒线排错
| 现象 | 原因与处理 |
|---|---|
| 显示“需要重新校准” | 绑定 Profile 已删除、验证失效,或 Token、分辨率、编码发生变化;打开“编辑/校准”,选择当前可用码流,在实际画面确认坐标后保存新版本。不要手工清状态或复制旧坐标冒充校准。 |
| 保存提示配置已被其他用户更新 | 当前页面的 `expectedVersion` 已过期;刷新列表,查看最新版本后重新编辑。系统会保留已成功写入的版本,不覆盖对方结果。 |
| 画面可见但无法添加更多顶点 | 方向警戒线最多两个点,多边形最多 64 个点;检查配置类型,必要时撤销或清空后重画。 |
| 提示边线交叉或面积过小 | 顶点顺序形成自交、重叠或退化多边形;拖动顶点消除交叉,确保至少三个不同且围成有效面积的点。 |
| 实时画面不可用 | 先到“实时监看”确认该 Profile 可播放,再检查 MediaMTX/WebRTC 公开地址。区域 API 不返回或要求填写 RTSP URI。 |
| Profile 已恢复但仍显示重校准 | 这是保守安全状态;恢复相同规格不会自动认可旧坐标。必须由有权限用户打开实际画面确认并保存新版本。 |
| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 |
| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 |
<!-- sense-area:end -->
<!-- sense-capabilities-jsonb:start -->
## Sense 旧设备能力字段迁移排错
启动迁移出现 `字段 "capabilities" 的默认值不能转换成类型 jsonb (SQLSTATE 42804)`,表示数据库仍保留旧版 `sense_devices.capabilities text DEFAULT ''`,而当前 GoAdmin 派生模型要求 JSONB。不要跳过迁移、删除设备记录或只手工删除默认值;旧单值数据仍可能在下一步转换失败。
工单 #92 的兼容迁移会在同一事务内锁定设备表并先验证全部旧值:空值转为 `[]`,`video`、`radar`、`contact`、`button`、`wearable`、`other` 等旧单值转为 JSON 数组,合法 JSON 数组保持数组。未知值或非数组 JSON 会拒绝迁移并整体回滚,不输出具体业务值。
处理步骤:
1. 停止所有连接该 Sense 数据库的服务实例。
2. 使用 `backup-sense.bat` 创建 PostgreSQL custom-format 备份,并确认备份文件可读取。
3. 部署包含 #92 的新 `sense.exe` 后重新运行 `migrate-sense.bat` 或正常启动。
4. 若提示“unsupported legacy data”,不要直接改表;保留错误、恢复测试副本并由维护人员确认旧能力语义。
5. 迁移成功后确认设备仍存在、能力标签正确,再启动其他实例。
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
<!-- sense-capabilities-jsonb:end -->
<!-- sense-media-path-constraint:start -->
## Sense 旧媒体路由迁移排错
旧库迁移出现 `约束 "uni_sense_media_routes_path" 不存在 (SQLSTATE 42704)`,表示旧 `sense_media_routes.path` 由 PostgreSQL 自动命名的唯一约束保护,而新 GORM 模型准备改用唯一索引;GORM 按推导名称删除旧约束时找不到实际名称。修正该名称后若继续出现 `source_ready ... contains null values (SQLSTATE 23502)`,表示非空旧表还缺少当前模型要求的运行态列。
工单 #95 的兼容迁移只在 PostgreSQL 旧表存在时执行:取得 ACCESS EXCLUSIVE 表锁,确认只有一个单列 `UNIQUE(path)` 约束,将实际约束名规范为 GORM 可识别名称;同时为旧路由初始化保守运行态 `source_ready=false`、`failure_count=0`、`last_error_code=''`,再继续 AutoMigrate。迁移不会把旧路由伪装成已就绪,服务启动后仍由对账恢复真实状态。复合约束、多重 path 约束或其他无法确认的唯一性结构会拒绝迁移并整体回滚。
处理步骤:
1. 停止连接该数据库的全部 Sense 实例,并确认 Sense 与 MediaMTX 相关端口已释放。
2. 使用 `backup-sense.bat` 生成 PostgreSQL custom-format 备份;非标准 PostgreSQL 安装目录需通过 `SENSE_POSTGRES_BIN` 指向包含 `pg_dump.exe`、`pg_restore.exe` 的目录。
3. 使用 `pg_restore --list <备份文件>` 确认备份可读取,再部署包含 #95 的 Windows 包。
4. 先运行 `migrate-sense.bat`;成功后确认旧路由数量不变、运行态列无空值、`path` 仍有唯一索引。
5. 再启动 Sense,检查首页、`/healthz`、MediaMTX Control API 和视频服务对账;验证完成后使用 `stop-sense.bat` 停止。
如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。
<!-- sense-media-path-constraint:end -->
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
wiki_revision: f0d16a60406eed6fd6968c6555c599554bbd1fae
synchronized_at: 2026-08-11T10:30:56Z
wiki_revision: e6c6e0d7f658a7c1040fe569026e7d0ec581701a
synchronized_at: 2026-08-17T02:54:53Z
<!-- gitea-wiki-mirror:end -->
# 产品需求
@@ -48,7 +48,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
### P0
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。默认登录有效期为固定 30 天,后端 JWT 与浏览器持久 Cookie 必须一致;版本更新后已有 Token 不自动延长,不提供滑动续期或服务端单 Token 撤销。
- **SEN-002 设备台账**:以 Device 为根实体,通过 `modality` 和 `capabilities` 表达 video/radar/contact/button/wearable/other;首期只完整实现 video,未实现适配器显示 `adapter_not_ready`。
- **SEN-003 ONVIF/RTSP 接入**:支持发现或手工添加、Profiles、StreamUri、主/子码流、校时、认证失败、重新探测和凭据更新。
- **SEN-004 批量开通**:默认 16 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。
+11 -7
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Documentation-Guide
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
wiki_revision: 45c86f2a0e4d3252e8042df5ee725e633dd497c1
synchronized_at: 2026-08-14T09:47:31Z
wiki_revision: 3d95c9d392e81cf59d2af1f6f21d8e67f580b68f
synchronized_at: 2026-08-15T03:02:47Z
<!-- gitea-wiki-mirror:end -->
# 交付文档指南
@@ -97,12 +97,16 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
<!-- sense-windows-package:start -->
## Sense Windows 运行包交付
交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。
交付对象为实施和运维人员。以 `sense-windows-amd64.zip` 交付后端程序、已构建前端、空值示例配置、迁移基线、许可证、启动/检查/停止/备份/恢复脚本与包内说明;不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。可按交付决定是否包含已审核的 `bin\mediamtx.exe`。
- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。
- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
- production 先复制 `config\sense.env.example` 为 `config\sense.env`,填写 PostgreSQL、至少 32 字符 JWT secret、凭据密钥、获准 ONVIF 网络和 MediaMTX 模式。进程环境中的同名非空值优先,脚本不执行 env 文件内容,也不打印秘密。
- 先运行 `check-sense.bat`,再运行 `start-sense.bat`。启动会先迁移,失败时不会开放 HTTP;`managed` MediaMTX 在 Sense HTTP 前启动,`external` 必须已有可达的回环 Control API,production 禁止 `disabled`。
- 首位管理员通过至少 32 字符的一次性 bootstrap token 和 `initialize-admin.bat -Username admin` 创建,密码由隐藏提示输入;成功后立即清空 token 并重启。仓库与交付包均无默认账号密码。
- 临时演示必须显式运行 `start-sense.bat demo`,使用独立 `config\sense.demo.env` 和名称含 demo/test 的数据库;不会回退 production 数据库,也不能作为生产部署。
- 日常停止优先在启动窗口按 Ctrl+C;窗口丢失或进程无响应时使用 `stop-sense.bat`。脚本会验证端口与可执行文件归属,拒绝停止其他程序。
- 备份使用 `backup-sense.bat` 生成 PostgreSQL custom-format 文件。恢复前停止服务并再次备份,执行 `restore-sense.bat` 时需确认目标数据库名及 `RESTORE-数据库名`,随后重新迁移。
- 交付时保存 ZIP 和 `MANIFEST.sha256` 的 SHA-256,至少验证首页、`/healthz`、数据库迁移、MediaMTX API、启动/停止和备份/恢复。真实摄像机、目标浏览器与客户数据库账号仍在授权现场验证。
- 包内 `README-WINDOWS.md` 是现场事实入口;真实 `config\sense.env`、运行生成的 `data\runtime\settings.yml`、日志和备份不得提交 Git 或重新打入 ZIP。
<!-- sense-windows-package:end -->
<!-- sense-device-ledger:start -->
+107
View File
@@ -0,0 +1,107 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-101-Sense-GoAdmin-应用外壳
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-101-Sense-GoAdmin-%E5%BA%94%E7%94%A8%E5%A4%96%E5%A3%B3.-
wiki_revision: a32fd29a7b8c4fa42396e9f249bcdd99d0c09e74
synchronized_at: 2026-08-27T07:52:48Z
<!-- gitea-wiki-mirror:end -->
# 101 Sense-GoAdmin-应用外壳
- 类型:缺陷修复
- 所属 Epic:#7
- 所属 MVP / 版本:#8 / Sense 首个独立纵切
- 状态:已完成
- 日期:2026-08-16
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/101
- Wiki 页面:Task-101-Sense-GoAdmin-应用外壳
- Wiki revision:见本地镜像头
## 背景与目标
Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只有在动态路由组件为 `Layout` 时才保留侧栏、顶部导航和标签页,因此点击设备管理等入口会用业务页面替换整个 GoAdmin 外壳。
本任务恢复冻结 GoAdmin 的标准父子菜单结构:应用外壳保持不变,模块切换只替换右侧内容区域。
## 最终方案
新增迁移 `2026081623100_sense_layout.go`,创建唯一的“视频感知”顶层目录:
- 顶层目录:`MenuType=M`、`Path=/sense`、`Component=Layout`。
- 设备管理、视频接入、视频服务、实时监看、区域与警戒线:作为相对路径的 `MenuType=C` 子页面。
- 页面下既有 `MenuType=F` 操作权限保持不变。
- 递归重算父目录、页面和按钮的完整 `paths`。
- 页面 URL、组件、权限标识、角色关联和业务 API 均不改变。
- 使用新增事务迁移兼容已经执行旧迁移的 PostgreSQL 数据库,不修改历史迁移。
与建单方案一致,没有修改 go-admin-ui 公共 Layout 或动态路由生成器。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go`:新增 GoAdmin Layout 菜单兼容迁移。
- `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go`:覆盖菜单层级、相对路径、完整 paths、角色关联保持、重复对齐、事务回滚和 PostgreSQL。
- `docs/02-architecture-and-code-map.md`:同步架构 Wiki 中的 Sense 动态菜单长期规则。
- `wiki-docs.json`、`docs/task/101-Sense-GoAdmin-应用外壳.md`:任务归档映射与镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 五个业务页位于 Layout 父路由下 | 通过 |
| 设备管理进入 `#/sense/devices`,标题与路径不串位 | 通过 |
| 切换和刷新后侧栏、顶部栏、标签页保留 | 通过 |
| 页面组件、权限标识和角色关联保持 | 通过 |
| 已有 PostgreSQL 数据库迁移无重复菜单 | 通过 |
| 重复对齐安全、失败事务回滚 | 通过 |
| 后端、前端和 Windows 包验证 | 通过 |
| Wiki、归档、PR 和证据 | 通过,用户已验收 |
## 测试
- `go test ./cmd/migrate/migration/version -run 'Test(AlignSenseLayout|MigrateSenseLayout|SenseLayout)' -count=1`:通过。
- 真实 PostgreSQL 隔离 schema `TestSenseLayoutMigrationOnPostgres`:通过并清理测试 schema。
- `go test ./...`:通过。
- `go vet ./...`、后端构建:通过。
- `pnpm lint`:0 error,32 条冻结上游/既有 warning。
- `pnpm test:unit -- --runInBand`:17 suites、46 tests 全部通过。
- `pnpm build:prod`:通过,4 条既有构建 warning。
- Windows PowerShell 5.1 包测试:21 assertions 通过;包审计通过。
- 本机生产数据库迁移及启动:通过。
- Headless Edge:依次打开五个菜单及刷新区域页面,`.app-wrapper`、侧栏、navbar、TagsView 全程可见,URL 均正确。
- Windows ZIP:`Sense/dist/sense-windows-amd64.zip`,SHA-256 `BAAA973F8502BB5B0BC080F11931E60419B25EFA8FF722A6B9F9B98364399642`,源码提交 `4423d528b1dc6ebe8b6f7b8272ca5111032a1608`。
- **未验证部分**:客户全新 Windows 主机及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;本机使用 admin 完成真实浏览器回归,用户已确认验收通过。
## 遗留问题
#99、#104 与 #101 均已由用户验收;#101 经 PR #102 合入 `dev`,`main` 保持不变。#90 的项目档案 Wiki 更新未混入本任务提交;客户全新 Windows 主机及三个非 admin 岗位的人工视觉验收仍未覆盖。
## 相关提交
- `4423d528b1dc6ebe8b6f7b8272ca5111032a1608` 修复 Sense GoAdmin 应用外壳。
- `e652109` 记录 Sense Layout 菜单长期架构。
## #99 合入后的组合验收包
- 用户验收 #99 后,PR #100 已合入 `dev`;本任务分支通过合并提交 `42dc77b1f904032131c51b3864184c4ccaea6b8f` 更新到该基线。
- 冲突处理保留 Wiki 事实源中 #99 的匿名只读 `app-config` 边界、#101 的 GoAdmin Layout 菜单规则,以及两张任务归档映射。
- 重新生成 `Sense/dist/sense-windows-amd64.zip`;包内 `source_commit=42dc77b1f904032131c51b3864184c4ccaea6b8f`。
- 新 ZIP SHA-256:`65AF5DD1CFB5D4F8339DCCC53717354495259F13630FBC0C6A4AFC1ACD794B26`。
- 固定工具链 production build、包审计、Windows PowerShell 5.1 包测试 21 项、真实 PostgreSQL 迁移/启动及 `TestRegisterBaseRouterExposesOnlyFrontendAppConfig` 均通过。
- 现场配置在构建前备份、ZIP 生成后恢复;ZIP 仍只包含模板配置。Sense 当前监听 `127.0.0.1:18080`。
## #104 合入后的最终组合验收包
- #104 已由用户验收并经 PR #105 合入 `dev@46c3232da40528d031367225b09d4f28c85a8312`;#101 分支通过合并提交 `8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0` 更新到该基线。
- 唯一合并冲突是 `Architecture-and-Code-Map` 镜像头;读取 Wiki revision `8df12aa3e136b1faee9c2dffb58ca39b045bc49f` 确认页面同时包含 #101 Layout 菜单规则和 #104 30 天登录规则后,以该事实源解决。业务代码无冲突。
- `go test ./...`、`go vet ./...`、`go build ./...` 全部通过;前端 lint 0 error(32 条既有 warning),18 suites / 48 tests 通过,production build 通过(4 条既有 warning)。
- Windows 包资源审计、包审计和 22 assertions 通过;使用现场 PostgreSQL 完成幂等迁移与生产启动,`data/runtime/settings.yml` 确认 `timeout: 2592000`。
- Chrome 真实回归依次验证设备管理、视频接入、视频服务、实时监看、区域与警戒线五个入口;URL 分别为 `#/sense/devices`、`#/sense/admission`、`#/sense/media`、`#/sense/liveview`、`#/sense/area`,侧栏、Navbar、TagsView 全程可见。直接刷新区域页面后外壳与选中标签保持,控制台无错误。
- 本机直连验证 `/health`、`/api/v1/app-config` 和 `/` 均返回 200;首次命令行请求受开发机 HTTP 代理影响,明确绕过代理后通过,不属于 Sense 服务错误。
- 组合 ZIP:`Sense/dist/sense-windows-amd64-101-104.zip`,56,461,005 bytes,SHA-256 `46B2B5654EE3F61788CA4E51F5CB66C1A7C7D1088EFFD39C0281E86E84F964A2`,包内 `source_commit=8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0`。标准 ZIP 内容相同且仅含模板配置;现场配置只恢复到解压目录。
- 当前组合服务监听 `127.0.0.1:18080`。临时现场配置备份已删除,源码目录 `node_modules` 已清理。
- **未验证部分**:客户全新 Windows 主机及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;用户已确认当前交付验收通过。
## 验收确认
- 2026-08-27,用户明确确认“#101通过验收”。
- PR #102 按规定合入 `dev`,`main` 未变更。
- 工单 #101 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-104-Sense-30天登录有效期
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-104-Sense-30%E5%A4%A9%E7%99%BB%E5%BD%95%E6%9C%89%E6%95%88%E6%9C%9F.-
wiki_revision: 2b4df23ec321de868840da61d87d8d6fce8537a7
synchronized_at: 2026-08-17T03:46:13Z
<!-- gitea-wiki-mirror:end -->
# 104 Sense-30天登录有效期
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-17
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/104
- Wiki 页面:Task-104-Sense-30天登录有效期
- Wiki revision:见本地镜像头
## 背景与目标
Sense 原生产、demo、SQLite 和完整配置默认 JWT 有效期为 3600 秒,前端 Token 使用浏览器会话 Cookie。按用户确认,将默认登录有效期统一调整为固定 30 天,使浏览器重启后在 Token 未过期时仍可保持登录。
## 最终方案
- 保留 go-admin JWT 中间件和 go-admin-ui `js-cookie` 封装;非开发模式未显式配置时默认使用 30 天,合法显式配置仍优先,开发模式保持上游行为。
- 生产、demo、SQLite、full 与 Windows 运行时生成配置统一使用 2,592,000 秒。
- `Sense-Admin-Token` Cookie 使用 `expires: 30`;退出登录仍删除 Cookie。
- 不是滑动续期,不增加 Refresh Token、服务端会话表或 Token 黑名单。已有 Token 不自动延长,部署后需重新登录。
- Token 泄露窗口扩大到 30 天;紧急失效需要轮换 JWT secret,这会使全部现有登录失效。
## 修改文件
- `Sense/server/common/middleware/auth.go`、`auth_test.go`:实现并验证默认 30 天 JWT。
- `Sense/server/config/settings*.yml`、`READMEN.md`:统一配置值和示例。
- `Sense/scripts/runtime/sense-common.ps1`、`Sense/tests/package/run-tests.ps1`:统一并验证 Windows 运行时配置。
- `Sense/ui/src/utils/auth.js`、`Sense/ui/tests/unit/utils/auth.spec.js`:持久化 Cookie 30 天并验证设置、读取和删除。
- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/09-product-requirements.md`:由 Wiki 同步的长期文档镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 非开发模式签发 JWT 的到期时间为签发后 30 天 | 通过 |
| Token Cookie 使用 30 天持久化期限,退出仍删除 | 通过 |
| 浏览器关闭后重新打开仍保持登录 | 通过(用户验收) |
| 所有运行配置及 Windows 生成值为 2,592,000 秒 | 通过 |
| Go、Vue、Windows 包测试与 PostgreSQL 启动回归 | 通过 |
| ZIP 仅含模板配置,不包含现场秘密或数据 | 通过 |
| 已有 Token 不自动延长并记录重新登录要求 | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `pnpm run lint`:0 error,32 条上游既有 warning。
- `pnpm exec vue-cli-service test:unit --runInBand`:18 suites、48 tests 通过。
- `Sense/tests/package/run-tests.bat -PackageRoot Sense/dist/sense-windows-amd64`:22 assertions 通过。
- Windows amd64 生产构建、资源审计、包审计:通过;4 条既有构建 warning。
- 独立 #104 包使用现场 PostgreSQL 完成迁移和启动,`127.0.0.1:18080` 监听成功,生成配置确认 `timeout: 2592000`。
- 包:`Sense/dist/sense-windows-amd64-104.zip`,56,457,546 bytes,SHA-256 `BCE3EDD3F5BD79C75A953CB7673D1C9867CE3BB89755D6A188C7CA1226D05CE3`,来源提交 `adc227f110bf0c456d1523eac151ed1d8360cc83`。
- 一次错误的 Vue 测试参数把 `--runInBand` 识别为模式而未找到测试,已改用正确命令并通过;一次临时运行数据恢复把日志目录复制成同名文件,修正验证环境后从失败点重试并通过,均非产品代码缺陷。
- **未验证部分**:无法用真实等待 30 天验证自然到期;以固定时钟 JWT 测试和 Cookie 参数单测覆盖。用户已于 2026-08-17 明确验收通过,浏览器关闭/重开行为由人工确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。
## 遗留问题
- 当前服务已恢复为用户原有的 #101 验证包,避免应用外壳回退;#104 独立包保留为具名 ZIP,未覆盖当前标准包。
- 当前架构没有服务端单 Token 撤销能力,这是确认范围内的安全限制。
## 验收确认
- 用户于 2026-08-17 明确回复“#104 验收通过”。
- PR #105 按仓库门禁合入 `dev`;`main` 不变。
## 相关提交
- `857ba45` feat: 将 Sense 登录有效期调整为30天 (#104)
- `a843121` docs: 记录 Sense 30天登录有效期 (#104)
- `adc227f` test: 验证 Sense 30天 JWT 到期时间 (#104)
@@ -0,0 +1,77 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-106-Sense-本机-Supervisor-实例
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-106-Sense-%E6%9C%AC%E6%9C%BA-Supervisor-%E5%AE%9E%E4%BE%8B.-
wiki_revision: fcff45a700cba3db939819cfc5deb1cc97b8155b
synchronized_at: 2026-08-27T08:17:20Z
<!-- gitea-wiki-mirror:end -->
# 106 Sense-本机-Supervisor-实例
- 类型:运维配置
- 所属 Epic:#7
- 所属 MVP / 版本:#8 / Sense 首个独立纵切
- 状态:已完成
- 日期:2026-08-27
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/106
- Wiki 页面:Task-106-Sense-本机-Supervisor-实例
- Wiki revision:见本地镜像头
## 背景与目标
本机 Sense 原由独立终端启动,不受 `D:\supervisor` 管理。目标是在不复制现场秘密、不修改 Sense 业务代码的前提下,让 Supervisor 托管现有 Windows 交付包,并提供自动启动、异常重启、进程组停止和独立日志。
当前 Brain、Bell 只有规则文件,没有可运行交付物,因此本任务只创建 `yovision-sense`,不创建空实例。
## 最终方案
- 在仓库外新增 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`。
- 工作目录使用 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。
- Supervisor 直接调用包内 `scripts/runtime/start-sense.ps1`,配置仍由 `config/sense.env` 读取。
- 配置启用 autostart、autorestart、启动重试、进程组停止和 50 MB × 5 的日志轮转。
- 日志写入 `D:\supervisor\logs\yovision-sense.log`;Supervisor 配置不含 environment、数据库连接、令牌、密码或摄像头凭据。
- 使用包内停止脚本核对并停止原 Sense PID 28440,释放 18080 后执行 Supervisor reload。
原计划按最坏情况说明 reload 会重启全部实例;实际命令返回 `Added Groups: yovision-sense`。dsh/goauto PID 未变化,原先处于 Backoff 的三个 Chorus 实例在 reload 后恢复 Running,因此没有观察到既有 Running 实例被重启。
## 修改文件
- `D:\supervisor\programs\yovision.conf`:新增本机 Supervisor 实例配置;该文件位于仓库外。
- `Local-Development-and-Verification` Wiki:记录实例位置、常用命令、秘密边界和验证方式。
- `docs/04-local-development-and-verification.md`:上述 Wiki 的只读镜像。
- `wiki-docs.json`、`docs/task/106-Sense-本机-Supervisor-实例.md`:任务归档登记与镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| `yovision-sense` 稳定为 Running | 通过 |
| Sense 监听 18080,健康检查与首页返回 200 | 通过 |
| Sense 管理的 MediaMTX 随单实例重启更新 PID | 通过 |
| Supervisor 配置不包含现场秘密 | 通过 |
| dsh/goauto 保持运行,Chorus 从既有 Backoff 恢复 | 通过 |
| Wiki、归档、提交、PR 和证据 | 通过,用户已验收 |
## 测试
- `supervisord.exe ctl /c supervisord.conf reload`:返回新增 `yovision-sense` 配置组。
- `supervisord.exe ctl /c supervisord.conf status`:七个实例均为 Running;`yovision-sense` Supervisor PID 28000。
- `GET /health`、`GET /healthz`、`GET /`:均返回 200。
- 受控执行 `restart yovision-sense`:Sense PID 从 38408 更新为 43916,MediaMTX PID 从 27880 更新为 27212,重启后健康检查与首页仍为 200。
- 外部配置 SHA-256:`7C5BDDD574384ED6038F0C2DAA8CE364FB1EA996B256831595ABF4E22EDA4769`。
- 配置敏感字段扫描:未发现 password、token、secret、`SENSE_DATABASE_URL` 或 `environment=`。
- **未验证部分**:未通过重启 Windows 验证开机后的整体 Supervisor 自启动;未故意崩溃 Sense 验证异常自动重启,已用 Supervisor 受控重启验证停止与拉起链路。
## 遗留问题
Supervisor 管理端口当前监听 `0.0.0.0:9009` 且未配置认证,这是任务开始前已存在的安全风险,本任务按确认范围未修改,建议另建安全工单处理。
## 相关提交
- `0646f5effd53536ae3f608c5e5dffe7fe950cb8f` 记录 Sense Supervisor 托管方式。
## 验收确认
- 2026-08-27,用户明确确认“#106 验收通过”。
- PR #107 按规定合入 `dev`,`main` 未变更。
- 工单 #106 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
@@ -0,0 +1,84 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-69-Sense多边形区域与方向警戒线配置
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-69-Sense%E5%A4%9A%E8%BE%B9%E5%BD%A2%E5%8C%BA%E5%9F%9F%E4%B8%8E%E6%96%B9%E5%90%91%E8%AD%A6%E6%88%92%E7%BA%BF%E9%85%8D%E7%BD%AE.-
wiki_revision: c679c3c2b41a190480732cd28cc52083c76843e5
synchronized_at: 2026-08-15T01:36:30Z
<!-- gitea-wiki-mirror:end -->
# 69 Sense 多边形区域与方向警戒线配置
- 类型:需求
- 所属 Epic:#7
- 所属 MVP:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/69
- 评审 PR:https://git.ilapage.cn/ila/yovision/pulls/87
## 背景与目标
在 #61 冻结的 GoAdmin/go-admin-ui 源码派生基线上,为网管和非技术实施人员提供绑定 Device/Profile/分辨率的危险区域与方向警戒线配置。配置必须可追溯、能识别视频 Profile 变化,并且 Brain、Bell 不启动时可独立管理和预览。
## 最终方案
- 新增当前区域定义与不可变版本表;创建为 v1,编辑、启停和重新校准均追加版本,不覆盖历史。
- 服务层在 PostgreSQL 事务内锁定当前定义并校验 `expectedVersion`;并发保存只允许一方成功,另一方返回稳定 409 冲突。
- 服务端根据监看路由解析 Device/Profile,保存归一化坐标和冻结的 Profile token、分辨率、编码等非敏感快照;不信任客户端提交的绑定元数据。
- 多边形支持 3–64 个点并拒绝越界、零面积、自交和重叠边;方向线固定两个点并记录正向/反向。
- admission 完成 Profile 替换后按 token、分辨率和编码比较,主动标记 `needs_recalibration`;读取时也保守识别 Profile 缺失、未验证或发生变化。
- 前端复用 go-admin-ui 的 BasicLayout、Axios、Element Plus 搜索、表格、分页、表单、Dialog、Tag、Alert 和权限按钮,仅新增视频坐标几何编辑器。
- 编辑器支持鼠标点选/拖动,以及键盘 Enter/Space 加点、方向键移动、Delete/Backspace 删除;提供可见焦点、文字错误、撤销、清空和未保存关闭确认。
- 权限沿用 GoAdmin Casbin:viewer 只读,implementation_operator/site_admin 可写;页面预览复用 #68 的实时监看会话。
## 修改文件
- `Sense/server/app/sense/area/**`:模型、DTO、几何校验、服务、API 与测试。
- `Sense/server/cmd/migrate/migration/version/2026081509000_area*`:表、菜单、权限迁移及测试。
- `Sense/server/app/admin/router/sense_area.go`:区域 API 路由。
- `Sense/server/app/sense/admission/service*.go`:Profile 替换触发重新校准。
- `Sense/ui/src/views/sense/area/**`、`Sense/ui/src/api/sense/area.js`:区域管理页面、请求与载荷。
- `Sense/ui/src/components/sense/geometry-editor/**`:业务专用几何编辑器。
- `Sense/ui/tests/unit/sense/area*.spec.js`:载荷、几何和页面行为单测。
- Wiki 的 Architecture、Business Rules、Local Development、Troubleshooting:记录架构、规则、验证和排错;`docs/` 为同步镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 多边形与方向线几何校验、版本并发和审计成立 | 通过 |
| 配置绑定明确的 Device/Profile/分辨率 | 通过 |
| Profile、分辨率或能力变化触发 needs_recalibration | 通过 |
| 旧版本可追溯且不能被静默覆盖 | 通过 |
| 画布键盘/鼠标操作、错误反馈和视觉风格一致 | 通过 |
| Brain/Bell 未启动时可独立保存、读取和预览配置 | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `go test -race ./app/sense/area ./app/sense/admission`:通过。
- PostgreSQL 17 实库:迁移、3 个菜单、10 条 Casbin 策略及并发更新测试通过;并发结果为一次成功、一次冲突、两个版本。
- `pnpm lint`:通过,0 error;保留 32 个冻结上游 warning。
- `pnpm test:unit`:16 suites / 45 tests 通过。
- `pnpm build:prod`:通过,保留 6 个冻结上游 warning。
- Edge/Playwright 浏览器烟测:鼠标绘制、键盘移动/删除和 ARIA 错误提示通过。
- Brain、Bell 未启动,独立后端与前端验证通过。
- **未验证部分**:客户现场授权摄像头、实际部署反向代理及目标客户浏览器上的端到端人工操作尚未验证,留给本工单人工验收或后续集成验收。
## 遗留问题
- Sense→Brain 的版本化区域配置发布属于后续共享契约工单,本工单未写入 `contracts/`,不阻塞 Sense 独立配置能力。
## 人工验收
- 用户于 2026-08-15 明确确认“#69 验收通过”。
- PR #87 已合并到 `dev`,合并提交:`61b79db9f72db3ca8b7187898b34763d9cab806c`。
- 工单在归档与父工单同步完成后关闭;本次未合入 `main`。
## 相关提交
- `17bd383` feat: 重建 Sense 区域与警戒线配置 (#69)
- `f82dd51` docs: 记录 Sense 区域配置架构 (#69)
- `61b79db9` Merge pull request '#87' from feature/69-sense-area into dev
@@ -0,0 +1,110 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-70-Sense-Windows配置启动与打包交付
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.-
wiki_revision: 9eb390dfd691acc089656a838dd3f12e24f97884
synchronized_at: 2026-08-16T11:33:28Z
<!-- gitea-wiki-mirror:end -->
# 70 Sense Windows配置启动与打包交付
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70
- Wiki 页面:Task-70-Sense-Windows配置启动与打包交付
- Wiki revision:见本地镜像头
## 背景与目标
在 #61–#69 完成冻结 GoAdmin 基线及 Sense 独立业务纵切后,重建 Windows amd64 前后端单包交付能力。交付必须继续使用 GoAdmin Cobra 的迁移与服务入口,并覆盖包内配置、PostgreSQL、MediaMTX、管理员初始化、停止、备份和恢复;不得回用 explore 中的自研运行框架,也不得包含默认密码或客户秘密。
## 最终方案
- `Sense/scripts/build/build-windows.ps1` 严格检查 Go 1.26.5、Node 22.22.1、pnpm 9.15.1,执行前后端生产构建、许可证和迁移基线复制、内容审计并生成目录与 ZIP。
- `Sense/scripts/runtime` 提供白名单 env 解析、check/start/stop/migrate/bootstrap/backup/restore。配置文件只作为数据读取,同名非空进程环境优先,日志不打印秘密。
- production 只接受 PostgreSQL,要求至少 32 字符 JWT secret,MediaMTX 使用 managed 或 external;启动前完成迁移与媒体服务就绪检查,失败不开放 HTTP。Demo 使用独立配置和名称含 demo/test 的数据库。
- 继续调用现有 `sense.exe migrate -c ...` 与 `sense.exe server -c ...`。在现有 Gin Engine 上增加可选同源 SPA fallback;未配置 `SENSE_WEB_ROOT` 时保持上游行为。MediaMTX 显式模式增加启动前就绪门禁,未配置模式保留既有惰性行为。
- 管理员初始化无默认账户密码;密码经隐藏提示输入。停止脚本验证端口与可执行文件归属;备份密码只通过子进程环境;恢复要求目标库名称和二次短语确认。
- 空白 PostgreSQL 17 已成功执行完整迁移,未复现 #67 曾记录的旧 `sys_config` 字段长度问题,因此未修改上游迁移。
## 修改文件
- `Sense/scripts/build/**`、`Sense/tests/package/**`:固定工具链构建、包审计与配置/失败路径自动化。
- `Sense/scripts/runtime/**`:Windows 配置、检查、迁移、启动停止、初始化、备份恢复入口。
- `Sense/config/**`、`Sense/package/**`、`Sense/README-WINDOWS.md`:空值示例、MediaMTX 基线和现场说明。
- `Sense/server/cmd/api/server.go`、`web.go`、`web_test.go`:现有 GoAdmin Gin 服务的可选 SPA 托管。
- `Sense/server/app/sense/media/runtime.go`、`runtime_test.go`:显式 MediaMTX 模式的启动前就绪门禁。
- `Sense/.gitignore`:忽略可重建交付产物并允许版本化构建脚本。
- Wiki `Architecture-and-Code-Map`、`Local-Development-and-Verification`、`Delivery-Documentation-Guide` 及对应 `docs/` 镜像:运行链、构建验证和现场交付说明。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 干净环境按单一命令生成 Windows amd64 交付包 | 通过;固定工具链构建目录和 ZIP |
| 包不包含 node_modules、构建缓存、真实秘密或客户数据 | 通过;包审计及源码残留检查通过 |
| start 读取 config/sense.env,进程环境优先且不执行内容 | 通过;自动化覆盖优先级、特殊字符和注入非执行 |
| production 检查 PostgreSQL、迁移、端口和 MediaMTX | 通过;隔离 PostgreSQL 17 与 MediaMTX 包 smoke 通过 |
| demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test |
| 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 |
最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,455,903 字节,SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。包内 `VERSION.txt` 记录实现提交 `b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`,包含 #92、#95 的旧库兼容修复、白屏修复及已验收 #97 的免验证码登录。
## 测试
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./app/sense/media ./cmd/api`:通过。
- PowerShell 包测试:21 项断言通过,覆盖配置注入不执行、特殊字符、环境优先级、demo 数据库隔离、不支持字段拒绝、HTML 本地资源正反例及全部脚本语法。
- `Sense/scripts/build/build-windows.ps1 -MediaMTXPath <已审核本机路径>`:通过;前端剩余 4 条非阻塞构建 warning;导致启动失败的 runtime 与 SCSS 导出 warning 已消除。
- `Sense/scripts/build/test-package.ps1 -PackageRoot Sense/dist/sense-windows-amd64`:通过。
- 隔离 PostgreSQL 17:空库 8 个迁移通过;首页、SPA fallback、`/healthz`、MediaMTX Control API、包外目录启动停止通过;119,630 字节 custom-format 备份及恢复到另一数据库通过。
- `git diff --check`:通过。
- `python dev_scripts/check_harness.py --strict`:未通过,原因仅为既存 `docs/task/66`、`docs/task/67` 缺少当前模板要求的“修改文件/未验证”章节;#70 未修改这两个既有归档,也未发现 #70 新增问题。
- **未验证部分**:尚未在全新客户 Windows 机器、客户生产 PostgreSQL 账号、目标浏览器和真实获准摄像机上验收;Windows 服务化不在本工单范围。
## 旧库交付回归
- 真实迁移前已生成仓库外 PostgreSQL custom-format 备份与配置副本,备份通过 `pg_restore --list` 校验。
- #92 成功把旧设备 `capabilities` 转为 JSONB;#95 继续兼容旧媒体路由 `path` 唯一约束和缺失运行态列。
- 真实库 2 条媒体路由完整保留,运行态列无空值,`idx_sense_media_routes_path` 与设备/Profile 组合唯一索引均有效,媒体迁移版本已登记。
- Web 首页、SPA、`/healthz`、MediaMTX Control API 均返回 200;停止脚本成功且 Sense/MediaMTX 监听端口全部清空。
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响而无法访问 loopback;使用明确绕过代理的本机 HTTP 客户端确认服务正常,该现象不属于 Sense 服务失败。
## 白屏验收反馈修复
- 用户运行发布包后访问生产入口出现白屏。只读诊断确认首页 HTML 返回 200,但现代浏览器请求的 `runtime.daef9028.js` 不在包内并返回 404;修复 runtime 内联配置后,浏览器继续暴露 GoAdmin `:export` 主题变量在 css-loader 6 下没有 JavaScript 导出的启动错误。
- 删除不可靠的 runtime 内联插件配置,使现代与 legacy runtime 都作为独立文件进入产物;为 css-loader 启用不改写普通类名的 ICSS mode,保留 GoAdmin 原有 SCSS `:export` 变量模式。
- 新增 `assert-web-assets.ps1`,构建阶段逐项核对 `index.html` 引用的本地 JS/CSS;缺失 runtime 的反例会直接使包构建失败。
- Chromium 最终打开 `http://127.0.0.1:18080/` 并进入账号登录页;首屏 7 个 JS/CSS 全部返回 200,白屏和阻止 Vue 挂载的错误消失。测试完成后停止 Sense 与 MediaMTX,18080 无监听。
- 浏览器仍观察到不阻塞首屏的既有 `/api/v1/app-config` 404 和上游默认百度统计请求;不属于本次白屏修复范围,未混入当前提交。
## 遗留问题
- Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。
- 客户环境验收需由实施人员使用脱敏测试账户和获准设备完成。
## 相关提交
- `6b79478` 建立 Sense Windows 交付包。
- `e4544a0` 记录 Sense Windows 交付流程。
- `4ca4abf` 修复 Windows 包白屏并增加静态资源闭环审计。
## #97 集成与最终重打包(2026-08-16)
- 将 `dev@116318df748ff0d46d3fe5f8a4f41a6507567eec` 合入 #70 分支,发布包现已包含 #97 的账号密码直接登录;登录页和登录载荷不再包含验证码字段或请求,兼容 captcha API 保留。
- Windows PowerShell 构建在生成清单时暴露 `Get-FileHash` 模块自动加载依赖;改用 .NET `SHA256` 流式计算,避免客户构建环境因模块加载差异失败。实现提交:`b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`。
- 固定工具链构建通过;21 项包测试、Go 全量 test/vet、65 个 ZIP 清单文件逐项哈希、模板配置/无 node_modules 审计通过。
- 使用仓库外配置备份完成真实 PostgreSQL 迁移、首页/SPA、MediaMTX、外部目录 start/stop smoke;测试后 18080/9997 无监听。本地解压目录恢复现场 `sense.env`,ZIP 内仍只含无秘密模板。
- 首次在 PowerShell 7 下调用 smoke 的 `Invoke-WebRequest` 出现 loopback 超时;同一服务用 curl 返回 200,按交付目标的 Windows PowerShell 5.1 正式入口复测全部通过,确认不是 Sense 服务阻塞。
- 新 ZIP:56,455,903 字节;SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。
## 人工验收
- 2026-08-16:用户明确验收通过 #70。
- #95 已先合入 `dev`,随后按依赖顺序合并 PR #89;`main` 保持不变。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-90-Sense项目根目录Windows启动脚本
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-90-Sense%E9%A1%B9%E7%9B%AE%E6%A0%B9%E7%9B%AE%E5%BD%95Windows%E5%90%AF%E5%8A%A8%E8%84%9A%E6%9C%AC.-
wiki_revision: cdfab02efe085b8e40cc19b0941e5c3d3c1ae7aa
synchronized_at: 2026-08-27T08:40:20Z
<!-- gitea-wiki-mirror:end -->
# 90 Sense项目根目录Windows启动脚本
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/90
- Wiki 页面:Task-90-Sense项目根目录Windows启动脚本
- Wiki revision:见本地镜像头
## 背景与目标
#70 建立的 Windows 交付包入口位于 `Sense/dist/sense-windows-amd64/start-sense.bat`,用户从项目目录启动时需要进入多层目录。工单 #90 增加项目根目录快捷入口,同时保持包内脚本为配置、迁移和服务编排的唯一事实源。
## 最终方案
新增 `Sense/start_sense.bat`。脚本使用 `%~dp0` 定位同一项目下的交付包,不依赖调用者当前工作目录;通过 `call ... %*` 原样透传 production、demo 和开关参数,并保存子脚本退出码。交付包入口不存在时输出预期路径和构建命令,返回退出码 2。
脚本不读取 `sense.env`、不处理密码或 token、不自动构建,也不直接启动 Go/Node 开发服务。项目 README 与 Wiki Project-Profile 记录快捷命令;#70 的包内说明和运行脚本保持不变。
## 修改文件
- `Sense/start_sense.bat`:项目根目录 Windows 启动入口。
- `Sense/README.md`:增加 production/demo 快捷启动说明与职责边界。
- Wiki `Project-Profile`、`docs/00-project-profile.md`:记录长期启动入口。
- `wiki-docs.json`、`docs/task/90-Sense项目根目录Windows启动脚本.md`:登记任务归档镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 从任意工作目录定位包内入口 | 通过;从 `C:\Windows` 使用隔离夹具和当前本地交付包验证 |
| 参数和退出码原样传递 | 通过;`demo -SkipMigration "two words"` 原样到达假包内脚本,退出码 17 保持 |
| 缺失交付包时明确失败 | 通过;输出预期路径和构建命令,退出码 2 |
| 不包含秘密、不解析配置、不复制启动实现 | 通过;脚本仅 17 行定位、检查、调用和退出码逻辑 |
| 项目说明和 Wiki 镜像一致 | 通过;受影响页面定向同步与检查通过 |
## 测试
- 隔离缺失包夹具:从 `C:\Windows` 调用,错误信息可行动,退出码 2。
- 隔离假包内脚本:参数输出为 `demo -SkipMigration "two words"`,子脚本退出码 17 被根入口保留。
- 当前 #70 本地交付包:从 `C:\Windows` 分别调用包内入口和根入口并传入安全的无效模式,两者均返回参数校验退出码 1,证明真实路径连接和退出码一致。
- 敏感关键词扫描:脚本不含 password、token、secret、database URL 或 credential。
- `git diff --check`:通过。
- Wiki 受影响页面定向 `sync_wiki_docs.py --check --config .tmp-wiki-90.json`:通过。
- **未验证部分**:未通过根入口实际启动 production/demo 服务,以避免在本工单重复操作数据库和服务进程;真实启动链已由 #70 验证。完整 Wiki 全量检查暂受待验收 PR #89 已更新但尚未合入 `dev` 的三个 #70 镜像影响,#90 只对不冲突页面做定向一致性检查。
## 遗留问题
- `dev` 合入 #70 后才包含根入口所调用的版本化包内构建和启动脚本;在此之前根入口会按设计提示先构建且返回 2。
## 相关提交
- `a865dda` 增加 Sense 根目录启动脚本。
- `06d982d` 记录 Sense 根目录启动方式。
## 最新 dev 组合回归(2026-08-27)
- 分支合并 `dev@15142157299936231fc4dda9aa6624bba34aeed3`,合并提交为 `404e25584e41e4c3ba4e8850b4aa07452ae3f7af`。
- 唯一冲突位于 `wiki-docs.json`:#90 与后续 #70/#95/#97/#99/#101/#104/#106 同时新增任务归档映射;解决时保留全部映射。Sense 业务代码和启动脚本无冲突。
- 在仓库外隔离夹具中从 `D:\` 调用根脚本,`demo -SkipMigration "two words"` 原样到达包内脚本,退出码 17 保持。
- 缺失交付包时输出预期路径与构建命令并返回 2;当前真实交付包入口存在于约定路径。
- 根脚本敏感关键词扫描、`git diff --check`、Harness 31 项单测通过;全量 Wiki 镜像检查通过。
- `check_harness.py --strict` 仍仅被既有 #66/#67 归档缺少模板章节的 4 项问题阻断,与 #90 无关。
- 为避免与当前 Supervisor 托管的生产实例争用 18080,本轮没有通过根入口重复启动服务;`yovision-sense` 保持 Running,健康检查返回 200。
## 验收确认
- 2026-08-27,用户明确确认“#90 验收通过”。
- PR #91 按规定合入 `dev`,`main` 未变更。
- 工单 #90 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-92-Sense旧设备能力JSONB兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-92-Sense%E6%97%A7%E8%AE%BE%E5%A4%87%E8%83%BD%E5%8A%9BJSONB%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e
synchronized_at: 2026-08-15T07:30:56Z
<!-- gitea-wiki-mirror:end -->
# 92 Sense旧设备能力JSONB兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92
- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
用户通过 Windows 包启动 Sense 时,设备迁移报 PostgreSQL `SQLSTATE 42804`。只读诊断确认旧数据库的 `sense_devices.capabilities` 为 `text NOT NULL DEFAULT ''::text`,一条旧记录保存受支持的单值能力但不是 JSON;新模型要求 JSONB,GORM AutoMigrate 无法直接转换旧默认值和数据。
目标是在不删除设备、不跳过迁移、不修改当前用户数据库的前提下,为旧 schema 提供确定性、可回滚的 JSONB 兼容迁移。
## 最终方案
在现有 `2026081414000` 设备迁移事务开头执行 PostgreSQL 专用兼容步骤。受影响数据库尚未登记该迁移版本,后置新版本无法越过失败点,因此兼容逻辑必须放在原失败迁移内。
兼容步骤只处理既有 text/varchar 列:取得 ACCESS EXCLUSIVE 表锁,读取并验证全部旧值后才开始改变默认值和数据。空值转为 `[]`;六种受支持旧单值转为单元素 JSON 数组;合法字符串数组规范化后保持语义。未知单值、对象、非字符串数组、未知数组元素和超过 16 项的数组会返回不含业务值的错误,整个事务回滚。
全部验证通过后删除旧 text 默认值,参数化更新规范 JSON,使用显式 `USING capabilities::jsonb` 转型并设置 `'[]'::jsonb` 默认值,再继续原有 GORM AutoMigrate、菜单、权限和迁移版本登记。非 PostgreSQL、表/列不存在以及已是 json/jsonb 时不执行旧值转换。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:事务内旧 text 能力验证、转换、锁表和 JSONB 默认值处理。
- `Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go`:纯函数和隔离 PostgreSQL 17 回归,包括完整设备迁移与版本登记。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:备份、重试、未知旧值和回退说明。
- `wiki-docs.json`、`docs/task/92-Sense旧设备能力JSONB兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧 text 默认值不再触发 SQLSTATE 42804 | 通过;隔离 PostgreSQL 重现结构成功转为 jsonb |
| 旧单值无损转换为 JSONB 数组 | 通过;`video` 转为单元素数组 |
| 空值与合法数组正确处理 | 通过;空字符串转空数组,合法数组保持顺序与值 |
| 未知值拒绝且无半成品 | 通过;类型、默认值和已验证行均保持旧状态 |
| 新库、jsonb 和重复执行兼容 | 通过;无表跳过、jsonb 重复调用无操作 |
| 全量 Go 和迁移回归通过 | 通过 |
| 当前用户数据库未被修改 | 通过;仅执行只读诊断,写测试使用独立数据库并在结束后删除 |
| Wiki 与归档一致 | 受影响页面定向检查通过 |
## 测试
- `go test ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1 -v`:通过。
- 隔离 PostgreSQL 17 `TestDeviceCapabilitiesMigrationOnPostgres`:旧单值/空值/合法数组、未知值事务回滚、无表、重复调用、完整设备迁移和 `sys_migration` 登记全部通过。
- 隔离数据库 `sense92_migration_test` 测试前确认不存在,测试后确认计数为 0。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1`:通过。
- `git diff --check`:通过。
- 受影响 Wiki 定向同步与检查:通过。
- **未验证部分**:按工单安全边界未在当前用户 `sense` 数据库执行写迁移,也未替换当前 `Sense/dist` 中的待验收 #70 二进制;需先备份,再部署包含 #92 的新包进行最终启动验收。全量 Wiki 检查仍会先发现待验收 PR #89 的 #70 镜像尚未合入 `dev`。
## 用户验收
- 用户于 2026-08-15 明确确认 `#92 验收通过`。
- 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。
- #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。
## 遗留问题
- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。
- Harness strict 的 #66/#67 既有归档格式问题不属于本工单。
## 相关提交
- `68b436a` 兼容旧设备能力字段迁移。
- `fe3badf` 覆盖旧设备完整迁移链。
- `aee5f45` 记录设备能力迁移排错。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-95-Sense旧媒体路由唯一约束兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-95-Sense%E6%97%A7%E5%AA%92%E4%BD%93%E8%B7%AF%E7%94%B1%E5%94%AF%E4%B8%80%E7%BA%A6%E6%9D%9F%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: 5f3bdf3786f2a72dac5d29236ff466743e2912b5
synchronized_at: 2026-08-16T11:31:47Z
<!-- gitea-wiki-mirror:end -->
# 95 Sense旧媒体路由唯一约束兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/95
- Wiki 页面:Task-95-Sense旧媒体路由唯一约束兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
#92 修复进入真实旧库后,设备能力字段已成功转换为 JSONB;下一条媒体迁移因 GORM 尝试删除不存在的推导约束名 `uni_sense_media_routes_path` 而报 SQLSTATE 42704。只读核对确认旧 `sense_media_routes.path` 实际由 PostgreSQL 自动命名约束 `sense_media_routes_path_key` 保证唯一,且表中已有 2 条路由。
隔离回归越过约束错误后进一步确认,旧非空表缺少当前模型要求的运行态列,直接新增 `source_ready NOT NULL` 会报 SQLSTATE 23502。目标是在不删除路由、不削弱 path 唯一性、不伪造媒体已就绪的前提下完成旧表迁移。
## 最终方案
在现有 `2026081419000` 媒体迁移事务开头执行 PostgreSQL 专用兼容步骤。仅当旧表存在时取得 ACCESS EXCLUSIVE 锁,从 pg_catalog 读取包含 path 的唯一约束;只接受唯一的单列 `UNIQUE(path)`,复合、多重或冲突结构拒绝迁移并整体回滚。
确认结构后,把数据库实际约束名规范为 GORM 能识别和移除的名称,让 AutoMigrate 转换为模型的 `idx_sense_media_routes_path` 唯一索引。锁在整个迁移事务提交前持续有效,因此约束切换期间没有并发写入窗口。
兼容步骤同时为旧路由添加并回填当前模型要求的运行态列:`source_ready=false`、`failure_count=0`、`last_error_code=''`,随后设为 NOT NULL。保守初值表示服务启动后必须重新对账,不把旧路由冒充为已经就绪;`next_retry_at` 保持可空并由 AutoMigrate 建立。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081419000_media.go`:旧约束识别、规范化、锁表和运行态列兼容。
- `Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go`:隔离 PostgreSQL 旧表、非空路由、空库、无表、重复执行、唯一性和不安全结构回滚测试。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:错误含义、备份、迁移、验证和回退步骤。
- `wiki-docs.json`、`docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧约束名不再触发 SQLSTATE 42704 | 通过;隔离和真实 PostgreSQL 均完成媒体迁移 |
| 既有媒体路由完整保留 | 通过;真实库迁移前后均为 2 条 |
| path 始终具有唯一性保护 | 通过;迁移后 `idx_sense_media_routes_path` 唯一索引有效,重复 path 写入被拒绝 |
| 无表、新库、已迁移库和重复兼容 | 通过 |
| 不安全结构拒绝并回滚 | 通过;复合 path 约束夹具未发生部分变更 |
| Go 全量与隔离 PostgreSQL 回归 | 通过 |
| #70 Windows 包真实迁移与启动 smoke | 通过;Web/SPA/health/MediaMTX 200,停止后端口清空 |
| Wiki 镜像与任务归档一致 | 通过 |
## 测试
- 隔离 PostgreSQL 17 `TestMediaMigrationOnPostgres`:旧 2 路由、旧约束、运行态回填、空库、无表、重复兼容、唯一性冲突和复合约束回滚全部通过;独立测试数据库每次运行后删除。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -count=1`:使用隔离 PostgreSQL 通过。
- #70 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与包审计通过。
- 迁移前 PostgreSQL custom-format 备份通过 `pg_restore --list`;真实迁移完成,2 条旧路由保留、运行态列无空值、媒体迁移版本登记、唯一索引有效。
- Web 首页、SPA、`/healthz` 与 MediaMTX Control API 均返回 200;`stop-sense.bat` 后相关端口无监听。
- `git diff --check`:通过。
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和真实获准摄像机上验收;当前回归使用本机 PostgreSQL、脱敏业务计数和已配置测试摄像机环境。Harness strict 仍只受既存 #66/#67 归档格式影响。
## 遗留问题
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响,访问 loopback 时失败;明确绕过代理的本机 HTTP 请求验证服务正常,不属于 Sense 服务端故障。
- #95 需先经用户验收并合入 `dev`,随后 #70 才能按依赖顺序完成合并。
## 相关提交
- `3dd4890` 兼容旧媒体路由约束和运行态列迁移。
- `6257859` 记录旧媒体路由迁移排错。
- `c088caf` #70 集成 #95 后用于 Windows 包真实回归。
## 人工验收
- 2026-08-16:用户明确验收通过 #95。
- 按依赖顺序先将 PR #96 合入 `dev`;`main` 保持不变。
@@ -0,0 +1,85 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-97-Sense免验证码登录与管理员密码重置
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-97-Sense%E5%85%8D%E9%AA%8C%E8%AF%81%E7%A0%81%E7%99%BB%E5%BD%95%E4%B8%8E%E7%AE%A1%E7%90%86%E5%91%98%E5%AF%86%E7%A0%81%E9%87%8D%E7%BD%AE.-
wiki_revision: 486ebcca10e4cef0bd905df59b928c17006db17e
synchronized_at: 2026-08-16T11:04:57Z
<!-- gitea-wiki-mirror:end -->
# 97 Sense免验证码登录与管理员密码重置
- 类型:安全行为调整 / 缺陷修复
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/97
- Wiki 页面:Task-97-Sense免验证码登录与管理员密码重置
- Wiki revision:见本地镜像头
## 背景与目标
GoAdmin 重建后的 Sense 登录页和生产后端重新启用了验证码,与用户确认的账号密码直接登录流程不一致。用户要求所有运行模式恢复免验证码登录,并把当前本地 PostgreSQL 的管理员账号设置为用户指定、满足现行 6–72 字节策略的密码;密码明文不得进入仓库、工单、Wiki 或日志。
## 最终方案
- 保留冻结 GoAdmin 的 Gin/GORM/JWT/Casbin `Authenticator`、登录路由、Vuex 登录动作、Element Plus 表单和同步身份审计。
- 登录 DTO 只保留 `username/password`;production、test、dev 均不再校验验证码。
- 登录页移除验证码字段、规则、图标、接口请求、失败刷新和样式;兼容保留 `/api/v1/captcha` 端点及上游存储初始化,便于回退。
- Swagger 登录载荷同步为只要求账号和密码。
- 修复失败认证分支未把用户名写入审计的问题,使错误密码尝试可按账号追踪。
- 目标数据库起初没有任何用户,因此没有执行不安全的直接插入;使用一次性高熵进程令牌走既有 `/api/v1/bootstrap` 安全初始化路径创建 `admin`,密码只在进程内传递。随后验证正确密码成功、错误密码拒绝和成功/失败审计。
- 密码最少 6 位、最多 72 字节且不强制字符复杂度的既有策略保持不变;JWT、RBAC、Cookie 和会话有效期未修改。
## 修改文件
- `Sense/server/common/middleware/handler/auth.go`:移除验证码校验并补齐失败登录用户名审计。
- `Sense/server/common/middleware/handler/login.go`:登录载荷只保留账号和密码。
- `Sense/server/common/middleware/handler/login_test.go`:覆盖无验证码登录载荷。
- `Sense/server/docs/admin/admin_docs.go`、`admin_swagger.json`、`admin_swagger.yaml`:同步登录接口模型。
- `Sense/ui/src/views/login/index.vue`:移除验证码 UI、请求和状态。
- `Sense/ui/tests/unit/login/loginPage.spec.js`:覆盖登录页只使用账号密码。
- Wiki `Product-Requirements`、`Local-Development-and-Verification` 及镜像:记录免验证码登录、安全审计和密码策略边界。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 登录页不显示验证码且不请求验证码接口 | 通过:组件状态、规则与方法均只含账号密码;生产构建通过 |
| 所有模式接受仅账号密码的登录载荷 | 通过:后端不再按模式进入 captcha 校验,DTO 定向测试通过 |
| 正确密码成功、错误密码拒绝并有脱敏审计 | 通过:本地 production/PostgreSQL smoke 成功;成功与失败审计均可按 admin 查询 |
| JWT、RBAC、未登录拒绝不变 | 通过:未认证管理路由返回 401,全量后端测试通过 |
| 密码策略仍为 6–72 字节 | 通过:现有密码策略测试通过,相关代码未修改 |
| 管理员密码安全设置且仓库无明文 | 通过:空用户库经一次性 bootstrap 创建,跟踪差异秘密扫描无泄漏 |
| 前后端测试和 production build | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `corepack pnpm@9.15.1 exec eslint src/views/login/index.vue tests/unit/login/loginPage.spec.js`:通过。
- 前端全量单测:17 个 suite、46 个 test 通过。
- `corepack pnpm@9.15.1 run build:prod`:通过;存在既有 Sass、SCSS export、代码生成器和体积 warning,未由本工单引入。
- production/PostgreSQL/MediaMTX smoke:安全初始化成功、免验证码登录成功、错误密码拒绝、未认证接口返回 401;身份审计包含首个管理员创建、登录成功和登录失败记录。
- `python -m unittest discover -s tests -v`:31 项通过。
- `python dev_scripts/check_harness.py --strict`:只因既有 #66/#67 归档缺少当前模板章节失败 4 项,与本工单修改无关。
- `git diff --check` 与跟踪差异密码扫描:通过。
- 测试结束后 Sense、MediaMTX 均已停止,18080/9997 无监听;`Sense/ui/node_modules` 与 `Sense/ui/dist` 已清理。
- **未验证部分**:尚未在 #70 最终 Windows ZIP、客户全新 Windows 主机和客户目标浏览器中重新打包验收;#70 与 #97 合入 `dev` 后需重新生成发布包。
## 遗留问题
- 验证码 API 为上游兼容而保留但登录链不使用;若未来永久删除,需单独清理工单核对依赖和回退。
- 取消验证码降低自动化暴力尝试阻力;本工单按用户确认保留失败登录审计,但未引入未确认的限流或锁定体系。
## 相关提交
- `0bbdb27f6db4f9d08445ca02abae9061c81598b8` 恢复免验证码登录并补充测试。
- `bc1a01848d1769250b706f207d542b63fee2afa6` 更新 Sense 登录安全边界镜像。
## 人工验收
- 2026-08-16:用户明确验收通过 #97。
- 按工作流将 PR #98 合入 `dev`;`main` 保持不变。
@@ -0,0 +1,79 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-99-Sense登录页只读配置接口
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-99-Sense%E7%99%BB%E5%BD%95%E9%A1%B5%E5%8F%AA%E8%AF%BB%E9%85%8D%E7%BD%AE%E6%8E%A5%E5%8F%A3.-
wiki_revision: 7d0dd9b216e443b00a97f021d49678b848006db9
synchronized_at: 2026-08-16T15:41:39Z
<!-- gitea-wiki-mirror:end -->
# 99 Sense登录页只读配置接口
- 类型:缺陷修复 / GoAdmin 路由精简回归
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-16
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/99
- Wiki 页面:Task-99-Sense登录页只读配置接口
- Wiki revision:见本地镜像头
## 背景与目标
用户启动 #70 Windows 发布包后,登录页弹出 `Request failed with status code 404`。只读诊断确认首页、runtime JS 和兼容 captcha 端点均为 200,唯一失败请求是登录页在 created 阶段发出的 `GET /api/v1/app-config`。
冻结 go-admin-ui 使用该匿名接口取得系统名称等登录外壳展示配置;Sense 后端保留了 `SysConfig.Get2SysApp` handler,但在最小化默认模块时没有注册此路由。目标是恢复这一个只读端点,同时继续禁用系统配置管理和写接口。
## 最终方案
- 在现有 `registerBaseRouter` 中单独注册匿名 `GET /api/v1/app-config`,直接复用 GoAdmin `SysConfig.Get2SysApp`。
- 不调用上游完整 `registerSysConfigRouter`,因此 `/api/v1/config`、`/api/v1/configKey` 和 `/api/v1/set-config` 不会随之开放。
- handler 继续只查询 `is_frontend=1` 的配置并返回标准响应;没有前端配置时返回空对象和业务码 200。
- 保持 #97 的免验证码登录、JWT、RBAC、Cookie、密码策略和 captcha 兼容端点不变。
- 基于实现提交重新生成 Windows 包,恢复仓库外备份的现场配置,并重新启动 Sense/MediaMTX。
## 修改文件
- `Sense/server/app/admin/router/sys_router.go`:恢复登录外壳必需的匿名只读 app-config 路由。
- `Sense/server/app/admin/router/sys_router_test.go`:锁定公开端点和八个仍禁用的配置管理路由。
- Wiki `Architecture-and-Code-Map` 及镜像:记录匿名只读例外与配置管理禁用边界。
- `wiki-docs.json` 与本任务镜像:登记任务归档。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 匿名 app-config 返回 HTTP 200 和标准结构 | 通过:HTTP 200、业务码 200 |
| 登录页不再出现该 404 | 通过:真实 Edge 页面 app-config 200,错误消息数 0 |
| 配置 CRUD/configKey/set-config 未启用 | 通过:GET/PUT 定向 smoke 均为 404 |
| 免验证码登录与认证边界不变 | 通过:登录页仅账号和密码,无验证码文本;后端全量测试通过 |
| Go 与 Windows 包测试 | 通过 |
| ZIP 无秘密、node_modules 或客户数据 | 通过:构建内置包审计通过,ZIP 使用模板配置 |
| Wiki、归档与证据完整 | 通过 |
## 测试
- `go test ./app/admin/router -run TestRegisterBaseRouterExposesOnlyFrontendAppConfig -count=1`:通过。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- Windows PowerShell 包测试:21 项断言通过。
- 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与 11 个 HTML 本地资源审计通过;存在既有 4 条非阻塞构建 warning。
- 真实运行:`GET /api/v1/app-config` 为 HTTP 200/业务码 200;配置 CRUD、configKey、set-config GET/PUT 均为 404。
- Headless Edge:进入 `/#/login?redirect=/dashboard`;app-config 200;错误消息 0;仅账号、密码两个输入;无验证码文本、失败请求或页面异常。
- 新 ZIP:56,457,323 字节;SHA-256 `A98EB70B709BBA546F4968723E4F89CE44DAF412FCC426869820FB7454CB3CC7`;包内 `source_commit=713c9e4e3003088e29aafcf1fdd3bd87078ec394`。
- 测试后 Sense 与 MediaMTX 已使用恢复的现场配置重新启动,监听 18080/9997。
- `git diff --check` 与 Wiki 定向同步检查:通过。
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和客户目标浏览器中验收;本轮使用当前机器 PostgreSQL、Microsoft Edge 和现有脱敏配置验证。
## 遗留问题
- 无本工单阻塞项。
## 相关提交
- `713c9e4e3003088e29aafcf1fdd3bd87078ec394` 恢复登录页只读配置接口并增加负向路由测试。
- `08b4b61` 记录登录外壳只读配置边界。
## 人工验收
- 用户于 2026-08-16 明确回复“#99 验收通过”。
- PR #100 已合入 `dev`,合并提交:`34ee5ed619d122a8670dc50039578686a7a9ef66`。
- 工单已按验收流程关闭;MVP #8 与 Epic #7 的子工单索引同步为完成。

Some files were not shown because too many files have changed in this diff Show More