Compare commits

...
Author SHA1 Message Date
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
ila 49aa79f3b9 Merge pull request '#147' from docs/8-mvp-acceptance into dev
同步 MVP #8 验收后的核心 Wiki 镜像。
2026-08-29 20:49:44 +08:00
QiuSW 64e20e6aed docs: 记录 MVP #8 验收状态 (#8) 2026-08-29 20:49:25 +08:00
ila 19c0868c5d Merge PR #146: Sense 独立验收脚本隔离与诊断修复 (#145)
用户于 2026-08-29 验收通过 #145。
2026-08-29 20:15:35 +08:00
QiuSW 1c6b30fac0 fix: 修复 Sense 独立验收脚本隔离与诊断 (#145) 2026-08-29 20:00:04 +08:00
ila 6702b8a5b9 Merge PR #144: Bell 独立纵切与 Windows 交付验证 (#134)
用户于 2026-08-29 验收通过 #134。
2026-08-29 17:34:07 +08:00
QiuSW a35f1d6770 test: 建立 Bell 独立纵切与 Windows 交付验证 (#134) 2026-08-29 17:30:03 +08:00
ila 6194b664ee Merge PR #143: 修复 Bell 生产外壳白屏 (#142)
用户已于 2026-08-29 明确验收通过 #142。
2026-08-29 17:22:33 +08:00
QiuSW 1caa429cad fix: 修复 Bell 生产外壳白屏 (#142) 2026-08-29 17:11:24 +08:00
ila f99fe8d4f7 Merge PR #141: 收敛 Bell 管理员默认菜单 (#140)
用户已于 2026-08-29 明确验收通过 #140。
2026-08-29 16:45:28 +08:00
QiuSW b4a8e0e1f1 fix: 收敛 Bell 管理员默认菜单 (#140) 2026-08-29 16:43:52 +08:00
ila 86c3e79121 Merge pull request '#139' from feature/138-bell-production-captcha into dev
fix: 恢复 Bell 生产验证码登录 (#138)
2026-08-29 16:27:09 +08:00
QiuSW cabc29c18b fix: 恢复 Bell 生产验证码登录 (#138) 2026-08-29 16:20:34 +08:00
ila 452cd71035 Merge PR #137: Bell 预警处置生命周期
关联 #133;用户验收后关闭工单。
2026-08-29 11:56:57 +08:00
QiuSW f09a61e5fb feat: 重建 Bell 预警处置生命周期 (#133) 2026-08-29 11:56:41 +08:00
ila afc58f7bf4 merge: 完成 Bell 规则与预警链路 (#132)
实现版本化规则、不可变评估事实、open Alert 投影、Event-Alert 双向查询及 GoAdmin UI/RBAC。
2026-08-29 10:02:36 +08:00
QiuSW b01ca1fe09 feat: 重建 Bell 规则与预警链路 (#132) 2026-08-29 10:01:09 +08:00
ila 689de560bb Merge PR #135: Bell Event、Receipt 与合成事件入口
Implements #131; merge into dev for user acceptance.
2026-08-29 09:01:11 +08:00
97 changed files with 5186 additions and 42 deletions
+59
View File
@@ -0,0 +1,59 @@
# Bell 独立纵切验收
本验收只使用 Bell 自身、临时 PostgreSQL 和项目内合成事件,不启动或调用 Sense、Brain,不连接默认 5432、生产数据库或客户数据。
## 固定工具链
```powershell
$env:GOTOOLCHAIN='go1.26.5'
go version
node --version
corepack pnpm@9.15.1 --version
```
预期分别为 Go 1.26.5、Node 22.22.1、pnpm 9.15.1。
## 源码验证
```powershell
Set-Location Bell\server
$env:GOTOOLCHAIN='go1.26.5'
go test ./... -count=1
go vet ./...
go build ./...
Set-Location ..\ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
corepack pnpm@9.15.1 test:unit --runInBand
corepack pnpm@9.15.1 build:prod
```
## Windows 包和隔离 E2E
```powershell
Set-Location <仓库根目录>
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64
```
E2E 自动完成并清理:临时 PostgreSQL、随机数据库/HTTP 端口、随机管理员/处置员凭据、迁移、健康检查、登录/RBAC、最小 Bell 菜单、规则、合成 Event/Receipt 幂等、Alert、20 路并发 ack、越权/缺参拒绝、close 重放幂等、两条生命周期时间线、冷重启、Windows stop 和日志泄密检查。原始包保持生产配置并先通过审计;业务自动化只把临时包副本切换为 `dev` 测试模式。生产验证码的获取、正确登录、错误及重放拒绝由 #138 的 `Bell/server/tests/bell_production_login/run-postgres.ps1` 覆盖,不暴露或识别验证码答案。
浏览器验收打开脚本输出的临时 `base_url`,检查:
- 匿名访问跳转登录页,并显示验证码输入;测试模式可填写任意非空验证码,生产验证码行为由 #138 回归覆盖;
- 登录后保留 GoAdmin 侧栏、顶部导航和标签页;
- 管理员显示 Bell 必要业务菜单,包括预警管理、事件查询、规则配置;处置员仅显示预警处理所需入口;
- 预警详情可显示关联事件、处理人、现场结果和两条处理时间线;
- 不显示开发工具、定时任务、系统监控等无关入口。
## 仓库闭环
```powershell
python dev_scripts/harness.py check --strict
git diff --check
git status --short --branch
```
浏览器人工/工具检查、真实生产数据库、客户网络和长期负载不由 API 单测替代;未执行的项目必须在工单证据中明确说明。
+59
View File
@@ -0,0 +1,59 @@
# Bell Windows 运行说明
Bell Windows 包包含独立后端、GoAdmin 管理端静态资源和启动、停止、检查脚本。正式运行需要独立 PostgreSQL;包内不提供默认账号、密码、JWT secret 或数据库。
## 配置
编辑 `config\bell.env`:
```text
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=host=127.0.0.1 port=5432 user=bell dbname=bell sslmode=disable
BELL_JWT_SECRET=<至少 32 字符的独立随机值>
BELL_BOOTSTRAP_USERNAME=<仅首次迁移使用>
BELL_BOOTSTRAP_PASSWORD=<仅首次迁移使用,至少 8 字符>
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
```
不要把真实配置提交到 Git。首次迁移成功后,建议从进程环境中移除 `BELL_BOOTSTRAP_PASSWORD`;它不会写入明文数据库。
## 启动、检查和停止
```bat
check-bell.bat
start-bell.bat
check-bell.bat -Running
stop-bell.bat
```
浏览器访问 `http://127.0.0.1:18091/`。`BELL_PORT` 是仅供本机 Web 网关访问的后端端口;`BELL_WEB_PORT` 是用户访问入口。启动脚本默认先执行幂等数据库迁移,再启动后端和 Web 网关;任一步失败都会返回非零退出码。
`stop-bell.bat` 只按包内 PID 文件和启动命令行核对后停止本包进程树,不按端口终止未知进程。运行日志位于 `runtime\logs`,不得包含密码、JWT 或登录 token。
## 构建和包审计
从仓库根目录运行:
```powershell
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
```
输出:
- `Bell\dist\bell-windows-amd64\`
- `Bell\dist\bell-windows-amd64.zip`
包内 `VERSION.txt`、`MANIFEST.sha256` 和 `LICENSES\` 分别记录源码提交、工具链、文件摘要、GoAdmin 来源及 MIT 许可证。
## 常见错误
- `BELL_DATABASE_URL is required`:设置独立 PostgreSQL 连接串。
- `PostgreSQL is unreachable`:启动 PostgreSQL,并检查地址和端口。
- `BELL_JWT_SECRET must contain...`:生成至少 32 字符、只供 Bell 使用的随机值。
- `port ... is already in use`:停止已有 Bell,或修改后端/Web 端口。
- `Bell database migration failed`:检查数据库是否存在、用户权限及迁移日志;不要删除已有 Event、Alert 或生命周期事实。
+9
View File
@@ -37,3 +37,12 @@ corepack pnpm@9.15.1 dev
```
生产构建使用 `corepack pnpm@9.15.1 build:prod`。生产环境不会生成或接受仓库默认管理员、默认 JWT secret 或默认数据库连接串。
## Windows 交付与独立验收
- Windows 构建:`Bell\scripts\build\build-windows.bat`
- 包审计:`pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64`
- 隔离 E2E:`pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64`
- 包内启动、检查和停止:`start-bell.bat`、`check-bell.bat -Running`、`stop-bell.bat`
完整配置、排错和验收标准见 `README-WINDOWS.md` 与 `ACCEPTANCE.md`。隔离 E2E 使用临时 PostgreSQL、随机端口和随机凭据,不启动或调用 Sense、Brain。
+11
View File
@@ -0,0 +1,11 @@
# Bell production environment. Copy values into process environment or this file.
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=
BELL_JWT_SECRET=
BELL_BOOTSTRAP_USERNAME=
BELL_BOOTSTRAP_PASSWORD=
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
+13
View File
@@ -0,0 +1,13 @@
param([Parameter(Mandatory = $true)][string]$WebRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot)
$index = Join-Path $root 'index.html'
if (-not (Test-Path -LiteralPath $index -PathType Leaf)) { throw 'web/index.html is missing.' }
$html = Get-Content -LiteralPath $index -Raw -Encoding UTF8
$references = [regex]::Matches($html, '(?:src|href)=["''](?<path>/[^"''?#]+)') | ForEach-Object { $_.Groups['path'].Value.TrimStart('/').Replace('/', '\') }
foreach ($relative in $references | Sort-Object -Unique) {
if ($relative -match '^https?:') { continue }
if (-not (Test-Path -LiteralPath (Join-Path $root $relative) -PathType Leaf)) { throw "web asset referenced by index.html is missing: $relative" }
}
Write-Host "Bell web asset check passed: $root"
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0build-windows.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0build-windows.ps1" %*)
exit /b %errorlevel%
+88
View File
@@ -0,0 +1,88 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$bellRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$repositoryRoot = Split-Path $bellRoot -Parent
$serverRoot = Join-Path $bellRoot 'server'
$uiRoot = Join-Path $bellRoot 'ui'
$distRoot = Join-Path $bellRoot 'dist'
$target = Join-Path $distRoot 'bell-windows-amd64'
$archive = Join-Path $distRoot 'bell-windows-amd64.zip'
$staging = Join-Path $distRoot ('.bell-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-FileSha256([string]$Path) {
$sha = [Security.Cryptography.SHA256]::Create(); $stream = [IO.File]::OpenRead($Path)
try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','') } finally { $stream.Dispose(); $sha.Dispose() }
}
Assert-ChildPath $bellRoot $distRoot; Assert-ChildPath $distRoot $target; Assert-ChildPath $distRoot $archive; Assert-ChildPath $distRoot $staging
$savedToolchain = $env:GOTOOLCHAIN
$env:GOTOOLCHAIN = 'go1.26.5'
try {
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; found $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; found $pnpmVersion." }
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 'Bell UI production build failed.' }
# The frozen Vue CLI differential build references a module runtime
# that ScriptExt removes from disk. The complete legacy bundle is
# present, so make that reproducible bundle the package entry point.
$builtIndex = Join-Path $uiRoot 'dist\index.html'
$html = Get-Content -LiteralPath $builtIndex -Raw -Encoding UTF8
$html = [regex]::Replace($html, '<script[^>]+type="module"[^>]*></script>', '')
$html = $html.Replace(' nomodule', '')
[IO.File]::WriteAllText($builtIndex, $html, (New-Object Text.UTF8Encoding($false)))
} 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 'bell.exe') .; if ($LASTEXITCODE -ne 0) { throw 'Bell 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 'LICENSES') | Out-Null
Copy-Item -Path (Join-Path $bellRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
foreach ($name in @('start-bell','stop-bell','check-bell')) { Copy-Item -LiteralPath (Join-Path $bellRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") }
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env.example')
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\settings.yml') -Destination (Join-Path $staging 'config\settings.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 $bellRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
Copy-Item -LiteralPath (Join-Path $bellRoot 'LICENSES') -Destination $staging -Recurse -Force
Copy-Item -LiteralPath (Join-Path $serverRoot 'LICENSE.md') -Destination (Join-Path $staging 'LICENSES\Bell-server-LICENSE.md')
Copy-Item -LiteralPath (Join-Path $uiRoot 'LICENSE') -Destination (Join-Path $staging 'LICENSES\Bell-ui-LICENSE')
$commit = (& git -C $repositoryRoot 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 'Bell package audit failed.' }
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { "$(Get-FileSha256 $file.FullName) $($file.FullName.Substring($staging.Length+1).Replace('\','/'))" }
[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 "Bell Windows package: $target"
Write-Host "Bell Windows archive: $archive"
} finally {
$env:GOTOOLCHAIN = $savedToolchain
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
}
+34
View File
@@ -0,0 +1,34 @@
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 = @(
'bell.exe','start-bell.bat','stop-bell.bat','check-bell.bat','README-WINDOWS.md',
'config\bell.env','config\bell.env.example','config\settings.yml','config\db.sql','config\pg.sql','web\index.html',
'scripts\runtime\bell-common.ps1','scripts\runtime\bell-web.ps1',
'LICENSES\SOURCES.md','LICENSES\go-admin-LICENSE.md','LICENSES\go-admin-ui-LICENSE',
'VERSION.txt'
)
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)" }
$config = Get-Content -LiteralPath (Join-Path $root 'config\bell.env') -Raw -Encoding UTF8
foreach ($secret in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD')) {
if ($config -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty credential field: $secret" }
}
$sources = Get-Content -LiteralPath (Join-Path $root 'LICENSES\SOURCES.md') -Raw -Encoding UTF8
foreach ($commit in @('f06540883b41d03782bb6b2c4150f298f328c6b6','67d393d713877572fab0b897296a4c1d525fc81d','424855aacf6905f3fde860c3331385cb25529a0d')) {
if (-not $sources.Contains($commit)) { throw "Package source evidence is missing commit $commit" }
}
$version = Get-Content -LiteralPath (Join-Path $root 'VERSION.txt') -Raw -Encoding UTF8
foreach ($entry in @('go=1.26.5','node=22.22.1','pnpm=9.15.1')) { if (-not $version.Contains($entry)) { throw "Package version evidence is missing $entry" } }
$textExtensions = @('.md','.txt','.env','.example','.ps1','.bat','.yml','.yaml','.json','.html','.js','.css')
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
$content = [string](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 "Bell package audit passed: $root"
+117
View File
@@ -0,0 +1,117 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$script:BellAllowedEnvironment = @(
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
'BELL_SYNTHETIC_EVENTS_ENABLED'
)
function Get-BellPackageRoot {
param([string]$ScriptDirectory = $PSScriptRoot)
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
}
function Import-BellEnvironment {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell 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 Bell configuration at line $lineNumber. Expected NAME=value." }
$name = $line.Substring(0, $separator).Trim()
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell 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) }
}
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Get-BellEnvironmentValue {
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function Test-BellTcpEndpoint {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
$client = [Net.Sockets.TcpClient]::new()
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
}
function Test-BellListenPortAvailable {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
}
function Get-BellDatabaseEndpoint {
param([Parameter(Mandatory = $true)][string]$Connection)
if ($Connection -match '^postgres(?:ql)?://') {
$uri = [Uri]$Connection
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
}
$values = @{}
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
$value = $match.Groups['value'].Value.Trim("'", '"')
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
}
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
}
function Get-BellPort {
param([string]$Name, [int]$Default)
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
$port = 0
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
return $port
}
function Initialize-BellRuntime {
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
if (-not $AllowOccupiedPorts) {
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
}
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
$webRoot = Join-Path $PackageRoot 'web'
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
return [pscustomobject]@{
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
}
}
function Wait-BellHealth {
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {}
Start-Sleep -Milliseconds 300
}
throw "Bell health check timed out: $BaseUrl/healthz"
}
+72
View File
@@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)][string]$WebRoot,
[Parameter(Mandatory = $true)][string]$ListenHost,
[Parameter(Mandatory = $true)][int]$ListenPort,
[Parameter(Mandatory = $true)][string]$BackendUrl
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
$handler = [Net.Http.HttpClientHandler]::new()
$handler.UseProxy = $false
$client = [Net.Http.HttpClient]::new($handler)
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
try {
$listener.Start()
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
while ($listener.IsListening) {
$context = $listener.GetContext()
try {
$request = $context.Request
$response = $context.Response
$path = $request.Url.AbsolutePath
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
$target = "$BackendUrl$($request.Url.PathAndQuery)"
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
if ($request.HasEntityBody) {
$memory = [IO.MemoryStream]::new()
$request.InputStream.CopyTo($memory)
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
$memory.Dispose()
}
foreach ($key in $request.Headers.AllKeys) {
if ($key -in @('Host','Content-Length')) { continue }
$values = $request.Headers.GetValues($key)
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
}
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$response.StatusCode = [int]$upstream.StatusCode
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$message.Dispose(); $upstream.Dispose()
} else {
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
$file = Join-Path $root 'index.html'
}
if ($response.StatusCode -ne 403) {
$bytes = [IO.File]::ReadAllBytes($file)
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
}
}
} catch {
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
} finally {
try { $context.Response.OutputStream.Close() } catch {}
}
}
} finally {
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
exit /b %errorlevel%
+12
View File
@@ -0,0 +1,12 @@
param([switch]$Running)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
if ($Running) {
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
}
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
exit /b %errorlevel%
+41
View File
@@ -0,0 +1,41 @@
param([switch]$SkipMigration)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
$backend = $null
$pidFile = $null
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root
$bell = Join-Path $root 'bell.exe'
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
$runtime = Join-Path $root 'runtime'
$logs = Join-Path $runtime 'logs'
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
$pidFile = Join-Path $runtime 'bell.pid'
if (Test-Path -LiteralPath $pidFile) {
$oldPid = 0
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
Remove-Item -LiteralPath $pidFile -Force
}
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
Push-Location $root
try {
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
Write-Host 'Applying pending Bell database migrations...'
& $bell migrate -c $state.SettingsPath
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
}
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
Wait-BellHealth -BaseUrl $state.BackendUrl
Write-Host "Bell is available at $($state.WebUrl)/"
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
} finally { Pop-Location }
} catch {
Write-Error $_.Exception.Message
exit 1
} finally {
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
exit /b %errorlevel%
+17
View File
@@ -0,0 +1,17 @@
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$pidFile = Join-Path $root 'runtime\bell.pid'
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
$processId = 0
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
$rootPattern = [regex]::Escape($root)
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
& taskkill.exe /PID $processId /T /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
Write-Host 'Bell backend and web process tree stopped.'
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+7
View File
@@ -0,0 +1,7 @@
param([string]$PostgresBin='D:\pgsql17\bin',[string]$PreparedPackageRoot='',[switch]$KeepTemporary,[switch]$BrowserHold)
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot '..\tests\e2e\run-isolated-e2e.ps1'),'-PostgresBin',$PostgresBin)
if(-not[string]::IsNullOrWhiteSpace($PreparedPackageRoot)){$arguments+=@('-PreparedPackageRoot',$PreparedPackageRoot)}
if($KeepTemporary){$arguments+='-KeepTemporary'}
if($BrowserHold){$arguments+='-BrowserHold'}
& pwsh.exe @arguments
exit $LASTEXITCODE
+1 -2
View File
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, answer, err := captcha.DriverDigitFunc()
id, b64s, _, err := captcha.DriverDigitFunc()
if err != nil {
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
e.Error(500, err, "验证码获取失败")
return
}
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
e.Custom(gin.H{
"code": 200,
"data": b64s,
@@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
}
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
systemAPI := apis.System{}
v1.GET("/captcha", systemAPI.GenerateCaptchaHandler)
api := apis.SysMenu{}
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
+86
View File
@@ -0,0 +1,86 @@
package alert
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"go-admin/app/bell/evaluation"
)
type Handler struct{ api.Api }
func (h Handler) List(c *gin.Context) {
var query PageQuery
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
if h.Errors != nil {
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
return
}
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
if err != nil {
h.Logger.Errorf("list Bell alerts failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
return
}
page, size := pageValues(query.PageIndex, query.PageSize)
h.PageOK(items, int(count), page, size, "查询成功")
}
func (h Handler) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, ErrNotFound) {
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
return
}
h.Logger.Errorf("get Bell alert failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
return
}
h.OK(detail, "查询成功")
}
func (h Handler) ListEvents(c *gin.Context) {
var query EventPageQuery
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
if h.Errors != nil {
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
return
}
items, count, err := NewService(h.Orm).ListEvents(c.Request.Context(), query)
if err != nil {
h.Logger.Errorf("list Bell events failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
return
}
page, size := pageValues(query.PageIndex, query.PageSize)
h.PageOK(items, int(count), page, size, "查询成功")
}
func (h Handler) EventResults(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
result, err := evaluation.NewService(h.Orm).ForEvent(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, evaluation.ErrEventNotFound) {
h.Error(http.StatusNotFound, evaluation.ErrEventNotFound, evaluation.ErrEventNotFound.Error())
return
}
h.Logger.Errorf("get Bell event rule results failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取规则评估失败"), "读取规则评估失败")
return
}
h.OK(result, "查询成功")
}
+40
View File
@@ -0,0 +1,40 @@
package alert
import (
"encoding/json"
"time"
)
type Alert struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
PrimaryRuleID string `json:"primaryRuleId" gorm:"type:uuid;not null;index"`
CorrelationKey string `json:"-" gorm:"size:384;not null"`
Status string `json:"status" gorm:"size:24;not null;default:open;index"`
Severity string `json:"severity" gorm:"size:16;not null;index"`
Summary string `json:"summary" gorm:"size:256;not null"`
Location string `json:"location" gorm:"size:256;not null;index"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
}
func (Alert) TableName() string { return "bell_alerts" }
type AlertEvent struct {
AlertID string `json:"alertId" gorm:"type:uuid;primaryKey"`
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
LinkedAt time.Time `json:"linkedAt" gorm:"type:timestamptz;not null"`
}
func (AlertEvent) TableName() string { return "bell_alert_events" }
type RuleMatch struct {
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
AlertID string `json:"alertId" gorm:"type:uuid;not null;index"`
RuleVersion int `json:"ruleVersion" gorm:"not null"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
Explanation string `json:"explanation" gorm:"size:512;not null"`
MatchedAt time.Time `json:"matchedAt" gorm:"type:timestamptz;not null"`
}
func (RuleMatch) TableName() string { return "bell_rule_matches" }
+136
View File
@@ -0,0 +1,136 @@
package alert
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/bell/event"
)
var ErrNotFound = errors.New("预警不存在")
type PageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
Status string `form:"status"`
Severity string `form:"severity"`
Location string `form:"location"`
}
type Summary struct {
Alert
RuleName string `json:"ruleName"`
EventCount int64 `json:"eventCount"`
}
type LinkedEvent struct {
event.Event
LinkedAt time.Time `json:"linkedAt"`
}
type Detail struct {
Alert Summary `json:"alert"`
Events []LinkedEvent `json:"events"`
Matches []RuleMatch `json:"matches"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) List(ctx context.Context, query PageQuery) ([]Summary, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
base := s.DB.WithContext(ctx).Table("bell_alerts a")
if query.Status = strings.TrimSpace(query.Status); query.Status != "" {
base = base.Where("a.status = ?", query.Status)
}
if query.Severity = strings.TrimSpace(query.Severity); query.Severity != "" {
base = base.Where("a.severity = ?", query.Severity)
}
if query.Location = strings.TrimSpace(query.Location); query.Location != "" {
base = base.Where("a.location ILIKE ?", "%"+query.Location+"%")
}
var count int64
if err := base.Count(&count).Error; err != nil {
return nil, 0, err
}
items := make([]Summary, 0)
err := base.Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").
Order("a.created_at DESC, a.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
return items, count, err
}
func (s Service) Get(ctx context.Context, id string) (Detail, error) {
if _, err := uuid.Parse(id); err != nil {
return Detail{}, ErrNotFound
}
detail := Detail{Events: make([]LinkedEvent, 0), Matches: make([]RuleMatch, 0)}
db := s.DB.WithContext(ctx)
err := db.Table("bell_alerts a").
Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").Where("a.id = ?", id).Take(&detail.Alert).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, err
}
if err = db.Table("bell_events e").Select("e.*, ae.linked_at").
Joins("JOIN bell_alert_events ae ON ae.event_id = e.id").
Where("ae.alert_id = ?", id).Order("e.occurred_at, e.id").Scan(&detail.Events).Error; err != nil {
return Detail{}, err
}
err = db.Where("alert_id = ?", id).Order("matched_at, event_id, rule_id").Find(&detail.Matches).Error
return detail, err
}
type EventPageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
EventType string `form:"eventType"`
Severity string `form:"severity"`
Location string `form:"location"`
}
type EventSummary struct {
event.Event
AlertCount int64 `json:"alertCount"`
}
func (s Service) ListEvents(ctx context.Context, query EventPageQuery) ([]EventSummary, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
db := s.DB.WithContext(ctx).Model(&event.Event{})
if value := strings.TrimSpace(query.EventType); value != "" {
db = db.Where("event_type ILIKE ?", "%"+value+"%")
}
if value := strings.TrimSpace(query.Severity); value != "" {
db = db.Where("severity = ?", value)
}
if value := strings.TrimSpace(query.Location); value != "" {
db = db.Where("location ILIKE ?", "%"+value+"%")
}
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, 0, err
}
items := make([]EventSummary, 0)
err := db.Select("bell_events.*, (SELECT count(*) FROM bell_alert_events ae WHERE ae.event_id = bell_events.id) AS alert_count").
Order("occurred_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
return items, count, err
}
func pageValues(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
@@ -0,0 +1,83 @@
package alert_lifecycle
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
)
type Handler struct{ api.Api }
func (h Handler) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err == nil {
current := actor(c)
detail.CanAck = detail.Projection.Status == StatusOpen && (current.Role == "admin" || current.Role == "operator")
detail.CanClose = detail.Projection.Status == StatusAcknowledged && (current.Role == "admin" || (detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == current.ID))
}
h.respond(c, Result{Detail: detail}, err)
}
func (h Handler) Ack(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
result, err := NewService(h.Orm).Ack(c.Request.Context(), c.Param("id"), actor(c))
h.respond(c, result, err)
}
func (h Handler) Close(c *gin.Context) {
if err := restoreCloseBody(c); err != nil {
h.MakeContext(c).Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
return
}
var input CloseInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
return
}
result, err := NewService(h.Orm).Close(c.Request.Context(), c.Param("id"), input, actor(c))
h.respond(c, result, err)
}
func (h Handler) respond(c *gin.Context, result Result, err error) {
if err == nil {
h.OK(result, "操作成功")
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
return
}
code := http.StatusInternalServerError
switch {
case errors.Is(err, ErrNotFound):
code = http.StatusNotFound
case errors.Is(err, ErrOutcomeRequired):
code = http.StatusBadRequest
case errors.Is(err, ErrAlreadyHandled), errors.Is(err, ErrInvalidTransition):
code = http.StatusConflict
case errors.Is(err, ErrForbidden):
code = http.StatusForbidden
default:
h.Logger.Errorf("Bell alert lifecycle failed: %v", err)
}
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "data": result})
c.Set("result", gin.H{"code": code, "data": "<redacted>"})
}
func actor(c *gin.Context) Actor {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return Actor{ID: user.GetUserId(c), Name: user.GetUserName(c), Role: role}
}
@@ -0,0 +1,50 @@
package alert_lifecycle
import "time"
const (
StatusOpen = "open"
StatusAcknowledged = "acknowledged"
StatusClosed = "closed"
)
type Projection struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Status string `json:"status"`
AcknowledgedBy *int `json:"acknowledgedBy,omitempty"`
AcknowledgedByName *string `json:"acknowledgedByName,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
ClosedBy *int `json:"closedBy,omitempty"`
ClosedByName *string `json:"closedByName,omitempty"`
ClosedAt *time.Time `json:"closedAt,omitempty"`
CloseOutcome *string `json:"closeOutcome,omitempty"`
CloseNote *string `json:"closeNote,omitempty"`
}
func (Projection) TableName() string { return "bell_alerts" }
type Fact struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
AlertID string `json:"alertId" gorm:"type:uuid;not null;uniqueIndex:bell_alert_transition"`
Transition string `json:"transition" gorm:"size:24;not null;uniqueIndex:bell_alert_transition"`
ActorID int `json:"actorId" gorm:"not null"`
ActorName string `json:"actorName" gorm:"size:128;not null"`
Outcome *string `json:"outcome,omitempty" gorm:"size:32"`
Note *string `json:"note,omitempty" gorm:"size:500"`
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
}
func (Fact) TableName() string { return "bell_alert_lifecycle_facts" }
type RejectionAudit struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
AlertID *string `json:"alertId,omitempty" gorm:"type:uuid;index"`
Action string `json:"action" gorm:"size:16;not null"`
ActorID int `json:"actorId" gorm:"not null;index"`
Reason string `json:"reason" gorm:"size:64;not null"`
ObservedStatus *string `json:"observedStatus,omitempty" gorm:"size:24"`
ObservedActor *int `json:"observedActor,omitempty"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
}
func (RejectionAudit) TableName() string { return "bell_alert_lifecycle_rejections" }
@@ -0,0 +1,47 @@
package alert_lifecycle
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const maxCloseRequestBytes = 8 * 1024
const closeBodyKey = "bell.lifecycle.close-body"
const closeBodyErrorKey = "bell.lifecycle.close-body-error"
func RedactRequestBody() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost || !strings.HasPrefix(c.Request.URL.Path, "/api/v1/bell/alerts/") || !strings.HasSuffix(c.Request.URL.Path, "/close") {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxCloseRequestBytes+1))
if err != nil {
c.Set(closeBodyErrorKey, err)
} else if len(body) > maxCloseRequestBytes {
c.Set(closeBodyErrorKey, errors.New("request body too large"))
} else {
c.Set(closeBodyKey, body)
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader([]byte(`{"redacted":true}`)))
c.Next()
}
}
func restoreCloseBody(c *gin.Context) error {
if value, ok := c.Get(closeBodyErrorKey); ok {
return value.(error)
}
value, ok := c.Get(closeBodyKey)
if !ok {
return errors.New("close request body was not captured")
}
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
return nil
}
@@ -0,0 +1,165 @@
package alert_lifecycle
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Actor struct {
ID int
Name, Role string
}
type Detail struct {
Projection Projection `json:"projection"`
Timeline []Fact `json:"timeline"`
CanAck bool `json:"canAck"`
CanClose bool `json:"canClose"`
}
type Result struct {
Detail Detail `json:"detail"`
Idempotent bool `json:"idempotent"`
Won bool `json:"won"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) Get(ctx context.Context, alertID string) (Detail, error) {
if _, err := uuid.Parse(alertID); err != nil {
return Detail{}, ErrNotFound
}
var projection Projection
if err := s.DB.WithContext(ctx).First(&projection, "id = ?", alertID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Detail{}, ErrNotFound
}
return Detail{}, err
}
facts := make([]Fact, 0)
if err := s.DB.WithContext(ctx).Where("alert_id = ?", alertID).Order("occurred_at, id").Find(&facts).Error; err != nil {
return Detail{}, err
}
return Detail{Projection: projection, Timeline: facts}, nil
}
func (s Service) Ack(ctx context.Context, alertID string, actor Actor) (Result, error) {
if _, err := uuid.Parse(alertID); err != nil {
s.reject(ctx, nil, "ack", actor.ID, "not_found", nil)
return Result{}, ErrNotFound
}
now := time.Now().UTC()
var projection Projection
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Raw(`UPDATE bell_alerts SET status='acknowledged', acknowledged_by=?, acknowledged_by_name=?, acknowledged_at=?, updated_at=? WHERE id=? AND status='open' RETURNING id,status,acknowledged_by,acknowledged_by_name,acknowledged_at,closed_by,closed_by_name,closed_at,close_outcome,close_note`, actor.ID, actor.Name, now, now, alertID).Scan(&projection)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusAcknowledged, ActorID: actor.ID, ActorName: actor.Name, OccurredAt: now}).Error
})
if err == nil {
detail, getErr := s.Get(ctx, alertID)
return Result{Detail: detail, Won: true}, getErr
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
detail, getErr := s.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == actor.ID {
s.reject(ctx, &alertID, "ack", actor.ID, "duplicate", &detail.Projection)
return Result{Detail: detail, Idempotent: true}, nil
}
s.reject(ctx, &alertID, "ack", actor.ID, "already_handled", &detail.Projection)
return Result{Detail: detail}, ErrAlreadyHandled
}
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor Actor) (Result, error) {
normalized, err := normalizeClose(input)
if err != nil {
s.reject(ctx, validAlertID(alertID), "close", actor.ID, "invalid_outcome", nil)
return Result{}, err
}
if _, err = uuid.Parse(alertID); err != nil {
s.reject(ctx, nil, "close", actor.ID, "not_found", nil)
return Result{}, ErrNotFound
}
var projection Projection
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if lockErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&projection, "id = ?", alertID).Error; lockErr != nil {
return lockErr
}
if projection.Status == StatusClosed {
return ErrInvalidTransition
}
if projection.Status != StatusAcknowledged {
return ErrInvalidTransition
}
if actor.Role != "admin" && (projection.AcknowledgedBy == nil || *projection.AcknowledgedBy != actor.ID) {
return ErrForbidden
}
now := time.Now().UTC()
var note *string
if normalized.Note != "" {
note = &normalized.Note
}
if updateErr := tx.Model(&projection).Updates(map[string]any{"status": StatusClosed, "closed_by": actor.ID, "closed_by_name": actor.Name, "closed_at": now, "close_outcome": normalized.Outcome, "close_note": note, "updated_at": now}).Error; updateErr != nil {
return updateErr
}
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusClosed, ActorID: actor.ID, ActorName: actor.Name, Outcome: &normalized.Outcome, Note: note, OccurredAt: now}).Error
})
if err == nil {
detail, getErr := s.Get(ctx, alertID)
return Result{Detail: detail, Won: true}, getErr
}
if !errors.Is(err, ErrInvalidTransition) && !errors.Is(err, ErrForbidden) && !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
detail, getErr := s.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Projection.Status == StatusClosed && detail.Projection.ClosedBy != nil && *detail.Projection.ClosedBy == actor.ID && detail.Projection.CloseOutcome != nil && *detail.Projection.CloseOutcome == normalized.Outcome && equalOptional(detail.Projection.CloseNote, normalized.Note) {
s.reject(ctx, &alertID, "close", actor.ID, "duplicate", &detail.Projection)
return Result{Detail: detail, Idempotent: true}, nil
}
reason := "invalid_transition"
publicErr := ErrInvalidTransition
if errors.Is(err, ErrForbidden) {
reason, publicErr = "forbidden", ErrForbidden
} else if detail.Projection.Status == StatusClosed {
reason = "conflicting_replay"
}
s.reject(ctx, &alertID, "close", actor.ID, reason, &detail.Projection)
return Result{Detail: detail}, publicErr
}
func (s Service) reject(ctx context.Context, alertID *string, action string, actorID int, reason string, projection *Projection) {
audit := RejectionAudit{ID: uuid.NewString(), AlertID: alertID, Action: action, ActorID: actorID, Reason: reason, CreatedAt: time.Now().UTC()}
if projection != nil {
audit.ObservedStatus = &projection.Status
audit.ObservedActor = projection.AcknowledgedBy
}
_ = s.DB.WithContext(ctx).Create(&audit).Error
}
func validAlertID(value string) *string {
if _, err := uuid.Parse(value); err != nil {
return nil
}
return &value
}
func equalOptional(value *string, other string) bool {
if value == nil {
return other == ""
}
return *value == other
}
@@ -0,0 +1,34 @@
package alert_lifecycle
import (
"errors"
"strings"
"unicode/utf8"
)
var (
ErrNotFound = errors.New("预警不存在")
ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
ErrInvalidTransition = errors.New("当前状态不能执行此操作")
ErrOutcomeRequired = errors.New("请选择有效的现场结果")
ErrForbidden = errors.New("您无权完成此预警")
)
type CloseInput struct {
Outcome string `json:"outcome"`
Note string `json:"note"`
}
func normalizeClose(input CloseInput) (CloseInput, error) {
input.Outcome = strings.TrimSpace(input.Outcome)
input.Note = strings.TrimSpace(input.Note)
switch input.Outcome {
case "danger_confirmed", "false_positive", "site_normal", "unable_to_confirm":
default:
return CloseInput{}, ErrOutcomeRequired
}
if !utf8.ValidString(input.Note) || utf8.RuneCountInString(input.Note) > 500 || strings.ContainsAny(input.Note, "\x00\r") {
return CloseInput{}, ErrOutcomeRequired
}
return input, nil
}
+20
View File
@@ -0,0 +1,20 @@
package evaluation
import (
"encoding/json"
"time"
)
// Evaluation is an immutable explanation of one rule version evaluated
// against one Event.
type Evaluation struct {
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
RuleVersion int `json:"ruleVersion" gorm:"not null"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
Matched bool `json:"matched" gorm:"not null"`
Explanation string `json:"explanation" gorm:"size:512;not null"`
EvaluatedAt time.Time `json:"evaluatedAt" gorm:"type:timestamptz;not null"`
}
func (Evaluation) TableName() string { return "bell_rule_evaluations" }
@@ -0,0 +1,50 @@
package evaluation
import (
"context"
"errors"
"github.com/google/uuid"
"gorm.io/gorm"
)
var ErrEventNotFound = errors.New("事件不存在")
type EventResults struct {
Evaluations []Evaluation `json:"evaluations"`
Alerts []AlertLink `json:"alerts"`
}
type AlertLink struct {
ID string `json:"id"`
Summary string `json:"summary"`
Status string `json:"status"`
Severity string `json:"severity"`
Location string `json:"location"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) ForEvent(ctx context.Context, eventID string) (EventResults, error) {
if _, err := uuid.Parse(eventID); err != nil {
return EventResults{}, ErrEventNotFound
}
var count int64
if err := s.DB.WithContext(ctx).Table("bell_events").Where("id = ?", eventID).Count(&count).Error; err != nil {
return EventResults{}, err
}
if count == 0 {
return EventResults{}, ErrEventNotFound
}
result := EventResults{Evaluations: make([]Evaluation, 0), Alerts: make([]AlertLink, 0)}
if err := s.DB.WithContext(ctx).Where("event_id = ?", eventID).Order("evaluated_at, rule_id").Find(&result.Evaluations).Error; err != nil {
return EventResults{}, err
}
err := s.DB.WithContext(ctx).Table("bell_alerts a").
Select("a.id, a.summary, a.status, a.severity, a.location").
Joins("JOIN bell_alert_events ae ON ae.alert_id = a.id").
Where("ae.event_id = ?", eventID).Order("a.created_at, a.id").Scan(&result.Alerts).Error
return result, err
}
@@ -0,0 +1,21 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/alert_lifecycle"
"go-admin/common/middleware"
)
func init() { registrars = append(registrars, registerAlertLifecycleRouter) }
func registerAlertLifecycleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := alert_lifecycle.Handler{}
routes := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
routes.GET("/alerts/:id/lifecycle", handler.Get)
routes.POST("/alerts/:id/ack", handler.Ack)
routes.POST("/alerts/:id/close", handler.Close)
}
}
+31
View File
@@ -0,0 +1,31 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/alert"
"go-admin/app/bell/rule"
"go-admin/common/middleware"
)
func init() {
registrars = append(registrars, registerRuleAlertRouter)
}
func registerRuleAlertRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
rules := rule.Handler{}
alerts := alert.Handler{}
secured := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
secured.GET("/rules", rules.List)
secured.POST("/rules", rules.Create)
secured.PUT("/rules/:id", rules.Update)
secured.PUT("/rules/:id/enabled", rules.SetEnabled)
secured.GET("/alerts", alerts.List)
secured.GET("/alerts/:id", alerts.Get)
secured.GET("/events", alerts.ListEvents)
secured.GET("/events/:id/rule-results", alerts.EventResults)
}
}
+100
View File
@@ -0,0 +1,100 @@
package rule
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
)
type Handler struct{ api.Api }
type enabledInput struct {
Enabled *bool `json:"enabled" binding:"required"`
}
func (h Handler) List(c *gin.Context) {
var query PageQuery
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrInvalid, "查询条件不正确")
return
}
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
if err != nil {
h.Logger.Errorf("list Bell rules failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取规则失败"), "读取规则失败")
return
}
page, size := pageValues(query.PageIndex, query.PageSize)
h.PageOK(items, int(count), page, size, "查询成功")
}
func (h Handler) Create(c *gin.Context) {
if !isAdmin(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
return
}
var input WriteInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm).Create(c.Request.Context(), input, user.GetUserId(c))
h.writeResult(item, err)
}
func (h Handler) Update(c *gin.Context) {
if !isAdmin(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
return
}
var input WriteInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.writeResult(item, err)
}
func (h Handler) SetEnabled(c *gin.Context) {
if !isAdmin(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
return
}
var input enabledInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil || input.Enabled == nil {
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, user.GetUserId(c))
h.writeResult(item, err)
}
func (h Handler) writeResult(item Rule, err error) {
switch {
case err == nil:
h.OK(item, "保存成功")
case errors.Is(err, ErrInvalid):
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
case errors.Is(err, ErrNotFound):
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
default:
h.Logger.Errorf("write Bell rule failed: %v", err)
h.Error(http.StatusConflict, errors.New("规则编码已存在或保存失败"), "规则编码已存在或保存失败")
}
}
func isAdmin(c *gin.Context) bool {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return role == "admin"
}
+22
View File
@@ -0,0 +1,22 @@
package rule
import "time"
// Rule is the current editable rule definition. Historical evaluations keep a
// complete versioned snapshot, so editing this row never rewrites history.
type Rule struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Code string `json:"code" gorm:"size:128;not null;uniqueIndex"`
Name string `json:"name" gorm:"size:128;not null"`
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
EventType *string `json:"eventType,omitempty" gorm:"size:128"`
MinimumSeverity string `json:"minimumSeverity" gorm:"size:16;not null"`
LocationContains *string `json:"locationContains,omitempty" gorm:"size:128"`
Version int `json:"version" gorm:"not null;default:1"`
CreatedBy int `json:"createdBy" gorm:"not null"`
UpdatedBy int `json:"updatedBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
}
func (Rule) TableName() string { return "bell_rules" }
+124
View File
@@ -0,0 +1,124 @@
package rule
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
Name string `form:"name"`
Enabled *bool `form:"enabled"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) List(ctx context.Context, query PageQuery) ([]Rule, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
db := s.DB.WithContext(ctx).Model(&Rule{})
if name := strings.TrimSpace(query.Name); name != "" {
db = db.Where("name ILIKE ? OR code ILIKE ?", "%"+name+"%", "%"+name+"%")
}
if query.Enabled != nil {
db = db.Where("enabled = ?", *query.Enabled)
}
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, 0, err
}
items := make([]Rule, 0)
err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
return items, count, err
}
func (s Service) Create(ctx context.Context, input WriteInput, actorID int) (Rule, error) {
normalized, err := Normalize(input, true)
if err != nil {
return Rule{}, err
}
now := time.Now().UTC()
item := Rule{
ID: uuid.NewString(), Code: normalized.Code, Name: normalized.Name, Enabled: true,
EventType: normalized.EventType, MinimumSeverity: normalized.MinimumSeverity,
LocationContains: normalized.LocationContains, Version: 1,
CreatedBy: actorID, UpdatedBy: actorID, CreatedAt: now, UpdatedAt: now,
}
if err = s.DB.WithContext(ctx).Create(&item).Error; err != nil {
return Rule{}, err
}
return item, nil
}
func (s Service) Update(ctx context.Context, id string, input WriteInput, actorID int) (Rule, error) {
if _, err := uuid.Parse(id); err != nil {
return Rule{}, ErrNotFound
}
normalized, err := Normalize(input, false)
if err != nil {
return Rule{}, err
}
var item Rule
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
updates := map[string]any{
"name": normalized.Name, "event_type": normalized.EventType,
"minimum_severity": normalized.MinimumSeverity, "location_contains": normalized.LocationContains,
"version": item.Version + 1, "updated_by": actorID, "updated_at": time.Now().UTC(),
}
if err := tx.Model(&item).Updates(updates).Error; err != nil {
return err
}
return tx.First(&item, "id = ?", id).Error
})
return item, err
}
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, actorID int) (Rule, error) {
if _, err := uuid.Parse(id); err != nil {
return Rule{}, ErrNotFound
}
var item Rule
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if item.Enabled == enabled {
return nil
}
if err := tx.Model(&item).Updates(map[string]any{
"enabled": enabled, "version": item.Version + 1,
"updated_by": actorID, "updated_at": time.Now().UTC(),
}).Error; err != nil {
return err
}
return tx.First(&item, "id = ?", id).Error
})
return item, err
}
func pageValues(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
+70
View File
@@ -0,0 +1,70 @@
package rule
import (
"errors"
"regexp"
"strings"
"unicode/utf8"
)
var (
ErrInvalid = errors.New("规则内容不符合要求")
ErrNotFound = errors.New("规则不存在")
codePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,127}$`)
)
type WriteInput struct {
Code string `json:"code"`
Name string `json:"name"`
EventType *string `json:"eventType"`
MinimumSeverity string `json:"minimumSeverity"`
LocationContains *string `json:"locationContains"`
}
func Normalize(input WriteInput, requireCode bool) (WriteInput, error) {
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
if requireCode && !codePattern.MatchString(input.Code) {
return WriteInput{}, ErrInvalid
}
if !validText(input.Name, 128) || !validSeverity(input.MinimumSeverity) {
return WriteInput{}, ErrInvalid
}
var err error
if input.EventType, err = optionalText(input.EventType, 128); err != nil {
return WriteInput{}, err
}
if input.LocationContains, err = optionalText(input.LocationContains, 128); err != nil {
return WriteInput{}, err
}
return input, nil
}
func validSeverity(value string) bool {
switch value {
case "low", "medium", "high", "critical":
return true
default:
return false
}
}
func optionalText(value *string, max int) (*string, error) {
if value == nil {
return nil, nil
}
normalized := strings.TrimSpace(*value)
if normalized == "" {
return nil, nil
}
if !validText(normalized, max) {
return nil, ErrInvalid
}
return &normalized, nil
}
func validText(value string, max int) bool {
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
!strings.ContainsAny(value, "\x00\r\n")
}
+3 -1
View File
@@ -20,6 +20,7 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
"go-admin/app/bell/alert_lifecycle"
bellrouter "go-admin/app/bell/router"
"go-admin/app/bell/synthetic"
"go-admin/common/bellconfig"
@@ -182,7 +183,8 @@ func initRouter() {
r.Use(common.Sentinel()).
Use(common.RequestId(pkg.TrafficKey)).
Use(api.SetRequestLogger).
Use(synthetic.RedactRequestBody())
Use(synthetic.RedactRequestBody()).
Use(alert_lifecycle.RedactRequestBody())
common.InitMiddleware(r)
@@ -0,0 +1,257 @@
package version_local
import (
"fmt"
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/alert"
"go-admin/app/bell/evaluation"
"go-admin/app/bell/rule"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellRuleAlert)
}
func migrateBellRuleAlert(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(rule.Rule), new(evaluation.Evaluation), new(alert.Alert), new(alert.AlertEvent), new(alert.RuleMatch), new(runtimeCasbinRule)); err != nil {
return err
}
for _, statement := range bellRuleAlertSchemaSQL {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
if err := seedBellRuleAlertAccess(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
var bellRuleAlertSchemaSQL = []string{
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_severity_check CHECK (minimum_severity IN ('low','medium','high','critical'))`,
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_version_check CHECK (version > 0)`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_status_check CHECK (status IN ('open','acknowledged','closed'))`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_rule_fk FOREIGN KEY (primary_rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id, correlation_key) WHERE status = 'open'`,
`CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id, alert_id)`,
`CREATE TRIGGER bell_rule_evaluations_immutable BEFORE UPDATE OR DELETE ON bell_rule_evaluations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_alert_events_immutable BEFORE UPDATE OR DELETE ON bell_alert_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_rule_matches_immutable BEFORE UPDATE OR DELETE ON bell_rule_matches FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE OR REPLACE FUNCTION bell_evaluate_new_event() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
current_rule bell_rules%ROWTYPE;
is_match boolean;
reasons text[];
explanation_text text;
snapshot jsonb;
target_alert_id uuid;
correlation text;
BEGIN
FOR current_rule IN SELECT * FROM bell_rules WHERE enabled = true ORDER BY id LOOP
reasons := ARRAY[]::text[];
IF current_rule.event_type IS NOT NULL AND current_rule.event_type <> NEW.event_type THEN
reasons := array_append(reasons, '事件类型不匹配');
END IF;
IF array_position(ARRAY['low','medium','high','critical'], NEW.severity) <
array_position(ARRAY['low','medium','high','critical'], current_rule.minimum_severity) THEN
reasons := array_append(reasons, '风险等级低于阈值');
END IF;
IF current_rule.location_contains IS NOT NULL AND
position(lower(current_rule.location_contains) in lower(NEW.location)) = 0 THEN
reasons := array_append(reasons, '地点条件不匹配');
END IF;
is_match := cardinality(reasons) = 0;
explanation_text := CASE WHEN is_match THEN '全部条件命中' ELSE array_to_string(reasons, ';') END;
snapshot := jsonb_build_object(
'id', current_rule.id, 'code', current_rule.code, 'name', current_rule.name,
'enabled', current_rule.enabled, 'eventType', current_rule.event_type,
'minimumSeverity', current_rule.minimum_severity,
'locationContains', current_rule.location_contains, 'version', current_rule.version
);
INSERT INTO bell_rule_evaluations(event_id, rule_id, rule_version, rule_snapshot, matched, explanation, evaluated_at)
VALUES(NEW.id, current_rule.id, current_rule.version, snapshot, is_match, explanation_text, now());
IF NOT is_match THEN
CONTINUE;
END IF;
correlation := lower(trim(NEW.location));
INSERT INTO bell_alerts(id, primary_rule_id, correlation_key, status, severity, summary, location, created_at, updated_at)
VALUES(gen_random_uuid(), current_rule.id, correlation, 'open', NEW.severity,
left(current_rule.name || ':' || NEW.event_type, 256), NEW.location, now(), now())
ON CONFLICT(primary_rule_id, correlation_key) WHERE status = 'open'
DO UPDATE SET
updated_at = now(),
severity = CASE
WHEN array_position(ARRAY['low','medium','high','critical'], EXCLUDED.severity) >
array_position(ARRAY['low','medium','high','critical'], bell_alerts.severity)
THEN EXCLUDED.severity ELSE bell_alerts.severity END
RETURNING id INTO target_alert_id;
INSERT INTO bell_alert_events(alert_id, event_id, linked_at)
VALUES(target_alert_id, NEW.id, now());
INSERT INTO bell_rule_matches(event_id, rule_id, alert_id, rule_version, rule_snapshot, explanation, matched_at)
VALUES(NEW.id, current_rule.id, target_alert_id, current_rule.version, snapshot, explanation_text, now());
END LOOP;
RETURN NEW;
END $$`,
`CREATE TRIGGER bell_events_evaluate_rules AFTER INSERT ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_evaluate_new_event()`,
}
type menuSeed struct {
ID int
Permission string
}
type apiSeed struct {
ID int
Path string
Action string
}
// runtimeCasbinRule deliberately matches the table used by the frozen
// go-admin-core gorm adapter. The legacy SysCasbinRule model is not the table
// loaded by middleware.AuthCheckRole in this baseline.
type runtimeCasbinRule struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Ptype string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V0 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V1 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V2 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V3 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V4 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V5 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
}
func (runtimeCasbinRule) TableName() string { return "casbin_rule" }
func seedBellRuleAlertAccess(tx *gorm.DB) error {
// db.sql contains explicit primary keys, so PostgreSQL sequences can lag
// behind the imported baseline data. Align them before allocating any new
// menu, API or role IDs.
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1));
SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1));
SELECT setval(pg_get_serial_sequence('sys_role','role_id'), GREATEST((SELECT max(role_id) FROM sys_role),1))`).Error; err != nil {
return err
}
root, err := insertMenu(tx, 0, "BellWarning", "预警中心", "warning", "/bell", "M", "", "", "Layout", 1)
if err != nil {
return err
}
alerts, err := insertMenu(tx, root.ID, "BellAlerts", "预警管理", "bell", "alerts", "C", "bell:alert:list", "", "/bell/alerts/index", 1)
if err != nil {
return err
}
events, err := insertMenu(tx, root.ID, "BellEvents", "事件查询", "list", "events", "C", "bell:event:list", "", "/bell/events/index", 2)
if err != nil {
return err
}
rules, err := insertMenu(tx, root.ID, "BellRules", "规则配置", "guide", "rules", "C", "bell:rule:list", "", "/bell/rules/index", 3)
if err != nil {
return err
}
addRule, err := insertMenu(tx, rules.ID, "", "新增规则", "", "", "F", "bell:rule:add", "POST", "", 1)
if err != nil {
return err
}
editRule, err := insertMenu(tx, rules.ID, "", "修改规则", "", "", "F", "bell:rule:edit", "PUT", "", 2)
if err != nil {
return err
}
apiSpecs := []struct{ title, path, action string }{
{"预警列表", "/api/v1/bell/alerts", "GET"}, {"预警详情", "/api/v1/bell/alerts/:id", "GET"},
{"事件列表", "/api/v1/bell/events", "GET"}, {"事件详情", "/api/v1/bell/events/:id", "GET"},
{"事件规则结果", "/api/v1/bell/events/:id/rule-results", "GET"},
{"规则列表", "/api/v1/bell/rules", "GET"}, {"新增规则", "/api/v1/bell/rules", "POST"},
{"修改规则", "/api/v1/bell/rules/:id", "PUT"}, {"启停规则", "/api/v1/bell/rules/:id/enabled", "PUT"},
}
apis := make([]apiSeed, 0, len(apiSpecs))
for _, spec := range apiSpecs {
seed, seedErr := insertAPI(tx, spec.title, spec.path, spec.action)
if seedErr != nil {
return seedErr
}
apis = append(apis, seed)
}
links := map[int][]apiSeed{
alerts.ID: {apis[0], apis[1]}, events.ID: {apis[2], apis[3], apis[4]}, rules.ID: {apis[5]},
addRule.ID: {apis[6]}, editRule.ID: {apis[7], apis[8]},
}
for menuID, menuAPIs := range links {
for _, item := range menuAPIs {
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id, sys_api_id) VALUES(?, ?) ON CONFLICT DO NOTHING", menuID, item.ID).Error; err != nil {
return err
}
}
}
var operatorRoleID int
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key = 'operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operatorRoleID).Error; err != nil {
return err
}
if operatorRoleID == 0 {
if err := tx.Raw(`INSERT INTO sys_role(role_name,status,role_key,role_sort,flag,remark,admin,data_scope,create_by,update_by,created_at,updated_at)
VALUES('处置员','2','operator',2,'','仅访问 Bell 预警处理入口',false,'',1,1,now(),now())
RETURNING role_id`).Scan(&operatorRoleID).Error; err != nil {
return err
}
}
for _, menu := range []menuSeed{root, alerts, events, rules} {
if err := tx.Exec("INSERT INTO sys_role_menu(role_id, menu_id) VALUES(?, ?) ON CONFLICT DO NOTHING", operatorRoleID, menu.ID).Error; err != nil {
return err
}
}
for _, item := range apis {
if item.Action != "GET" {
continue
}
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
return err
}
}
return nil
}
func insertMenu(tx *gorm.DB, parentID int, name, title, icon, path, menuType, permission, action, component string, sort int) (menuSeed, error) {
var id int
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at)
VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`,
name, title, icon, path, menuType, action, permission, parentID, component, sort).Scan(&id).Error
if err != nil {
return menuSeed{}, err
}
paths := fmt.Sprintf("/0/%d", id)
if parentID != 0 {
var parentPaths string
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id = ?", parentID).Scan(&parentPaths).Error; err != nil {
return menuSeed{}, err
}
paths = fmt.Sprintf("%s/%d", parentPaths, id)
}
if err = tx.Exec("UPDATE sys_menu SET paths = ? WHERE menu_id = ?", paths, id).Error; err != nil {
return menuSeed{}, err
}
return menuSeed{ID: id, Permission: permission}, nil
}
func insertAPI(tx *gorm.DB, title, path, action string) (apiSeed, error) {
var id int
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by)
VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
return apiSeed{ID: id, Path: path, Action: action}, err
}
@@ -0,0 +1,96 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/alert_lifecycle"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellAlertLifecycle)
}
func migrateBellAlertLifecycle(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(alert_lifecycle.Fact), new(alert_lifecycle.RejectionAudit)); err != nil {
return err
}
for _, statement := range alertLifecycleSQL {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
if err := seedAlertLifecycleAccess(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
var alertLifecycleSQL = []string{
`ALTER TABLE bell_alerts ADD COLUMN acknowledged_by bigint, ADD COLUMN acknowledged_by_name varchar(128), ADD COLUMN acknowledged_at timestamptz, ADD COLUMN closed_by bigint, ADD COLUMN closed_by_name varchar(128), ADD COLUMN closed_at timestamptz, ADD COLUMN close_outcome varchar(32), ADD COLUMN close_note varchar(500)`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_ack_user_fk FOREIGN KEY (acknowledged_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_user_fk FOREIGN KEY (closed_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_outcome_check CHECK (close_outcome IS NULL OR close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_actor_fk FOREIGN KEY (actor_id) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_transition_check CHECK (transition IN ('acknowledged','closed'))`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_outcome_check CHECK (outcome IS NULL OR outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
`CREATE TRIGGER bell_alert_lifecycle_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_alert_lifecycle_rejections_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_rejections FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
}
func seedAlertLifecycleAccess(tx *gorm.DB) error {
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1)); SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil {
return err
}
var alertMenuID int
if err := tx.Table("sys_menu").Select("menu_id").Where("permission = ?", "bell:alert:list").Scan(&alertMenuID).Error; err != nil || alertMenuID == 0 {
return gorm.ErrRecordNotFound
}
ackMenu, err := insertMenu(tx, alertMenuID, "", "开始处理", "", "", "F", "bell:alert:ack", "POST", "", 1)
if err != nil {
return err
}
closeMenu, err := insertMenu(tx, alertMenuID, "", "记录结果", "", "", "F", "bell:alert:close", "POST", "", 2)
if err != nil {
return err
}
specs := []struct{ title, path, action string }{{"预警处理时间线", "/api/v1/bell/alerts/:id/lifecycle", "GET"}, {"开始处理预警", "/api/v1/bell/alerts/:id/ack", "POST"}, {"记录结果并完成", "/api/v1/bell/alerts/:id/close", "POST"}}
apis := make([]apiSeed, 0, len(specs))
for _, spec := range specs {
item, itemErr := insertAPI(tx, spec.title, spec.path, spec.action)
if itemErr != nil {
return itemErr
}
apis = append(apis, item)
}
for _, link := range []struct {
menu int
api apiSeed
}{{alertMenuID, apis[0]}, {ackMenu.ID, apis[1]}, {closeMenu.ID, apis[2]}} {
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", link.menu, link.api.ID).Error; err != nil {
return err
}
}
var operatorRoleID int
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&operatorRoleID).Error; err != nil || operatorRoleID == 0 {
return gorm.ErrRecordNotFound
}
for _, menuID := range []int{ackMenu.ID, closeMenu.ID} {
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operatorRoleID, menuID).Error; err != nil {
return err
}
}
for _, item := range apis {
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,52 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellMinimalMenu)
}
func migrateBellMinimalMenu(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ApplyBellMinimalMenuVisibility(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
// ApplyBellMinimalMenuVisibility keeps the imported GoAdmin menu records for
// rollback and upgrades, but exposes only Bell product entries and the three
// RBAC administration pages required to maintain local accounts.
func ApplyBellMinimalMenuVisibility(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE sys_menu
SET visible = '1', updated_at = now()
WHERE menu_type IN ('M', 'C')
AND deleted_at IS NULL
AND visible IS DISTINCT FROM '1'
`).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE sys_menu
SET visible = '0', updated_at = now()
WHERE menu_type IN ('M', 'C')
AND deleted_at IS NULL
AND (
path IN ('/admin', '/admin/sys-user', '/admin/sys-menu', '/admin/sys-role', '/bell')
OR permission IN ('admin:sysUser:list', 'admin:sysMenu:list', 'admin:sysRole:list',
'bell:alert:list', 'bell:event:list', 'bell:rule:list')
)
AND visible IS DISTINCT FROM '0'
`).Error
}
@@ -0,0 +1,172 @@
package bell_alert_lifecycle_test
import (
"context"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/app/bell/alert"
"go-admin/app/bell/alert_lifecycle"
"go-admin/app/bell/event"
"go-admin/app/bell/rule"
)
func TestConcurrentLifecycleAndPersistence(t *testing.T) {
dsn := os.Getenv("BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("integration database not configured")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
actors := createActors(t, db)
service := alert_lifecycle.NewService(db)
alertID := createAlert(t, db, "main")
const attempts = 20
var won atomic.Int32
results := make(chan alert_lifecycle.Result, attempts)
var wg sync.WaitGroup
for i := 0; i < attempts; i++ {
wg.Add(1)
actor := actors[i%2]
go func() {
defer wg.Done()
result, _ := service.Ack(ctx, alertID, actor)
if result.Won {
won.Add(1)
}
results <- result
}()
}
wg.Wait()
close(results)
if won.Load() != 1 {
t.Fatalf("ack winners=%d", won.Load())
}
detail, err := service.Get(ctx, alertID)
if err != nil || detail.Projection.Status != alert_lifecycle.StatusAcknowledged || len(detail.Timeline) != 1 {
t.Fatalf("ack projection=%#v err=%v", detail, err)
}
winner := actors[0]
loser := actors[1]
if detail.Projection.AcknowledgedBy == nil || *detail.Projection.AcknowledgedBy != winner.ID {
winner, loser = loser, winner
}
for result := range results {
if result.Detail.Projection.AcknowledgedBy != nil && *result.Detail.Projection.AcknowledgedBy != winner.ID {
t.Fatal("later ack did not report the true winner")
}
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal"}, loser); err == nil {
t.Fatal("non-owner close succeeded")
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{}, winner); err == nil {
t.Fatal("missing outcome succeeded")
}
closed, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
if err != nil || !closed.Won {
t.Fatalf("close failed: %#v %v", closed, err)
}
replay, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
if err != nil || !replay.Idempotent {
t.Fatalf("close replay=%#v %v", replay, err)
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "false_positive"}, winner); err == nil {
t.Fatal("conflicting close replay succeeded")
}
if err = db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Update("actor_name", "tampered").Error; err == nil {
t.Fatal("lifecycle fact update succeeded")
}
var facts, rejects int64
db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Count(&facts)
db.Model(&alert_lifecycle.RejectionAudit{}).Where("alert_id = ?", alertID).Count(&rejects)
if facts != 2 || rejects < 20 {
t.Fatalf("facts=%d rejects=%d", facts, rejects)
}
sqlDB, _ := db.DB()
_ = sqlDB.Close()
reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
after, err := alert_lifecycle.NewService(reopened).Get(ctx, alertID)
if err != nil || after.Projection.Status != alert_lifecycle.StatusClosed || len(after.Timeline) != 2 {
t.Fatalf("restart detail=%#v err=%v", after, err)
}
rollbackID := createAlert(t, reopened, "rollback")
if err = reopened.Exec(`CREATE FUNCTION bell_test_reject_lifecycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced lifecycle failure'; END $$`).Error; err != nil {
t.Fatal(err)
}
if err = reopened.Exec(`CREATE TRIGGER bell_test_reject_lifecycle BEFORE INSERT ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
t.Fatal(err)
}
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, rollbackID, winner); err == nil {
t.Fatal("forced lifecycle failure succeeded")
}
rollback, _ := alert_lifecycle.NewService(reopened).Get(ctx, rollbackID)
if rollback.Projection.Status != alert_lifecycle.StatusOpen || len(rollback.Timeline) != 0 {
t.Fatal("failed ack left partial projection")
}
if err = reopened.Exec(`DROP TRIGGER bell_test_reject_lifecycle ON bell_alert_lifecycle_facts; DROP FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
t.Fatal(err)
}
adminCloseID := createAlert(t, reopened, "admin-close")
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, adminCloseID, winner); err != nil {
t.Fatal(err)
}
administrator := loser
administrator.Role = "admin"
if result, closeErr := alert_lifecycle.NewService(reopened).Close(ctx, adminCloseID, alert_lifecycle.CloseInput{Outcome: "danger_confirmed"}, administrator); closeErr != nil || !result.Won {
t.Fatalf("administrator close failed: %#v %v", result, closeErr)
}
}
func createActors(t *testing.T, db *gorm.DB) []alert_lifecycle.Actor {
t.Helper()
var roleID int
db.Table("sys_role").Select("role_id").Where("role_key='operator'").Scan(&roleID)
result := make([]alert_lifecycle.Actor, 2)
for i := range result {
user := adminmodels.SysUser{Username: "bell_133_operator_" + string(rune('a'+i)), Password: "test-password-133", NickName: "处置员" + string(rune('A'+i)), RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
result[i] = alert_lifecycle.Actor{ID: user.UserId, Name: user.NickName, Role: "operator"}
}
return result
}
func createAlert(t *testing.T, db *gorm.DB, suffix string) string {
t.Helper()
ctx := context.Background()
eventType := "lifecycle_" + suffix
createdRule, err := rule.NewService(db).Create(ctx, rule.WriteInput{Code: "lifecycle-" + suffix, Name: "生命周期规则", EventType: &eventType, MinimumSeverity: "low"}, 1)
if err != nil {
t.Fatal(err)
}
created, err := event.NewService(db).Ingest(ctx, event.Command{ProducerID: "bell.lifecycle-test", SourceEventID: suffix, EventType: eventType, OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: "测试地点" + suffix, Severity: "high", Attributes: map[string]any{}}, 1)
if err != nil {
t.Fatal(err)
}
items, _, err := alert.NewService(db).List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 100})
if err != nil {
t.Fatal(err)
}
for _, item := range items {
if item.PrimaryRuleID == createdRule.ID {
return item.ID
}
}
t.Fatal("alert not created")
return created.Event.ID
}
@@ -0,0 +1,28 @@
[CmdletBinding()] param([string]$PostgresBin='D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference='Stop'; $started=$false; $server=$null
$root=Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-133-'+[guid]::NewGuid().ToString('N'))
$data=Join-Path $root 'postgres'; $log=Join-Path $root 'postgres.log'; $serverRoot=(Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path; $serverExe=Join-Path $root 'bell.exe'
function FreePort { $l=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0); try{$l.Start();return ([Net.IPEndPoint]$l.LocalEndpoint).Port}finally{$l.Stop()} }
function WaitPort([int]$port){for($i=0;$i -lt 120;$i++){try{$c=[Net.Sockets.TcpClient]::new();$ok=$c.ConnectAsync('127.0.0.1',$port).Wait(250)-and$c.Connected;$c.Dispose();if($ok){return}}catch{};Start-Sleep -Milliseconds 250};throw 'PostgreSQL did not start'}
function Login([string]$base,[string]$username,[string]$password){$body=@{username=$username;password=$password;code='0';uuid='0'}|ConvertTo-Json -Compress; $result=Invoke-RestMethod -Method Post -Uri "$base/api/v1/login" -ContentType 'application/json' -Body $body -NoProxy; if([int]$result.code-ne 200){throw "login failed: $username"}; return @{Authorization="Bearer $($result.token)"}}
New-Item -ItemType Directory -Path $root|Out-Null; $port=FreePort
try {
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){if(-not(Test-Path (Join-Path $PostgresBin $name))){throw "Missing $name"}}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale|Out-Null; if($LASTEXITCODE-ne 0){throw 'initdb failed'}
$args="-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"; Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $args -WindowStyle Hidden|Out-Null; WaitPort $port; $started=$true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_133; if($LASTEXITCODE-ne 0){throw 'createdb failed'}
$bellPort=FreePort; $base="http://127.0.0.1:$bellPort"; $env:GOTOOLCHAIN='go1.26.5'; $env:BELL_DATABASE_URL="host=127.0.0.1 port=$port user=postgres dbname=bell_133 sslmode=disable"; $env:BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL=$env:BELL_DATABASE_URL; $env:BELL_JWT_SECRET=[guid]::NewGuid().ToString('N')+[guid]::NewGuid().ToString('N'); $env:BELL_BOOTSTRAP_USERNAME='bell_133_admin'; $env:BELL_BOOTSTRAP_PASSWORD=[guid]::NewGuid().ToString('N'); $env:BELL_HOST='127.0.0.1'; $env:BELL_PORT=$bellPort.ToString()
Push-Location $serverRoot; try { go run . migrate -c config/settings.demo.yml *> (Join-Path $root 'migrate.log'); if($LASTEXITCODE-ne 0){throw "migration failed: $root"}; go test ./tests/bell_alert_lifecycle -count=1 -v; if($LASTEXITCODE-ne 0){throw 'lifecycle test failed'}; go build -o $serverExe . } finally { Pop-Location }
$server=Start-Process $serverExe -ArgumentList @('server','-c','config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput (Join-Path $root 'server.out') -RedirectStandardError (Join-Path $root 'server.err') -WindowStyle Hidden -PassThru; WaitPort $bellPort
$a=Login $base 'bell_133_operator_a' 'test-password-133'; $b=Login $base 'bell_133_operator_b' 'test-password-133'; $list=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts?status=open" -Headers $a -NoProxy; $id=[string]$list.data.list[0].id; if([string]::IsNullOrWhiteSpace($id)){throw 'open alert missing'}
$ack=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; $late=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $b -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$ack.code-ne 200-or[int]$late.code-ne 409){throw 'ack API semantics failed'}
$forbidden=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $b -ContentType 'application/json' -Body '{"outcome":"site_normal"}' -NoProxy; $missing=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$forbidden.code-ne 403-or[int]$missing.code-ne 400){throw 'close rejection semantics failed'}
$body='{"outcome":"site_normal","note":"现场正常"}'; $closed=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $replay=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $timeline=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts/$id/lifecycle" -Headers $a -NoProxy; if([int]$closed.code-ne 200-or-not$replay.data.idempotent-or$timeline.data.detail.timeline.Count-ne 2){throw 'close/timeline API semantics failed'}
$leaks=& (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $port -U postgres -d bell_133 -Atc "select count(*) from sys_opera_log where oper_url like '%/bell/alerts/%/close' and ((oper_param <> '' and oper_param not like '%redacted%') or json_result not like '%redacted%');"; if($LASTEXITCODE-ne 0-or[int]$leaks-ne 0){throw 'lifecycle note leaked into GoAdmin operation log'}
Write-Output 'BELL_133_HTTP ack=200 late_ack=409 forbidden_close=403 missing_outcome=400 close=200 replay=true timeline=2'
} finally {
if($null-ne$server-and-not$server.HasExited){Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000)|Out-Null}
if($started){& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')}
foreach($name in @('BELL_DATABASE_URL','BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')){Remove-Item "Env:$name" -ErrorAction SilentlyContinue}
}
@@ -0,0 +1,7 @@
package bell_alert_lifecycle_test
import "testing"
// Input and state validation are exercised through the PostgreSQL service test;
// this sentinel keeps the package runnable without an integration database.
func TestLifecyclePackageLoadsWithoutDatabase(t *testing.T) {}
@@ -0,0 +1,120 @@
package bell_minimal_menu_test
import (
"os"
"reflect"
"sort"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
versionlocal "go-admin/cmd/migrate/migration/version-local"
)
var expectedVisibleMenus = []string{
"事件查询",
"用户管理",
"系统管理",
"菜单管理",
"角色管理",
"规则配置",
"预警中心",
"预警管理",
}
func TestBellMinimalMenuMigration(t *testing.T) {
databaseURL := os.Getenv("BELL_MINIMAL_MENU_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("minimal menu database is not configured")
}
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
assertVisibleMenus(t, db)
assertUnusedMenusRetainedAndHidden(t, db)
assertOperatorHasNoDefaultMenus(t, db)
var rowCountBefore int64
if err = db.Table("sys_menu").Count(&rowCountBefore).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`UPDATE sys_menu SET visible = '0' WHERE title IN ('开发工具','定时任务','系统工具')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`UPDATE sys_menu SET visible = '1' WHERE title IN ('系统管理','预警中心')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
t.Fatal(err)
}
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
t.Fatalf("reapplying minimal menu policy failed: %v", err)
}
var rowCountAfter int64
if err = db.Table("sys_menu").Count(&rowCountAfter).Error; err != nil {
t.Fatal(err)
}
if rowCountAfter != rowCountBefore {
t.Fatalf("menu records changed during visibility migration: before=%d after=%d", rowCountBefore, rowCountAfter)
}
assertVisibleMenus(t, db)
assertUnusedMenusRetainedAndHidden(t, db)
}
func assertVisibleMenus(t *testing.T, db *gorm.DB) {
t.Helper()
var titles []string
if err := db.Table("sys_menu").
Where("menu_type IN ? AND deleted_at IS NULL AND visible = ?", []string{"M", "C"}, "0").
Order("title").Pluck("title", &titles).Error; err != nil {
t.Fatal(err)
}
sort.Strings(titles)
expected := append([]string(nil), expectedVisibleMenus...)
sort.Strings(expected)
if !reflect.DeepEqual(titles, expected) {
t.Fatalf("visible menu mismatch\nwant: %v\n got: %v", expected, titles)
}
}
func assertUnusedMenusRetainedAndHidden(t *testing.T, db *gorm.DB) {
t.Helper()
for _, title := range []string{"开发工具", "定时任务", "系统工具"} {
var values []string
if err := db.Table("sys_menu").Where("title = ? AND deleted_at IS NULL", title).Pluck("visible", &values).Error; err != nil {
t.Fatal(err)
}
if len(values) == 0 {
t.Fatalf("unused upstream menu %q was deleted", title)
}
for _, visible := range values {
if visible != "1" {
t.Fatalf("unused upstream menu %q remains visible=%q", title, visible)
}
}
}
}
func assertOperatorHasNoDefaultMenus(t *testing.T, db *gorm.DB) {
t.Helper()
var titles []string
err := db.Raw(`
SELECT DISTINCT m.title
FROM sys_role r
JOIN sys_role_menu rm ON rm.role_id = r.role_id
JOIN sys_menu m ON m.menu_id = rm.menu_id
WHERE r.role_key = 'operator'
AND m.menu_type IN ('M', 'C')
AND m.deleted_at IS NULL
AND (m.path LIKE '/admin%' OR m.permission LIKE 'admin:%')
`).Scan(&titles).Error
if err != nil {
t.Fatal(err)
}
if len(titles) != 0 {
t.Fatalf("operator retains default administration menus: %v", titles)
}
}
@@ -0,0 +1,79 @@
[CmdletBinding()]
param([string]$PostgresBin = 'D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$started = $false
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-140-' + [guid]::NewGuid().ToString('N'))
$data = Join-Path $root 'postgres'
$log = Join-Path $root 'postgres.log'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-ForPort([int]$Port) {
for ($attempt = 0; $attempt -lt 120; $attempt++) {
try {
$client = [Net.Sockets.TcpClient]::new()
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
$client.Dispose()
if ($connected) { return }
} catch {
}
Start-Sleep -Milliseconds 250
}
throw 'PostgreSQL did not start'
}
New-Item -ItemType Directory -Path $root | Out-Null
$port = Get-FreePort
try {
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { throw "Missing $name" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
Wait-ForPort $port
$started = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_140
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_140 sslmode=disable"
$env:BELL_MINIMAL_MENU_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_140_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = (Get-FreePort).ToString()
Push-Location $serverRoot
try {
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
go test ./tests/bell_minimal_menu -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'minimal menu test failed' }
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate-repeat.log')
if ($LASTEXITCODE -ne 0) { throw "repeat migration failed; evidence: $root" }
} finally {
Pop-Location
}
Write-Output 'BELL_140_MINIMAL_MENU fresh=true upgrade=true repeat=true admin_whitelist=true operator_default_menu=false'
} finally {
if ($started) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
}
foreach ($name in @('BELL_DATABASE_URL', 'BELL_MINIMAL_MENU_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,195 @@
package bell_production_login_test
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/config/source/file"
"github.com/go-admin-team/go-admin-core/sdk"
sdkapi "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/captcha"
"github.com/mojocn/base64Captcha"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
adminrouter "go-admin/app/admin/router"
bellrouter "go-admin/app/bell/router"
"go-admin/common/bellconfig"
"go-admin/common/database"
"go-admin/common/middleware"
"go-admin/common/storage"
ext "go-admin/config"
)
type apiResponse struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
ID string `json:"id"`
Msg string `json:"msg"`
Token string `json:"token"`
}
func TestProductionCaptchaLoginAndRouteBoundary(t *testing.T) {
if os.Getenv("BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL") == "" {
t.Skip("production login database is not configured")
}
if err := os.MkdirAll("temp/logs", 0o700); err != nil {
t.Fatal(err)
}
gin.SetMode(gin.TestMode)
config.ExtendConfig = &ext.ExtConfig
config.Setup(file.NewSource(file.WithPath("../../config/settings.yml")))
if err := bellconfig.ApplyRequiredEnvironment(); err != nil {
t.Fatal(err)
}
if config.ApplicationConfig.Mode != "prod" {
t.Fatalf("expected production mode, got %q", config.ApplicationConfig.Mode)
}
database.Setup()
storage.Setup()
engine := gin.New()
sdk.Runtime.SetEngine(engine)
engine.Use(sdkapi.SetRequestLogger)
engine.Use(middleware.WithContextDb)
authMiddleware, err := middleware.AuthInit()
if err != nil {
t.Fatal(err)
}
adminrouter.InitSysRouter(engine, authMiddleware)
adminrouter.InitExamplesRouter(engine, authMiddleware)
bellrouter.InitRouter()
captchaResponse := requestJSON(t, engine, http.MethodGet, "/api/v1/captcha", nil, "")
if captchaResponse.Code != 200 || captchaResponse.ID == "" || !bytes.Contains(captchaResponse.Data, []byte("data:image/")) {
t.Fatalf("unexpected captcha response: code=%d id=%q data=%s", captchaResponse.Code, captchaResponse.ID, captchaResponse.Data)
}
username := os.Getenv("BELL_BOOTSTRAP_USERNAME")
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
answer := "813907"
validID := "bell-138-valid"
if err := base64Captcha.DefaultMemStore.Set(validID, answer); err != nil {
t.Fatal(err)
}
if !captcha.Verify(validID, answer, false) {
t.Fatal("known captcha was not stored")
}
login := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
if login.Code != 200 || login.Token == "" {
t.Fatalf("valid captcha login failed: code=%d msg=%q", login.Code, login.Msg)
}
replay := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
if replay.Code == 200 {
t.Fatal("used captcha was accepted again")
}
wrongID := "bell-138-wrong"
if err := base64Captcha.DefaultMemStore.Set(wrongID, answer); err != nil {
t.Fatal(err)
}
wrong := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, "000000"), "")
if wrong.Code == 200 {
t.Fatal("incorrect captcha was accepted")
}
consumed := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, answer), "")
if consumed.Code == 200 {
t.Fatal("captcha used by a failed attempt was not consumed")
}
unauthenticated := requestJSON(t, engine, http.MethodGet, "/api/v1/bell/alerts", nil, "")
if unauthenticated.Code == 200 {
t.Fatal("unauthenticated Bell business API was accepted")
}
disabled := httptest.NewRecorder()
engine.ServeHTTP(disabled, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil))
if disabled.Code != http.StatusNotFound {
t.Fatalf("disabled default route returned HTTP %d", disabled.Code)
}
assertSecretsAbsentFromAudit(t, password, answer, login.Token)
assertSecretsAbsentFromLogs(t, password, answer, login.Token)
if captcha.Verify(captchaResponse.ID, "deliberately-wrong", true) {
t.Fatal("generated captcha accepted a deliberately incorrect answer")
}
}
func loginBody(username, password, id, answer string) map[string]string {
return map[string]string{"username": username, "password": password, "uuid": id, "code": answer}
}
func requestJSON(t *testing.T, engine http.Handler, method, path string, body any, token string) apiResponse {
t.Helper()
var payload []byte
var err error
if body != nil {
payload, err = json.Marshal(body)
if err != nil {
t.Fatal(err)
}
}
request := httptest.NewRequest(method, path, bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var response apiResponse
if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode %s response (HTTP %d): %v: %s", path, recorder.Code, err, recorder.Body.String())
}
return response
}
func assertSecretsAbsentFromAudit(t *testing.T, secrets ...string) {
t.Helper()
db := sdk.Runtime.GetDbByKey("").Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
for _, table := range []string{"sys_login_log", "sys_opera_log"} {
for _, secret := range secrets {
var count int64
query := "SELECT count(*) FROM " + table + " WHERE row_to_json(" + table + ")::text LIKE ?"
if err := db.Raw(query, "%"+secret+"%").Scan(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("secret leaked into %s", table)
}
}
}
}
func assertSecretsAbsentFromLogs(t *testing.T, secrets ...string) {
t.Helper()
patterns := append([]string{"DriverDigitFunc answer:"}, secrets...)
err := filepath.WalkDir("temp", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, pattern := range patterns {
if pattern != "" && strings.Contains(string(content), pattern) {
t.Fatalf("sensitive value found in server log %s", path)
}
}
return nil
})
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
}
@@ -0,0 +1,83 @@
[CmdletBinding()]
param([string]$PostgresBin = 'D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$started = $false
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-138-' + [guid]::NewGuid().ToString('N'))
$data = Join-Path $root 'postgres'
$log = Join-Path $root 'postgres.log'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-ForPort([int]$Port) {
for ($attempt = 0; $attempt -lt 120; $attempt++) {
try {
$client = [Net.Sockets.TcpClient]::new()
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
$client.Dispose()
if ($connected) {
return
}
} catch {
}
Start-Sleep -Milliseconds 250
}
throw 'PostgreSQL did not start'
}
New-Item -ItemType Directory -Path $root | Out-Null
$port = Get-FreePort
try {
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) {
throw "Missing $name"
}
}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
Wait-ForPort $port
$started = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_138
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_138 sslmode=disable"
$env:BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_138_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = (Get-FreePort).ToString()
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
Push-Location $serverRoot
try {
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
go test ./tests/bell_production_login -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'production login test failed' }
} finally {
Pop-Location
}
Write-Output 'BELL_138_PRODUCTION_LOGIN captcha=200 valid_login=200 wrong_rejected=true replay_rejected=true secrets_absent=true'
} finally {
if ($started) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
}
foreach ($name in @('BELL_DATABASE_URL', 'BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,214 @@
package bell_rule_alert_test
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/app/bell/alert"
"go-admin/app/bell/evaluation"
"go-admin/app/bell/event"
"go-admin/app/bell/receipt"
"go-admin/app/bell/rule"
)
func TestPostgresRuleEvaluationAndAlertProjection(t *testing.T) {
dsn := os.Getenv("BELL_RULE_ALERT_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set BELL_RULE_ALERT_TEST_DATABASE_URL to run the isolated PostgreSQL test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
rules := rule.NewService(db)
events := event.NewService(db)
evaluations := evaluation.NewService(db)
alerts := alert.NewService(db)
assertOperatorAccess(t, db)
createOperatorUser(t, db)
eventType := "danger_area_entered"
location := "东门"
first, err := rules.Create(ctx, rule.WriteInput{Code: "area-high", Name: "高风险区域", EventType: &eventType, MinimumSeverity: "high", LocationContains: &location}, 1)
if err != nil {
t.Fatal(err)
}
second, err := rules.Create(ctx, rule.WriteInput{Code: "all-high", Name: "全局高风险", MinimumSeverity: "high"}, 1)
if err != nil {
t.Fatal(err)
}
otherEventType := "fire_detected"
_, err = rules.Create(ctx, rule.WriteInput{Code: "critical-fire", Name: "仅严重火情", EventType: &otherEventType, MinimumSeverity: "critical"}, 1)
if err != nil {
t.Fatal(err)
}
created := ingest(t, events, "event-001", "东门 A 区", "high")
result, err := evaluations.ForEvent(ctx, created.Event.ID)
if err != nil {
t.Fatal(err)
}
if len(result.Evaluations) != 3 || len(result.Alerts) != 2 {
t.Fatalf("expected 3 evaluations and 2 alerts, got %d and %d", len(result.Evaluations), len(result.Alerts))
}
matched, unmatched := 0, 0
for _, item := range result.Evaluations {
if item.Matched {
matched++
} else if item.Explanation != "" {
unmatched++
}
}
if matched != 2 || unmatched != 1 {
t.Fatalf("unexpected match explanations: matched=%d unmatched=%d", matched, unmatched)
}
replay := ingest(t, events, "event-001", "东门 A 区", "high")
if !replay.Duplicate || replay.Event.ID != created.Event.ID {
t.Fatalf("idempotent replay created another fact: %#v", replay)
}
assertCount(t, db, "bell_rule_evaluations", 3)
assertCount(t, db, "bell_alerts", 2)
secondEvent := ingest(t, events, "event-002", "东门 A 区", "critical")
assertCount(t, db, "bell_alerts", 2)
items, total, err := alerts.List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 20, Status: "open"})
if err != nil || total != 2 || len(items) != 2 {
t.Fatalf("unexpected alert list: total=%d len=%d err=%v", total, len(items), err)
}
for _, item := range items {
if item.EventCount != 2 || item.Status != "open" || item.Severity != "critical" {
t.Fatalf("open alert did not aggregate and escalate: %#v", item)
}
detail, detailErr := alerts.Get(ctx, item.ID)
if detailErr != nil || len(detail.Events) != 2 || len(detail.Matches) != 2 {
t.Fatalf("event-alert navigation is incomplete: events=%d matches=%d err=%v", len(detail.Events), len(detail.Matches), detailErr)
}
}
updated, err := rules.Update(ctx, first.ID, rule.WriteInput{Name: "高风险区域(更新)", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location}, 1)
if err != nil || updated.Version != 2 {
t.Fatalf("rule version was not incremented: version=%d err=%v", updated.Version, err)
}
if _, err = rules.SetEnabled(ctx, second.ID, false, 1); err != nil {
t.Fatal(err)
}
thirdEvent := ingest(t, events, "event-003", "东门 B 区", "medium")
thirdResults, err := evaluations.ForEvent(ctx, thirdEvent.Event.ID)
if err != nil {
t.Fatal(err)
}
if len(thirdResults.Evaluations) != 2 || len(thirdResults.Alerts) != 1 {
t.Fatalf("disabled rule was evaluated: evaluations=%d alerts=%d", len(thirdResults.Evaluations), len(thirdResults.Alerts))
}
var snapshot struct {
Version int `json:"version"`
}
for _, item := range thirdResults.Evaluations {
if item.RuleID == first.ID {
if err = json.Unmarshal(item.RuleSnapshot, &snapshot); err != nil || item.RuleVersion != 2 || snapshot.Version != 2 {
t.Fatalf("versioned rule snapshot missing: item=%#v snapshot=%#v err=%v", item, snapshot, err)
}
}
}
if err = db.Model(&evaluation.Evaluation{}).Where("event_id = ? AND rule_id = ?", created.Event.ID, first.ID).Update("explanation", "tampered").Error; err == nil {
t.Fatal("immutable evaluation update unexpectedly succeeded")
}
if err = db.Exec(`CREATE FUNCTION bell_test_reject_alert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced alert failure'; END $$`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TRIGGER bell_test_reject_alert BEFORE INSERT ON bell_alerts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_alert()`).Error; err != nil {
t.Fatal(err)
}
failedID := "event-rollback"
_, ingestErr := events.Ingest(ctx, eventCommand(failedID, "东门 C 区", "high"), 1)
if ingestErr == nil {
t.Fatal("forced alert failure did not roll back Event ingest")
}
var eventCount, receiptCount int64
db.Model(&event.Event{}).Where("source_event_id = ?", failedID).Count(&eventCount)
db.Model(&receipt.Receipt{}).Where("source_event_id = ?", failedID).Count(&receiptCount)
if eventCount != 0 || receiptCount != 0 {
t.Fatalf("transaction failure left partial facts: events=%d receipts=%d", eventCount, receiptCount)
}
if err = db.Exec(`DROP TRIGGER bell_test_reject_alert ON bell_alerts; DROP FUNCTION bell_test_reject_alert()`).Error; err != nil {
t.Fatal(err)
}
if secondEvent.Event.ID == thirdEvent.Event.ID {
t.Fatal("independent Events unexpectedly share an id")
}
}
func ingest(t *testing.T, service event.Service, sourceID, location, severity string) event.Result {
t.Helper()
result, err := service.Ingest(context.Background(), eventCommand(sourceID, location, severity), 1)
if err != nil {
t.Fatal(err)
}
return result
}
func eventCommand(sourceID, location, severity string) event.Command {
return event.Command{ProducerID: "bell.rule-test", SourceEventID: sourceID, EventType: "danger_area_entered", OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: location, Severity: severity, Attributes: map[string]any{"test": true}}
}
func assertCount(t *testing.T, db *gorm.DB, table string, want int64) {
t.Helper()
var got int64
if err := db.Table(table).Count(&got).Error; err != nil {
t.Fatal(err)
}
if got != want {
t.Fatal(fmt.Sprintf("%s count: got %d want %d", table, got, want))
}
}
func assertOperatorAccess(t *testing.T, db *gorm.DB) {
t.Helper()
var menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount int64
if err := db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id = rm.role_id").
Joins("JOIN sys_menu m ON m.menu_id = rm.menu_id").
Where("r.role_key = ? AND m.path IN ?", "operator", []string{"/bell", "alerts", "events", "rules"}).Count(&menuCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ? AND v1 LIKE ?", "operator", "GET", "/api/v1/bell/rules%").Count(&ruleWritePolicyCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ? AND v1 IN ?", "operator", "POST", []string{"/api/v1/bell/alerts/:id/ack", "/api/v1/bell/alerts/:id/close"}).Count(&lifecycleWritePolicyCount).Error; err != nil {
t.Fatal(err)
}
if menuCount != 4 || readPolicyCount < 6 || ruleWritePolicyCount != 0 || lifecycleWritePolicyCount > 2 {
t.Fatalf("operator access escaped Bell scope: menus=%d reads=%d rule_writes=%d lifecycle_writes=%d", menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount)
}
}
func createOperatorUser(t *testing.T, db *gorm.DB) {
t.Helper()
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
if password == "" {
t.Skip("set BELL_RULE_ALERT_OPERATOR_PASSWORD for the HTTP RBAC continuation")
}
var roleID int
if err := db.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("load operator role: id=%d err=%v", roleID, err)
}
user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,143 @@
[CmdletBinding()]
param(
[string]$PostgresBin = 'D:\pgsql17\bin'
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$pgStarted = $false
$server = $null
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-132-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $testRoot 'postgres'
$pgLog = Join-Path $testRoot 'postgres.log'
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
$serverOut = Join-Path $testRoot 'bell.out.log'
$serverErr = Join-Path $testRoot 'bell.err.log'
$serverExe = Join-Path $testRoot 'bell-server.exe'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Get-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
$client = [Net.Sockets.TcpClient]::new()
try {
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
} catch {
$connected = $false
} finally {
$client.Dispose()
}
if ($connected -eq $Open) { return }
Start-Sleep -Milliseconds 250
}
throw "TCP port $Port did not reach open=$Open"
}
function Wait-Health([string]$BaseUrl) {
for ($attempt = 0; $attempt -lt 100; $attempt++) {
try {
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return }
} catch {}
Start-Sleep -Milliseconds 300
}
throw 'Bell health endpoint did not become ready'
}
New-Item -ItemType Directory -Path $testRoot | Out-Null
$pgPort = Get-FreeTcpPort
$bellPort = Get-FreeTcpPort
$baseUrl = "http://127.0.0.1:$bellPort"
$database = 'bell_132'
try {
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
$path = Join-Path $PostgresBin $required
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' }
$pgArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput $pgCtlOut -RedirectStandardError $pgCtlErr -WindowStyle Hidden | Out-Null
Wait-Tcp -Port $pgPort -Open $true
$pgStarted = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres $database
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
$env:BELL_RULE_ALERT_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_132_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = $bellPort.ToString()
Push-Location $serverRoot
try {
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw "Bell migration failed; see $(Join-Path $testRoot 'migrate.log')" }
go test ./tests/bell_rule_alert -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'Bell rule-alert integration test failed' }
go build -o $serverExe .
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
} finally {
Pop-Location
}
$server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
Wait-Health $baseUrl
$unauthorized = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/rules" -TimeoutSec 5 -NoProxy
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated rule list returned code $($unauthorized.code)" }
$adminLoginBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$adminLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminLoginBody -TimeoutSec 5 -NoProxy
if ([int]$adminLogin.code -ne 200) { throw 'Bell administrator login failed' }
$adminHeaders = @{ Authorization = "Bearer $($adminLogin.token)" }
$adminRule = @{ code = 'http-admin'; name = '管理员 HTTP 规则'; eventType = $null; minimumSeverity = 'high'; locationContains = $null } | ConvertTo-Json -Compress
$adminWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $adminRule -TimeoutSec 5 -NoProxy
if ([int]$adminWrite.code -ne 200 -or [int]$adminWrite.data.version -ne 1) { throw 'administrator rule create failed' }
$operatorLoginBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$operatorLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorLoginBody -TimeoutSec 5 -NoProxy
if ([int]$operatorLogin.code -ne 200) { throw 'Bell operator login failed' }
$operatorHeaders = @{ Authorization = "Bearer $($operatorLogin.token)" }
foreach ($path in @('/api/v1/bell/rules','/api/v1/bell/events','/api/v1/bell/alerts')) {
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
if ([int]$read.code -ne 200) { throw "operator read $path returned code $($read.code)" }
}
$operatorWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $operatorHeaders -ContentType 'application/json' -Body $adminRule -TimeoutSec 5 -NoProxy
if ([int]$operatorWrite.code -ne 403) { throw "operator rule write returned code $($operatorWrite.code)" }
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
foreach ($title in @('预警中心','预警管理','事件查询','规则配置')) {
if (-not $menuJson.Contains($title)) { throw "operator menu is missing $title" }
}
if ($menuJson.Contains('系统管理') -or $menuJson.Contains('开发工具')) { throw 'operator menu exposed unrelated GoAdmin modules' }
Write-Output 'BELL_132_HTTP unauthenticated=401 admin_write=200 operator_reads=200 operator_write=403 minimal_menu=true'
} finally {
if ($null -ne $server -and -not $server.HasExited) {
Stop-Process -Id $server.Id -Force
$server.WaitForExit(5000) | Out-Null
}
if ($pgStarted) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
}
foreach ($name in @('BELL_DATABASE_URL','BELL_RULE_ALERT_TEST_DATABASE_URL','BELL_RULE_ALERT_OPERATOR_PASSWORD','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
Write-Verbose "Bell #132 temporary artifacts: $testRoot"
}
@@ -0,0 +1,55 @@
package bell_rule_alert_test
import (
"errors"
"testing"
"go-admin/app/bell/rule"
)
func TestRuleNormalizeTrimsAndNormalizesFields(t *testing.T) {
eventType := " danger_area_entered "
location := " 东门 "
got, err := rule.Normalize(rule.WriteInput{
Code: " AREA_HIGH ", Name: " 高风险区域 ", EventType: &eventType,
MinimumSeverity: " HIGH ", LocationContains: &location,
}, true)
if err != nil {
t.Fatal(err)
}
if got.Code != "area_high" || got.Name != "高风险区域" || got.MinimumSeverity != "high" {
t.Fatalf("unexpected normalized rule: %#v", got)
}
if got.EventType == nil || *got.EventType != "danger_area_entered" || got.LocationContains == nil || *got.LocationContains != "东门" {
t.Fatalf("optional fields were not normalized: %#v", got)
}
}
func TestRuleNormalizeRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
input rule.WriteInput
}{
{name: "invalid code", input: rule.WriteInput{Code: "Bad Code", Name: "规则", MinimumSeverity: "low"}},
{name: "missing name", input: rule.WriteInput{Code: "valid-code", Name: " ", MinimumSeverity: "low"}},
{name: "unknown severity", input: rule.WriteInput{Code: "valid-code", Name: "规则", MinimumSeverity: "urgent"}},
{name: "control character", input: rule.WriteInput{Code: "valid-code", Name: "规则\n泄露", MinimumSeverity: "low"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := rule.Normalize(test.input, true); !errors.Is(err, rule.ErrInvalid) {
t.Fatalf("expected ErrInvalid, got %v", err)
}
})
}
}
func TestRuleUpdateKeepsImmutableCodeOutsideInput(t *testing.T) {
got, err := rule.Normalize(rule.WriteInput{Code: "ignored invalid code", Name: "更新后规则", MinimumSeverity: "medium"}, false)
if err != nil {
t.Fatal(err)
}
if got.Name != "更新后规则" || got.MinimumSeverity != "medium" {
t.Fatalf("unexpected update normalization: %#v", got)
}
}
@@ -0,0 +1,8 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$web = Join-Path ([IO.Path]::GetFullPath($PackageRoot)) 'web'
& (Join-Path $PSScriptRoot '..\..\scripts\build\assert-web-assets.ps1') -WebRoot $web
$index = Get-Content -LiteralPath (Join-Path $web 'index.html') -Raw -Encoding UTF8
if ($index -notmatch 'id=["'']app["'']') { throw 'Bell package does not contain the GoAdmin Vue application mount.' }
Write-Host 'Bell GoAdmin shell compatibility check passed.'
+204
View File
@@ -0,0 +1,204 @@
param(
[string]$PostgresBin = 'D:\pgsql17\bin',
[string]$PreparedPackageRoot = '',
[switch]$KeepTemporary,
[switch]$BrowserHold
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
$bellRoot = Join-Path $repositoryRoot 'Bell'
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('bell-e2e-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $temporary 'postgres'
$pgLog = Join-Path $temporary 'postgres.log'
$runtimeOut = Join-Path $temporary 'bell-launcher.out.log'
$runtimeErr = Join-Path $temporary 'bell-launcher.err.log'
$launcher = $null
$pgStarted = $false
$savedEnvironment = @{}
function Get-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0)
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
}
function Get-UniqueFreePorts([int]$Count) {
$ports = [Collections.Generic.List[int]]::new()
while ($ports.Count -lt $Count) { $port=Get-FreeTcpPort; if (-not $ports.Contains($port)) { $ports.Add($port) } }
return $ports.ToArray()
}
function New-RandomText([int]$Bytes=32) {
$buffer=New-Object byte[] $Bytes; $generator=[Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+','A').Replace('/','B')
}
function Set-TestEnvironment([string]$Name,[string]$Value) {
if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name]=[Environment]::GetEnvironmentVariable($Name,'Process') }
[Environment]::SetEnvironmentVariable($Name,$Value,'Process')
}
function Wait-Tcp([int]$Port,[bool]$Open,[int]$Attempts=120) {
for($i=0;$i -lt $Attempts;$i++) {
$client=[Net.Sockets.TcpClient]::new()
try { $connected=$client.ConnectAsync('127.0.0.1',$Port).Wait(250)-and$client.Connected } catch { $connected=$false } finally { $client.Dispose() }
if($connected -eq $Open){return}; Start-Sleep -Milliseconds 250
}
throw "TCP port $Port did not reach open=$Open"
}
function Wait-Health([string]$BaseUrl) {
for($i=0;$i -lt 120;$i++){try{$health=Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy;if($health.status-eq'ok'-and$health.service-eq'bell'){return}}catch{};Start-Sleep -Milliseconds 300}
throw "Bell health endpoint did not become ready: $BaseUrl"
}
function Invoke-BellJson {
param([string]$Method,[string]$Path,$Body=$null,[string]$Token='',[int]$ExpectedCode=200)
$headers=@{};if($Token){$headers.Authorization="Bearer $Token"}
$arguments=@{Method=$Method;Uri="$script:baseUrl$Path";Headers=$headers;TimeoutSec=20;NoProxy=$true}
if($null-ne$Body){$arguments.ContentType='application/json; charset=utf-8';$arguments.Body=$Body|ConvertTo-Json -Depth 12 -Compress}
try{$response=Invoke-RestMethod @arguments}catch{throw "Bell request failed for $Method $Path`: $($_.Exception.Message)"}
if([int]$response.code-ne$ExpectedCode){throw "Unexpected Bell code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"}
return $response
}
function Login([string]$Username,[string]$Password){$response=Invoke-BellJson POST '/api/v1/login' @{username=$Username;password=$Password;code='0';uuid='0'};if([string]::IsNullOrWhiteSpace($response.token)){throw "Login did not return a token for $Username"};return [string]$response.token}
function Get-VisibleMenuTitles($Menus,[bool]$AncestorsVisible=$true) {
foreach($menu in @($Menus)) {
if($null-eq$menu){continue}
$visible=$AncestorsVisible-and([string]$menu.visible-eq'0')
if($visible-and-not[string]::IsNullOrWhiteSpace([string]$menu.title)){[string]$menu.title}
if($menu.PSObject.Properties.Name-contains'children'){
Get-VisibleMenuTitles -Menus $menu.children -AncestorsVisible $visible
}
}
}
function Start-Package([string]$Root){
$script:launcher=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',"`"$(Join-Path $Root 'start-bell.bat')`"") -WorkingDirectory $Root -RedirectStandardOutput $runtimeOut -RedirectStandardError $runtimeErr -WindowStyle Hidden -PassThru
Wait-Health $script:baseUrl
}
function Stop-Package([string]$Root){
& (Join-Path $Root 'stop-bell.bat') | Out-Host
if($LASTEXITCODE-ne 0){throw 'Bell package stop failed'}
Wait-Tcp -Port $script:webPort -Open $false -Attempts 40
Wait-Tcp -Port $script:backendPort -Open $false -Attempts 40
if($script:launcher-and-not$script:launcher.HasExited){$script:launcher.WaitForExit(5000)|Out-Null}
$script:launcher=$null
}
function Stop-ProcessTree($Process){if($Process-and-not$Process.HasExited){& taskkill.exe /PID $Process.Id /T /F 2>$null|Out-Null}}
New-Item -ItemType Directory -Path $temporary | Out-Null
try {
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){$path=Join-Path $PostgresBin $name;if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Required PostgreSQL tool not found: $path"}}
if([string]::IsNullOrWhiteSpace($PreparedPackageRoot)){
& (Join-Path $bellRoot 'scripts\build\build-windows.ps1')
if($LASTEXITCODE-ne 0){throw 'Bell Windows package build failed'}
$preparedPackageRoot=Join-Path $bellRoot 'dist\bell-windows-amd64'
}else{$preparedPackageRoot=[IO.Path]::GetFullPath($PreparedPackageRoot)}
& (Join-Path $bellRoot 'scripts\build\test-package.ps1') -PackageRoot $preparedPackageRoot
& (Join-Path $bellRoot 'tests\compatibility\assert-go-admin-shell.ps1') -PackageRoot $preparedPackageRoot
$packageRoot=Join-Path $temporary 'package'
Copy-Item -LiteralPath $preparedPackageRoot -Destination $packageRoot -Recurse
# Production captcha behavior is covered by #138. The isolated business
# E2E uses a disposable package copy in dev mode so it never needs to
# expose or OCR a captcha answer.
$settingsPath=Join-Path $packageRoot 'config\settings.yml'
$settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8
$testSettings=[regex]::Replace($settings,'(?m)^(\s*mode:\s*)prod\s*$','$1dev')
if($testSettings-eq$settings){throw 'Packaged settings did not contain the expected production mode'}
[IO.File]::WriteAllText($settingsPath,$testSettings,(New-Object Text.UTF8Encoding($false)))
$pgPort,$script:backendPort,$script:webPort=Get-UniqueFreePorts 3
if($pgPort-eq 5432){throw 'E2E must not use the default PostgreSQL port'}
$script:baseUrl="http://127.0.0.1:$script:webPort"
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U bell_e2e -A trust --encoding=UTF8 --no-locale|Out-Null
if($LASTEXITCODE-ne 0){throw 'isolated PostgreSQL initdb failed'}
$pgArguments="-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput (Join-Path $temporary 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden|Out-Null
Wait-Tcp -Port $pgPort -Open $true;$pgStarted=$true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U bell_e2e bell_e2e
if($LASTEXITCODE-ne 0){throw 'isolated Bell database creation failed'}
$adminName='bell_e2e_admin_'+(New-RandomText 5).ToLowerInvariant();$adminPassword=New-RandomText 20
$operatorPassword=New-RandomText 20;$jwt=New-RandomText 48
$environment=@{
BELL_HOST='127.0.0.1';BELL_PORT="$script:backendPort";BELL_WEB_HOST='127.0.0.1';BELL_WEB_PORT="$script:webPort";
BELL_DATABASE_URL="host=127.0.0.1 port=$pgPort user=bell_e2e dbname=bell_e2e sslmode=disable";
BELL_JWT_SECRET=$jwt;BELL_BOOTSTRAP_USERNAME=$adminName;BELL_BOOTSTRAP_PASSWORD=$adminPassword;
BELL_AUTO_MIGRATE='true';BELL_SYNTHETIC_EVENTS_ENABLED='true'
}
foreach($item in $environment.GetEnumerator()){Set-TestEnvironment $item.Key $item.Value}
Start-Package $packageRoot
$anonymous=Invoke-BellJson GET '/api/v1/bell/alerts' $null '' 401
$adminToken=Login $adminName $adminPassword
$psql=Join-Path $PostgresBin 'psql.exe'
$operatorRole=[int]((&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select role_id from sys_role where role_key='operator';").Trim())
if($operatorRole-lt 1){throw 'operator role was not migrated'}
$operators=@(
@{username='bell_e2e_operator_a';nickName='处置员A'},
@{username='bell_e2e_operator_b';nickName='处置员B'}
)
foreach($operator in $operators){[void](Invoke-BellJson POST '/api/v1/sys-user' @{username=$operator.username;password=$operatorPassword;nickName=$operator.nickName;phone='13800000000';roleId=$operatorRole;sex='1';email="$($operator.username)@invalid.local";deptId=1;postId=1;status='2'} $adminToken)}
$tokenA=Login $operators[0].username $operatorPassword;$tokenB=Login $operators[1].username $operatorPassword
$adminMenu=Invoke-BellJson GET '/api/v1/menurole' $null $adminToken
$operatorMenu=Invoke-BellJson GET '/api/v1/menurole' $null $tokenA
$adminVisible=@(Get-VisibleMenuTitles $adminMenu.data)
$operatorVisible=@(Get-VisibleMenuTitles $operatorMenu.data)
foreach($label in @('预警管理','事件查询','规则配置')){if($adminVisible-notcontains$label){throw "administrator menu is missing $label; visible=$($adminVisible-join',')"}}
foreach($label in @('预警管理','事件查询')){if($operatorVisible-notcontains$label){throw "operator menu is missing $label; visible=$($operatorVisible-join',')"}}
foreach($label in @('开发工具','定时任务','系统监控')){if($adminVisible-contains$label-or$operatorVisible-contains$label){throw "unrelated menu is visible: $label"}}
$eventType='bell_e2e_danger';$ruleBody=@{code='bell-e2e-danger';name='E2E危险区域规则';eventType=$eventType;minimumSeverity='medium';locationContains='东门'}
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $tokenA 403)
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $adminToken)
$eventBody=Get-Content -LiteralPath (Join-Path $bellRoot 'tests\fixtures\synthetic-danger-event.json') -Raw -Encoding UTF8|ConvertFrom-Json
$eventBody.eventType=$eventType
$created=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
$replay=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
if($created.data.duplicate-ne$false-or$replay.data.duplicate-ne$true-or$created.data.event.id-ne$replay.data.event.id){throw 'synthetic Event idempotency failed'}
$eventId=[string]$created.data.event.id
$alerts=Invoke-BellJson GET '/api/v1/bell/alerts?status=open&pageIndex=1&pageSize=20' $null $tokenA
$alert=@($alerts.data.list)[0]
if(-not$alert){throw 'rule evaluation did not create an open Alert'}
$alertId=[string]$alert.id
$alertDetail=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId" $null $tokenA).data
if(@($alertDetail.events.id)-notcontains$eventId){throw 'created Alert is not linked to the synthetic Event'}
$requests=for($i=0;$i-lt 20;$i++){[pscustomobject]@{Token=$(if($i%2-eq0){$tokenA}else{$tokenB})}}
$acks=$requests|ForEach-Object -Parallel {
$headers=@{Authorization="Bearer $($_.Token)"}
$response=Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/alerts/$using:alertId/ack" -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20 -NoProxy
[pscustomobject]@{Token=$_.Token;Response=$response}
} -ThrottleLimit 20
$winners=@($acks|Where-Object{$_.Response.data.won-eq$true})
if($winners.Count-ne 1){throw "concurrent ack winners=$($winners.Count)"}
$lifecycle=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $tokenA).data.detail
if($lifecycle.timeline.Count-ne 1-or$lifecycle.projection.status-ne'acknowledged'){throw 'ack lifecycle projection is inconsistent'}
$winnerToken=[string]$winners[0].Token
$loserToken=$(if($winnerToken-eq$tokenA){$tokenB}else{$tokenA})
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal'} $loserToken 403)
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{} $winnerToken 400)
$closed=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
$closeReplay=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
if($closed.data.won-ne$true-or$closeReplay.data.idempotent-ne$true){throw 'close or idempotent replay failed'}
$final=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
if($final.timeline.Count-ne 2-or$final.projection.status-ne'closed'){throw 'closed timeline is incomplete'}
$facts=(&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select (select count(*) from bell_events),(select count(*) from bell_event_receipts),(select count(*) from bell_alert_lifecycle_facts where alert_id='$alertId');").Trim()
if($facts-ne'1|1|2'){throw "unexpected persisted fact counts: $facts"}
Stop-Package $packageRoot
Start-Package $packageRoot
$after=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
if($after.timeline.Count-ne 2-or$after.projection.closeOutcome-ne'site_normal'){throw 'cold restart lost lifecycle state'}
$rootPage=Invoke-WebRequest -Uri "$script:baseUrl/" -TimeoutSec 10 -NoProxy
if($rootPage.StatusCode-ne 200-or$rootPage.Content-notmatch'id=["'']app["'']'){throw 'packaged GoAdmin web shell is not available'}
if($BrowserHold){
$browserSession=Join-Path $temporary 'browser-session.json';$browserDone=Join-Path $temporary 'browser-done'
@{baseUrl=$script:baseUrl;username=$adminName;password=$adminPassword;alertId=$alertId}|ConvertTo-Json|Set-Content -LiteralPath $browserSession -Encoding UTF8
Write-Host "Bell browser session ready: $browserSession"
for($i=0;$i-lt 1200-and-not(Test-Path -LiteralPath $browserDone);$i++){Start-Sleep -Milliseconds 500}
if(-not(Test-Path -LiteralPath $browserDone)){throw 'Browser verification did not signal completion within 10 minutes'}
}
Stop-Package $packageRoot
foreach($log in @($runtimeOut,$runtimeErr,(Join-Path $packageRoot 'runtime\logs\bell.out.log'),(Join-Path $packageRoot 'runtime\logs\bell.err.log'))){if(Test-Path $log){$text=[string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue);foreach($secret in @($adminPassword,$operatorPassword,$jwt,$adminToken,$tokenA,$tokenB)){if($text.Contains($secret)){throw "runtime log exposed an E2E credential: $log"}}}}
Write-Host "Bell isolated E2E passed: health/login/RBAC, minimal menu, Event/Receipt idempotency, Rule/Alert, 20 concurrent ack, close authorization/idempotency, timeline, cold restart, package start/stop. base_url=$script:baseUrl"
} finally {
try { if($launcher){Stop-Package $packageRoot} } catch { Stop-ProcessTree $launcher }
if($pgStarted){Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$pgData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporary 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden|Out-Null;try{Wait-Tcp -Port $pgPort -Open $false -Attempts 40}catch{}}
foreach($item in $savedEnvironment.GetEnumerator()){[Environment]::SetEnvironmentVariable($item.Key,$item.Value,'Process')}
if(-not$KeepTemporary-and(Test-Path -LiteralPath $temporary)){$resolved=[IO.Path]::GetFullPath($temporary);if(-not$resolved.StartsWith([IO.Path]::GetTempPath(),[StringComparison]::OrdinalIgnoreCase)){throw "Unsafe temporary cleanup path: $resolved"};Remove-Item -LiteralPath $resolved -Recurse -Force}elseif($KeepTemporary){Write-Host "Kept Bell E2E directory: $temporary"}
}
+12
View File
@@ -0,0 +1,12 @@
{
"sourceEventId": "bell-e2e-danger-001",
"eventType": "danger_area_entered",
"occurredAt": "2026-08-29T00:00:00Z",
"location": "东门危险区域",
"severity": "high",
"evidenceRef": "e2e/evidence/bell-e2e-danger-001",
"attributes": {
"target": "anonymous",
"fixture": true
}
}
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getAlertLifecycle(id) { return request({ url: `/api/v1/bell/alerts/${id}/lifecycle`, method: 'get' }) }
export function acknowledgeAlert(id) { return request({ url: `/api/v1/bell/alerts/${id}/ack`, method: 'post' }) }
export function closeAlert(id, data) { return request({ url: `/api/v1/bell/alerts/${id}/close`, method: 'post', data }) }
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function listAlerts(query) {
return request({ url: '/api/v1/bell/alerts', method: 'get', params: query })
}
export function getAlert(id) {
return request({ url: `/api/v1/bell/alerts/${id}`, method: 'get' })
}
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export function listEvents(query) {
return request({ url: '/api/v1/bell/events', method: 'get', params: query })
}
export function getEvent(id) {
return request({ url: `/api/v1/bell/events/${id}`, method: 'get' })
}
export function getEventRuleResults(id) {
return request({ url: `/api/v1/bell/events/${id}/rule-results`, method: 'get' })
}
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listRules(query) {
return request({ url: '/api/v1/bell/rules', method: 'get', params: query })
}
export function createRule(data) {
return request({ url: '/api/v1/bell/rules', method: 'post', data })
}
export function updateRule(id, data) {
return request({ url: `/api/v1/bell/rules/${id}`, method: 'put', data })
}
export function setRuleEnabled(id, enabled) {
return request({ url: `/api/v1/bell/rules/${id}/enabled`, method: 'put', data: { enabled }})
}
@@ -15,7 +15,7 @@
<script>
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
import { mapGetters } from 'vuex'
export default {
@@ -29,7 +29,7 @@
import { mapGetters } from 'vuex'
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
export default {
components: { SidebarItem, Logo },
+1 -1
View File
@@ -20,7 +20,7 @@ import RightPanel from '@/components/RightPanel'
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
export default {
name: 'MainLayout',
+1 -1
View File
@@ -1,4 +1,4 @@
import variables from '@/styles/element-variables.scss'
import variables from '@/styles/element-variables.scss?module'
import defaultSettings from '@/settings'
const { showSettings, topNav, tagsView, fixedHeader, sidebarLogo, themeStyle } = defaultSettings
@@ -0,0 +1,28 @@
<template>
<div class="lifecycle-actions">
<el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" />
<el-button v-if="lifecycle.canAck" v-permisaction="['bell:alert:ack']" type="primary" :loading="loading" @click="ack">我已看到并开始处理</el-button>
<el-button v-if="lifecycle.canClose" v-permisaction="['bell:alert:close']" type="primary" :loading="loading" @click="dialog=true">记录现场结果并完成</el-button>
<el-dialog v-model="dialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" @closed="reset">
<el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false" class="form-alert" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="form.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item>
<el-form-item label="补充说明(可选)"><el-input v-model="form.note" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="loading" @click="finish">确认结果并完成</el-button></template>
</el-dialog>
</div>
</template>
<script>
import { acknowledgeAlert, closeAlert } from '@/api/bell/alert-lifecycle'
export default {
name: 'BellLifecycleActions', props: { alertId: { type: String, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed'],
data() { return { loading: false, error: '', dialog: false, form: { outcome: '', note: '' }, rules: { outcome: [{ required: true, message: '请选择现场结果', trigger: 'change' }] }} },
methods: {
async ack() { this.loading = true; this.error = ''; try { const r = await acknowledgeAlert(this.alertId); this.msgSuccess(r.data.idempotent ? '您已在处理此预警' : '已记录由您开始处理'); this.$emit('changed') } catch (e) { this.error = e.message || '开始处理失败'; this.$emit('changed') } finally { this.loading = false } },
async finish() { try { await this.$refs.formRef.validate(); this.loading = true; this.error = ''; const r = await closeAlert(this.alertId, this.form); this.msgSuccess(r.data.idempotent ? '该结果已记录' : '预警已完成'); this.dialog = false; this.$emit('changed') } catch (e) { if (e && e.message) this.error = e.message } finally { this.loading = false } },
reset() { this.form = { outcome: '', note: '' }; this.$refs.formRef && this.$refs.formRef.clearValidate() }
}
}
</script>
<style scoped>.lifecycle-actions{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}.lifecycle-actions .el-alert{flex-basis:100%}.form-alert{margin-bottom:16px}.outcome-group{display:grid;gap:10px}</style>
@@ -0,0 +1,2 @@
<template><el-timeline><el-timeline-item v-for="item in items" :key="item.id" :timestamp="parseTime(item.occurredAt)" :type="item.transition==='closed'?'success':'primary'"><strong>{{ item.transition === 'closed' ? '处理完成' : '开始处理' }}</strong> · {{ item.actorName }}<div v-if="item.outcome">现场结果:{{ outcomeName(item.outcome) }}<span v-if="item.note">;{{ item.note }}</span></div></el-timeline-item><el-empty v-if="!items.length" description="尚无处理记录" /></el-timeline></template>
<script>export default { name: 'BellLifecycleTimeline', props: { items: { type: Array, default: () => [] }}, methods: { outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
+3
View File
@@ -0,0 +1,3 @@
<template><div><el-descriptions :column="1" border><el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item><el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item><el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusName(lifecycle.projection.status || detail.alert.status) }}</el-descriptions-item><el-descriptions-item label="处理人">{{ lifecycle.projection.acknowledgedByName || '尚未开始处理' }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeOutcome" label="现场结果">{{ outcomeName(lifecycle.projection.closeOutcome) }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeNote" label="处理说明">{{ lifecycle.projection.closeNote }}</el-descriptions-item><el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item><el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item></el-descriptions><LifecycleActions :alert-id="detail.alert.id" :lifecycle="lifecycle" @changed="$emit('changed')" /><h3>关联事件</h3><el-table :data="detail.events" border row-key="id"><el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column><el-table-column prop="eventType" label="事件类型" min-width="150" /><el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="$emit('go-event',scope.row.id)">查看</el-button></template></el-table-column></el-table><h3>处理时间线</h3><LifecycleTimeline :items="lifecycle.timeline" /><h3>命中说明</h3><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">规则 v{{ match.ruleVersion }}:{{ match.explanation }}</el-timeline-item></el-timeline></div></template>
<script>import LifecycleActions from './components/LifecycleActions.vue'; import LifecycleTimeline from './components/LifecycleTimeline.vue'; export default { name: 'BellAlertDetail', components: { LifecycleActions, LifecycleTimeline }, props: { detail: { type: Object, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed', 'go-event'], methods: { statusName(v) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[v] || v }, severityName(v) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[v] || v }, outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
<style scoped>h3{font-size:16px;margin:22px 0 10px}</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><h2>预警管理</h2><p>先开始处理,再记录现场结果完成预警。</p></div></template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="状态" prop="status"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:130px"><el-option label="待处理" value="open" /><el-option label="处理中" value="acknowledged" /><el-option label="已完成" value="closed" /></el-select></el-form-item>
<el-form-item label="风险" prop="severity"><el-select v-model="query.severity" clearable placeholder="全部风险" style="width:130px"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
<el-table-column prop="createdAt" label="创建时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.createdAt) }}</template></el-table-column>
<el-table-column prop="summary" label="预警事项" min-width="200" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="140" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusName(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="eventCount" label="关联事件" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
<template #empty><el-empty description="暂无预警" /></template>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-drawer v-model="drawer" title="预警详情" size="min(720px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { getAlert, listAlerts } from '@/api/bell/alert'
import { getAlertLifecycle } from '@/api/bell/alert-lifecycle'
import BellAlertDetail from './detail.vue'
export default {
name: 'BellAlerts',
components: { BellAlertDetail },
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, detail: null, lifecycle: null, activeId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, status: '', severity: '', location: '' }
}
},
created() { this.load().then(() => { if (this.$route.query.alertId) this.openDetail({ id: this.$route.query.alertId }) }) },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listAlerts(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '预警加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.detail = null; this.lifecycle = null; this.activeId = row.id
try { const [detailResponse, lifecycleResponse] = await Promise.all([getAlert(row.id), getAlertLifecycle(row.id)]); this.detail = detailResponse.data; this.lifecycle = lifecycleResponse.data.detail } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
},
async reloadDetail() { await this.openDetail({ id: this.activeId }); await this.load() },
goEvent(id) { this.drawer = false; this.$router.push({ path: '/bell/events', query: { eventId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' },
statusName(value) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[value] || value },
statusType(value) { return { open: 'danger', acknowledged: 'warning', closed: 'success' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header>
<div class="page-heading">
<div><h2>事件查询</h2><p>原始事件只读保存,规则评估和预警不会改写事件。</p></div>
</div>
</template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="事件类型" prop="eventType"><el-input v-model="query.eventType" clearable placeholder="请输入事件类型" @keyup.enter="search" /></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
<el-form-item label="风险" prop="severity">
<el-select v-model="query.severity" clearable placeholder="全部风险" style="width: 130px">
<el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
<el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column>
<el-table-column prop="eventType" label="事件类型" min-width="160" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="150" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column prop="alertCount" label="关联预警" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
<template #empty><el-empty description="暂无事件" /></template>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-drawer v-model="drawer" title="事件详情" size="min(680px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<template v-if="selected">
<el-descriptions :column="1" border>
<el-descriptions-item label="发生事项">{{ selected.eventType }}</el-descriptions-item>
<el-descriptions-item label="地点">{{ selected.location }}</el-descriptions-item>
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
<el-descriptions-item label="紧急程度">{{ severityName(selected.severity) }}</el-descriptions-item>
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
<el-descriptions-item label="事件来源">{{ selected.producerId }}</el-descriptions-item>
<el-descriptions-item label="来源事件编号">{{ selected.sourceEventId }}</el-descriptions-item>
<el-descriptions-item label="证据引用">{{ selected.evidenceRef || '无' }}</el-descriptions-item>
</el-descriptions>
<h3>关联预警</h3>
<el-table :data="results.alerts" border row-key="id">
<el-table-column prop="summary" label="预警事项" min-width="180" />
<el-table-column prop="status" label="状态" width="90"><template #default><el-tag type="danger">待处理</el-tag></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="goAlert(scope.row.id)">查看</el-button></template></el-table-column>
<template #empty><el-empty description="该事件未生成预警" /></template>
</el-table>
<h3>规则评估</h3>
<el-table :data="results.evaluations" border row-key="ruleId">
<el-table-column label="规则" min-width="160"><template #default="scope">{{ scope.row.ruleSnapshot && scope.row.ruleSnapshot.name }}</template></el-table-column>
<el-table-column prop="ruleVersion" label="版本" width="70" />
<el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.matched ? 'success' : 'info'">{{ scope.row.matched ? '命中' : '未命中' }}</el-tag></template></el-table-column>
<el-table-column prop="explanation" label="说明" min-width="180" />
<template #empty><el-empty description="接收时没有启用规则" /></template>
</el-table>
</template>
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
export default {
name: 'BellEvents',
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, selected: null, results: { alerts: [], evaluations: [] },
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, eventType: '', location: '', severity: '' }
}
},
created() { this.load().then(() => { if (this.$route.query.eventId) this.openDetail({ id: this.$route.query.eventId }) }) },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listEvents(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '事件加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.selected = null
try { const [eventResponse, resultResponse] = await Promise.all([getEvent(row.id), getEventRuleResults(row.id)]); this.selected = eventResponse.data; this.results = resultResponse.data } catch (error) { this.detailError = error.message || '事件详情加载失败' } finally { this.detailLoading = false }
},
goAlert(id) { this.drawer = false; this.$router.push({ path: '/bell/alerts', query: { alertId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><div><h2>规则配置</h2><p>规则修改会产生新版本,已有事件的命中快照不会改变。</p></div><el-button v-permisaction="['bell:rule:add']" type="primary" @click="openCreate">新增规则</el-button></div></template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="规则" prop="name"><el-input v-model="query.name" clearable placeholder="名称或编码" @keyup.enter="search" /></el-form-item>
<el-form-item label="状态" prop="enabled"><el-select v-model="query.enabled" clearable placeholder="全部状态" style="width:130px"><el-option label="已启用" :value="true" /><el-option label="已停用" :value="false" /></el-select></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
<el-table v-loading="loading" :data="items" border row-key="id">
<el-table-column prop="name" label="规则名称" min-width="170" />
<el-table-column prop="code" label="规则编码" min-width="150" />
<el-table-column label="事件类型" min-width="150"><template #default="scope">{{ scope.row.eventType || '全部' }}</template></el-table-column>
<el-table-column label="最低风险" width="100"><template #default="scope"><el-tag :type="severityType(scope.row.minimumSeverity)">{{ severityName(scope.row.minimumSeverity) }}</el-tag></template></el-table-column>
<el-table-column label="地点包含" min-width="130"><template #default="scope">{{ scope.row.locationContains || '不限' }}</template></el-table-column>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canEdit" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column>
<el-table-column label="操作" width="90"><template #default="scope"><el-button v-permisaction="['bell:rule:edit']" link type="primary" @click="openEdit(scope.row)">编辑</el-button></template></el-table-column>
<template #empty><el-empty description="暂无规则" /></template>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="dialog" :title="editing ? '编辑规则' : '新增规则'" width="min(560px, calc(100vw - 32px))" :close-on-click-modal="false" @closed="clearForm">
<el-alert title="保存后仅影响随后接收的事件,历史命中记录保持原样。" type="info" :closable="false" class="state-alert" />
<el-form ref="ruleForm" :model="form" :rules="formRules" label-position="top">
<el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" :disabled="editing" placeholder="如 gate-danger" /></el-form-item>
<el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="事件类型"><el-input v-model.trim="form.eventType" placeholder="留空表示全部类型" maxlength="128" /></el-form-item>
<el-form-item label="最低风险" prop="minimumSeverity"><el-select v-model="form.minimumSeverity" style="width:100%"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点包含"><el-input v-model.trim="form.locationContains" placeholder="留空表示不限地点" maxlength="128" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
export default {
name: 'BellRules',
data() {
return {
loading: false, saving: false, error: '', items: [], total: 0, dialog: false, editing: false, editingId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null },
form: { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' },
formRules: {
code: [{ required: true, pattern: /^[a-z0-9][a-z0-9_-]{1,127}$/, message: '请输入至少2位小写字母、数字、下划线或短横线', trigger: 'blur' }],
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
minimumSeverity: [{ required: true, message: '请选择最低风险', trigger: 'change' }]
}
}
},
computed: {
canEdit() { const values = this.$store.getters.permisaction || []; return values.includes('*:*:*') || values.includes('bell:rule:edit') }
},
created() { this.load() },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listRules(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '规则加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.query.enabled = null; this.search() },
openCreate() { this.editing = false; this.editingId = ''; this.dialog = true },
openEdit(row) {
this.editing = true; this.editingId = row.id
this.form = { code: row.code, name: row.name, eventType: row.eventType || '', minimumSeverity: row.minimumSeverity, locationContains: row.locationContains || '' }
this.dialog = true
},
async save() {
try {
await this.$refs.ruleForm.validate(); this.saving = true
const payload = { ...this.form, eventType: this.form.eventType || null, locationContains: this.form.locationContains || null }
if (this.editing) await updateRule(this.editingId, payload); else await createRule(payload)
this.msgSuccess(this.editing ? '规则已更新并生成新版本' : '规则已创建'); this.dialog = false; await this.load()
} catch (error) { if (error && error.message) this.error = error.message } finally { this.saving = false }
},
async toggle(row) {
try { await setRuleEnabled(row.id, row.enabled); this.msgSuccess(row.enabled ? '规则已启用' : '规则已停用'); await this.load() } catch (error) { row.enabled = !row.enabled; this.error = error.message || '规则状态更新失败' }
},
clearForm() { this.$refs.ruleForm && this.$refs.ruleForm.clearValidate(); this.form = { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' } },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}
</style>
@@ -0,0 +1,14 @@
import request from '@/utils/request'
import { acknowledgeAlert, closeAlert, getAlertLifecycle } from '@/api/bell/alert-lifecycle'
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
describe('Bell alert lifecycle API', () => {
beforeEach(() => request.mockClear())
it('maps timeline, ack and close to protected Alert routes', async() => {
await getAlertLifecycle('a1'); await acknowledgeAlert('a1'); await closeAlert('a1', { outcome: 'site_normal' })
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/alerts/a1/lifecycle', method: 'get' },
{ url: '/api/v1/bell/alerts/a1/ack', method: 'post' },
{ url: '/api/v1/bell/alerts/a1/close', method: 'post', data: { outcome: 'site_normal' }}
])
})
})
@@ -0,0 +1,17 @@
jest.mock('@/api/bell/alert-lifecycle', () => ({
acknowledgeAlert: jest.fn(),
closeAlert: jest.fn(),
getAlertLifecycle: jest.fn()
}))
import AlertDetail from '@/views/bell/alerts/detail.vue'
import LifecycleTimeline from '@/views/bell/alerts/components/LifecycleTimeline.vue'
describe('Bell ordinary-user lifecycle wording', () => {
it('maps technical states and outcomes to familiar wording', () => {
expect(AlertDetail.methods.statusName('open')).toBe('待处理')
expect(AlertDetail.methods.statusName('acknowledged')).toBe('处理中')
expect(AlertDetail.methods.statusName('closed')).toBe('已完成')
expect(AlertDetail.methods.outcomeName('false_positive')).toBe('误报')
expect(LifecycleTimeline.methods.outcomeName('unable_to_confirm')).toBe('无法确认')
})
})
@@ -0,0 +1,41 @@
import request from '@/utils/request'
import { getAlert, listAlerts } from '@/api/bell/alert'
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
describe('Bell rule-alert API adapters', () => {
beforeEach(() => request.mockClear())
it('maps read operations to the versioned Bell routes', async() => {
await listAlerts({ status: 'open' })
await getAlert('alert-1')
await listEvents({ severity: 'high' })
await getEvent('event-1')
await getEventRuleResults('event-1')
await listRules({ enabled: true })
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/alerts', method: 'get', params: { status: 'open' }},
{ url: '/api/v1/bell/alerts/alert-1', method: 'get' },
{ url: '/api/v1/bell/events', method: 'get', params: { severity: 'high' }},
{ url: '/api/v1/bell/events/event-1', method: 'get' },
{ url: '/api/v1/bell/events/event-1/rule-results', method: 'get' },
{ url: '/api/v1/bell/rules', method: 'get', params: { enabled: true }}
])
})
it('maps administrator writes without exposing unrelated operations', async() => {
const payload = { name: '区域规则', minimumSeverity: 'high' }
await createRule(payload)
await updateRule('rule-1', payload)
await setRuleEnabled('rule-1', false)
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/rules', method: 'post', data: payload },
{ url: '/api/v1/bell/rules/rule-1', method: 'put', data: payload },
{ url: '/api/v1/bell/rules/rule-1/enabled', method: 'put', data: { enabled: false }}
])
})
})
@@ -0,0 +1,44 @@
import RulesPage from '@/views/bell/rules/index.vue'
import { listRules } from '@/api/bell/rule'
jest.mock('@/api/bell/rule', () => ({
createRule: jest.fn(),
listRules: jest.fn(),
setRuleEnabled: jest.fn(),
updateRule: jest.fn()
}))
function pageContext() {
return {
loading: false,
error: '',
items: [],
total: 0,
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }
}
}
describe('Bell ordinary warning rule page', () => {
beforeEach(() => jest.clearAllMocks())
it('only enables rule changes for the GoAdmin edit permission', () => {
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:list'] }}})).toBe(false)
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:edit'] }}})).toBe(true)
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['*:*:*'] }}})).toBe(true)
})
it('keeps an empty list as an intentional page state', async() => {
listRules.mockResolvedValue({ data: { list: [], count: 0 }})
const context = pageContext()
await RulesPage.methods.load.call(context)
expect(context).toMatchObject({ loading: false, error: '', items: [], total: 0 })
})
it('surfaces a failed list request instead of leaving a blank page', async() => {
listRules.mockRejectedValue(new Error('网络不可用'))
const context = pageContext()
await RulesPage.methods.load.call(context)
expect(context.loading).toBe(false)
expect(context.error).toBe('网络不可用')
})
})
@@ -0,0 +1,16 @@
const fs = require('fs')
const path = require('path')
const valueImports = [
['src/store/modules/settings.js', "@/styles/element-variables.scss?module"],
['src/layout/index.vue', "@/styles/variables.scss?module"],
['src/layout/components/Sidebar/Logo.vue', "@/styles/variables.scss?module"],
['src/layout/components/Sidebar/index.vue', "@/styles/variables.scss?module"]
]
describe('GoAdmin shell Sass value imports', () => {
it.each(valueImports)('%s explicitly requests CSS Modules exports', (file, request) => {
const source = fs.readFileSync(path.join(__dirname, '../../..', file), 'utf8')
expect(source).toContain(`from '${request}'`)
})
})
+8
View File
@@ -15,6 +15,14 @@
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
默认入口只复制 Git 已跟踪的 `Sense/` 源码到系统临时目录,因此正常开发工作区中已有的 `node_modules`、`dist`、本地配置、日志和其他未跟踪文件不会进入验收副本。`-PreparedPackageRoot` 也会先把指定包复制到本次临时目录,运行时配置、浏览器脚本、截图和日志不会写回原包或源码树。
Sense 进程提前退出或 HTTP 就绪超时时,脚本返回非零并输出阶段、退出状态、临时日志位置和经过过滤、截断的日志摘要;数据库连接、密码、token、Cookie、JWT 和 credential key 不得出现在诊断中。默认无论成功或失败都会清理所属进程和临时目录;`-KeepTemporary` 仅用于排错,仍会停止进程,但保留目录可能包含随机运行时秘密,必须限制访问并在排错后安全删除。
为避免 Windows 首次扫描临时复制的 MediaMTX 二进制占用产品固定的就绪窗口,E2E 会先在同一动态端口和临时配置上启动一次包内 MediaMTX,确认 Control API 可用并完全停止,再由 Sense 以 managed 模式启动并完成生命周期验收。预检失败会单独报告 `MediaMTX preflight` 阶段,不会被误报为 Sense HTTP 超时。
HTTP、RTSP、HLS、Control API 和 ONVIF 动态端口使用 TCP 绑定探测;WebRTC 本地 UDP 端口必须使用 UDP socket 实际绑定探测,不得用 TCP 空闲结果代替,避免落入 Windows 的 UDP 排除或占用范围。
## 回归矩阵
| 范围 | 自动化证据 | 判定 |
@@ -49,4 +49,18 @@ foreach ($file in $fixtureFiles) {
if ($file.Extension -eq '.json') { [void]($content | ConvertFrom-Json); $passed++ }
}
$e2eScript = Read-Utf8 (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1')
Assert-True ($e2eScript.Contains('Copy-TrackedSenseSource $repositoryRoot $senseCopy')) 'Sense E2E no longer copies only tracked source'
Assert-True (-not $e2eScript.Contains('Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse')) 'Sense E2E regressed to recursive source-tree copying'
Assert-True ($e2eScript.Contains("`$browserScript = Join-Path `$PSScriptRoot 'browser-smoke.cjs'")) 'browser smoke script no longer runs from its tracked location'
Assert-True ($e2eScript.Contains("`$packageRoot = Join-Path `$temporary 'prepared-package'")) 'prepared package no longer runs from an isolated temporary copy'
Assert-True ($e2eScript.Contains("-Process `$process -Stage 'Sense HTTP'")) 'Sense HTTP readiness no longer observes the package process'
Assert-True ($e2eScript.Contains('Get-SafeLogSummary')) 'Sense readiness diagnostics no longer use log redaction'
Assert-True ($e2eScript.Contains("-Stage 'MediaMTX preflight'")) 'Sense E2E no longer preflights the copied MediaMTX package and config'
Assert-True ($e2eScript.Contains('function Get-FreeUdpPort')) 'Sense E2E no longer probes WebRTC UDP ports with the UDP protocol'
Assert-True ($e2eScript.Contains('do { $webrtcUDPort = Get-FreeUdpPort }')) 'Sense E2E WebRTC UDP port regressed to TCP-only discovery'
& powershell.exe -NoProfile -File (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1') -HarnessSelfTest
if ($LASTEXITCODE -ne 0) { throw 'Sense E2E harness self-test failed' }
Write-Host "Sense compatibility regression passed: $passed assertions."
+141 -16
View File
@@ -3,11 +3,13 @@ param(
[string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe',
[string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe',
[string]$PreparedPackageRoot = '',
[switch]$HarnessSelfTest,
[switch]$KeepTemporary
)
if ($PSVersionTable.PSEdition -eq 'Core') {
$legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser)
if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) }
if ($HarnessSelfTest) { $legacyArguments += '-HarnessSelfTest' }
if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' }
& powershell.exe @legacyArguments
exit $LASTEXITCODE
@@ -34,8 +36,10 @@ $fixtureStatus = Join-Path $temporary 'fixture-status.json'
$server = $null
$fixture = $null
$publisher = $null
$preflightMedia = $null
$pgStarted = $false
$savedEnvironment = @{}
$sensitiveValues = @()
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
@@ -61,15 +65,68 @@ function Set-TestEnvironment([string]$Name, [string]$Value) {
}
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
}
function Wait-Http([string]$Uri, [int]$Attempts = 120) {
function Get-FreeUdpPort {
$client = [Net.Sockets.UdpClient]::new([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, 0))
try { return ([Net.IPEndPoint]$client.Client.LocalEndPoint).Port } finally { $client.Dispose() }
}
function Copy-TrackedSenseSource([string]$RepositoryRoot, [string]$Destination) {
$tracked = @(& git -C $RepositoryRoot ls-files -- 'Sense')
if ($LASTEXITCODE -ne 0 -or $tracked.Count -eq 0) { throw 'Could not enumerate tracked Sense source files' }
$sensePrefix = 'Sense\'
foreach ($relative in $tracked) {
$normalized = ([string]$relative).Replace('/', '\')
if (-not $normalized.StartsWith($sensePrefix, [StringComparison]::Ordinal)) {
throw "Unexpected tracked path outside Sense: $relative"
}
$source = Join-Path $RepositoryRoot $normalized
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Tracked Sense source is missing: $relative" }
$target = Join-Path $Destination $normalized.Substring($sensePrefix.Length)
$parent = Split-Path -Parent $target
if (-not (Test-Path -LiteralPath $parent)) { [void](New-Item -ItemType Directory -Path $parent -Force) }
Copy-Item -LiteralPath $source -Destination $target
}
}
function Get-SafeLogSummary([string[]]$Paths, [int]$MaximumCharacters = 2000) {
$parts = New-Object System.Collections.Generic.List[string]
foreach ($path in @($Paths)) {
if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
$text = [string](@(Get-Content -LiteralPath $path -Tail 20 -ErrorAction SilentlyContinue) -join ' | ')
foreach ($secret in @($script:sensitiveValues)) {
if (-not [string]::IsNullOrWhiteSpace($secret) -and $secret.Length -ge 4) { $text = $text.Replace($secret, '<redacted>') }
}
$text = $text -replace '(?i)((?:password|token|secret|credential(?:_key)?|database(?:_url)?|cookie|authorization)["'']?\s*[:=]\s*["'']?)[^\s,;"'']+', '$1<redacted>'
$text = $text -replace '(?i)(postgres(?:ql)?://)[^\s]+', '$1<redacted>'
if ($text.Length -gt $MaximumCharacters) { $text = $text.Substring($text.Length - $MaximumCharacters) }
if (-not [string]::IsNullOrWhiteSpace($text)) { $parts.Add("$([IO.Path]::GetFileName($path)): $text") }
}
if ($parts.Count -eq 0) { return '<no log output>' }
return ($parts -join ' || ')
}
function Wait-Http {
param(
[string]$Uri,
[int]$Attempts = 120,
$Process = $null,
[string]$Stage = 'HTTP endpoint',
[string[]]$LogPaths = @()
)
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
if ($null -ne $Process -and $Process.HasExited) {
try { $Process.WaitForExit(); $Process.Refresh() } catch {}
$exitCode = try { [string]$Process.ExitCode } catch { 'unknown' }
if ([string]::IsNullOrWhiteSpace($exitCode)) { $exitCode = 'unknown' }
$summary = Get-SafeLogSummary $LogPaths
throw "$Stage process exited before readiness: exit_code=$exitCode; log_files=$($LogPaths -join ','); summary=$summary"
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1
if ($response.StatusCode -eq 200) { return }
} catch {}
Start-Sleep -Milliseconds 500
}
throw "HTTP endpoint did not become ready: $Uri"
$processState = if ($null -eq $Process) { 'not-observed' } elseif ($Process.HasExited) { "exited:$($Process.ExitCode)" } else { 'running' }
$summary = Get-SafeLogSummary $LogPaths
throw "$Stage did not become ready: uri=$Uri; process_state=$processState; log_files=$($LogPaths -join ','); summary=$summary"
}
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
@@ -104,13 +161,62 @@ function Invoke-SenseJson {
function Start-SensePackage([string]$PackageRoot) {
$launcher = Join-Path $PackageRoot 'start-sense.bat'
$process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru
Wait-Http "$script:baseUrl/"
Wait-Http -Uri "$script:baseUrl/" -Process $process -Stage 'Sense HTTP' -LogPaths @($runtimeLog, $runtimeError)
return $process
}
function Stop-ProcessTree($Process) {
if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null }
}
function Invoke-HarnessSelfTest {
$root = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-selftest-' + [guid]::NewGuid().ToString('N'))
$originalSensitiveValues = @($script:sensitiveValues)
try {
$fixtureRepository = Join-Path $root 'repository'
$trackedSource = Join-Path $fixtureRepository 'Sense\tracked.txt'
$ignoredSource = Join-Path $fixtureRepository 'Sense\ui\node_modules\ignored.txt'
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $trackedSource) -Force)
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $ignoredSource) -Force)
[IO.File]::WriteAllText($trackedSource, 'tracked', (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText($ignoredSource, 'ignored', (New-Object Text.UTF8Encoding($false)))
& git -C $fixtureRepository init --quiet
& git -C $fixtureRepository add -- 'Sense/tracked.txt'
if ($LASTEXITCODE -ne 0) { throw 'Harness self-test could not prepare tracked source' }
$copy = Join-Path $root 'copy'
Copy-TrackedSenseSource $fixtureRepository $copy
if (-not (Test-Path -LiteralPath (Join-Path $copy 'tracked.txt'))) { throw 'Harness self-test did not copy tracked source' }
if (Test-Path -LiteralPath (Join-Path $copy 'ui\node_modules\ignored.txt')) { throw 'Harness self-test copied ignored node_modules content' }
$udpPort = Get-FreeUdpPort
$udpProbe = [Net.Sockets.UdpClient]::new()
try { $udpProbe.Client.Bind([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, $udpPort)) } finally { $udpProbe.Dispose() }
$diagnosticLog = Join-Path $root 'sense.err.log'
[IO.File]::WriteAllText($diagnosticLog, 'SENSE_JWT_SECRET=unit-secret-value', (New-Object Text.UTF8Encoding($false)))
$script:sensitiveValues = @('unit-secret-value')
$exited = [pscustomobject]@{ HasExited = $true; ExitCode = 23 }
$earlyFailure = ''
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 3 -Process $exited -Stage 'Self-test early exit' -LogPaths @($diagnosticLog) } catch { $earlyFailure = $_.Exception.Message }
if ($earlyFailure -notmatch 'exit_code=23' -or $earlyFailure.Contains('unit-secret-value') -or $earlyFailure -notmatch '<redacted>') {
throw "Harness self-test early-exit diagnostic was unsafe or incomplete: $earlyFailure"
}
$timeoutFailure = ''
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 1 -Stage 'Self-test timeout' -LogPaths @($diagnosticLog) } catch { $timeoutFailure = $_.Exception.Message }
if ($timeoutFailure -notmatch 'process_state=not-observed' -or $timeoutFailure.Contains('unit-secret-value') -or $timeoutFailure -notmatch '<redacted>') {
throw "Harness self-test timeout diagnostic was unsafe or incomplete: $timeoutFailure"
}
Write-Host 'Sense E2E harness self-test passed: tracked copy, UDP bind, early exit, timeout and redaction.'
} finally {
$script:sensitiveValues = $originalSensitiveValues
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
}
if ($HarnessSelfTest) {
Invoke-HarnessSelfTest
exit 0
}
try {
foreach ($required in @(
(Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'),
@@ -121,25 +227,32 @@ try {
}
$ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source
if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) {
New-Item -ItemType Directory -Path $repoCopy | Out-Null
Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse
New-Item -ItemType Directory -Path $senseCopy -Force | Out-Null
Copy-TrackedSenseSource $repositoryRoot $senseCopy
& git -C $repoCopy init --quiet
& git -C $repoCopy config user.name 'Sense E2E'
& git -C $repoCopy config user.email 'sense-e2e@invalid.local'
& git -C $repoCopy commit --allow-empty --quiet -m 'temporary acceptance source'
& git -C $repoCopy config core.autocrlf false
& git -C $repoCopy add -- Sense
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source staging failed' }
& git -C $repoCopy commit --quiet -m 'temporary acceptance source'
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source commit failed' }
Write-Host 'Building Sense Windows package in an isolated temporary copy...'
& pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX
if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' }
$packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64'
} else {
$packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot)
if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' }
$senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..'))
Write-Host "Using prepared isolated package: $packageRoot"
$preparedInput = [IO.Path]::GetFullPath($PreparedPackageRoot)
if (-not (Test-Path -LiteralPath (Join-Path $preparedInput 'sense.exe'))) { throw 'prepared Sense package is invalid' }
$packageRoot = Join-Path $temporary 'prepared-package'
Copy-Item -LiteralPath $preparedInput -Destination $packageRoot -Recurse
Write-Host "Using temporary copy of prepared package: $packageRoot"
}
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8
$tcpPorts = @(Get-UniqueFreePorts 7)
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $mediaAPIPort, $onvifPort = $tcpPorts
do { $webrtcUDPort = Get-FreeUdpPort } while ($tcpPorts -contains $webrtcUDPort)
$script:baseUrl = "http://127.0.0.1:$sensePort"
Write-Host "Initializing isolated PostgreSQL on port $pgPort..."
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null
@@ -162,6 +275,16 @@ try {
'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:'
) -join "`n"
[IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false)))
$preflightOut = Join-Path $temporary 'mediamtx-preflight.out.log'
$preflightError = Join-Path $temporary 'mediamtx-preflight.err.log'
$preflightBinary = Join-Path $packageRoot 'bin\mediamtx.exe'
$preflightConfig = Join-Path $packageRoot 'config\mediamtx.yml'
$preflightMedia = Start-Process -FilePath $preflightBinary -ArgumentList $preflightConfig -WorkingDirectory (Split-Path -Parent $preflightConfig) -RedirectStandardOutput $preflightOut -RedirectStandardError $preflightError -WindowStyle Hidden -PassThru
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $preflightMedia -Stage 'MediaMTX preflight' -LogPaths @($preflightOut, $preflightError) -Attempts 60
Stop-ProcessTree $preflightMedia
Wait-Tcp -Port $mediaAPIPort -Open $false -Attempts 40
$preflightMedia = $null
Write-Host 'MediaMTX package/config preflight passed before managed Sense startup.'
$jwt = New-RandomText 48
$bootstrap = New-RandomText 48
@@ -173,6 +296,7 @@ try {
$cameraUser = 'fixture_' + (New-RandomText 8)
$cameraPassword = New-RandomText 24
$database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable"
$script:sensitiveValues = @($jwt, $bootstrap, $adminPassword, $credentialKey, $cameraUser, $cameraPassword, $database)
$environment = @{
SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database;
SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap;
@@ -198,7 +322,7 @@ try {
if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' }
$server = Start-SensePackage $packageRoot
Wait-Http "http://127.0.0.1:$mediaAPIPort/v3/config/global/get"
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $server -Stage 'MediaMTX API' -LogPaths @($runtimeLog, $runtimeError)
$publisherArguments = @(
'-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp',
@@ -214,6 +338,7 @@ try {
if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' }
$login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword }
$token = [string]$login.token
$script:sensitiveValues += $token
if ($token.Length -lt 20) { throw 'login did not return a usable token' }
$unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401
@@ -265,13 +390,12 @@ try {
$area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0]
if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' }
$browserScript = Join-Path $senseCopy 'ui\sense-browser-smoke.cjs'
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript
$browserScript = Join-Path $PSScriptRoot 'browser-smoke.cjs'
foreach ($item in @{
SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser;
SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png')
}.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
Push-Location (Join-Path $senseCopy 'ui')
Push-Location $temporary
try { & node.exe $browserScript } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' }
@@ -299,7 +423,7 @@ try {
$plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim()
if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' }
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError)) {
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError, $preflightOut, $preflightError)) {
if (Test-Path $log) {
$text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue)
if ($null -eq $text) { $text = '' }
@@ -311,6 +435,7 @@ try {
Stop-ProcessTree $publisher
Stop-ProcessTree $fixture
Stop-ProcessTree $server
Stop-ProcessTree $preflightMedia
if ($pgStarted) {
$pgStopArguments = "-D `"$pgData`" -m fast stop"
[void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru)
+35
View File
@@ -0,0 +1,35 @@
# Sense → Brain 媒体源与区域规则配置契约 v1
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
## 文件
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
- `examples/valid/`:可接受的 active 与待重校准快照。
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
## 消费规则
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
6. `effective_at` 不得早于 `published_at`。
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
## 可复制验证
从仓库根目录运行:
```powershell
& contracts\tests\source-config-v1\run.ps1
```
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
@@ -0,0 +1,29 @@
# v1 兼容、迁移与回退
## 兼容规则
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
## revision 与生效
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
## 支持周期
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
## 回退
1. 停止分发有问题的新主版本或新 revision。
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,8 @@
{
"coordinate-out-of-range.json": "schema",
"credential-field.json": "secret",
"internal-path.json": "internal path",
"profile-binding-mismatch.json": "profile binding",
"query-token.json": "secret",
"unknown-major-version.json": "unknown schema"
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v2",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,62 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream",
"width": 1920,
"height": 1080,
"encoding": "H264",
"frame_rate": 25
},
"media": {
"ref": "media:site-east/entry-01/main",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-7",
"state": "active",
"profile_binding": {
"profile_id": "main-stream",
"width": 1920,
"height": 1080
},
"areas": [
{
"id": "danger-yard",
"version": 3,
"kind": "danger_area",
"enabled": true,
"points": [
{"x": 0.12, "y": 0.18},
{"x": 0.82, "y": 0.18},
{"x": 0.76, "y": 0.78},
{"x": 0.18, "y": 0.72}
]
}
],
"directional_lines": [
{
"id": "entry-line",
"version": 2,
"kind": "directional_line",
"enabled": true,
"start": {"x": 0.2, "y": 0.5},
"end": {"x": 0.8, "y": 0.5},
"trigger_direction": "left_to_right"
}
]
},
"integrity": {
"algorithm": "sha256",
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
}
}
@@ -0,0 +1,39 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 8,
"published_at": "2026-08-31T01:00:00Z",
"effective_at": "2026-08-31T01:00:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream-v2",
"width": 1280,
"height": 720,
"encoding": "H265",
"frame_rate": 20
},
"media": {
"ref": "media:site-east/entry-01/main-v2",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-8",
"state": "recalibration_required",
"profile_binding": {
"profile_id": "main-stream-v2",
"width": 1280,
"height": 720
},
"areas": [],
"directional_lines": []
},
"integrity": {
"algorithm": "sha256",
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
}
}
@@ -0,0 +1,35 @@
# 生产者与消费者 mapper 字段表
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|---|---|---|
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
## 测试责任
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
@@ -0,0 +1,273 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
"title": "YoVision Sense to Brain source configuration snapshot v1",
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"config_id",
"revision",
"published_at",
"effective_at",
"site",
"logical_device",
"profile",
"media",
"rule_set",
"integrity"
],
"properties": {
"schema_version": {
"const": "yovision.source-config/v1"
},
"config_id": {
"$ref": "#/$defs/stable_id"
},
"revision": {
"type": "integer",
"minimum": 1
},
"published_at": {
"type": "string",
"format": "date-time"
},
"effective_at": {
"type": "string",
"format": "date-time"
},
"site": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"logical_device": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"profile": {
"$ref": "#/$defs/profile"
},
"media": {
"type": "object",
"additionalProperties": false,
"required": ["ref", "transport"],
"properties": {
"ref": {
"type": "string",
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
},
"transport": {
"enum": ["rtsp"]
}
}
},
"rule_set": {
"type": "object",
"additionalProperties": false,
"required": [
"version",
"state",
"profile_binding",
"areas",
"directional_lines"
],
"properties": {
"version": {
"$ref": "#/$defs/stable_id"
},
"state": {
"enum": ["active", "disabled", "recalibration_required"]
},
"profile_binding": {
"$ref": "#/$defs/profile_binding"
},
"areas": {
"type": "array",
"items": {
"$ref": "#/$defs/area_rule"
},
"maxItems": 1024
},
"directional_lines": {
"type": "array",
"items": {
"$ref": "#/$defs/directional_line_rule"
},
"maxItems": 1024
}
}
},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "value"],
"properties": {
"algorithm": {
"const": "sha256"
},
"value": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"extensions": {
"type": "object",
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
"propertyNames": {
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
},
"additionalProperties": {
"type": "object"
}
}
},
"$defs": {
"stable_id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
},
"positive_integer": {
"type": "integer",
"minimum": 1
},
"positive_number": {
"type": "number",
"exclusiveMinimum": 0
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": ["id", "width", "height", "encoding", "frame_rate"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
},
"encoding": {
"enum": ["H264", "H265", "MJPEG"]
},
"frame_rate": {
"$ref": "#/$defs/positive_number"
}
}
},
"profile_binding": {
"type": "object",
"additionalProperties": false,
"required": ["profile_id", "width", "height"],
"properties": {
"profile_id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
}
}
},
"point": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y"],
"properties": {
"x": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"y": {
"type": "number",
"minimum": 0,
"maximum": 1
}
}
},
"area_rule": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "kind", "enabled", "points"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "danger_area"
},
"enabled": {
"type": "boolean"
},
"points": {
"type": "array",
"items": {
"$ref": "#/$defs/point"
},
"minItems": 3,
"maxItems": 256
}
}
},
"directional_line_rule": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"kind",
"enabled",
"start",
"end",
"trigger_direction"
],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "directional_line"
},
"enabled": {
"type": "boolean"
},
"start": {
"$ref": "#/$defs/point"
},
"end": {
"$ref": "#/$defs/point"
},
"trigger_direction": {
"enum": ["left_to_right", "right_to_left"]
}
}
}
}
}
@@ -0,0 +1,2 @@
jsonschema==4.23.0
rfc8785==0.1.4
+33
View File
@@ -0,0 +1,33 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$testDirectory = $PSScriptRoot
$requirements = Join-Path $testDirectory 'requirements.txt'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $workDirectory | Out-Null
$virtualEnvironment = Join-Path $workDirectory '.venv'
python -m venv $virtualEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
& $python -m pip install --quiet --requirement $requirements
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' }
& $python -m unittest discover -s $testDirectory -p 'test_*.py' -v
if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' }
}
finally {
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a temporary directory outside $tempRoot"
}
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
}
}
@@ -0,0 +1,261 @@
from __future__ import annotations
import copy
import hashlib
import json
import re
import unittest
from datetime import datetime
from pathlib import Path
from typing import Any
import rfc8785
from jsonschema import Draft202012Validator, FormatChecker
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1"
SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json"
VALID_ROOT = CONTRACT_ROOT / "examples" / "valid"
INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid"
FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE)
FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]")
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise AssertionError(f"{path} must contain a JSON object")
return value
SCHEMA = load_json(SCHEMA_PATH)
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
def integrity_value(payload: dict[str, Any]) -> str:
content = copy.deepcopy(payload)
content.pop("integrity", None)
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
def set_integrity(payload: dict[str, Any]) -> None:
payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)}
def reject_secrets(value: Any, path: str = "config") -> None:
if isinstance(value, dict):
for key, child in value.items():
if FORBIDDEN_KEY.search(str(key)):
raise ValueError(f"secret field is forbidden at {path}.{key}")
reject_secrets(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
reject_secrets(child, f"{path}[{index}]")
def polygon_area(points: list[dict[str, float]]) -> float:
return abs(
sum(
point["x"] * points[(index + 1) % len(points)]["y"]
- points[(index + 1) % len(points)]["x"] * point["y"]
for index, point in enumerate(points)
)
/ 2
)
def validate_payload(payload: dict[str, Any]) -> None:
if payload.get("schema_version") != "yovision.source-config/v1":
raise ValueError("unknown schema major version")
reject_secrets(payload)
media_ref = str(payload.get("media", {}).get("ref", ""))
if (
FORBIDDEN_MEDIA_CHARACTER.search(media_ref)
or "://" in media_ref
or re.match(r"^[A-Za-z]:", media_ref)
):
raise ValueError("secret, query, authority, or internal path in media reference")
errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path))
if errors:
first = errors[0]
location = ".".join(str(part) for part in first.absolute_path) or "config"
raise ValueError(f"schema validation failed at {location}: {first.message}")
profile = payload["profile"]
binding = payload["rule_set"]["profile_binding"]
if (binding["profile_id"], binding["width"], binding["height"]) != (
profile["id"],
profile["width"],
profile["height"],
):
raise ValueError("profile binding does not match the media profile")
published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
if effective_at < published_at:
raise ValueError("effective_at precedes published_at")
rule_set = payload["rule_set"]
rules = [*rule_set["areas"], *rule_set["directional_lines"]]
identifiers = [rule["id"] for rule in rules]
if len(identifiers) != len(set(identifiers)):
raise ValueError("rule ids must be unique across the rule set")
if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules):
raise ValueError("recalibration-required rules must not remain enabled")
for area in rule_set["areas"]:
if polygon_area(area["points"]) <= 1e-12:
raise ValueError(f"area {area['id']} is a degenerate polygon")
for line in rule_set["directional_lines"]:
if line["start"] == line["end"]:
raise ValueError(f"directional line {line['id']} has identical endpoints")
if payload["integrity"]["value"] != integrity_value(payload):
raise ValueError("integrity digest mismatch")
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
validate_payload(previous)
validate_payload(current)
if previous["config_id"] != current["config_id"]:
raise ValueError("config_id cannot change within one revision stream")
if current["revision"] <= previous["revision"]:
raise ValueError("revision must increase strictly")
previous_profile = previous["profile"]
current_profile = current["profile"]
profile_changed = any(
previous_profile[field] != current_profile[field]
for field in ("id", "width", "height", "encoding")
)
previous_rule_versions = sorted(
(rule["id"], rule["version"])
for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
)
current_rule_versions = sorted(
(rule["id"], rule["version"])
for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
)
if (
profile_changed
and previous_rule_versions == current_rule_versions
and current["rule_set"]["state"] != "recalibration_required"
):
raise ValueError("profile changed without rule recalibration state or new rule versions")
class SourceConfigV1ContractTests(unittest.TestCase):
def test_schema_is_valid_draft_2020_12(self) -> None:
Draft202012Validator.check_schema(SCHEMA)
def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None:
examples = sorted(VALID_ROOT.glob("*.json"))
self.assertGreaterEqual(len(examples), 2)
for path in examples:
with self.subTest(path=path.name):
validate_payload(load_json(path))
def test_invalid_examples_fail_for_the_declared_reason(self) -> None:
expected = load_json(INVALID_ROOT / "expected-errors.json")
self.assertGreaterEqual(len(expected), 6)
for filename, reason in expected.items():
with self.subTest(path=filename):
with self.assertRaisesRegex(ValueError, str(reason)):
validate_payload(load_json(INVALID_ROOT / filename))
def test_tampering_is_detected_after_other_validation(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["revision"] += 1
with self.assertRaisesRegex(ValueError, "integrity digest mismatch"):
validate_payload(payload)
def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}}
set_integrity(payload)
validate_payload(payload)
payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}}
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "secret field"):
validate_payload(payload)
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["profile_binding"]["width"] = 1280
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "profile binding"):
validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["state"] = "recalibration_required"
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "must not remain enabled"):
validate_payload(payload)
def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None:
previous = load_json(VALID_ROOT / "active.json")
current = copy.deepcopy(previous)
current["revision"] = previous["revision"]
set_integrity(current)
with self.assertRaisesRegex(ValueError, "revision must increase"):
validate_transition(previous, current)
current["revision"] += 1
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
current["rule_set"]["profile_binding"].update(
{"profile_id": "main-stream-v2", "width": 1280, "height": 720}
)
set_integrity(current)
with self.assertRaisesRegex(ValueError, "without rule recalibration"):
validate_transition(previous, current)
validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json"))
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["areas"][0]["points"] = [
{"x": 0, "y": 0},
{"x": 0.5, "y": 0.5},
{"x": 1, "y": 1},
]
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "degenerate polygon"):
validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "ids must be unique"):
validate_payload(payload)
def test_effective_time_cannot_precede_publication(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["effective_at"] = "2026-08-30T23:59:59Z"
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "precedes"):
validate_payload(payload)
def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None:
for path in sorted(VALID_ROOT.glob("*.json")):
serialized = json.dumps(load_json(path), ensure_ascii=False).lower()
self.assertNotIn("brain.internal.input", serialized)
self.assertNotIn("streamuri", serialized)
self.assertNotIn("profiletoken", serialized)
self.assertNotIn("database", serialized)
self.assertNotRegex(serialized, r"[a-z]:\\")
def test_mapper_documents_both_product_test_responsibilities(self) -> None:
mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8")
self.assertIn("Sense 生产者契约测试", mapper)
self.assertIn("Brain 消费者契约测试", mapper)
self.assertIn("brain.internal.input/v1", mapper)
self.assertIn("admissionProfile.StreamURI", mapper)
if __name__ == "__main__":
unittest.main()
+7 -7
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: 5894b3f4e3152420bd9addd63c1ce80205a6fd80
synchronized_at: 2026-08-27T15:22:01Z
wiki_revision: 3ec1fe54504a9c5eabb76dc19f0e46eb6c58ba08
synchronized_at: 2026-08-29T12:37:08Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -19,7 +19,7 @@ synchronized_at: 2026-08-27T15:22:01Z
| 首期客户场景 | 民办寄宿学校,默认 16 路高风险点位 |
| 首期规则 | 越线、危险区域、聚集等匿名安全规则;不启用人脸 |
| 产品形态 | Sense 与 Bell 两个独立销售产品,Brain 为独立推理交付单元 |
| 当前阶段 | Sense 独立纵切、Brain Python 骨架、Bell GoAdmin 产品骨架已通过用户验收并合入 `dev`;旧实现归档于 `explore`,`main` 仍为审核基线 |
| 当前阶段 | MVP #8 三项目首个独立纵切已于 2026-08-29 通过用户验收并合入 `dev`:Sense 完成摄像头接入到区域配置,Brain 完成合成输入到匿名本地事件,Bell 完成合成事件到 Alert ack/close;旧实现归档于 `explore`,`main` 仍为审核基线 |
| 历史来源 | `D:\OPC\yovision_old`,只读追溯 |
## DevHarness 来源与基线
@@ -54,7 +54,7 @@ YoVision 采用 DevHarness 的共同工作流、统一 `harness.py` 命令、Git
- 证据:客户侧 MinIO/S3 兼容对象存储;常态录像优先留在客户已有 NVR。
- 首期验证平台:NVIDIA x86/Jetson;M1-M3 不承诺 GB/T 28181、信创或原生 App。
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。当前 `dev` 中的 Sense、Bell 已分别从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时仍必须核对冻结 go-admin-doc。Brain 已建立独立 Python/PyTorch 包骨架,但尚未包含推理业务能力。
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。当前 `dev` 中的 Sense、Bell 已分别从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时仍必须核对冻结 go-admin-doc。Brain 已在独立 Python/PyTorch 包骨架上完成合成/本地输入、解码、匿名检测与单路跟踪、区域/方向越线判定和项目内匿名事件输出;真实 GPU、生产模型和跨项目契约仍属后续范围。
## 阅读入口
@@ -108,20 +108,20 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
<!-- sense-runtime:start -->
## Sense 重建状态
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;当前成果已合入 `dev`,并于 2026-08-27 通过用户验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;#145 又修复默认验收入口的受控源码复制、UDP 端口探测、临时清理和脱敏诊断。当前成果已合入 `dev`,并随 MVP #8 于 2026-08-29 通过三项目独立纵切验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
<!-- sense-runtime:end -->
<!-- bell-runtime:start -->
## Bell 重建状态
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。当前最小启用骨架已通过后端、前端和隔离 PostgreSQL smoke,并于 2026-08-27 通过用户验收、合入 `dev`;事件、规则、Alert 等业务能力继续按独立工单迁移。
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。#131–#134 已完成 Event/Receipt、合成事件、规则匹配、Alert ack/close、审计时间线、Windows 交付和独立 E2E;生产验证码、最小菜单和 GoAdmin 外壳缺陷也已闭环。当前成果已合入 `dev`,并随 MVP #8 于 2026-08-29 通过三项目独立纵切验收。
<!-- bell-runtime:end -->
<!-- brain-runtime:start -->
## Brain 初始化状态
Brain 已建立 CPython 3.11.15 / PyTorch 2.12.1 的无界面包骨架,提供安装、版本、runtime-info 与 CPU/CUDA smoke 入口。CPU wheel、包测试和 CPU tensor smoke 已通过,并于 2026-08-27 通过用户验收、合入 `dev`;CUDA wheel、真实 GPU、视频、模型、规则、事件与部署尚未验证或实现。
Brain 已在 CPython 3.11.15 / PyTorch 2.12.1 无界面包骨架上完成合成与本地视频输入、可替换解码、匿名检测与单路跟踪、危险区域与方向越线判定,以及项目内匿名事件输出。独立验收中 43 项测试通过,CLI 合成输入实际生成 `brain.internal.event-candidate/v1` 匿名事件;当前成果已合入 `dev`,并随 MVP #8 于 2026-08-29 通过用户验收。CUDA wheel、真实 GPU、生产模型、容量和跨项目事件契约仍未验证。
<!-- brain-runtime:end -->
## 分支治理
+62 -2
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: 578ddbaae3d7e846037d958085e40609cd398bef
synchronized_at: 2026-08-28T08:02:32Z
wiki_revision: 14b961599d6954357142713a5667fb37d38e86b7
synchronized_at: 2026-08-29T12:37:31Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -236,3 +236,63 @@ PostgreSQL 表 `sense_provisioning_batches` 保存幂等键、配额快照和汇
- GoAdmin 路由位于 `Sense/server/app/admin/router/sense_media_shard.go`,只开放列表、详情和迁移预检三个 GET 接口。go-admin-ui 页面位于 `Sense/ui/src/views/sense/media-shard/`,复用 BasicLayout、Element Plus 表格、进度、Dialog、Tag、Alert 和权限指令。
- 迁移 `2026082814000_media_shard.go` 建表、注册动态菜单并为 implementation_operator、site_admin、viewer 建立只读权限;生产迁移和启动不写入合成分片。
<!-- sense-media-shards:end -->
<!-- sense-outbox:start -->
## Sense 内部可靠投递入口
工单 #78 在 `Sense/server/app/sense/outbox/` 建立内部事务 Outbox。业务写入通过同一 GORM 事务创建领域记录与 outbox;`Sense/server/app/sense/local_event/outbox.go` 是当前首个原子写入入口。GoAdmin 路由位于 `Sense/server/app/admin/router/sense_outbox.go`,迁移与菜单/RBAC 位于 `Sense/server/cmd/migrate/migration/version/2026082815000_outbox.go`,前端页面位于 `Sense/ui/src/views/sense/outbox/index.vue`。
内部状态为 pending、processing、retry、dead、delivered。relay 使用数据库 claim、lease 和版本号避免并发重复领取;失败按退避进入 retry,超过上限进入 dead,租约过期可恢复。成功投递写入永久幂等收据。当前模块不定义 Brain/Bell 正式 schema、connector 或机器身份,内部 payload 也不通过管理 API 暴露。
<!-- sense-outbox:end -->
<!-- sense-ops-alerts:start -->
## Sense 运维告警代码路径
工单 #79 在 `Sense/server/app/sense/ops_alert/` 建立持久化运维告警:`sense_ops_alerts` 以“告警类型 + 对象类型 + 对象 ID”唯一指纹保存当前生命周期,`sense_ops_alert_transitions` 追加发现、确认、健康恢复、恢复确认、恢复失败和再次发生历史。健康事实只读取既有设备接入、媒体路由、媒体分片和边缘节点投影,不建立第二套设备或媒体状态事实源。
GoAdmin 路由位于 `Sense/server/app/admin/router/sense_ops_alert.go`,API 为 `GET /api/v1/ops-alerts`、`GET /api/v1/ops-alerts/:id`、`POST /api/v1/ops-alerts/evaluate`、`POST /api/v1/ops-alerts/:id/acknowledge` 和 `POST /api/v1/ops-alerts/:id/recover`。前端入口为 `Sense/ui/src/views/sense/ops-alert/index.vue`,继续复用 GoAdmin BasicLayout、动态菜单、Axios、Element Plus 表格/表单/分页/Dialog/Tag/Alert 和权限指令。
本模块只写 Sense 运维告警和 GoAdmin 操作审计,不导入或写入本地安全事件、Brain、Bell、Outbox 或共享契约模型。viewer 只读;implementation_operator 与 site_admin 可刷新健康事实、确认和恢复。
<!-- sense-ops-alerts:end -->
<!-- brain-input-v1:start -->
## Brain 内部输入与配置边界
Brain 的首个独立输入边界位于 `Brain/src/yovision_brain/input/`,项目内配置模型位于 `Brain/src/yovision_brain/config/`。配置显式标记为 `brain.internal.input/v1`,只用于 Brain 独立开发与测试,不是 Sense→Brain 共享契约。
输入端口当前提供确定性 RGB 合成源和显式本地文件源。两者携带逻辑设备、Profile 与分辨率元数据;合成源提供固定种子、帧序列和确定性时间基准,本地文件源提供可替换解码器消费的容器字节、EOF 和协作取消边界。错误只暴露安全文件标签,不把机器绝对路径、凭据或客户数据写入日志/事件。
正式 RTSP、Sense 源配置、共享区域契约和跨项目投递仍由协调工单建立版本化 `contracts/` 适配器,不得把本内部模型直接发布给 Sense 或 Bell。
<!-- brain-input-v1:end -->
<!-- brain-decode-v1:start -->
## Brain 可替换解码边界
Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内部 `InputPacket` 端口,向后续视觉模块输出顺序、纳秒时间戳、逻辑设备、Profile、分辨率、像素格式和尺寸变化标记明确的 `DecodedFrame`。具体后端通过 `DecoderBackend` 注册,不要求检测、跟踪或规则层依赖某个编解码 SDK。
当前独立纵切支持确定性 RGB24 合成帧,以及标准库实现的最小 YUV4MPEG2 C444 本地视频流。Y4M 只用于匿名本地/合成验证;生产 RTSP、FFmpeg/PyAV、NVIDIA 硬件解码、重连和多路调度仍是后续范围。损坏输入、不支持格式、Profile 尺寸不匹配和安全大小上限均产生明确错误;正常 EOF 与主动取消不伪装成失败。
<!-- brain-decode-v1:end -->
<!-- brain-vision-v1:start -->
## Brain 匿名检测与单路跟踪边界
`Brain/src/yovision_brain/vision/` 定义可替换 Detector、匿名边界框观测和会话内单路 IoU 跟踪。输出仅包含类别 `anonymous_target`、置信度、边界框、帧时间和当前进程内轨迹 ID;轨迹 ID 不跨进程、不跨摄像头,也不是自然人身份。
当前基线是版本 `1.0.0` 的 YoVision first-party 亮度连通区域算法,并提供 PyTorch 2.12.1 张量实现;不分发外部模型权重,PyTorch 许可已在 Brain 第三方清单记录。它用于验证匿名检测/跟踪链路,不代表人员检测效果,不承诺召回率或误报率。人脸、生物特征和跨摄像头 ReID 均未启用。
<!-- brain-vision-v1:end -->
<!-- brain-rules-v1:start -->
## Brain 区域与方向越线规则边界
`Brain/src/yovision_brain/rules/` 只消费匿名轨迹。轨迹框底边中心是归一化规则锚点;多边形边界视为区域内,状态区分 outside、entered、inside。有向警戒线按起点→终点的左右侧定义 `left_to_right` / `right_to_left`,deadband 内不触发且保留上一次显著侧。
每个结果绑定规则配置版本、Profile、分辨率、锚点和可解释原因。结果是 Brain 内部候选,不是标准事件或 Bell Alert;时段、持续时间、冷却、聚集和正式 Sense 配置契约不在本阶段。
<!-- brain-rules-v1:end -->
<!-- brain-local-events-v1:start -->
## Brain 独立纵切与内部事件边界
`Brain/src/yovision_brain/app/` 编排输入、解码、匿名检测/跟踪和规则端口;`Brain/src/yovision_brain/events/` 将触发结果映射为 `brain.internal.event-candidate/v1` 并写入可替换 JSON Lines sink。事件 ID 基于规范化输入事实与版本的 SHA-256,同一输入、配置和实现版本重复运行保持稳定。
内部候选包含逻辑输入引用、规则/模型版本、发生时间、匿名框和解释原因,不包含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或证据引用。该格式不是 Brain→Bell 共享契约;Bell API、Outbox、机器身份、证据和跨项目投递必须由协调工单另行实现。
<!-- brain-local-events-v1:end -->
+25 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: 999eb1aee3558ff75cfa929acac77841af20b742
synchronized_at: 2026-08-28T08:02:42Z
wiki_revision: 27749cbf699093d997284afc277ea53e73a5876f
synchronized_at: 2026-08-29T12:37:41Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -210,3 +210,26 @@ synchronized_at: 2026-08-28T08:02:42Z
- 跨分片迁移预检是只读操作,只检查源状态、全部受影响路径和候选目标容量;即使预检通过也不授予执行权限。实际迁移必须另建高风险工单并取得人工确认。
- 额外分片只能配置为 external,Control API 必须使用无用户信息、无查询参数、无路径的本机回环 HTTP 地址。Sense 不停止外部实例。
<!-- sense-media-shards:end -->
<!-- sense-outbox:start -->
## Sense 内部 Outbox 业务规则
- 领域记录与 outbox 必须在同一 PostgreSQL 事务中提交;任一写入失败时两者一起回滚。
- 幂等键在消息表唯一,成功后还保留永久投递收据;重试和人工恢复沿用原业务记录与幂等键。
- worker 只能领取到期的 pending/retry 或租约已过期的 processing 记录;同一记录不能被两个 worker 同时成功领取。
- 失败保留脱敏错误与尝试历史,按退避等待;达到最大次数进入 dead。人工重新排队必须填写原因并记录操作者,不删除历史。
- implementation_operator、site_admin、viewer 可查看;只有 implementation_operator、site_admin 可重新排队。
- 未配置外部 connector 时保留内部记录且不阻断 Sense 核心功能。测试 sink 在 prod/production 模式禁止启用。
- 管理 API 不返回内部 payload、外部凭据或机器身份;Brain/Bell 正式协议属于后续协调工单。
<!-- sense-outbox:end -->
<!-- sense-ops-alerts:start -->
## Sense 运维告警规则
- 六类运维告警固定为:设备/边缘节点离线、设备认证失败、设备时间漂移、媒体状态对账失败、媒体分片异常、控制隧道异常。
- 每个“告警类型 + 对象类型 + 对象 ID”只有一条记录;同一源版本重复刷新不增加发现次数,也不产生第二条活动告警。恢复后再次异常复用原记录、递增处理周期并保留全部历史。
- 状态为 `unacknowledged`(待确认)、`acknowledged`(已确认)、`recovering`(恢复观察)、`recovered`(已恢复)。人工确认只表示已接手,不表示故障恢复。
- 健康事实恢复后先进入固定 5 分钟观察窗口;只有 `recovering` 且观察窗口结束后才能人工确认恢复。观察期再次异常返回原处理状态并追加恢复失败历史。
- 确认和恢复都要求 6–256 字符原因、当前版本和允许的状态;旧版本或错误状态返回冲突。所有动作写入独立流转历史和 GoAdmin 操作审计。
- 运维告警永远设置为 Sense 内部运维记录,不创建本地安全事件或 Bell Alert,不进入跨项目 Outbox,也不实现通知升级。
<!-- sense-ops-alerts:end -->
+111 -2
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: 05435d53528271a866c525655486689afc198762
synchronized_at: 2026-08-28T08:02:52Z
wiki_revision: e6068ff0e42765d32ff4e0ee0e8e51cf7d79b7da
synchronized_at: 2026-08-29T12:37:58Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -515,3 +515,112 @@ go test ./cmd/migrate/migration/version -run TestMediaShardMigrationOnPostgres -
验证应覆盖任意配置容量、稳定重复分配、容量耗尽、分片故障后归属不变、设备/Profile/路径影响范围、只读迁移预检、只读 RBAC、Control API 不出现在响应,以及 Brain/Bell 均不运行。没有专用 PostgreSQL 连接时必须记录真库迁移测试未执行。
<!-- sense-media-shards:end -->
<!-- sense-outbox:start -->
## Sense Outbox 本地验证
从 `Sense/server` 运行完整后端测试:
```powershell
go test ./...
```
PostgreSQL 多 worker 集成测试必须使用专用隔离数据库,不得指向开发或生产库:
```powershell
$env:SENSE_OUTBOX_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
go test ./app/sense/outbox -run TestPostgresConcurrentWorkersDoNotClaimSameMessage -count=1 -v
```
前端从 `Sense/ui` 运行:
```powershell
pnpm lint
pnpm test:unit -- --runInBand
pnpm build:prod
```
验证至少覆盖:领域记录与 outbox 原子回滚、并发 claim/lease、租约恢复、退避与 dead、人工重新排队及操作人、永久幂等收据、production 禁用测试 sink、只读/恢复权限、API 不泄露 payload,以及 Brain/Bell 均不运行时页面可观察。无专用 PostgreSQL 连接时必须明确记录真库并发测试未执行。
<!-- sense-outbox:end -->
<!-- sense-ops-alerts:start -->
## Sense 运维告警验证
从后端目录运行:
```powershell
cd Sense/server
go test ./app/sense/ops_alert ./app/admin/router ./cmd/migrate/migration/version
go test ./...
```
从前端目录运行:
```powershell
cd Sense/ui
pnpm lint
pnpm test:unit -- --runInBand
pnpm build:prod
```
故障注入至少覆盖六类来源、相同源版本重复刷新、健康恢复、5 分钟观察门槛、观察期复发、恢复后再次发生、旧版本并发冲突、viewer 只读权限和脱敏操作审计。Brain/Bell 不启动。隔离启动 smoke 必须验证 `2026082816000_ops_alert.go` 迁移、菜单和 API 注册;不得把开发或生产数据库当作破坏性故障注入库。
<!-- sense-ops-alerts:end -->
<!-- brain-input-v1:start -->
## Brain 合成与本地输入验证
从仓库根目录使用 Brain 的隔离 CPython 3.11 环境执行:
```powershell
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/input Brain/tests/config -q
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
```
定向测试覆盖固定种子与时间基准、Profile/分辨率和规则配置、EOF、取消、文件不存在、非法配置、凭据字段拒绝及安全错误文本。测试只使用运行时生成的小型匿名字节文件,不启动 Sense/Bell,不连接摄像头或网络服务。
<!-- brain-input-v1:end -->
<!-- brain-decode-v1:start -->
## Brain 视频解码验证
```powershell
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/decode -q
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
```
定向测试使用运行时生成的匿名 YUV4MPEG2 字节流,覆盖跨输入分块解码、顺序与时间戳、Profile/分辨率、RGB24 尺寸变化、正常 EOF、主动取消、截断帧、不支持格式/色度和配置尺寸不匹配。该结果不证明生产 RTSP、硬件解码、GPU 或多路性能。
<!-- brain-decode-v1:end -->
<!-- brain-vision-v1:start -->
## Brain 匿名检测与跟踪验证
```powershell
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/vision -q
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cpu
```
定向测试覆盖空帧、目标出现/移动、短暂遮挡、消失、轨迹结束、会话 ID 边界及 PyTorch CPU 后端。合成几何帧不含人脸或客户数据;结果只证明链路可运行,不是效果评估。
<!-- brain-vision-v1:end -->
<!-- brain-rules-v1:start -->
## Brain 区域与方向越线验证
```powershell
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/rules -q
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
```
定向测试覆盖区域外/进入/内部、边界点、正反方向、贴线 deadband、无效多边形/警戒线、重复 ID 和 Profile/分辨率不匹配;只使用合成归一化几何与匿名轨迹。
<!-- brain-rules-v1:end -->
<!-- brain-local-events-v1:start -->
## Brain 独立纵切运行与验证
```powershell
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/events Brain/tests/app -q
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
Brain\.venv\Scripts\python.exe -m yovision_brain.app --config Brain\tests\fixtures\events\area.json --output -
```
CLI 将内部事件 JSON Lines 写入 stdout,并把 completed/cancelled、帧数、检测数和事件数摘要写入 stderr。配置文件必须显式提供,当前使用 JSON;无命中正常返回零事件,读取/配置/模块失败返回非零且不回显机器路径。命令不启动 Sense/Bell、不连接摄像头或网络。
<!-- brain-local-events-v1:end -->
+42 -2
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: dcbdcf563017a1749fa76ad5f78c74a2c3cc6be1
synchronized_at: 2026-08-28T06:16:00Z
wiki_revision: eac307b5aa55ff770ff034c53a65b70dc01cb00d
synchronized_at: 2026-08-29T12:39:32Z
<!-- gitea-wiki-mirror:end -->
# 产品需求
@@ -232,3 +232,43 @@ Sense 为网管和非技术运维人员提供只读的“边缘节点”页面
本能力只管理 Sense 自有投影,不建立 Brain/Bell 共享身份、控制协议或跨项目回填执行;Brain、Bell 未运行时仍可独立查看。合成节点仅供显式开发和测试,不由生产启动或迁移自动写入。
<!-- sense-edge-nodes:end -->
<!-- brain-input-delivery:start -->
## BRN-001 独立输入适配交付边界
BRN-001 的首个独立实现已通过工单 #11 验收。Brain 可在 Sense、Bell 均未启动时使用固定种子和时间基准生成可重复的 RGB 合成帧,也可从显式本地路径读取容器字节供后续解码层消费;两种输入都携带 Brain 内部逻辑设备、Profile 和分辨率信息,并支持 EOF、协作取消及安全可定位错误。
项目内配置版本为 `brain.internal.input/v1`,可承载测试用区域和方向线,但它不是 Sense→Brain 共享契约。正式 RTSP、Sense 源/区域配置和跨项目机器身份仍须由协调工单在版本化 `contracts/` 中冻结;不得让 Sense 或 Bell 直接依赖此内部模型。配置和测试不得包含摄像头凭据、客户视频、个人数据或机器绝对路径。
<!-- brain-input-delivery:end -->
<!-- brain-decode-delivery:start -->
## BRN-002 独立解码交付边界
BRN-002 的首个解码阶段已通过工单 #13 验收。Brain 通过可替换 `DecoderBackend` 把内部输入转换为顺序、纳秒时间戳、逻辑设备、Profile、分辨率和像素格式明确的帧;当前独立路径支持确定性 RGB24 与匿名本地 YUV4MPEG2 C444。正常 EOF、主动取消、损坏或不支持格式、尺寸变化/不匹配均有明确结果。
该验收不包括生产 RTSP、FFmpeg/PyAV、NVIDIA 硬件解码、多路性能或客户视频,不得据此声明 GPU/生产编解码能力。
<!-- brain-decode-delivery:end -->
<!-- brain-vision-delivery:start -->
## BRN-002 匿名检测与跟踪交付边界
工单 #14 已验收匿名目标检测和会话内单路跟踪。输出只包含匿名类别、置信度、边界框、帧时间和当前进程内轨迹 ID;不包含姓名、人脸模板、生物特征、摄像头凭据或跨摄像头身份。
当前版本化基线是无外部权重的 first-party 亮度目标算法及 PyTorch 2.12.1 张量后端,只证明匿名检测/跟踪接口与链路可运行。真实人员检测效果、GPU、召回率、误报率和 ReID 均未验证或启用。
<!-- brain-vision-delivery:end -->
<!-- brain-rules-delivery:start -->
## BRN-003/BRN-004 区域与方向规则交付边界
工单 #15 已验收 Brain 内部危险区域与方向越线判定。轨迹框底边中心为归一化锚点;多边形边界视为区域内,状态区分 outside、entered、inside;有向线按起点→终点区分左右方向,并使用 deadband 抑制贴线抖动。
每个结果绑定规则配置版本、Profile、分辨率和解释原因。结果仍是 Brain 内部候选,不是 Bell Alert 或正式共享事件;聚集、完整时段/持续/冷却和正式 Sense 配置契约仍是后续范围。
<!-- brain-rules-delivery:end -->
<!-- brain-local-events-delivery:start -->
## BRN-005 独立内部事件候选交付边界
工单 #16 已验收 Brain 首个独立纵切:合成/本地输入经过解码、匿名检测/单路跟踪和区域/方向规则后,可输出 `brain.internal.event-candidate/v1` JSON Lines 候选。事件 ID 基于规范化输入事实与版本生成稳定 SHA-256;相同输入、配置和版本重复运行不制造不同 ID。
内部候选只含逻辑输入引用、规则/模型版本、发生时间、匿名观测和解释原因,不含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或伪造证据。该格式不是正式 Brain→Bell 契约;证据、机器身份、Outbox/可靠投递和跨项目 E2E 仍须协调工单实现。
<!-- brain-local-events-delivery:end -->
+39 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Roadmap
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Roadmap.-
wiki_revision: 2d5b8550109a8ff0795ad46dd0f96c85929fb506
synchronized_at: 2026-08-11T10:31:04Z
wiki_revision: 5142de162b4665bd7c9ff201168cb0d4a7552f35
synchronized_at: 2026-08-29T12:40:13Z
<!-- gitea-wiki-mirror:end -->
# 产品路线图
@@ -84,3 +84,40 @@ Sense/Brain Event → 持久 Outbox/可靠投递
- 契约未冻结却尝试共享数据库、用户会话或内部文件;
- 新纵切尚未验收却删除或覆盖旧仓库;
- 许可证、隐私或客户/法务门禁未满足却进入生产试点。
<!-- brain-input-delivery:start -->
## Brain 独立纵切进度
- 工单 #11 已验收:确定性合成输入、本地文件输入和 Brain 内部版本化配置已合入 `dev`。
- 下一项按真实依赖进入 #13 视频解码流水线;#14 检测/跟踪、#15 区域/越线和 #16 项目内匿名事件仍需依次完成。
- 当前输入模型只用于 Brain 独立纵切,不代替阶段 2 的 Sense→Brain 正式契约。
<!-- brain-input-delivery:end -->
<!-- brain-decode-delivery:start -->
## Brain 解码进度
- 工单 #13 已验收:可替换解码端口、RGB24 和匿名本地 YUV4MPEG2 路径已合入 `dev`。
- 下一项进入 #14 匿名检测与单路跟踪;#15、#16 仍按依赖顺序推进。
<!-- brain-decode-delivery:end -->
<!-- brain-vision-delivery:start -->
## Brain 匿名视觉进度
- 工单 #14 已验收并合入 `dev`;下一项进入 #15 区域与方向越线规则。
- 当前基线不代表生产模型效果,#16 项目内事件仍未完成。
<!-- brain-vision-delivery:end -->
<!-- brain-rules-delivery:start -->
## Brain 规则进度
- 工单 #15 已验收并合入 `dev`;下一项进入 #16 独立纵切与内部匿名事件。
- #16 完成前,Brain 首个独立纵切仍未闭环。
<!-- brain-rules-delivery:end -->
<!-- brain-local-events-delivery:start -->
## Brain 首个独立纵切完成状态
- #10、#11、#13、#14、#15、#16 已全部通过用户验收。
- Brain 可在 Sense/Bell 未启动时,以合成输入产生稳定的项目内匿名区域事件。
- 下一步是 MVP #8 三项目独立纵切集成验收;正式跨项目契约与投递不属于该 MVP。
<!-- brain-local-events-delivery:end -->
+24 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
wiki_revision: ce246054849b5b797dc4da0a55116238a4c00d7e
synchronized_at: 2026-08-28T08:04:52Z
wiki_revision: b21bbc64f323f465b78b557537c38e334de31f45
synchronized_at: 2026-08-29T12:41:01Z
<!-- gitea-wiki-mirror:end -->
# YoVision 部署与运维
@@ -112,3 +112,25 @@ Sense\start_sense.bat
页面“运行异常”表示最近一次 Control API 探测失败;“状态已陈旧”表示运行循环已超过 30 秒没有更新探测结果。故障时先在详情定位设备/Profile/路径,不要手工改数据库归属。迁移预检不会执行迁移;任何实际跨分片迁移都必须另建高风险工单和回退方案。
<!-- sense-media-shards:end -->
<!-- sense-outbox:start -->
## Sense 可靠投递运维与排错
升级后应执行包含 `2026082815000_outbox.go` 的数据库迁移。看不到“可靠投递”菜单时,先确认迁移成功,再重新登录或刷新动态菜单。页面提供等待投递、重试、处理中/租约和死信数量;未配置正式 connector 时队列保留,不影响 Sense 设备接入、实时监看和其他核心能力。
积压时先查看状态、可用时间、租约、尝试次数和最近脱敏错误。processing 长时间不恢复时检查 worker 是否仍运行、数据库时间与租约是否过期;不要手工清空租约或删除消息。dead 只能由 implementation_operator 或 site_admin 在排除根因后填写恢复原因重新排队,原业务记录、幂等键和失败历史必须保留。
日志、页面和 API 不得输出内部 payload、外部凭据或机器身份。production 配置不得启用测试 sink。正式 Brain/Bell connector、机器身份、共享 schema 和跨项目 E2E 必须通过后续协调工单交付;停用 relay 可以作为回退,但不得删除未投递记录或永久幂等收据。
<!-- sense-outbox:end -->
<!-- sense-ops-alerts:start -->
## Sense 运维告警运行与排错
升级后必须执行包含 `2026082816000_ops_alert.go` 的数据库迁移。看不到“运维告警”菜单时,先确认迁移成功,再重新登录或刷新动态菜单。viewer 只能查看列表和详情;implementation_operator、site_admin 可使用“刷新状态”、确认和恢复。
“刷新状态”只读取 Sense 数据库中已有的设备接入、媒体路由、媒体分片和边缘节点健康投影。没有对应健康投影时不会伪造演示告警;先检查上游模块是否已完成探测或心跳入库。分片超过 30 秒没有探测、节点超过 90 秒没有心跳会被判定异常。
确认后仍显示活动告警是正常行为:确认只代表有人处理。源状态健康后进入“恢复观察”,稳定满 5 分钟才能确认恢复;期间复发会返回待确认或已确认。恢复操作被拒绝时先刷新列表,检查健康状态、观察起始时间和页面版本,不要手工改表或删除历史。
运维告警排错不得粘贴设备地址、Stream URI、摄像头凭据、JWT、Cookie 或数据库连接。需要回退时可停止使用刷新/处置入口,但不得删除 `sense_ops_alerts` 或 `sense_ops_alert_transitions` 历史;规则语义变化必须另建工单。
<!-- sense-ops-alerts:end -->