Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5128f080b4 | ||
|
|
e81f00e9aa | ||
|
|
5adee5c3b4 | ||
|
|
a69ef627c7 | ||
|
|
d4de462d44 | ||
|
|
0276bceab5 | ||
|
|
c2b2943a3a | ||
|
|
55b12df373 | ||
|
|
27d465c250 | ||
|
|
4a2c4aa638 | ||
|
|
504dd1a2e9 | ||
|
|
e2f7183ecf | ||
|
|
eb4e1a9ea1 | ||
|
|
9055f2522c | ||
|
|
130087a1ba | ||
|
|
0e53e04e95 | ||
|
|
04c5deecfb | ||
|
|
82afce1f81 | ||
|
|
cf00d73436 | ||
|
|
ae9bd015c2 | ||
|
|
06e0790f00 | ||
|
|
009dc3cca0 | ||
|
|
573113eb3b | ||
|
|
b548b05874 | ||
|
|
23a85278cb | ||
|
|
96777a948f | ||
|
|
c2b023c9fe | ||
|
|
4c35da9ef6 | ||
|
|
30c43aa8d7 | ||
|
|
a22d3ce0f1 | ||
|
|
359c553452 | ||
|
|
2e61167500 | ||
|
|
54c58551ae | ||
|
|
67391acb16 | ||
|
|
2a395aa126 | ||
|
|
e4fed702c4 | ||
|
|
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,59 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrChannelKeyUnavailable = errors.New("联系人通道加密密钥未配置或格式错误")
|
||||
|
||||
func ParseChannelKey(value string) ([]byte, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, ErrChannelKeyUnavailable
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptAddress(key []byte, value string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, ErrChannelKeyUnavailable
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(value), nil), nil
|
||||
}
|
||||
|
||||
func decryptAddress(key, encoded []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", ErrChannelKeyUnavailable
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(encoded) < gcm.NonceSize() {
|
||||
return "", errors.New("通道密文已损坏")
|
||||
}
|
||||
plain, err := gcm.Open(nil, encoded[:gcm.NonceSize()], encoded[gcm.NonceSize():], nil)
|
||||
return string(plain), err
|
||||
}
|
||||
|
||||
func fingerprint(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"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"`
|
||||
ExpectedVersion int `json:"expectedVersion" binding:"required"`
|
||||
}
|
||||
type validationInput struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func (h Handler) service() (Service, error) {
|
||||
key, err := ParseChannelKey(os.Getenv("BELL_CONTACT_CHANNEL_KEY"))
|
||||
return NewService(h.Orm, key), err
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var q PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm, nil).List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
h.Error(500, errors.New("读取联系人失败"), "读取联系人失败")
|
||||
return
|
||||
}
|
||||
p, s := pageValues(q.PageIndex, q.PageSize)
|
||||
h.PageOK(items, int(count), p, s, "查询成功")
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).Create(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) Update(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input enabledInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil || input.Enabled == nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, input.ExpectedVersion, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) AddChannel(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护通道"), "仅管理员可维护通道")
|
||||
return
|
||||
}
|
||||
if err := restoreChannelBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
var input ChannelInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
service, keyErr := h.service()
|
||||
if keyErr != nil {
|
||||
h.Error(503, keyErr, keyErr.Error())
|
||||
return
|
||||
}
|
||||
item, err := service.AddChannel(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) ValidateChannel(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可验证通道"), "仅管理员可验证通道")
|
||||
return
|
||||
}
|
||||
var input validationInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).RecordValidation(c.Request.Context(), c.Param("id"), input.Status, input.Detail, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) result(item any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid):
|
||||
h.Error(400, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(404, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.Error(409, err, err.Error())
|
||||
case errors.Is(err, ErrChannelKeyUnavailable):
|
||||
h.Error(503, err, err.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell contact failed: %v", err)
|
||||
h.Error(409, errors.New("联系人保存失败"), "联系人保存失败")
|
||||
}
|
||||
}
|
||||
func admin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
var _ = http.StatusOK
|
||||
@@ -0,0 +1,68 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
Role string `json:"role" gorm:"size:128;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
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 (Contact) TableName() string { return "bell_contacts" }
|
||||
|
||||
type Channel struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
|
||||
Kind string `json:"kind" gorm:"size:16;not null"`
|
||||
AddressCiphertext []byte `json:"-" gorm:"type:bytea;not null"`
|
||||
AddressFingerprint string `json:"-" gorm:"size:64;not null;index"`
|
||||
AddressMasked string `json:"addressMasked" gorm:"size:64;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Channel) TableName() string { return "bell_contact_channels" }
|
||||
|
||||
type ChannelValidation struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ChannelID string `json:"channelId" gorm:"type:uuid;not null;index"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
Detail string `json:"detail" gorm:"size:256;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (ChannelValidation) TableName() string { return "bell_contact_channel_validations" }
|
||||
|
||||
type AuditFact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
|
||||
Action string `json:"action" gorm:"size:32;not null"`
|
||||
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AuditFact) TableName() string { return "bell_contact_audit_facts" }
|
||||
|
||||
type ChannelView struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
AddressMasked string `json:"addressMasked"`
|
||||
Status string `json:"status"`
|
||||
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ContactView struct {
|
||||
Contact
|
||||
Channels []ChannelView `json:"channels"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxChannelRequestBytes = 8 * 1024
|
||||
const channelBodyKey = "bell.contact.channel-body"
|
||||
const channelBodyErrorKey = "bell.contact.channel-body-error"
|
||||
|
||||
var redactedChannelBody = []byte(`{"redacted":true}`)
|
||||
|
||||
// RedactRequestBody must run before GoAdmin's LoggerToFile middleware. The
|
||||
// handler restores the original body from Gin context, while sys_opera_log
|
||||
// only sees a fixed marker and never the contact address.
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || !isChannelCreatePath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxChannelRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(channelBodyErrorKey, err)
|
||||
} else if len(body) > maxChannelRequestBytes {
|
||||
c.Set(channelBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(channelBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(redactedChannelBody))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreChannelBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(channelBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(channelBodyKey)
|
||||
if !ok {
|
||||
return errors.New("channel request body was not captured")
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func isChannelCreatePath(path string) bool {
|
||||
const prefix = "/api/v1/bell/contacts/"
|
||||
const suffix = "/channels"
|
||||
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) {
|
||||
return false
|
||||
}
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
|
||||
return id != "" && !strings.Contains(id, "/")
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, key []byte) Service { return Service{DB: db, Key: key} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]ContactView, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Contact{})
|
||||
if name := strings.TrimSpace(query.Name); name != "" {
|
||||
db = db.Where("name ILIKE ? OR role 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
|
||||
}
|
||||
var contacts []Contact
|
||||
if err := db.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&contacts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views := make([]ContactView, 0, len(contacts))
|
||||
for _, item := range contacts {
|
||||
view, err := s.view(ctx, item)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return views, count, nil
|
||||
}
|
||||
|
||||
func (s Service) Create(ctx context.Context, input WriteInput, actor int) (ContactView, error) {
|
||||
input, err := normalizeContact(input)
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Contact{ID: uuid.NewString(), Name: input.Name, Role: input.Role, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, "created", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return ContactView{Contact: item, Channels: []ChannelView{}}, nil
|
||||
}
|
||||
|
||||
func (s Service) Update(ctx context.Context, id string, input WriteInput, actor int) (ContactView, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return ContactView{}, ErrNotFound
|
||||
}
|
||||
input, err := normalizeContact(input)
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
if input.ExpectedVersion < 1 {
|
||||
return ContactView{}, ErrInvalid
|
||||
}
|
||||
var item Contact
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "role": input.Role, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
var count int64
|
||||
_ = tx.Model(&Contact{}).Where("id = ?", id).Count(&count).Error
|
||||
if count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, "updated", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, expectedVersion, actor int) (ContactView, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return ContactView{}, ErrNotFound
|
||||
}
|
||||
if expectedVersion < 1 {
|
||||
return ContactView{}, ErrInvalid
|
||||
}
|
||||
var item Contact
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, expectedVersion).Updates(map[string]any{"enabled": enabled, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, map[bool]string{true: "enabled", false: "disabled"}[enabled], actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
|
||||
func (s Service) AddChannel(ctx context.Context, contactID string, input ChannelInput, actor int) (ChannelView, error) {
|
||||
if len(s.Key) != 32 {
|
||||
return ChannelView{}, ErrChannelKeyUnavailable
|
||||
}
|
||||
if _, err := uuid.Parse(contactID); err != nil {
|
||||
return ChannelView{}, ErrNotFound
|
||||
}
|
||||
input, err := normalizeChannel(input)
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
ciphertext, err := encryptAddress(s.Key, input.Address)
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Channel{ID: uuid.NewString(), ContactID: contactID, Kind: input.Kind, AddressCiphertext: ciphertext, AddressFingerprint: fingerprint(input.Address), AddressMasked: maskAddress(input.Address), CreatedBy: actor, CreatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var contact Contact
|
||||
if err := tx.First(&contact, "id = ?", contactID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, contact, "channel_added", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
return ChannelView{ID: item.ID, Kind: item.Kind, AddressMasked: item.AddressMasked, Status: "pending"}, nil
|
||||
}
|
||||
|
||||
func (s Service) RecordValidation(ctx context.Context, channelID, status, detail string, actor int) (ChannelView, error) {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
detail = strings.TrimSpace(detail)
|
||||
if status != "verified" && status != "failed" {
|
||||
return ChannelView{}, ErrInvalid
|
||||
}
|
||||
if len([]rune(detail)) > 256 || hasControl(detail) {
|
||||
return ChannelView{}, ErrInvalid
|
||||
}
|
||||
var channel Channel
|
||||
fact := ChannelValidation{ID: uuid.NewString(), ChannelID: channelID, Status: status, Detail: detail, ActorID: actor, CreatedAt: time.Now().UTC()}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&channel, "id = ?", channelID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&fact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var c Contact
|
||||
if err := tx.First(&c, "id = ?", channel.ContactID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, c, "channel_validation_"+status, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
return ChannelView{ID: channel.ID, Kind: channel.Kind, AddressMasked: channel.AddressMasked, Status: status, ValidatedAt: &fact.CreatedAt}, nil
|
||||
}
|
||||
|
||||
// DecryptChannelAddress is intentionally server-only. API responses never expose this value.
|
||||
func (s Service) DecryptChannelAddress(ctx context.Context, channelID string) (string, error) {
|
||||
var item Channel
|
||||
if err := s.DB.WithContext(ctx).First(&item, "id = ?", channelID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decryptAddress(s.Key, item.AddressCiphertext)
|
||||
}
|
||||
|
||||
func (s Service) view(ctx context.Context, item Contact) (ContactView, error) {
|
||||
var channels []Channel
|
||||
if err := s.DB.WithContext(ctx).Where("contact_id = ?", item.ID).Order("created_at,id").Find(&channels).Error; err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
views := make([]ChannelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
view := ChannelView{ID: ch.ID, Kind: ch.Kind, AddressMasked: ch.AddressMasked, Status: "pending"}
|
||||
var fact ChannelValidation
|
||||
err := s.DB.WithContext(ctx).Where("channel_id = ?", ch.ID).Order("created_at DESC,id DESC").Take(&fact).Error
|
||||
if err == nil {
|
||||
view.Status = fact.Status
|
||||
view.ValidatedAt = &fact.CreatedAt
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ContactView{}, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return ContactView{Contact: item, Channels: views}, nil
|
||||
}
|
||||
|
||||
func appendAudit(tx *gorm.DB, item Contact, action string, actor int) error {
|
||||
snapshot, err := json.Marshal(map[string]any{"id": item.ID, "name": item.Name, "role": item.Role, "enabled": item.Enabled, "version": item.Version})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&AuditFact{ID: uuid.NewString(), ContactID: item.ID, Action: action, Snapshot: snapshot, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
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,60 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("联系人或通道信息不符合要求")
|
||||
ErrNotFound = errors.New("联系人或通道不存在")
|
||||
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
|
||||
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
)
|
||||
|
||||
type WriteInput struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
|
||||
type ChannelInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func normalizeContact(input WriteInput) (WriteInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Role = strings.TrimSpace(input.Role)
|
||||
if input.Name == "" || len([]rune(input.Name)) > 128 || input.Role == "" || len([]rune(input.Role)) > 128 || hasControl(input.Name+input.Role) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func normalizeChannel(input ChannelInput) (ChannelInput, error) {
|
||||
input.Kind = strings.ToLower(strings.TrimSpace(input.Kind))
|
||||
input.Address = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(input.Address), " ", ""), "-", "")
|
||||
if (input.Kind != "sms" && input.Kind != "voice") || !phonePattern.MatchString(input.Address) {
|
||||
return ChannelInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func maskAddress(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return strings.Repeat("*", min(8, len(runes)-4)) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func hasControl(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 32 || r == 127 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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) List(c *gin.Context) {
|
||||
var q PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
h.Error(500, errors.New("读取排班失败"), "读取排班失败")
|
||||
return
|
||||
}
|
||||
p, s := pageValues(q.PageIndex, q.PageSize)
|
||||
h.PageOK(items, int(count), p, s, "查询成功")
|
||||
}
|
||||
func (h Handler) CreateGroup(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input GroupInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateGroup(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) UpdateGroup(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input GroupInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).UpdateGroup(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) AddMember(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input MemberInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).AddMember(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) CreateSchedule(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input ScheduleInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateSchedule(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) Publish(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可发布排班"), "仅管理员可发布排班")
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Publish(c.Request.Context(), c.Param("id"), user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) CreateOverride(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护替班"), "仅管理员可维护替班")
|
||||
return
|
||||
}
|
||||
var input OverrideInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateOverride(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h *Handler) bind(c *gin.Context, value any) {
|
||||
h.MakeContext(c).MakeOrm().Bind(value, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
}
|
||||
}
|
||||
func (h Handler) result(item any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid) || errors.Is(err, ErrCoverage):
|
||||
h.Error(400, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(404, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.Error(409, err, err.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell duty schedule failed: %v", err)
|
||||
h.Error(409, errors.New("排班保存失败"), "排班保存失败")
|
||||
}
|
||||
}
|
||||
func admin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
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 (Group) TableName() string { return "bell_duty_groups" }
|
||||
|
||||
type Member struct {
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;primaryKey"`
|
||||
Role string `json:"role" gorm:"size:16;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Member) TableName() string { return "bell_duty_members" }
|
||||
|
||||
type ScheduleVersion struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null"`
|
||||
EffectiveFrom time.Time `json:"effectiveFrom" gorm:"type:timestamptz;not null"`
|
||||
EffectiveTo *time.Time `json:"effectiveTo,omitempty" gorm:"type:timestamptz"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
PublishedBy *int `json:"publishedBy,omitempty"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty" gorm:"type:timestamptz"`
|
||||
}
|
||||
|
||||
func (ScheduleVersion) TableName() string { return "bell_duty_schedule_versions" }
|
||||
|
||||
type RotationSlot struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ScheduleVersionID string `json:"scheduleVersionId" gorm:"type:uuid;not null;index"`
|
||||
Weekday int `json:"weekday" gorm:"not null"`
|
||||
StartMinute int `json:"startMinute" gorm:"not null"`
|
||||
EndMinute int `json:"endMinute" gorm:"not null"`
|
||||
PrimaryContactID string `json:"primaryContactId" gorm:"type:uuid;not null"`
|
||||
BackupContactID string `json:"backupContactId" gorm:"type:uuid;not null"`
|
||||
}
|
||||
|
||||
func (RotationSlot) TableName() string { return "bell_duty_rotation_slots" }
|
||||
|
||||
type Override struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
OriginalContactID string `json:"originalContactId" gorm:"type:uuid;not null"`
|
||||
ReplacementContactID string `json:"replacementContactId" gorm:"type:uuid;not null"`
|
||||
StartsAt time.Time `json:"startsAt" gorm:"type:timestamptz;not null;index"`
|
||||
EndsAt time.Time `json:"endsAt" gorm:"type:timestamptz;not null"`
|
||||
Reason string `json:"reason" gorm:"size:256;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Override) TableName() string { return "bell_duty_overrides" }
|
||||
|
||||
type AuditFact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
Action string `json:"action" gorm:"size:32;not null"`
|
||||
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AuditFact) TableName() string { return "bell_duty_audit_facts" }
|
||||
|
||||
type GroupView struct {
|
||||
Group
|
||||
Members []Member `json:"members"`
|
||||
Schedules []ScheduleView `json:"schedules"`
|
||||
Overrides []Override `json:"overrides"`
|
||||
}
|
||||
type ScheduleView struct {
|
||||
ScheduleVersion
|
||||
Slots []RotationSlot `json:"slots"`
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
)
|
||||
|
||||
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, q PageQuery) ([]GroupView, int64, error) {
|
||||
p, z := pageValues(q.PageIndex, q.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Group{})
|
||||
if name := strings.TrimSpace(q.Name); name != "" {
|
||||
db = db.Where("name ILIKE ?", "%"+name+"%")
|
||||
}
|
||||
if q.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *q.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var groups []Group
|
||||
if err := db.Order("created_at DESC,id DESC").Offset((p - 1) * z).Limit(z).Find(&groups).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views := make([]GroupView, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
v, err := s.view(ctx, g)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
return views, count, nil
|
||||
}
|
||||
func (s Service) CreateGroup(ctx context.Context, input GroupInput, actor int) (GroupView, error) {
|
||||
input, err := normalizeGroup(input)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Group{ID: uuid.NewString(), Name: input.Name, Timezone: input.Timezone, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, item.ID, "group_created", item, actor)
|
||||
})
|
||||
return GroupView{Group: item, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}, err
|
||||
}
|
||||
func (s Service) UpdateGroup(ctx context.Context, id string, input GroupInput, actor int) (GroupView, error) {
|
||||
input, err := normalizeGroup(input)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
if input.ExpectedVersion < 1 {
|
||||
return GroupView{}, ErrInvalid
|
||||
}
|
||||
var item Group
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
r := tx.Model(&Group{}).Where("id=? AND version=?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "timezone": input.Timezone, "version": gorm.Expr("version+1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id=?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, id, "group_updated", item, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
func (s Service) AddMember(ctx context.Context, groupID string, input MemberInput, actor int) (Member, error) {
|
||||
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
|
||||
if input.Role != "primary" && input.Role != "backup" {
|
||||
return Member{}, ErrInvalid
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Member{GroupID: groupID, ContactID: input.ContactID, Role: input.Role, CreatedBy: actor, CreatedAt: now}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := assertEnabledContact(tx, input.ContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.First(&Group{}, "id=?", groupID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_id"}, {Name: "contact_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role", "created_by", "created_at"})}).Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, groupID, "member_saved", item, actor)
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
func (s Service) CreateSchedule(ctx context.Context, groupID string, input ScheduleInput, actor int) (ScheduleView, error) {
|
||||
if input.EffectiveFrom.IsZero() || (input.EffectiveTo != nil && !input.EffectiveTo.After(input.EffectiveFrom)) {
|
||||
return ScheduleView{}, ErrInvalid
|
||||
}
|
||||
if err := validateSlots(input.Slots); err != nil {
|
||||
return ScheduleView{}, err
|
||||
}
|
||||
var result ScheduleView
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var group Group
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, "id=?", groupID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, slot := range input.Slots {
|
||||
if err := assertGroupMember(tx, groupID, slot.PrimaryContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertGroupMember(tx, groupID, slot.BackupContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var latest int
|
||||
tx.Model(&ScheduleVersion{}).Where("group_id=?", groupID).Select("coalesce(max(version),0)").Scan(&latest)
|
||||
now := time.Now().UTC()
|
||||
version := ScheduleVersion{ID: uuid.NewString(), GroupID: groupID, Version: latest + 1, Timezone: group.Timezone, EffectiveFrom: input.EffectiveFrom.UTC(), EffectiveTo: input.EffectiveTo, Status: "draft", CreatedBy: actor, CreatedAt: now}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slots := make([]RotationSlot, 0, len(input.Slots))
|
||||
for _, in := range input.Slots {
|
||||
slots = append(slots, RotationSlot{ID: uuid.NewString(), ScheduleVersionID: version.ID, Weekday: in.Weekday, StartMinute: in.StartMinute, EndMinute: in.EndMinute, PrimaryContactID: in.PrimaryContactID, BackupContactID: in.BackupContactID})
|
||||
}
|
||||
if err := tx.Create(&slots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := audit(tx, groupID, "schedule_created", version, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ScheduleView{ScheduleVersion: version, Slots: slots}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
func (s Service) Publish(ctx context.Context, id string, actor int) (ScheduleView, error) {
|
||||
var result ScheduleView
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item ScheduleVersion
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id=?", id).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if item.Status != "draft" {
|
||||
return ErrConflict
|
||||
}
|
||||
var slots []RotationSlot
|
||||
if err := tx.Where("schedule_version_id=?", id).Find(&slots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
inputs := make([]SlotInput, 0, len(slots))
|
||||
for _, v := range slots {
|
||||
inputs = append(inputs, SlotInput{Weekday: v.Weekday, StartMinute: v.StartMinute, EndMinute: v.EndMinute, PrimaryContactID: v.PrimaryContactID, BackupContactID: v.BackupContactID})
|
||||
}
|
||||
if err := validateSlots(inputs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range slots {
|
||||
if err := assertVerifiedContact(tx, v.PrimaryContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertVerifiedContact(tx, v.BackupContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&item).Updates(map[string]any{"status": "published", "published_by": actor, "published_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
item.Status = "published"
|
||||
item.PublishedBy = &actor
|
||||
item.PublishedAt = &now
|
||||
if err := audit(tx, item.GroupID, "schedule_published", item, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ScheduleView{ScheduleVersion: item, Slots: slots}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
func (s Service) CreateOverride(ctx context.Context, groupID string, input OverrideInput, actor int) (Override, error) {
|
||||
input, err := normalizeOverride(input)
|
||||
if err != nil {
|
||||
return Override{}, err
|
||||
}
|
||||
item := Override{ID: uuid.NewString(), GroupID: groupID, OriginalContactID: input.OriginalContactID, ReplacementContactID: input.ReplacementContactID, StartsAt: input.StartsAt.UTC(), EndsAt: input.EndsAt.UTC(), Reason: input.Reason, CreatedBy: actor, CreatedAt: time.Now().UTC()}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := assertGroupMember(tx, groupID, input.OriginalContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertGroupMember(tx, groupID, input.ReplacementContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var overlaps int64
|
||||
if err := tx.Model(&Override{}).Where("group_id=? AND original_contact_id=? AND starts_at < ? AND ends_at > ?", groupID, input.OriginalContactID, item.EndsAt, item.StartsAt).Count(&overlaps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if overlaps > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, groupID, "override_created", item, actor)
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
func (s Service) view(ctx context.Context, g Group) (GroupView, error) {
|
||||
v := GroupView{Group: g, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("role,contact_id").Find(&v.Members).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
var versions []ScheduleVersion
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("version DESC").Find(&versions).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
for _, sv := range versions {
|
||||
var slots []RotationSlot
|
||||
if err := s.DB.WithContext(ctx).Where("schedule_version_id=?", sv.ID).Order("weekday,start_minute").Find(&slots).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Schedules = append(v.Schedules, ScheduleView{ScheduleVersion: sv, Slots: slots})
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("starts_at DESC").Limit(50).Find(&v.Overrides).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func assertEnabledContact(tx *gorm.DB, id string) error {
|
||||
var c contact.Contact
|
||||
if err := tx.Where("id=? AND enabled=true", id).First(&c).Error; err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func assertGroupMember(tx *gorm.DB, groupID, contactID string) error {
|
||||
if err := assertEnabledContact(tx, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&Member{}).Where("group_id=? AND contact_id=?", groupID, contactID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func assertVerifiedContact(tx *gorm.DB, contactID string) error {
|
||||
if err := assertEnabledContact(tx, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
err := tx.Raw(`SELECT count(*) FROM bell_contact_channels c WHERE c.contact_id=? AND (SELECT v.status FROM bell_contact_channel_validations v WHERE v.channel_id=c.id ORDER BY v.created_at DESC,v.id DESC LIMIT 1)='verified'`, contactID).Scan(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func audit(tx *gorm.DB, groupID, action string, value any, actor int) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&AuditFact{ID: uuid.NewString(), GroupID: groupID, Action: action, Snapshot: data, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
func pageValues(p, s int) (int, int) {
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if s < 1 || s > 100 {
|
||||
s = 20
|
||||
}
|
||||
return p, s
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("值班排班信息不符合要求")
|
||||
ErrNotFound = errors.New("值班组或排班不存在")
|
||||
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
|
||||
ErrCoverage = errors.New("周排班存在空档或重叠")
|
||||
)
|
||||
|
||||
type GroupInput struct {
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
type MemberInput struct {
|
||||
ContactID string `json:"contactId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
type SlotInput struct {
|
||||
Weekday int `json:"weekday"`
|
||||
StartMinute int `json:"startMinute"`
|
||||
EndMinute int `json:"endMinute"`
|
||||
PrimaryContactID string `json:"primaryContactId"`
|
||||
BackupContactID string `json:"backupContactId"`
|
||||
}
|
||||
type ScheduleInput struct {
|
||||
EffectiveFrom time.Time `json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `json:"effectiveTo"`
|
||||
Slots []SlotInput `json:"slots"`
|
||||
}
|
||||
type OverrideInput struct {
|
||||
OriginalContactID string `json:"originalContactId"`
|
||||
ReplacementContactID string `json:"replacementContactId"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func normalizeGroup(input GroupInput) (GroupInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Timezone = strings.TrimSpace(input.Timezone)
|
||||
if input.Name == "" || len([]rune(input.Name)) > 128 {
|
||||
return GroupInput{}, ErrInvalid
|
||||
}
|
||||
if _, err := time.LoadLocation(input.Timezone); err != nil {
|
||||
return GroupInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
func validateSlots(slots []SlotInput) error {
|
||||
if len(slots) == 0 {
|
||||
return ErrCoverage
|
||||
}
|
||||
byDay := map[int][]SlotInput{}
|
||||
for _, slot := range slots {
|
||||
if slot.Weekday < 0 || slot.Weekday > 6 || slot.StartMinute < 0 || slot.EndMinute > 1440 || slot.StartMinute >= slot.EndMinute || slot.PrimaryContactID == "" || slot.BackupContactID == "" || slot.PrimaryContactID == slot.BackupContactID {
|
||||
return ErrInvalid
|
||||
}
|
||||
byDay[slot.Weekday] = append(byDay[slot.Weekday], slot)
|
||||
}
|
||||
for day := 0; day < 7; day++ {
|
||||
daySlots := byDay[day]
|
||||
sort.Slice(daySlots, func(i, j int) bool { return daySlots[i].StartMinute < daySlots[j].StartMinute })
|
||||
cursor := 0
|
||||
for _, slot := range daySlots {
|
||||
if slot.StartMinute != cursor {
|
||||
return ErrCoverage
|
||||
}
|
||||
cursor = slot.EndMinute
|
||||
}
|
||||
if cursor != 1440 {
|
||||
return ErrCoverage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeOverride(input OverrideInput) (OverrideInput, error) {
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
if input.OriginalContactID == "" || input.ReplacementContactID == "" || input.OriginalContactID == input.ReplacementContactID || input.StartsAt.IsZero() || !input.EndsAt.After(input.StartsAt) || input.Reason == "" || len([]rune(input.Reason)) > 256 {
|
||||
return OverrideInput{}, ErrInvalid
|
||||
}
|
||||
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,117 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
type EvidenceClient struct {
|
||||
Endpoint string
|
||||
Signer machine_identity.Signer
|
||||
HTTP interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
|
||||
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
|
||||
path := "/v1/evidence/" + status.EvidenceID
|
||||
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "machine_identity_error")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", newCorrelationID())
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
code := "evidence_unavailable"
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = "evidence_timeout"
|
||||
}
|
||||
return c.degrade(db, status, "unavailable", code)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if readErr != nil || len(body) > 64*1024 {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return c.degrade(db, status, "unavailable", "evidence_not_found")
|
||||
}
|
||||
if response.StatusCode == http.StatusGone {
|
||||
return c.degrade(db, status, "expired", "evidence_expired")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return c.degrade(db, status, "unavailable", "evidence_unavailable")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var evidence Evidence
|
||||
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
canonical, err := canonicalJSON(body)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func newCorrelationID() string {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "request-id-fallback"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
|
||||
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Bell evidence key: %w", err)
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
|
||||
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
const MaxRequestBytes = 64 * 1024
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
Enabled bool
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (h Handler) Post(c *gin.Context) {
|
||||
if !h.Enabled {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
|
||||
return
|
||||
}
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
} else if !requestIDPattern.MatchString(requestID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
|
||||
return
|
||||
}
|
||||
c.Header("X-Request-ID", requestID)
|
||||
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
|
||||
return
|
||||
}
|
||||
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
|
||||
relayID := strings.TrimSpace(relayHeader)
|
||||
if relayID != relayHeader || !validID(relayID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
code := machineErrorCode(err)
|
||||
if code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeProblem(c, status, code, "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
parsed, err := ParseEvent(body)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnsupportedSchema) {
|
||||
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
|
||||
return
|
||||
}
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
|
||||
return
|
||||
}
|
||||
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if result.Disposition == "duplicate" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, result)
|
||||
}
|
||||
|
||||
func writeProblem(c *gin.Context, status int, code, message, existing string) {
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
|
||||
}
|
||||
|
||||
func machineErrorCode(err error) string {
|
||||
var machineErr *machine_identity.Error
|
||||
if errors.As(err, &machineErr) {
|
||||
return machineErr.Code
|
||||
}
|
||||
return "machine_token_invalid"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EventSchemaVersion = "yovision.event/v1"
|
||||
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
SiteRef string `json:"site_ref"`
|
||||
DeviceRef string `json:"device_ref"`
|
||||
ProfileRef string `json:"profile_ref"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Severity string `json:"severity"`
|
||||
Rule Rule `json:"rule"`
|
||||
Model Model `json:"model"`
|
||||
Observation Observation `json:"observation"`
|
||||
Region Region `json:"region"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
TrackID string `json:"track_id"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
|
||||
}
|
||||
|
||||
type Region struct {
|
||||
RegionID string `json:"region_id"`
|
||||
Kind string `json:"kind"`
|
||||
CrossingDirection string `json:"crossing_direction,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CapturedAt string `json:"captured_at"`
|
||||
StatusUpdatedAt string `json:"status_updated_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
|
||||
Failure *EvidenceFailure `json:"failure,omitempty"`
|
||||
}
|
||||
|
||||
type EvidenceIntegrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Digest string `json:"digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type EvidenceFailure struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ParsedEvent struct {
|
||||
Event Event
|
||||
Canonical json.RawMessage
|
||||
Digest string
|
||||
Occurred time.Time
|
||||
}
|
||||
|
||||
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
|
||||
// from the immutable Event and from Alert acknowledgement/close facts.
|
||||
type EvidenceStatus struct {
|
||||
EventID string `gorm:"type:uuid;primaryKey"`
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null"`
|
||||
Resolution string `gorm:"size:16;not null;index"`
|
||||
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
|
||||
LastError string `gorm:"size:64;not null;default:''"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
CheckedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
|
||||
@@ -0,0 +1,42 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReplayToken is Bell-owned security state. It is intentionally separate from
|
||||
// business Receipt idempotency and remains effective across process restarts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
|
||||
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
func RegisterRuntime(engine *gin.Engine) error {
|
||||
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
db := sdk.Runtime.GetDbByKey("")
|
||||
if db == nil {
|
||||
return fmt.Errorf("Bell event ingress database is unavailable")
|
||||
}
|
||||
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
|
||||
return fmt.Errorf("Bell event ingress migration is required")
|
||||
}
|
||||
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell machine identity registry: %w", err)
|
||||
}
|
||||
var resolver EvidenceRefresher
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
|
||||
resolver, err = LoadEvidenceClient(os.Getenv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell evidence resolver: %w", err)
|
||||
}
|
||||
}
|
||||
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
|
||||
engine.POST("/v1/events", handler.Post)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
|
||||
|
||||
type EvidenceRefresher interface {
|
||||
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
|
||||
if s.DB == nil {
|
||||
return IngestResult{}, errors.New("event database is unavailable")
|
||||
}
|
||||
var output IngestResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var existing struct {
|
||||
EventID string
|
||||
PayloadSHA256 string
|
||||
}
|
||||
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != parsed.Digest {
|
||||
output.EventID = existing.EventID
|
||||
return ErrIdempotencyConflict
|
||||
}
|
||||
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
|
||||
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
eventID := uuid.NewString()
|
||||
var evidenceRef *string
|
||||
if len(parsed.Event.Evidence) > 0 {
|
||||
value := parsed.Event.Evidence[0].EvidenceID
|
||||
evidenceRef = &value
|
||||
}
|
||||
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
|
||||
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
|
||||
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
|
||||
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&receiptItem).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, evidence := range parsed.Event.Evidence {
|
||||
payload, marshalErr := json.Marshal(evidence)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
canonical, canonicalErr := canonicalJSON(payload)
|
||||
if canonicalErr != nil {
|
||||
return canonicalErr
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
|
||||
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
|
||||
if err := tx.Create(&status).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
if auditErr != nil {
|
||||
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
|
||||
}
|
||||
return output, ErrIdempotencyConflict
|
||||
}
|
||||
if err != nil || s.Resolver == nil {
|
||||
return output, err
|
||||
}
|
||||
var statuses []EvidenceStatus
|
||||
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
for _, status := range statuses {
|
||||
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
|
||||
// remains accepted even when Sense is unavailable.
|
||||
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEvent = errors.New("invalid_event")
|
||||
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
func ParseEvent(raw []byte) (ParsedEvent, error) {
|
||||
var event Event
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
|
||||
}
|
||||
if event.SchemaVersion == "" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.SchemaVersion != EventSchemaVersion {
|
||||
return ParsedEvent{}, ErrUnsupportedSchema
|
||||
}
|
||||
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
|
||||
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
|
||||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
|
||||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
|
||||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
|
||||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
|
||||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
for _, value := range event.Observation.BBoxNormalized {
|
||||
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
|
||||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Evidence == nil || len(event.Evidence) > 8 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence := map[string]bool{}
|
||||
for _, evidence := range event.Evidence {
|
||||
evidenceJSON, marshalErr := json.Marshal(evidence)
|
||||
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
|
||||
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence[string(canonicalEvidence)] = true
|
||||
}
|
||||
canonical, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if containsExplicitNull(raw) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
|
||||
}
|
||||
|
||||
func validateEvidence(value Evidence) error {
|
||||
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
|
||||
(value.Type != "snapshot" && value.Type != "clip") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if value.ExpiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
switch value.Status {
|
||||
case "pending", "processing":
|
||||
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "success":
|
||||
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
|
||||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "failed":
|
||||
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
|
||||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
default:
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func containsExplicitNull(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if decoder.Decode(&value) != nil {
|
||||
return true
|
||||
}
|
||||
return hasNull(value)
|
||||
}
|
||||
|
||||
func hasNull(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validID(value string) bool {
|
||||
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryDocument struct {
|
||||
Version string `json:"version"`
|
||||
Audience string `json:"audience"`
|
||||
Principals []registryPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type registryPrincipal struct {
|
||||
PrincipalID string `json:"principal_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Keys []registryKey `json:"keys"`
|
||||
}
|
||||
|
||||
type registryKey struct {
|
||||
KeyID string `json:"kid"`
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
|
||||
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
|
||||
return nil, errors.New("machine principal registry path and audience are required")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine principal registry")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document registryDocument
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records := make([]KeyRecord, 0)
|
||||
for _, principal := range document.Principals {
|
||||
if len(principal.Keys) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
for _, key := range principal.Keys {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
|
||||
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
|
||||
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
|
||||
}
|
||||
}
|
||||
return NewRegistry(records...)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
|
||||
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
|
||||
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
|
||||
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "yovision.machine-identity/v1"
|
||||
TokenType = "YOVISION-MACHINE+JWT"
|
||||
MaxLifetime = 5 * time.Minute
|
||||
AllowedSkew = 30 * time.Second
|
||||
MaxKeyOverlap = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Error struct{ Code string }
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
|
||||
func codeError(code string) error { return &Error{Code: code} }
|
||||
|
||||
// BearerToken deliberately has no cookie or query fallback.
|
||||
func BearerToken(authorization string) (string, error) {
|
||||
parts := strings.Split(authorization, " ")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
|
||||
return "", codeError("machine_token_missing")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version string `json:"ver"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scopes []string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
Method string `json:"htm"`
|
||||
Path string `json:"htu"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
}
|
||||
|
||||
type protectedHeader struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version string `json:"ver"`
|
||||
}
|
||||
|
||||
type KeyRecord struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PublicKey ed25519.PublicKey
|
||||
Audience string
|
||||
Scopes []string
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]KeyRecord
|
||||
}
|
||||
|
||||
func NewRegistry(records ...KeyRecord) (*Registry, error) {
|
||||
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
|
||||
for _, record := range records {
|
||||
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
|
||||
return nil, errors.New("invalid machine key record")
|
||||
}
|
||||
if _, exists := r.keys[record.KeyID]; exists {
|
||||
return nil, errors.New("duplicate machine key id")
|
||||
}
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
r.keys[record.KeyID] = record
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
record, ok := r.keys[keyID]
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(keyID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.keys[keyID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
record.Revoked = true
|
||||
r.keys[keyID] = record
|
||||
return true
|
||||
}
|
||||
|
||||
type ReplayStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
}
|
||||
|
||||
// ReplayCache must atomically persist accepted (principal, jti) pairs until
|
||||
// expiry. ReplayStore is process-local and intended for tests or a single
|
||||
// uninterrupted process; connector implementations inject a durable store.
|
||||
type ReplayCache interface {
|
||||
Consume(principal, tokenID string, expiresAt, now time.Time) bool
|
||||
}
|
||||
|
||||
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
|
||||
|
||||
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiry := range s.used {
|
||||
if !expiry.After(now) {
|
||||
delete(s.used, key)
|
||||
}
|
||||
}
|
||||
key := principal + "\x00" + tokenID
|
||||
if _, exists := s.used[key]; exists {
|
||||
return false
|
||||
}
|
||||
s.used[key] = expiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("machine private key path is required")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine private key")
|
||||
}
|
||||
block, rest := pem.Decode(raw)
|
||||
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
|
||||
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse machine private key")
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("machine private key is not Ed25519")
|
||||
}
|
||||
return slices.Clone(key), nil
|
||||
}
|
||||
|
||||
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
|
||||
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
|
||||
return "", errors.New("incomplete machine signer configuration")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
if !allowedMethod(method) {
|
||||
return "", errors.New("unsupported machine request method")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
|
||||
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
|
||||
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
|
||||
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
|
||||
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
|
||||
return signingInput + "." + rawBase64(signature), nil
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
Registry *Registry
|
||||
Replay ReplayCache
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
|
||||
if v.Registry == nil || v.Replay == nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || strings.Contains(token, "=") {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
headerBytes, err := decodeRaw(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var header protectedHeader
|
||||
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
record, ok := v.Registry.Lookup(header.KeyID)
|
||||
if !ok {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
signature, err := decodeRaw(parts[2])
|
||||
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !record.Enabled || record.Revoked {
|
||||
return Claims{}, codeError("machine_identity_revoked")
|
||||
}
|
||||
claimsBytes, err := decodeRaw(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var claims Claims
|
||||
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if v.Now != nil {
|
||||
now = v.Now().UTC()
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
|
||||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_expired")
|
||||
}
|
||||
if claims.Audience != audience || record.Audience != audience {
|
||||
return Claims{}, codeError("machine_audience_denied")
|
||||
}
|
||||
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
|
||||
return Claims{}, codeError("machine_scope_denied")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
|
||||
return Claims{}, codeError("machine_token_replayed")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeClosed(raw []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validClaimsShape(claims Claims) bool {
|
||||
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
|
||||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
|
||||
return false
|
||||
}
|
||||
if !validScopeList(claims.Scopes) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(claims.BodySHA256)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScopeList(scopes []string) bool {
|
||||
if len(scopes) == 0 || len(scopes) > 4 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, scope := range scopes {
|
||||
if !validScopes[scope] || seen[scope] {
|
||||
return false
|
||||
}
|
||||
seen[scope] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
|
||||
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
|
||||
}
|
||||
return parsed.EscapedPath(), nil
|
||||
}
|
||||
|
||||
func allowedMethod(method string) bool {
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate machine token id: %w", err)
|
||||
}
|
||||
return rawBase64(raw), nil
|
||||
}
|
||||
|
||||
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
|
||||
|
||||
func decodeRaw(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type crossLanguageVector struct {
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Token string `json:"token"`
|
||||
Now int64 `json:"now"`
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"required_scope"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body_base64"`
|
||||
}
|
||||
|
||||
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
||||
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var coded *Error
|
||||
if !errors.As(err, &coded) {
|
||||
t.Fatalf("expected coded error, got %v", err)
|
||||
}
|
||||
return coded.Code
|
||||
}
|
||||
|
||||
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
||||
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
||||
t.Fatalf("accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
mint := func() string {
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
token := mint()
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryRevocationAndRotation(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
||||
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rotation, err := NewRegistry(
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
||||
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
||||
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotation.Revoke("brain-old-0001") {
|
||||
t.Fatal("old key was not revoked")
|
||||
}
|
||||
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
||||
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
||||
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
||||
if err := safe.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := safe
|
||||
unsafe.VerifyHostname = false
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("unsafe hostname policy accepted")
|
||||
}
|
||||
unsafe = safe
|
||||
unsafe.TLSMinVersion = tls.VersionTLS11
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("TLS 1.1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
||||
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
||||
raw, err := os.ReadFile(vectorPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var vector crossLanguageVector
|
||||
if err = json.Unmarshal(raw, &vector); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
||||
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != "yv:brain:vector" {
|
||||
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
||||
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
||||
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
||||
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
||||
}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
file := filepath.Join(t.TempDir(), "principals.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := LoadRegistry(file, "yovision-bell")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
||||
t.Fatal("registry record missing")
|
||||
}
|
||||
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
||||
t.Fatal("wrong registry audience accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TransportPolicy struct {
|
||||
TLSMinVersion uint16
|
||||
VerifyCertificate bool
|
||||
VerifyHostname bool
|
||||
ConnectTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func (p TransportPolicy) Validate() error {
|
||||
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
|
||||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
|
||||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
|
||||
return errors.New("machine transport policy is unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
|
||||
TLSHandshakeTimeout: p.ConnectTimeout,
|
||||
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
|
||||
}
|
||||
@@ -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,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/contact"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerContactRouter) }
|
||||
func registerContactRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
h := contact.Handler{}
|
||||
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
secured.GET("/contacts", h.List)
|
||||
secured.POST("/contacts", h.Create)
|
||||
secured.PUT("/contacts/:id", h.Update)
|
||||
secured.PUT("/contacts/:id/enabled", h.SetEnabled)
|
||||
secured.POST("/contacts/:id/channels", h.AddChannel)
|
||||
secured.POST("/contact-channels/:id/validations", h.ValidateChannel)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/duty_schedule"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerDutyScheduleRouter) }
|
||||
func registerDutyScheduleRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
h := duty_schedule.Handler{}
|
||||
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
secured.GET("/duty-groups", h.List)
|
||||
secured.POST("/duty-groups", h.CreateGroup)
|
||||
secured.PUT("/duty-groups/:id", h.UpdateGroup)
|
||||
secured.POST("/duty-groups/:id/members", h.AddMember)
|
||||
secured.POST("/duty-groups/:id/schedules", h.CreateSchedule)
|
||||
secured.POST("/duty-schedules/:id/publish", h.Publish)
|
||||
secured.POST("/duty-groups/:id/overrides", h.CreateOverride)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
@@ -32,6 +33,9 @@ func InitRouter() {
|
||||
for _, register := range registrars {
|
||||
register(v1, authMiddleware)
|
||||
}
|
||||
if err := event_ingress.RegisterRuntime(engine); err != nil {
|
||||
log.Errorf("Bell event ingress init error: %v", err)
|
||||
}
|
||||
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
|
||||
registerSyntheticRouter(v1, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -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,8 @@ import (
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/router"
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/app/bell/contact"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/bellconfig"
|
||||
@@ -182,7 +184,9 @@ func initRouter() {
|
||||
r.Use(common.Sentinel()).
|
||||
Use(common.RequestId(pkg.TrafficKey)).
|
||||
Use(api.SetRequestLogger).
|
||||
Use(synthetic.RedactRequestBody())
|
||||
Use(synthetic.RedactRequestBody()).
|
||||
Use(alert_lifecycle.RedactRequestBody()).
|
||||
Use(contact.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,29 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventIngress)
|
||||
}
|
||||
|
||||
func migrateBellEventIngress(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(
|
||||
&event_ingress.ReplayToken{},
|
||||
&event_ingress.EvidenceStatus{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellEventIngressMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const version = "2026083112000"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err = migrateBellEventIngress(db, version); err != nil {
|
||||
t.Fatalf("migration attempt %d: %v", attempt+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, model := range map[string]any{
|
||||
"replay tokens": &event_ingress.ReplayToken{},
|
||||
"evidence statuses": &event_ingress.EvidenceStatus{},
|
||||
} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(model).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", name, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("migration inserted %d %s fixtures", count, name)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&event_ingress.ReplayToken{}, "ExpiresAt") {
|
||||
t.Fatal("replay expiry index missing")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := event_ingress.ReplayToken{Principal: "brain", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
|
||||
if err = db.Create(&token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&token).Error; err == nil {
|
||||
t.Fatal("duplicate replay token accepted")
|
||||
}
|
||||
|
||||
var applied int64
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("migration records=%d, want 1", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellContactSchedule)
|
||||
}
|
||||
func migrateBellContactSchedule(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&contact.Contact{}, &contact.Channel{}, &contact.ChannelValidation{}, &contact.AuditFact{}, &duty.Group{}, &duty.Member{}, &duty.ScheduleVersion{}, &duty.RotationSlot{}, &duty.Override{}, &duty.AuditFact{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
for _, sql := range contactScheduleSQL {
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedContactScheduleAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var contactScheduleSQL = []string{
|
||||
`ALTER TABLE bell_contacts ADD CONSTRAINT bell_contacts_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_kind_check CHECK (kind IN ('sms','voice'))`,
|
||||
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_contact_channel_identity_idx ON bell_contact_channels(contact_id,kind,address_fingerprint)`,
|
||||
`CREATE TRIGGER bell_contact_channels_immutable BEFORE UPDATE OR DELETE ON bell_contact_channels FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_status_check CHECK (status IN ('verified','failed'))`,
|
||||
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_channel_fk FOREIGN KEY (channel_id) REFERENCES bell_contact_channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_contact_audit_facts ADD CONSTRAINT bell_contact_audit_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_groups ADD CONSTRAINT bell_duty_groups_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_role_check CHECK (role IN ('primary','backup'))`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_duty_schedule_group_version_idx ON bell_duty_schedule_versions(group_id,version)`,
|
||||
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_status_check CHECK (status IN ('draft','published'))`,
|
||||
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE OR REPLACE FUNCTION bell_guard_schedule_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' OR OLD.status = 'published' THEN RAISE EXCEPTION 'Bell published schedule cannot be changed' USING ERRCODE = '55000'; END IF; IF NEW.status <> 'published' OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Bell schedule transition is invalid' USING ERRCODE = '55000'; END IF; RETURN NEW; END $$`,
|
||||
`CREATE TRIGGER bell_duty_schedule_version_guard BEFORE UPDATE OR DELETE ON bell_duty_schedule_versions FOR EACH ROW EXECUTE FUNCTION bell_guard_schedule_version()`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_range_check CHECK (weekday BETWEEN 0 AND 6 AND start_minute >= 0 AND end_minute <= 1440 AND start_minute < end_minute AND primary_contact_id <> backup_contact_id)`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_version_fk FOREIGN KEY (schedule_version_id) REFERENCES bell_duty_schedule_versions(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_primary_fk FOREIGN KEY (primary_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_backup_fk FOREIGN KEY (backup_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_range_check CHECK (starts_at < ends_at AND original_contact_id <> replacement_contact_id)`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_original_fk FOREIGN KEY (original_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_replacement_fk FOREIGN KEY (replacement_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_audit_facts ADD CONSTRAINT bell_duty_audit_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE TRIGGER bell_contact_validations_immutable BEFORE UPDATE OR DELETE ON bell_contact_channel_validations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_contact_audit_immutable BEFORE UPDATE OR DELETE ON bell_contact_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_slots_immutable BEFORE UPDATE OR DELETE ON bell_duty_rotation_slots FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_overrides_immutable BEFORE UPDATE OR DELETE ON bell_duty_overrides FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_audit_immutable BEFORE UPDATE OR DELETE ON bell_duty_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
|
||||
type contactScheduleSeed struct {
|
||||
ID int
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
func seedContactScheduleAccess(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 rootID int
|
||||
if err := tx.Raw("SELECT menu_id FROM sys_menu WHERE path='/bell' AND parent_id=0 ORDER BY menu_id LIMIT 1").Scan(&rootID).Error; err != nil || rootID == 0 {
|
||||
return fmt.Errorf("Bell menu root missing")
|
||||
}
|
||||
contacts, err := insertContactScheduleMenu(tx, rootID, "BellContacts", "联系人与通道", "user", "contacts", "C", "bell:contact:list", "", "/bell/contacts/index", 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dutyMenu, err := insertContactScheduleMenu(tx, rootID, "BellDutySchedules", "值班排班", "time", "duty-schedules", "C", "bell:duty:list", "", "/bell/duty-schedules/index", 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contactWrite, err := insertContactScheduleMenu(tx, contacts.ID, "", "维护联系人", "", "", "F", "bell:contact:write", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dutyWrite, err := insertContactScheduleMenu(tx, dutyMenu.ID, "", "维护排班", "", "", "F", "bell:duty:write", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := []struct{ title, path, action string }{
|
||||
{"联系人列表", "/api/v1/bell/contacts", "GET"}, {"新增联系人", "/api/v1/bell/contacts", "POST"}, {"修改联系人", "/api/v1/bell/contacts/:id", "PUT"}, {"启停联系人", "/api/v1/bell/contacts/:id/enabled", "PUT"}, {"新增联系通道", "/api/v1/bell/contacts/:id/channels", "POST"}, {"记录通道验证", "/api/v1/bell/contact-channels/:id/validations", "POST"},
|
||||
{"值班组列表", "/api/v1/bell/duty-groups", "GET"}, {"新增值班组", "/api/v1/bell/duty-groups", "POST"}, {"修改值班组", "/api/v1/bell/duty-groups/:id", "PUT"}, {"保存值班成员", "/api/v1/bell/duty-groups/:id/members", "POST"}, {"新增排班版本", "/api/v1/bell/duty-groups/:id/schedules", "POST"}, {"发布排班版本", "/api/v1/bell/duty-schedules/:id/publish", "POST"}, {"新增临时替班", "/api/v1/bell/duty-groups/:id/overrides", "POST"},
|
||||
}
|
||||
apis := make([]contactScheduleSeed, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
v, e := insertContactScheduleAPI(tx, s.title, s.path, s.action)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
apis = append(apis, v)
|
||||
}
|
||||
links := map[int][]contactScheduleSeed{contacts.ID: {apis[0]}, contactWrite.ID: apis[1:6], dutyMenu.ID: {apis[6]}, dutyWrite.ID: apis[7:]}
|
||||
for menu, items := range links {
|
||||
for _, item := range items {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", menu, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var operator 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(&operator).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if operator != 0 {
|
||||
for _, menu := range []contactScheduleSeed{contacts, dutyMenu} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operator, menu.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range []contactScheduleSeed{apis[0], apis[6]} {
|
||||
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 insertContactScheduleMenu(tx *gorm.DB, parent int, name, title, icon, path, menuType, permission, action, component string, sort int) (contactScheduleSeed, 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, parent, component, sort).Scan(&id).Error
|
||||
if err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
var parentPaths string
|
||||
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id=?", parent).Scan(&parentPaths).Error; err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
if err = tx.Exec("UPDATE sys_menu SET paths=? WHERE menu_id=?", fmt.Sprintf("%s/%d", parentPaths, id), id).Error; err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
return contactScheduleSeed{ID: id}, nil
|
||||
}
|
||||
func insertContactScheduleAPI(tx *gorm.DB, title, path, action string) (contactScheduleSeed, 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 contactScheduleSeed{ID: id, Path: path, Action: action}, err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellContactScheduleMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = migrateBellContactSchedule(db, "2026090110000"); err != nil {
|
||||
t.Fatalf("attempt %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
for name, model := range map[string]any{"contacts": &contact.Contact{}, "channels": &contact.Channel{}, "validations": &contact.ChannelValidation{}, "groups": &duty.Group{}, "members": &duty.Member{}, "versions": &duty.ScheduleVersion{}, "slots": &duty.RotationSlot{}, "overrides": &duty.Override{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&common.Migration{}).Where("version=?", "2026090110000").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("migration records=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -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,169 @@
|
||||
package bell_contact_schedule_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestContactSchedulePostgres(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL to run PostgreSQL verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
contacts := contact.NewService(db, key)
|
||||
|
||||
primary, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人甲", Role: "主值班"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人乙", Role: "备值班"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primaryChannel, err := contacts.AddChannel(ctx, primary.ID, contact.ChannelInput{Kind: "sms", Address: "+8613800000001"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backupChannel, err := contacts.AddChannel(ctx, backup.ID, contact.ChannelInput{Kind: "voice", Address: "+8613800000002"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(primaryChannel.AddressMasked, "13800000001") {
|
||||
t.Fatal("channel response leaked address")
|
||||
}
|
||||
plain, err := contacts.DecryptChannelAddress(ctx, primaryChannel.ID)
|
||||
if err != nil || plain != "+8613800000001" {
|
||||
t.Fatalf("server-only decrypt failed: %q %v", plain, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(primaryChannel)
|
||||
if strings.Contains(string(encoded), plain) {
|
||||
t.Fatal("serialized channel leaked plaintext")
|
||||
}
|
||||
if _, err = contacts.RecordValidation(ctx, primaryChannel.ID, "verified", "合成验证", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.RecordValidation(ctx, backupChannel.ID, "verified", "合成验证", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.Update(ctx, primary.ID, contact.WriteInput{Name: "联系人甲", Role: "主值班", ExpectedVersion: 99}, 1); !errors.Is(err, contact.ErrConflict) {
|
||||
t.Fatalf("stale contact update err=%v", err)
|
||||
}
|
||||
if _, err = contacts.SetEnabled(ctx, primary.ID, false, primary.Version, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, _, err := contacts.List(ctx, contact.PageQuery{PageIndex: 1, PageSize: 20})
|
||||
if err != nil || len(listed) != 2 {
|
||||
t.Fatalf("contact list len=%d err=%v", len(listed), err)
|
||||
}
|
||||
var primaryView *contact.ContactView
|
||||
for index := range listed {
|
||||
if listed[index].ID == primary.ID {
|
||||
primaryView = &listed[index]
|
||||
}
|
||||
}
|
||||
if primaryView == nil || primaryView.Enabled || len(primaryView.Channels) != 1 || primaryView.Channels[0].Status != "verified" {
|
||||
t.Fatalf("contact enabled state was coupled to validation: %#v", primaryView)
|
||||
}
|
||||
// Re-enable with the new version before assigning duty.
|
||||
var disabled contact.Contact
|
||||
if err = db.First(&disabled, "id=?", primary.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.SetEnabled(ctx, primary.ID, true, disabled.Version, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duties := duty.NewService(db)
|
||||
group, err := duties.CreateGroup(ctx, duty.GroupInput{Name: "夜间值班组", Timezone: "Asia/Shanghai"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: primary.ID, Role: "primary"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: backup.ID, Role: "backup"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: []duty.SlotInput{{Weekday: 0, StartMinute: 0, EndMinute: 720, PrimaryContactID: primary.ID, BackupContactID: backup.ID}}}, 1); !errors.Is(err, duty.ErrCoverage) {
|
||||
t.Fatalf("schedule gap was accepted: %v", err)
|
||||
}
|
||||
slots := make([]duty.SlotInput, 0, 7)
|
||||
for day := 0; day < 7; day++ {
|
||||
slots = append(slots, duty.SlotInput{Weekday: day, StartMinute: 0, EndMinute: 1440, PrimaryContactID: primary.ID, BackupContactID: backup.ID})
|
||||
}
|
||||
v1, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: slots}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published, err := duties.Publish(ctx, v1.ID, 1)
|
||||
if err != nil || published.Status != "published" {
|
||||
t.Fatalf("publish status=%s err=%v", published.Status, err)
|
||||
}
|
||||
v2, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(24 * time.Hour), Slots: slots}, 1)
|
||||
if err != nil || v2.Version != 2 {
|
||||
t.Fatalf("second version=%d err=%v", v2.Version, err)
|
||||
}
|
||||
var persisted duty.ScheduleVersion
|
||||
if err = db.First(&persisted, "id=?", v1.ID).Error; err != nil || persisted.Version != 1 || persisted.Status != "published" {
|
||||
t.Fatalf("historical version changed: %#v err=%v", persisted, err)
|
||||
}
|
||||
now := time.Now().UTC().Add(2 * time.Hour)
|
||||
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now, EndsAt: now.Add(time.Hour), Reason: "合成替班"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now.Add(30 * time.Minute), EndsAt: now.Add(90 * time.Minute), Reason: "重叠替班"}, 1); !errors.Is(err, duty.ErrConflict) {
|
||||
t.Fatalf("overlap err=%v", err)
|
||||
}
|
||||
|
||||
if err = db.Model(&contact.ChannelValidation{}).Where("channel_id=?", primaryChannel.ID).Update("detail", "tampered").Error; err == nil {
|
||||
t.Fatal("validation fact was mutable")
|
||||
}
|
||||
if err = db.Model(&duty.RotationSlot{}).Where("schedule_version_id=?", v1.ID).Update("start_minute", 1).Error; err == nil {
|
||||
t.Fatal("published rotation slot was mutable")
|
||||
}
|
||||
if err = db.Model(&duty.ScheduleVersion{}).Where("id=?", v1.ID).Update("effective_from", time.Now().UTC()).Error; err == nil {
|
||||
t.Fatal("published schedule version was mutable")
|
||||
}
|
||||
var menus, reads, writes 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{"contacts", "duty-schedules"}).Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("v0=? AND v2=? AND v1 IN ?", "operator", "GET", []string{"/api/v1/bell/contacts", "/api/v1/bell/duty-groups"}).Count(&reads).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("v0=? AND v2<>? AND (v1 LIKE ? OR v1 LIKE ?)", "operator", "GET", "/api/v1/bell/contacts%", "/api/v1/bell/duty-%").Count(&writes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if menus != 2 || reads != 2 || writes != 0 {
|
||||
t.Fatalf("operator access escaped scope: menus=%d reads=%d writes=%d", menus, reads, writes)
|
||||
}
|
||||
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
|
||||
if password != "" {
|
||||
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("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,124 @@
|
||||
[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-183-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $testRoot 'postgres'
|
||||
$log = Join-Path $testRoot 'postgres.log'
|
||||
$pgOut = Join-Path $testRoot 'pg.out'
|
||||
$pgErr = Join-Path $testRoot 'pg.err'
|
||||
$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-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
function Wait-Port([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$open = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($open) { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "PostgreSQL port $Port did not 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') { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw 'Bell health endpoint did not become ready'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreePort
|
||||
$bellPort = Get-FreePort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name) -PathType Leaf)) { throw "Missing PostgreSQL tool: $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 $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -RedirectStandardOutput $pgOut -RedirectStandardError $pgErr -WindowStyle Hidden | Out-Null
|
||||
Wait-Port $pgPort
|
||||
$pgStarted = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres bell_183
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=bell_183 sslmode=disable"
|
||||
$env:BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_183_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_CONTACT_CHANNEL_KEY = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef0123456789abcdef'))
|
||||
$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 "migration failed: $(Join-Path $testRoot 'migrate.log')" }
|
||||
go test ./tests/bell_contact_schedule -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'contact schedule tests 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
|
||||
$adminBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$admin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminBody -NoProxy
|
||||
$adminHeaders = @{ Authorization = "Bearer $($admin.token)" }
|
||||
$contactBody = @{ name = 'HTTP联系人'; role = '测试值班' } | ConvertTo-Json -Compress
|
||||
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $contactBody -NoProxy
|
||||
if ([int]$created.code -ne 200) { throw 'administrator contact create failed' }
|
||||
$address = '+8613900000003'
|
||||
$channelBody = @{ kind = 'sms'; address = $address } | ConvertTo-Json -Compress
|
||||
$channel = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts/$($created.data.id)/channels" -Headers $adminHeaders -ContentType 'application/json' -Body $channelBody -NoProxy
|
||||
if ([int]$channel.code -ne 200 -or ($channel | ConvertTo-Json -Depth 10 -Compress).Contains($address)) { throw 'write-only channel HTTP boundary failed' }
|
||||
$logRow = ''
|
||||
for ($attempt = 0; $attempt -lt 40; $attempt++) {
|
||||
$logRow = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $pgPort -U postgres -d bell_183 -Atc "SELECT id::text || '|' || coalesce(oper_param,'') FROM sys_opera_log WHERE oper_url LIKE '/api/v1/bell/contacts/%/channels' ORDER BY id DESC LIMIT 1"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'operation log query failed' }
|
||||
if ($logRow) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if (-not $logRow -or $logRow.Contains($address)) { throw "operation log redaction failed: $logRow" }
|
||||
$loggedBody = ($logRow -split '\|', 2)[1]
|
||||
if ($loggedBody -and -not $loggedBody.Contains('"redacted":true')) { throw "unexpected operation log marker: $loggedBody" }
|
||||
|
||||
$operatorBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$operator = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorBody -NoProxy
|
||||
$operatorHeaders = @{ Authorization = "Bearer $($operator.token)" }
|
||||
foreach ($path in @('/api/v1/bell/contacts', '/api/v1/bell/duty-groups')) {
|
||||
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -NoProxy
|
||||
if ([int]$read.code -ne 200) { throw "operator read failed: $path" }
|
||||
}
|
||||
$denied = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $operatorHeaders -ContentType 'application/json' -Body $contactBody -NoProxy
|
||||
if ([int]$denied.code -ne 403) { throw "operator write returned $($denied.code)" }
|
||||
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -NoProxy
|
||||
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
|
||||
foreach ($title in @('联系人与通道', '值班排班')) { if (-not $menuJson.Contains($title)) { throw "operator menu missing $title" } }
|
||||
Write-Output 'BELL_183_HTTP admin_contact=200 channel_write_only=true operator_reads=200 operator_write=403 menus=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 $data -m fast stop *> (Join-Path $testRoot 'stop.log') }
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_RULE_ALERT_OPERATOR_PASSWORD', 'BELL_CONTACT_CHANNEL_KEY', 'BELL_HOST', 'BELL_PORT')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue }
|
||||
Write-Verbose "Bell #183 artifacts: $testRoot"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package bell_contact_schedule_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
)
|
||||
|
||||
func TestChannelKeyAndWriteOnlyRoundTrip(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
key, err := contact.ParseChannelKey(encoded)
|
||||
if err != nil || len(key) != 32 {
|
||||
t.Fatalf("key parse failed: len=%d err=%v", len(key), err)
|
||||
}
|
||||
for _, value := range []string{"", "short", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))} {
|
||||
if _, err = contact.ParseChannelKey(value); !errors.Is(err, contact.ErrChannelKeyUnavailable) {
|
||||
t.Fatalf("invalid key accepted: %q err=%v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,404 @@
|
||||
package event_ingress_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
func TestPostgresConcurrentBusinessAndSecurityIdempotency(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_EVENT_INGRESS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_EVENT_INGRESS_TEST_DATABASE_URL to run PostgreSQL concurrency verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := event_ingress.ParseEvent(fixture(t, "dangerous-area.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers = 12
|
||||
var created, duplicate, failures atomic.Int32
|
||||
var wait sync.WaitGroup
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
result, ingestErr := (event_ingress.Service{DB: db}).Ingest(context.Background(), parsed)
|
||||
if ingestErr != nil {
|
||||
failures.Add(1)
|
||||
return
|
||||
}
|
||||
if result.Disposition == "created" {
|
||||
created.Add(1)
|
||||
} else if result.Disposition == "duplicate" {
|
||||
duplicate.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if created.Load() != 1 || duplicate.Load() != workers-1 || failures.Load() != 0 {
|
||||
t.Fatalf("concurrent ingest created=%d duplicate=%d failures=%d", created.Load(), duplicate.Load(), failures.Load())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var consumed atomic.Int32
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if (event_ingress.PersistentReplayStore{DB: db}).Consume("yv:sense:school-a", "concurrent-token-id-0001", now.Add(time.Minute), now) {
|
||||
consumed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if consumed.Load() != 1 {
|
||||
t.Fatalf("concurrent replay consume accepted %d requests", consumed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRegistrationIsOptionalAndMigrationGated(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
|
||||
disabled := gin.New()
|
||||
if err := event_ingress.RegisterRuntime(disabled); err != nil || len(disabled.Routes()) != 0 {
|
||||
t.Fatalf("disabled runtime err=%v routes=%#v", err, disabled.Routes())
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:bell-runtime?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sdk.Runtime.SetDb("", db)
|
||||
t.Cleanup(func() { sdk.Runtime.SetDb("", nil) })
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
|
||||
if err = event_ingress.RegisterRuntime(gin.New()); err == nil {
|
||||
t.Fatal("enabled runtime started without formal migration")
|
||||
}
|
||||
if err = db.AutoMigrate(&event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryPath := writeRegistry(t, "yovision-bell", "yv:sense:school-a", "sense-key-0001")
|
||||
t.Setenv("BELL_MACHINE_PRINCIPAL_REGISTRY", registryPath)
|
||||
registered := gin.New()
|
||||
if err = event_ingress.RegisterRuntime(registered); err != nil {
|
||||
t.Fatalf("enabled runtime did not register after migration: %v", err)
|
||||
}
|
||||
routes := registered.Routes()
|
||||
if len(routes) != 1 || routes[0].Method != http.MethodPost || routes[0].Path != "/v1/events" {
|
||||
t.Fatalf("unexpected ingress routes: %#v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractFixtureIdempotencyConflictAndReplayPersistence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
databasePath := filepath.Join(t.TempDir(), "bell-ingress.sqlite")
|
||||
db := openDatabasePath(t, databasePath)
|
||||
body := fixture(t, "dangerous-area.json")
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
newHandler := func() event_ingress.Handler {
|
||||
return event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
}
|
||||
|
||||
firstToken := mint(t, signer, body)
|
||||
first := request(t, newHandler(), body, firstToken)
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first ingest status=%d body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
var created event_ingress.IngestResult
|
||||
decode(t, first, &created)
|
||||
if created.Disposition != "created" || created.PayloadSHA256 != "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1" {
|
||||
t.Fatalf("unexpected created result: %+v", created)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sqlDatabase.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = openDatabasePath(t, databasePath)
|
||||
|
||||
// A new process-local Handler and replay store still reject the old token,
|
||||
// proving that security replay state is durable rather than in-memory.
|
||||
replayedToken := request(t, newHandler(), body, firstToken)
|
||||
if replayedToken.Code != http.StatusUnauthorized || !strings.Contains(replayedToken.Body.String(), "machine_token_replayed") {
|
||||
t.Fatalf("token replay status=%d body=%s", replayedToken.Code, replayedToken.Body.String())
|
||||
}
|
||||
|
||||
duplicate := request(t, newHandler(), body, mint(t, signer, body))
|
||||
if duplicate.Code != http.StatusOK {
|
||||
t.Fatalf("business duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
var duplicateResult event_ingress.IngestResult
|
||||
decode(t, duplicate, &duplicateResult)
|
||||
if duplicateResult.Disposition != "duplicate" || duplicateResult.EventID != created.EventID {
|
||||
t.Fatalf("duplicate did not retain event identity: %+v", duplicateResult)
|
||||
}
|
||||
numericVariant := bytes.Replace(body, []byte(`0.93`), []byte(`0.930`), 1)
|
||||
numericDuplicate := request(t, newHandler(), numericVariant, mint(t, signer, numericVariant))
|
||||
if numericDuplicate.Code != http.StatusOK || !strings.Contains(numericDuplicate.Body.String(), created.PayloadSHA256) {
|
||||
t.Fatalf("JCS-equivalent numeric payload was not a duplicate: %d %s", numericDuplicate.Code, numericDuplicate.Body.String())
|
||||
}
|
||||
|
||||
var changed map[string]any
|
||||
if err = json.Unmarshal(body, &changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed["severity"] = "critical"
|
||||
conflicting, _ := json.Marshal(changed)
|
||||
conflict := request(t, newHandler(), conflicting, mint(t, signer, conflicting))
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "idempotency_conflict") || !strings.Contains(conflict.Body.String(), created.EventID) {
|
||||
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 1)
|
||||
assertCount(t, db, &receipt.Receipt{}, 1)
|
||||
assertCount(t, db, &receipt.IngestAudit{}, 4)
|
||||
}
|
||||
|
||||
func TestEvidenceDegradationIdentityErrorsAndDisabledConnector(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
registry, _ := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
handler := event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
|
||||
pending := fixture(t, "dangerous-area.json")
|
||||
if response := requestWithID(t, handler, pending, mint(t, signer, pending), "short"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request_id") {
|
||||
t.Fatalf("invalid request id status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
missingRequestID := requestWithID(t, handler, pending, mint(t, signer, pending), "")
|
||||
if missingRequestID.Code != http.StatusCreated || !requestIDPatternForTest(missingRequestID.Header().Get("X-Request-ID")) {
|
||||
t.Fatalf("trusted hop did not create a request id: %d %s", missingRequestID.Code, missingRequestID.Body.String())
|
||||
}
|
||||
queryResponse := requestTarget(t, handler, pending, mint(t, signer, pending), "/v1/events?debug=true")
|
||||
if queryResponse.Code != http.StatusBadRequest || !strings.Contains(queryResponse.Body.String(), "invalid_request_target") {
|
||||
t.Fatalf("query target was accepted: %d %s", queryResponse.Code, queryResponse.Body.String())
|
||||
}
|
||||
if response := request(t, handler, pending, mint(t, signer, pending)); response.Code != http.StatusOK {
|
||||
t.Fatalf("pending evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
failed := fixture(t, "directional-line-crossed.json")
|
||||
if response := request(t, handler, failed, mint(t, signer, failed)); response.Code != http.StatusCreated {
|
||||
t.Fatalf("failed evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 2)
|
||||
|
||||
wrongAudienceToken, err := signer.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", pending)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response := request(t, handler, pending, wrongAudienceToken); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong audience was not forbidden: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response := request(t, event_ingress.Handler{Enabled: false}, pending, "none"); response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("disabled connector status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceResolverCurrentMissingExpiredAndTimeout(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
status := event_ingress.EvidenceStatus{EventID: "event-1", EvidenceID: "ev-school-east-0001", OwnerID: "sense-school-a", Status: "pending", Resolution: "snapshot", CurrentPayload: json.RawMessage(`{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"pending","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:01.125Z"}`), CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&status).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
response := func(code int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
client := event_ingress.EvidenceClient{Endpoint: "https://sense.example", Signer: signer, HTTP: doFunc(func(*http.Request) (*http.Response, error) {
|
||||
return response(http.StatusNotFound, `{}`), nil
|
||||
})}
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_not_found" {
|
||||
t.Fatalf("missing resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusGone, `{}`), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "expired" {
|
||||
t.Fatalf("expired resolution=%s db=%v", status.Resolution, err)
|
||||
}
|
||||
current := `{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"success","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:02.125Z","content_type":"image/jpeg","integrity":{"algorithm":"sha256","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1}}`
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, current), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "current" || status.Status != "success" || status.LastError != "" {
|
||||
t.Fatalf("current status=%s resolution=%s error=%s db=%v", status.Status, status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_timeout" {
|
||||
t.Fatalf("timeout resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *gorm.DB {
|
||||
return openDatabasePath(t, filepath.Join(t.TempDir(), "bell-ingress.sqlite"))
|
||||
}
|
||||
|
||||
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", name)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeRegistry(t *testing.T, audience, principal, keyID string) string {
|
||||
t.Helper()
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": audience,
|
||||
"principals": []any{map[string]any{
|
||||
"principal_id": principal, "enabled": true,
|
||||
"keys": []any{map[string]any{
|
||||
"kid": keyID, "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
"status": "active", "scopes": []string{"events:ingest"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "registry.json")
|
||||
if err = os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mint(t *testing.T, signer machine_identity.Signer, body []byte) string {
|
||||
t.Helper()
|
||||
token, err := signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler event_ingress.Handler, body []byte, token string) *httptest.ResponseRecorder {
|
||||
return requestWithID(t, handler, body, token, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, requestID string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, "/v1/events", requestID)
|
||||
}
|
||||
|
||||
func requestTarget(t *testing.T, handler event_ingress.Handler, body []byte, token, target string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, target, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestTargetWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, target, requestID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
handler.Post(context)
|
||||
return response
|
||||
}
|
||||
|
||||
func requestIDPatternForTest(value string) bool {
|
||||
if len(value) < 16 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || index > 0 && strings.ContainsRune("._:-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decode(t *testing.T, response *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, db *gorm.DB, model any, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil || count != expected {
|
||||
t.Fatalf("count %T=%d expected=%d err=%v", model, count, expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
type doFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function doFunc) Do(request *http.Request) (*http.Response, error) { return function(request) }
|
||||
@@ -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,7 @@
|
||||
import request from '@/utils/request'
|
||||
export function listContacts(query) { return request({ url: '/api/v1/bell/contacts', method: 'get', params: query }) }
|
||||
export function createContact(data) { return request({ url: '/api/v1/bell/contacts', method: 'post', data }) }
|
||||
export function updateContact(id, data) { return request({ url: `/api/v1/bell/contacts/${id}`, method: 'put', data }) }
|
||||
export function setContactEnabled(id, enabled, expectedVersion) { return request({ url: `/api/v1/bell/contacts/${id}/enabled`, method: 'put', data: { enabled, expectedVersion }}) }
|
||||
export function addContactChannel(id, data) { return request({ url: `/api/v1/bell/contacts/${id}/channels`, method: 'post', data }) }
|
||||
export function validateContactChannel(id, data) { return request({ url: `/api/v1/bell/contact-channels/${id}/validations`, method: 'post', data }) }
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from '@/utils/request'
|
||||
export function listDutyGroups(query) { return request({ url: '/api/v1/bell/duty-groups', method: 'get', params: query }) }
|
||||
export function createDutyGroup(data) { return request({ url: '/api/v1/bell/duty-groups', method: 'post', data }) }
|
||||
export function updateDutyGroup(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}`, method: 'put', data }) }
|
||||
export function saveDutyMember(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/members`, method: 'post', data }) }
|
||||
export function createDutySchedule(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/schedules`, method: 'post', data }) }
|
||||
export function publishDutySchedule(id) { return request({ url: `/api/v1/bell/duty-schedules/${id}/publish`, method: 'post' }) }
|
||||
export function createDutyOverride(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/overrides`, method: 'post', data }) }
|
||||
@@ -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,21 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper><el-card>
|
||||
<template #header><div class="heading"><div><h2>联系人与通道</h2><p>通道地址保存后只显示脱敏值,验证状态与联系人启用状态相互独立。</p></div><el-button v-permisaction="['bell:contact:write']" 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:120px"><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" />
|
||||
<el-table v-loading="loading" :data="items" border row-key="id">
|
||||
<el-table-column prop="name" label="联系人" min-width="130" /><el-table-column prop="role" label="岗位" min-width="130" />
|
||||
<el-table-column label="通道" min-width="260"><template #default="scope"><div v-if="scope.row.channels.length"><el-tag v-for="ch in scope.row.channels" :key="ch.id" :type="statusType(ch.status)" class="channel">{{ kindName(ch.kind) }} {{ ch.addressMasked }} · {{ statusName(ch.status) }}</el-tag></div><span v-else class="muted">未配置</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canWrite" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column><el-table-column prop="version" label="版本" width="70" />
|
||||
<el-table-column label="操作" width="210" fixed="right"><template #default="scope"><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openChannel(scope.row)">新增通道</el-button><el-dropdown v-if="scope.row.channels.length && canWrite" @command="command => validate(scope.row, command)"><el-button link type="primary">验证通道</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-for="ch in scope.row.channels" :key="ch.id" :command="ch">{{ kindName(ch.kind) }} {{ ch.addressMasked }}</el-dropdown-item></el-dropdown-menu></template></el-dropdown></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-dialog v-model="contactDialog" :title="editing?'编辑联系人':'新增联系人'" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-form ref="contactForm" :model="form" :rules="rules" label-position="top"><el-form-item label="称呼" prop="name"><el-input v-model.trim="form.name" maxlength="128" /></el-form-item><el-form-item label="岗位" prop="role"><el-input v-model.trim="form.role" maxlength="128" /></el-form-item></el-form><template #footer><el-button @click="contactDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveContact">保存</el-button></template></el-dialog>
|
||||
<el-dialog v-model="channelDialog" title="新增联系通道" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-alert title="号码仅在本次填写时可见,保存后只返回脱敏值。" type="info" :closable="false" class="state" /><el-form ref="channelForm" :model="channel" :rules="channelRules" label-position="top"><el-form-item label="通道" prop="kind"><el-select v-model="channel.kind" style="width:100%"><el-option label="短信" value="sms" /><el-option label="语音" value="voice" /></el-select></el-form-item><el-form-item label="号码" prop="address"><el-input v-model.trim="channel.address" autocomplete="off" placeholder="请输入合法测试号码" /></el-form-item></el-form><template #footer><el-button @click="channelDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel">保存</el-button></template></el-dialog>
|
||||
</el-card></template></BasicLayout>
|
||||
</template>
|
||||
<script>
|
||||
import { addContactChannel, createContact, listContacts, setContactEnabled, updateContact, validateContactChannel } from '@/api/bell/contact'
|
||||
export default { name: 'BellContacts', data() { return { loading: false, saving: false, error: '', items: [], total: 0, contactDialog: false, channelDialog: false, editing: false, editingId: '', channelContactId: '', query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }, form: { name: '', role: '', expectedVersion: 0 }, channel: { kind: 'sms', address: '' }, rules: { name: [{ required: true, message: '请输入称呼', trigger: 'blur' }], role: [{ required: true, message: '请输入岗位', trigger: 'blur' }] }, channelRules: { kind: [{ required: true, message: '请选择通道', trigger: 'change' }], address: [{ required: true, pattern: /^\+?[0-9 -]{6,24}$/, message: '请输入有效号码', trigger: 'blur' }] }} }, computed: { canWrite() { const p = this.$store.getters.permisaction || []; return p.includes('*:*:*') || p.includes('bell:contact:write') } }, created() { this.load() }, methods: { async load() { this.loading = true; this.error = ''; try { const r = await listContacts(this.query); this.items = r.data.list || []; this.total = r.data.count || 0 } catch (e) { this.error = e.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.form = { name: '', role: '', expectedVersion: 0 }; this.contactDialog = true }, openEdit(row) { this.editing = true; this.editingId = row.id; this.form = { name: row.name, role: row.role, expectedVersion: row.version }; this.contactDialog = true }, openChannel(row) { this.channelContactId = row.id; this.channel = { kind: 'sms', address: '' }; this.channelDialog = true }, async saveContact() { try { await this.$refs.contactForm.validate(); this.saving = true; if (this.editing) await updateContact(this.editingId, this.form); else await createContact(this.form); this.msgSuccess('联系人已保存'); this.contactDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async saveChannel() { try { await this.$refs.channelForm.validate(); this.saving = true; await addContactChannel(this.channelContactId, this.channel); this.msgSuccess('通道已加密保存,等待验证'); this.channelDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async toggle(row) { try { await setContactEnabled(row.id, row.enabled, row.version); this.msgSuccess(row.enabled ? '联系人已启用' : '联系人已停用'); await this.load() } catch (e) { row.enabled = !row.enabled; this.error = e.message || '状态更新失败' } }, async validate(row, ch) { try { await this.$confirm(`确认合成验证 ${ch.addressMasked} 成功?本操作不会发送外部消息。`, '记录验证结果', { type: 'warning' }); await validateContactChannel(ch.id, { status: 'verified', detail: '人工合成验证' }); this.msgSuccess('验证事实已记录'); await this.load() } catch (e) { if (e !== 'cancel' && e !== 'close' && e && e.message) this.error = e.message } }, kindName(v) { return { sms: '短信', voice: '语音' }[v] || v }, statusName(v) { return { pending: '待验证', verified: '已验证', failed: '验证失败' }[v] || v }, statusType(v) { return { pending: 'warning', verified: 'success', failed: 'danger' }[v] || 'info' } }}
|
||||
</script>
|
||||
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h2{margin:0}.heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state{margin-bottom:16px}.channel{margin:2px 6px 2px 0}.muted{color:var(--el-text-color-secondary)}</style>
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user