Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e61167500 | ||
|
|
67391acb16 | ||
|
|
49aa79f3b9 | ||
|
|
64e20e6aed | ||
|
|
19c0868c5d | ||
|
|
1c6b30fac0 | ||
|
|
6702b8a5b9 | ||
|
|
a35f1d6770 | ||
|
|
6194b664ee | ||
|
|
1caa429cad | ||
|
|
f99fe8d4f7 | ||
|
|
b4a8e0e1f1 | ||
|
|
86c3e79121 | ||
|
|
cabc29c18b | ||
|
|
452cd71035 | ||
|
|
f09a61e5fb | ||
|
|
afc58f7bf4 | ||
|
|
b01ca1fe09 | ||
|
|
689de560bb |
@@ -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 单测替代;未执行的项目必须在工单证据中明确说明。
|
||||
@@ -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 或生命周期事实。
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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, "查询成功")
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }) }
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}'`)
|
||||
})
|
||||
})
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 -->
|
||||
|
||||
## 分支治理
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user