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 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
76 changed files with 2898 additions and 187 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
}
@@ -80,6 +80,11 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysMenu{}
api2 := apis.SysDept{}
configAPI := apis.SysConfig{}
// The GoAdmin login shell reads frontend-only branding before a user is
// authenticated. Keep this single read route public without registering the
// disabled system-configuration CRUD and write routes.
v1.GET("/app-config", configAPI.Get2SysApp)
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
@@ -0,0 +1,37 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestRegisterBaseRouterExposesOnlyFrontendAppConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerBaseRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
routes := make(map[string]struct{})
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = struct{}{}
}
if _, ok := routes["GET /api/v1/app-config"]; !ok {
t.Fatal("anonymous frontend app-config route is not registered")
}
for _, disabled := range []string{
"GET /api/v1/config",
"POST /api/v1/config",
"GET /api/v1/config/:id",
"PUT /api/v1/config/:id",
"DELETE /api/v1/config",
"GET /api/v1/configKey/:configKey",
"GET /api/v1/set-config",
"PUT /api/v1/set-config",
} {
if _, ok := routes[disabled]; ok {
t.Fatalf("disabled system-configuration route was registered: %s", disabled)
}
}
}
+12
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,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,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
}
+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,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)
})
})
+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 -->
+15 -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: 0ba2909431bd04320a9b31121126b59b1824e3dc
synchronized_at: 2026-08-15T01:13:02Z
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 只返回是否已配置,不提供凭据读取接口。
@@ -130,3 +132,13 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。
<!-- sense-area:end -->
<!-- sense-windows-delivery:start -->
## Sense Windows 交付运行链
工单 #70 在 GoAdmin 派生入口上建立 Windows amd64 交付链。构建入口为 `Sense/scripts/build/build-windows.ps1`;运行包入口为 `start-sense.bat`,它先调用现有 `sense.exe migrate -c data\runtime\settings.yml`,迁移成功后再调用 `sense.exe server -c ...`。前端生产构建复制到包内 `web/`,后端只在显式配置 `SENSE_WEB_ROOT` 时提供同源静态资源和 SPA fallback,API 与健康检查不会被 fallback 覆盖。
运行脚本将 `config\sense.env` 当作数据解析,只接受白名单 `SENSE_*` 字段,不执行文件内容;同名非空进程环境变量优先。生成的 `data\runtime\settings.yml` 含运行秘密,只能留在部署目录。production 固定使用 PostgreSQL,要求至少 32 字符 JWT secret,且 MediaMTX 只能为 `managed` 或 `external`;`managed` 在 HTTP 启动前拉起包内二进制,`external` 在 HTTP 启动前确认回环 Control API 可达。Demo 使用独立配置和名称含 demo/test 的隔离数据库,默认禁用 MediaMTX,绝不回退 production 数据库。
交付包同时提供检查、迁移、管理员初始化、停止、备份和恢复入口。停止脚本只操作当前包且监听配置端口的 Sense 进程树;备份密码只进入子进程环境;恢复要求数据库名和二次短语确认。包不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。
<!-- sense-windows-delivery:end -->
+62 -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: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
synchronized_at: 2026-08-15T01:13:07Z
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 -->
@@ -282,3 +313,29 @@ corepack pnpm@9.15.1 build:prod
浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。
<!-- sense-area:end -->
<!-- sense-supervisor:start -->
## Sense 本机 Supervisor 托管
本机开发/演示环境可由 `D:\supervisor` 托管已经构建的 Sense Windows 交付包。实例配置位于仓库外的 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`;工作目录固定为 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。
Supervisor 配置只调用包内 `scripts\runtime\start-sense.ps1`,运行参数继续从包内 `config\sense.env` 读取。不得把数据库连接、JWT 密钥、摄像头凭据或其他秘密复制到 Supervisor 配置或工单。
常用命令:
```powershell
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf status yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf restart yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf stop yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf start yovision-sense
Get-Content D:\supervisor\logs\yovision-sense.log -Tail 100
```
新增或修改 `programs/*.conf` 后执行:
```powershell
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf reload
```
当前 Go Supervisor 的 `reload` 会重新读取独立配置;实际受影响实例必须以命令输出和 reload 前后 PID 为准。切换托管前先停止占用 Sense 端口的非 Supervisor 实例,防止自动启动进入 Backoff。验证至少包含 Supervisor 状态为 Running、`http://127.0.0.1:18080/health` 与首页返回 200,以及受控重启后 Sense 和受管 MediaMTX PID 均更新。
<!-- sense-supervisor:end -->
+20 -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: a169b2323323d9de6304e9b430ddbe9888ea1d25
synchronized_at: 2026-08-15T07:16:25Z
wiki_revision: 1a452e9aafdfe01580f37f9179584b89516cf992
synchronized_at: 2026-08-15T07:59:38Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -134,3 +134,21 @@ synchronized_at: 2026-08-15T07:16:25Z
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
<!-- sense-capabilities-jsonb:end -->
<!-- sense-media-path-constraint:start -->
## Sense 旧媒体路由迁移排错
旧库迁移出现 `约束 "uni_sense_media_routes_path" 不存在 (SQLSTATE 42704)`,表示旧 `sense_media_routes.path` 由 PostgreSQL 自动命名的唯一约束保护,而新 GORM 模型准备改用唯一索引;GORM 按推导名称删除旧约束时找不到实际名称。修正该名称后若继续出现 `source_ready ... contains null values (SQLSTATE 23502)`,表示非空旧表还缺少当前模型要求的运行态列。
工单 #95 的兼容迁移只在 PostgreSQL 旧表存在时执行:取得 ACCESS EXCLUSIVE 表锁,确认只有一个单列 `UNIQUE(path)` 约束,将实际约束名规范为 GORM 可识别名称;同时为旧路由初始化保守运行态 `source_ready=false`、`failure_count=0`、`last_error_code=''`,再继续 AutoMigrate。迁移不会把旧路由伪装成已就绪,服务启动后仍由对账恢复真实状态。复合约束、多重 path 约束或其他无法确认的唯一性结构会拒绝迁移并整体回滚。
处理步骤:
1. 停止连接该数据库的全部 Sense 实例,并确认 Sense 与 MediaMTX 相关端口已释放。
2. 使用 `backup-sense.bat` 生成 PostgreSQL custom-format 备份;非标准 PostgreSQL 安装目录需通过 `SENSE_POSTGRES_BIN` 指向包含 `pg_dump.exe`、`pg_restore.exe` 的目录。
3. 使用 `pg_restore --list <备份文件>` 确认备份可读取,再部署包含 #95 的 Windows 包。
4. 先运行 `migrate-sense.bat`;成功后确认旧路由数量不变、运行态列无空值、`path` 仍有唯一索引。
5. 再启动 Sense,检查首页、`/healthz`、MediaMTX Control API 和视频服务对账;验证完成后使用 `stop-sense.bat` 停止。
如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。
<!-- sense-media-path-constraint:end -->
+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,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 的子工单索引同步勾选。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-92-Sense旧设备能力JSONB兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-92-Sense%E6%97%A7%E8%AE%BE%E5%A4%87%E8%83%BD%E5%8A%9BJSONB%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: d10479f732597bd90dac4de03edc0cd4b39b6352
synchronized_at: 2026-08-15T07:19:37Z
wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e
synchronized_at: 2026-08-15T07:30:56Z
<!-- gitea-wiki-mirror:end -->
# 92 Sense旧设备能力JSONB兼容迁移
@@ -11,7 +11,7 @@ synchronized_at: 2026-08-15T07:19:37Z
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:待验收
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92
- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移
@@ -62,6 +62,12 @@ synchronized_at: 2026-08-15T07:19:37Z
- 受影响 Wiki 定向同步与检查:通过。
- **未验证部分**:按工单安全边界未在当前用户 `sense` 数据库执行写迁移,也未替换当前 `Sense/dist` 中的待验收 #70 二进制;需先备份,再部署包含 #92 的新包进行最终启动验收。全量 Wiki 检查仍会先发现待验收 PR #89 的 #70 镜像尚未合入 `dev`。
## 用户验收
- 用户于 2026-08-15 明确确认 `#92 验收通过`。
- 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。
- #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。
## 遗留问题
- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。
@@ -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 的子工单索引同步为完成。
+32
View File
@@ -131,6 +131,38 @@
{
"page": "Task-69-Sense多边形区域与方向警戒线配置",
"path": "docs/task/69-Sense多边形区域与方向警戒线配置.md"
},
{
"page": "Task-90-Sense项目根目录Windows启动脚本",
"path": "docs/task/90-Sense项目根目录Windows启动脚本.md"
},
{
"page": "Task-70-Sense-Windows配置启动与打包交付",
"path": "docs/task/70-Sense-Windows配置启动与打包交付.md"
},
{
"page": "Task-95-Sense旧媒体路由唯一约束兼容迁移",
"path": "docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md"
},
{
"page": "Task-97-Sense免验证码登录与管理员密码重置",
"path": "docs/task/97-Sense免验证码登录与管理员密码重置.md"
},
{
"page": "Task-101-Sense-GoAdmin-应用外壳",
"path": "docs/task/101-Sense-GoAdmin-应用外壳.md"
},
{
"page": "Task-99-Sense登录页只读配置接口",
"path": "docs/task/99-Sense登录页只读配置接口.md"
},
{
"page": "Task-104-Sense-30天登录有效期",
"path": "docs/task/104-Sense-30天登录有效期.md"
},
{
"page": "Task-106-Sense-本机-Supervisor-实例",
"path": "docs/task/106-Sense-本机-Supervisor-实例.md"
}
]
}