Compare commits

...
Author SHA1 Message Date
ila 5128f080b4 Merge Bell 联系人与值班排班 (#183)
用户已于 2026-09-01 明确验收通过 #183。
2026-09-01 14:14:35 +08:00
QiuSW e81f00e9aa feat: 实现 Bell 联系人与值班排班 (#183) 2026-09-01 12:16:52 +08:00
ila 5adee5c3b4 merge: 同步三项目协调 E2E 文档 (#171)
用户于 2026-08-31 明确验收通过 #171。
2026-08-31 21:42:47 +08:00
QiuSW a69ef627c7 docs: 修正协调 E2E 清理边界措辞 (#171) 2026-08-31 21:12:33 +08:00
QiuSW d4de462d44 docs: 同步协调 E2E 运行与排障说明 (#171) 2026-08-31 21:08:32 +08:00
ila 0276bceab5 merge: 完成三项目协调隔离 E2E (#155)
用户已于 2026-08-31 明确验收通过 #155。
2026-08-31 20:49:20 +08:00
QiuSW c2b2943a3a test: 增加三项目协调隔离 E2E (#155) 2026-08-31 17:34:37 +08:00
ila 55b12df373 docs: 同步根级部署编排说明 (#168)
用户于 2026-08-31 明确验收通过 #168。
2026-08-31 16:57:34 +08:00
QiuSW 27d465c250 docs: 同步根级部署编排说明 (#168) 2026-08-31 16:32:48 +08:00
ila 4a2c4aa638 feat: 建立三项目可选部署编排 (#154)
用户于 2026-08-31 明确验收通过 #154。
2026-08-31 16:16:49 +08:00
QiuSW 504dd1a2e9 test: 补充三端独立编排验证 (#154) 2026-08-31 16:12:14 +08:00
QiuSW e2f7183ecf feat: 建立三项目可选部署编排 (#154) 2026-08-31 16:08:05 +08:00
ila eb4e1a9ea1 merge: 同步 connector 验收文档 (#152 #153)
同步已验收 #152/#153 的 Wiki 核心镜像。
2026-08-31 15:40:54 +08:00
QiuSW 9055f2522c docs: 记录 connector 验收 (#152 #153) 2026-08-31 15:36:19 +08:00
ila 130087a1ba merge: 完成可靠事件与证据 connector (#153)
用户已于 2026-08-31 验收通过 #153。
2026-08-31 15:16:18 +08:00
ila 0e53e04e95 merge: 完成 Sense 与 Brain connector (#152)
用户已于 2026-08-31 验收通过 #152。
2026-08-31 15:15:48 +08:00
QiuSW 04c5deecfb feat: 实现可靠事件与证据 connector (#153) 2026-08-31 14:57:48 +08:00
QiuSW 82afce1f81 feat: 实现 Sense Brain 控制连接器 (#152) 2026-08-31 11:51:58 +08:00
ila cf00d73436 Merge pull request '#163' from docs/151-machine-identity-acceptance
docs: 同步 #151 机器身份与安全传输 v1
2026-08-31 11:24:10 +08:00
QiuSW ae9bd015c2 docs: 记录三项目机器身份与安全传输 v1 (#151) 2026-08-31 11:23:52 +08:00
ila 06e0790f00 Merge pull request '#162' from task/151-machine-identity
feat: 建立三项目机器身份与安全传输 v1 (#151)
2026-08-31 11:14:52 +08:00
QiuSW 009dc3cca0 feat: 建立三项目机器身份与安全传输 v1 (#151) 2026-08-31 10:53:51 +08:00
ila 573113eb3b Merge pull request '#161' from docs/150-contract-acceptance
docs: 同步 #150 标准事件与证据 v1 契约
2026-08-31 10:05:00 +08:00
QiuSW b548b05874 docs: 记录标准事件与证据 v1 契约 (#150) 2026-08-31 10:04:39 +08:00
ila 23a85278cb Merge pull request '#158' from feature/150-event-evidence-contract into dev
冻结 Sense/Brain→Bell 标准事件与证据引用契约 v1(#150)。
2026-08-31 09:55:35 +08:00
ila 96777a948f Merge pull request '#160' from docs/148-149-contract-acceptance into dev
同步 #148、#149 冻结契约的 Wiki 核心镜像。
2026-08-31 09:41:48 +08:00
QiuSW c2b023c9fe docs: 记录冻结的 Sense Brain v1 契约 (#148 #149) 2026-08-31 09:41:33 +08:00
ila 4c35da9ef6 Merge pull request '#157' from feature/149-runtime-status-contract into dev
冻结 Brain→Sense 推理运行与健康状态契约 v1(#149)。
2026-08-31 09:34:58 +08:00
ila 30c43aa8d7 Merge pull request '#159' from feature/148-source-config-contract into dev
冻结 Sense→Brain 媒体源与区域规则配置契约 v1(#148)。
2026-08-31 09:34:43 +08:00
QiuSW a22d3ce0f1 fix: 对齐多配置运行状态语义 (#149) 2026-08-31 08:59:48 +08:00
QiuSW 359c553452 fix: align evidence success state (#150) 2026-08-31 08:57:25 +08:00
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 54c58551ae fix: 统一运行状态契约版本标识 (#149) 2026-08-31 08:49:49 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
QiuSW 2a395aa126 feat: freeze event evidence contract v1 (#150) 2026-08-31 08:45:57 +08:00
QiuSW e4fed702c4 feat: 冻结 Brain 运行状态契约 v1 (#149) 2026-08-31 08:44:36 +08:00
ila 49aa79f3b9 Merge pull request '#147' from docs/8-mvp-acceptance into dev
同步 MVP #8 验收后的核心 Wiki 镜像。
2026-08-29 20:49:44 +08:00
QiuSW 64e20e6aed docs: 记录 MVP #8 验收状态 (#8) 2026-08-29 20:49:25 +08:00
ila 19c0868c5d Merge PR #146: Sense 独立验收脚本隔离与诊断修复 (#145)
用户于 2026-08-29 验收通过 #145。
2026-08-29 20:15:35 +08:00
QiuSW 1c6b30fac0 fix: 修复 Sense 独立验收脚本隔离与诊断 (#145) 2026-08-29 20:00:04 +08:00
ila 6702b8a5b9 Merge PR #144: Bell 独立纵切与 Windows 交付验证 (#134)
用户于 2026-08-29 验收通过 #134。
2026-08-29 17:34:07 +08:00
QiuSW a35f1d6770 test: 建立 Bell 独立纵切与 Windows 交付验证 (#134) 2026-08-29 17:30:03 +08:00
ila 6194b664ee Merge PR #143: 修复 Bell 生产外壳白屏 (#142)
用户已于 2026-08-29 明确验收通过 #142。
2026-08-29 17:22:33 +08:00
QiuSW 1caa429cad fix: 修复 Bell 生产外壳白屏 (#142) 2026-08-29 17:11:24 +08:00
ila f99fe8d4f7 Merge PR #141: 收敛 Bell 管理员默认菜单 (#140)
用户已于 2026-08-29 明确验收通过 #140。
2026-08-29 16:45:28 +08:00
QiuSW b4a8e0e1f1 fix: 收敛 Bell 管理员默认菜单 (#140) 2026-08-29 16:43:52 +08:00
ila 86c3e79121 Merge pull request '#139' from feature/138-bell-production-captcha into dev
fix: 恢复 Bell 生产验证码登录 (#138)
2026-08-29 16:27:09 +08:00
QiuSW cabc29c18b fix: 恢复 Bell 生产验证码登录 (#138) 2026-08-29 16:20:34 +08:00
ila 452cd71035 Merge PR #137: Bell 预警处置生命周期
关联 #133;用户验收后关闭工单。
2026-08-29 11:56:57 +08:00
QiuSW f09a61e5fb feat: 重建 Bell 预警处置生命周期 (#133) 2026-08-29 11:56:41 +08:00
ila afc58f7bf4 merge: 完成 Bell 规则与预警链路 (#132)
实现版本化规则、不可变评估事实、open Alert 投影、Event-Alert 双向查询及 GoAdmin UI/RBAC。
2026-08-29 10:02:36 +08:00
230 changed files with 16410 additions and 92 deletions
+59
View File
@@ -0,0 +1,59 @@
# Bell 独立纵切验收
本验收只使用 Bell 自身、临时 PostgreSQL 和项目内合成事件,不启动或调用 Sense、Brain,不连接默认 5432、生产数据库或客户数据。
## 固定工具链
```powershell
$env:GOTOOLCHAIN='go1.26.5'
go version
node --version
corepack pnpm@9.15.1 --version
```
预期分别为 Go 1.26.5、Node 22.22.1、pnpm 9.15.1。
## 源码验证
```powershell
Set-Location Bell\server
$env:GOTOOLCHAIN='go1.26.5'
go test ./... -count=1
go vet ./...
go build ./...
Set-Location ..\ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
corepack pnpm@9.15.1 test:unit --runInBand
corepack pnpm@9.15.1 build:prod
```
## Windows 包和隔离 E2E
```powershell
Set-Location <仓库根目录>
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64
```
E2E 自动完成并清理:临时 PostgreSQL、随机数据库/HTTP 端口、随机管理员/处置员凭据、迁移、健康检查、登录/RBAC、最小 Bell 菜单、规则、合成 Event/Receipt 幂等、Alert、20 路并发 ack、越权/缺参拒绝、close 重放幂等、两条生命周期时间线、冷重启、Windows stop 和日志泄密检查。原始包保持生产配置并先通过审计;业务自动化只把临时包副本切换为 `dev` 测试模式。生产验证码的获取、正确登录、错误及重放拒绝由 #138 的 `Bell/server/tests/bell_production_login/run-postgres.ps1` 覆盖,不暴露或识别验证码答案。
浏览器验收打开脚本输出的临时 `base_url`,检查:
- 匿名访问跳转登录页,并显示验证码输入;测试模式可填写任意非空验证码,生产验证码行为由 #138 回归覆盖;
- 登录后保留 GoAdmin 侧栏、顶部导航和标签页;
- 管理员显示 Bell 必要业务菜单,包括预警管理、事件查询、规则配置;处置员仅显示预警处理所需入口;
- 预警详情可显示关联事件、处理人、现场结果和两条处理时间线;
- 不显示开发工具、定时任务、系统监控等无关入口。
## 仓库闭环
```powershell
python dev_scripts/harness.py check --strict
git diff --check
git status --short --branch
```
浏览器人工/工具检查、真实生产数据库、客户网络和长期负载不由 API 单测替代;未执行的项目必须在工单证据中明确说明。
+59
View File
@@ -0,0 +1,59 @@
# Bell Windows 运行说明
Bell Windows 包包含独立后端、GoAdmin 管理端静态资源和启动、停止、检查脚本。正式运行需要独立 PostgreSQL;包内不提供默认账号、密码、JWT secret 或数据库。
## 配置
编辑 `config\bell.env`:
```text
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=host=127.0.0.1 port=5432 user=bell dbname=bell sslmode=disable
BELL_JWT_SECRET=<至少 32 字符的独立随机值>
BELL_BOOTSTRAP_USERNAME=<仅首次迁移使用>
BELL_BOOTSTRAP_PASSWORD=<仅首次迁移使用,至少 8 字符>
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
```
不要把真实配置提交到 Git。首次迁移成功后,建议从进程环境中移除 `BELL_BOOTSTRAP_PASSWORD`;它不会写入明文数据库。
## 启动、检查和停止
```bat
check-bell.bat
start-bell.bat
check-bell.bat -Running
stop-bell.bat
```
浏览器访问 `http://127.0.0.1:18091/`。`BELL_PORT` 是仅供本机 Web 网关访问的后端端口;`BELL_WEB_PORT` 是用户访问入口。启动脚本默认先执行幂等数据库迁移,再启动后端和 Web 网关;任一步失败都会返回非零退出码。
`stop-bell.bat` 只按包内 PID 文件和启动命令行核对后停止本包进程树,不按端口终止未知进程。运行日志位于 `runtime\logs`,不得包含密码、JWT 或登录 token。
## 构建和包审计
从仓库根目录运行:
```powershell
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
```
输出:
- `Bell\dist\bell-windows-amd64\`
- `Bell\dist\bell-windows-amd64.zip`
包内 `VERSION.txt`、`MANIFEST.sha256` 和 `LICENSES\` 分别记录源码提交、工具链、文件摘要、GoAdmin 来源及 MIT 许可证。
## 常见错误
- `BELL_DATABASE_URL is required`:设置独立 PostgreSQL 连接串。
- `PostgreSQL is unreachable`:启动 PostgreSQL,并检查地址和端口。
- `BELL_JWT_SECRET must contain...`:生成至少 32 字符、只供 Bell 使用的随机值。
- `port ... is already in use`:停止已有 Bell,或修改后端/Web 端口。
- `Bell database migration failed`:检查数据库是否存在、用户权限及迁移日志;不要删除已有 Event、Alert 或生命周期事实。
+9
View File
@@ -37,3 +37,12 @@ corepack pnpm@9.15.1 dev
```
生产构建使用 `corepack pnpm@9.15.1 build:prod`。生产环境不会生成或接受仓库默认管理员、默认 JWT secret 或默认数据库连接串。
## Windows 交付与独立验收
- Windows 构建:`Bell\scripts\build\build-windows.bat`
- 包审计:`pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64`
- 隔离 E2E:`pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64`
- 包内启动、检查和停止:`start-bell.bat`、`check-bell.bat -Running`、`stop-bell.bat`
完整配置、排错和验收标准见 `README-WINDOWS.md` 与 `ACCEPTANCE.md`。隔离 E2E 使用临时 PostgreSQL、随机端口和随机凭据,不启动或调用 Sense、Brain。
+11
View File
@@ -0,0 +1,11 @@
# Bell production environment. Copy values into process environment or this file.
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=
BELL_JWT_SECRET=
BELL_BOOTSTRAP_USERNAME=
BELL_BOOTSTRAP_PASSWORD=
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
+13
View File
@@ -0,0 +1,13 @@
param([Parameter(Mandatory = $true)][string]$WebRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot)
$index = Join-Path $root 'index.html'
if (-not (Test-Path -LiteralPath $index -PathType Leaf)) { throw 'web/index.html is missing.' }
$html = Get-Content -LiteralPath $index -Raw -Encoding UTF8
$references = [regex]::Matches($html, '(?:src|href)=["''](?<path>/[^"''?#]+)') | ForEach-Object { $_.Groups['path'].Value.TrimStart('/').Replace('/', '\') }
foreach ($relative in $references | Sort-Object -Unique) {
if ($relative -match '^https?:') { continue }
if (-not (Test-Path -LiteralPath (Join-Path $root $relative) -PathType Leaf)) { throw "web asset referenced by index.html is missing: $relative" }
}
Write-Host "Bell web asset check passed: $root"
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0build-windows.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0build-windows.ps1" %*)
exit /b %errorlevel%
+88
View File
@@ -0,0 +1,88 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$bellRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$repositoryRoot = Split-Path $bellRoot -Parent
$serverRoot = Join-Path $bellRoot 'server'
$uiRoot = Join-Path $bellRoot 'ui'
$distRoot = Join-Path $bellRoot 'dist'
$target = Join-Path $distRoot 'bell-windows-amd64'
$archive = Join-Path $distRoot 'bell-windows-amd64.zip'
$staging = Join-Path $distRoot ('.bell-windows-amd64.staging-' + $PID)
function Assert-ChildPath([string]$Parent,[string]$Child) {
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
$childPath = [IO.Path]::GetFullPath($Child)
if (-not $childPath.StartsWith($parentPath,[StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe build path outside $Parent`: $Child" }
}
function Get-FileSha256([string]$Path) {
$sha = [Security.Cryptography.SHA256]::Create(); $stream = [IO.File]::OpenRead($Path)
try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','') } finally { $stream.Dispose(); $sha.Dispose() }
}
Assert-ChildPath $bellRoot $distRoot; Assert-ChildPath $distRoot $target; Assert-ChildPath $distRoot $archive; Assert-ChildPath $distRoot $staging
$savedToolchain = $env:GOTOOLCHAIN
$env:GOTOOLCHAIN = 'go1.26.5'
try {
Push-Location $serverRoot
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
$nodeVersion = (& node --version).Trim().TrimStart('v')
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; found $goVersion." }
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; found $pnpmVersion." }
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
New-Item -ItemType Directory -Path $staging | Out-Null
Push-Location $uiRoot
try {
& corepack pnpm@9.15.1 install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
& corepack pnpm@9.15.1 run build:prod
if ($LASTEXITCODE -ne 0) { throw 'Bell UI production build failed.' }
# The frozen Vue CLI differential build references a module runtime
# that ScriptExt removes from disk. The complete legacy bundle is
# present, so make that reproducible bundle the package entry point.
$builtIndex = Join-Path $uiRoot 'dist\index.html'
$html = Get-Content -LiteralPath $builtIndex -Raw -Encoding UTF8
$html = [regex]::Replace($html, '<script[^>]+type="module"[^>]*></script>', '')
$html = $html.Replace(' nomodule', '')
[IO.File]::WriteAllText($builtIndex, $html, (New-Object Text.UTF8Encoding($false)))
} finally { Pop-Location }
$oldGOOS,$oldGOARCH,$oldCGO = $env:GOOS,$env:GOARCH,$env:CGO_ENABLED
try {
$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
Push-Location $serverRoot
try { & go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'bell.exe') .; if ($LASTEXITCODE -ne 0) { throw 'Bell server Windows build failed.' } } finally { Pop-Location }
} finally { $env:GOOS,$env:GOARCH,$env:CGO_ENABLED=$oldGOOS,$oldGOARCH,$oldCGO }
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'),(Join-Path $staging 'config'),(Join-Path $staging 'LICENSES') | Out-Null
Copy-Item -Path (Join-Path $bellRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
foreach ($name in @('start-bell','stop-bell','check-bell')) { Copy-Item -LiteralPath (Join-Path $bellRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") }
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env.example')
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\settings.yml') -Destination (Join-Path $staging 'config\settings.yml')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
Copy-Item -LiteralPath (Join-Path $bellRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
Copy-Item -LiteralPath (Join-Path $bellRoot 'LICENSES') -Destination $staging -Recurse -Force
Copy-Item -LiteralPath (Join-Path $serverRoot 'LICENSE.md') -Destination (Join-Path $staging 'LICENSES\Bell-server-LICENSE.md')
Copy-Item -LiteralPath (Join-Path $uiRoot 'LICENSE') -Destination (Join-Path $staging 'LICENSES\Bell-ui-LICENSE')
$commit = (& git -C $repositoryRoot rev-parse HEAD).Trim()
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'),@("source_commit=$commit",'go=1.26.5','node=22.22.1','pnpm=9.15.1'),(New-Object Text.UTF8Encoding($false)))
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
if ($LASTEXITCODE -ne 0) { throw 'Bell package audit failed.' }
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { "$(Get-FileSha256 $file.FullName) $($file.FullName.Substring($staging.Length+1).Replace('\','/'))" }
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'),$manifest,(New-Object Text.UTF8Encoding($false)))
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
Move-Item -LiteralPath $staging -Destination $target
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
Write-Host "Bell Windows package: $target"
Write-Host "Bell Windows archive: $archive"
} finally {
$env:GOTOOLCHAIN = $savedToolchain
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
}
+34
View File
@@ -0,0 +1,34 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($PackageRoot)
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
$required = @(
'bell.exe','start-bell.bat','stop-bell.bat','check-bell.bat','README-WINDOWS.md',
'config\bell.env','config\bell.env.example','config\settings.yml','config\db.sql','config\pg.sql','web\index.html',
'scripts\runtime\bell-common.ps1','scripts\runtime\bell-web.ps1',
'LICENSES\SOURCES.md','LICENSES\go-admin-LICENSE.md','LICENSES\go-admin-ui-LICENSE',
'VERSION.txt'
)
foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } }
& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web')
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules','.git','dist','.cache') }
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db','.sqlite','.sqlite3','.dump','.bak') }
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
$config = Get-Content -LiteralPath (Join-Path $root 'config\bell.env') -Raw -Encoding UTF8
foreach ($secret in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD')) {
if ($config -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty credential field: $secret" }
}
$sources = Get-Content -LiteralPath (Join-Path $root 'LICENSES\SOURCES.md') -Raw -Encoding UTF8
foreach ($commit in @('f06540883b41d03782bb6b2c4150f298f328c6b6','67d393d713877572fab0b897296a4c1d525fc81d','424855aacf6905f3fde860c3331385cb25529a0d')) {
if (-not $sources.Contains($commit)) { throw "Package source evidence is missing commit $commit" }
}
$version = Get-Content -LiteralPath (Join-Path $root 'VERSION.txt') -Raw -Encoding UTF8
foreach ($entry in @('go=1.26.5','node=22.22.1','pnpm=9.15.1')) { if (-not $version.Contains($entry)) { throw "Package version evidence is missing $entry" } }
$textExtensions = @('.md','.txt','.env','.example','.ps1','.bat','.yml','.yaml','.json','.html','.js','.css')
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
$content = [string](Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue)
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') { throw "Package contains a forbidden default credential or private key marker: $($file.FullName)" }
}
Write-Host "Bell package audit passed: $root"
+117
View File
@@ -0,0 +1,117 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$script:BellAllowedEnvironment = @(
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
'BELL_SYNTHETIC_EVENTS_ENABLED'
)
function Get-BellPackageRoot {
param([string]$ScriptDirectory = $PSScriptRoot)
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
}
function Import-BellEnvironment {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" }
$lineNumber = 0
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
$lineNumber++
$line = $rawLine.Trim()
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
$separator = $line.IndexOf('=')
if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." }
$name = $line.Substring(0, $separator).Trim()
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" }
$value = $line.Substring($separator + 1)
if ($value.Length -ge 2) {
$first, $last = $value[0], $value[$value.Length - 1]
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
}
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Get-BellEnvironmentValue {
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function Test-BellTcpEndpoint {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
$client = [Net.Sockets.TcpClient]::new()
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
}
function Test-BellListenPortAvailable {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
}
function Get-BellDatabaseEndpoint {
param([Parameter(Mandatory = $true)][string]$Connection)
if ($Connection -match '^postgres(?:ql)?://') {
$uri = [Uri]$Connection
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
}
$values = @{}
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
$value = $match.Groups['value'].Value.Trim("'", '"')
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
}
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
}
function Get-BellPort {
param([string]$Name, [int]$Default)
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
$port = 0
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
return $port
}
function Initialize-BellRuntime {
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
if (-not $AllowOccupiedPorts) {
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
}
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
$webRoot = Join-Path $PackageRoot 'web'
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
return [pscustomobject]@{
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
}
}
function Wait-BellHealth {
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {}
Start-Sleep -Milliseconds 300
}
throw "Bell health check timed out: $BaseUrl/healthz"
}
+72
View File
@@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)][string]$WebRoot,
[Parameter(Mandatory = $true)][string]$ListenHost,
[Parameter(Mandatory = $true)][int]$ListenPort,
[Parameter(Mandatory = $true)][string]$BackendUrl
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
$handler = [Net.Http.HttpClientHandler]::new()
$handler.UseProxy = $false
$client = [Net.Http.HttpClient]::new($handler)
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
try {
$listener.Start()
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
while ($listener.IsListening) {
$context = $listener.GetContext()
try {
$request = $context.Request
$response = $context.Response
$path = $request.Url.AbsolutePath
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
$target = "$BackendUrl$($request.Url.PathAndQuery)"
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
if ($request.HasEntityBody) {
$memory = [IO.MemoryStream]::new()
$request.InputStream.CopyTo($memory)
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
$memory.Dispose()
}
foreach ($key in $request.Headers.AllKeys) {
if ($key -in @('Host','Content-Length')) { continue }
$values = $request.Headers.GetValues($key)
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
}
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$response.StatusCode = [int]$upstream.StatusCode
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$message.Dispose(); $upstream.Dispose()
} else {
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
$file = Join-Path $root 'index.html'
}
if ($response.StatusCode -ne 403) {
$bytes = [IO.File]::ReadAllBytes($file)
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
}
}
} catch {
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
} finally {
try { $context.Response.OutputStream.Close() } catch {}
}
}
} finally {
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
exit /b %errorlevel%
+12
View File
@@ -0,0 +1,12 @@
param([switch]$Running)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
if ($Running) {
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
}
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
exit /b %errorlevel%
+41
View File
@@ -0,0 +1,41 @@
param([switch]$SkipMigration)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
$backend = $null
$pidFile = $null
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root
$bell = Join-Path $root 'bell.exe'
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
$runtime = Join-Path $root 'runtime'
$logs = Join-Path $runtime 'logs'
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
$pidFile = Join-Path $runtime 'bell.pid'
if (Test-Path -LiteralPath $pidFile) {
$oldPid = 0
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
Remove-Item -LiteralPath $pidFile -Force
}
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
Push-Location $root
try {
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
Write-Host 'Applying pending Bell database migrations...'
& $bell migrate -c $state.SettingsPath
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
}
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
Wait-BellHealth -BaseUrl $state.BackendUrl
Write-Host "Bell is available at $($state.WebUrl)/"
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
} finally { Pop-Location }
} catch {
Write-Error $_.Exception.Message
exit 1
} finally {
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
exit /b %errorlevel%
+17
View File
@@ -0,0 +1,17 @@
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$pidFile = Join-Path $root 'runtime\bell.pid'
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
$processId = 0
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
$rootPattern = [regex]::Escape($root)
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
& taskkill.exe /PID $processId /T /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
Write-Host 'Bell backend and web process tree stopped.'
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+7
View File
@@ -0,0 +1,7 @@
param([string]$PostgresBin='D:\pgsql17\bin',[string]$PreparedPackageRoot='',[switch]$KeepTemporary,[switch]$BrowserHold)
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot '..\tests\e2e\run-isolated-e2e.ps1'),'-PostgresBin',$PostgresBin)
if(-not[string]::IsNullOrWhiteSpace($PreparedPackageRoot)){$arguments+=@('-PreparedPackageRoot',$PreparedPackageRoot)}
if($KeepTemporary){$arguments+='-KeepTemporary'}
if($BrowserHold){$arguments+='-BrowserHold'}
& pwsh.exe @arguments
exit $LASTEXITCODE
+1 -2
View File
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, answer, err := captcha.DriverDigitFunc()
id, b64s, _, err := captcha.DriverDigitFunc()
if err != nil {
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
e.Error(500, err, "验证码获取失败")
return
}
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
e.Custom(gin.H{
"code": 200,
"data": b64s,
@@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
}
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
systemAPI := apis.System{}
v1.GET("/captcha", systemAPI.GenerateCaptchaHandler)
api := apis.SysMenu{}
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
@@ -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
}
+59
View File
@@ -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[:])
}
+146
View File
@@ -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
+68
View File
@@ -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"`
}
+62
View File
@@ -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, "/")
}
+251
View File
@@ -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,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)
}
}
+21
View File
@@ -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)
}
+4
View File
@@ -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)
}
+5 -1
View File
@@ -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,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
}
@@ -177,7 +177,7 @@ func assertCount(t *testing.T, db *gorm.DB, table string, want int64) {
func assertOperatorAccess(t *testing.T, db *gorm.DB) {
t.Helper()
var menuCount, readPolicyCount, writePolicyCount int64
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 {
@@ -186,11 +186,14 @@ func assertOperatorAccess(t *testing.T, db *gorm.DB) {
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 <> ?", "operator", "GET").Count(&writePolicyCount).Error; err != nil {
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 menuCount != 4 || readPolicyCount != 6 || writePolicyCount != 0 {
t.Fatalf("operator access is not read-only and minimal: menus=%d reads=%d writes=%d", menuCount, readPolicyCount, writePolicyCount)
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)
}
}
@@ -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.'
+204
View File
@@ -0,0 +1,204 @@
param(
[string]$PostgresBin = 'D:\pgsql17\bin',
[string]$PreparedPackageRoot = '',
[switch]$KeepTemporary,
[switch]$BrowserHold
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
$bellRoot = Join-Path $repositoryRoot 'Bell'
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('bell-e2e-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $temporary 'postgres'
$pgLog = Join-Path $temporary 'postgres.log'
$runtimeOut = Join-Path $temporary 'bell-launcher.out.log'
$runtimeErr = Join-Path $temporary 'bell-launcher.err.log'
$launcher = $null
$pgStarted = $false
$savedEnvironment = @{}
function Get-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0)
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
}
function Get-UniqueFreePorts([int]$Count) {
$ports = [Collections.Generic.List[int]]::new()
while ($ports.Count -lt $Count) { $port=Get-FreeTcpPort; if (-not $ports.Contains($port)) { $ports.Add($port) } }
return $ports.ToArray()
}
function New-RandomText([int]$Bytes=32) {
$buffer=New-Object byte[] $Bytes; $generator=[Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+','A').Replace('/','B')
}
function Set-TestEnvironment([string]$Name,[string]$Value) {
if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name]=[Environment]::GetEnvironmentVariable($Name,'Process') }
[Environment]::SetEnvironmentVariable($Name,$Value,'Process')
}
function Wait-Tcp([int]$Port,[bool]$Open,[int]$Attempts=120) {
for($i=0;$i -lt $Attempts;$i++) {
$client=[Net.Sockets.TcpClient]::new()
try { $connected=$client.ConnectAsync('127.0.0.1',$Port).Wait(250)-and$client.Connected } catch { $connected=$false } finally { $client.Dispose() }
if($connected -eq $Open){return}; Start-Sleep -Milliseconds 250
}
throw "TCP port $Port did not reach open=$Open"
}
function Wait-Health([string]$BaseUrl) {
for($i=0;$i -lt 120;$i++){try{$health=Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy;if($health.status-eq'ok'-and$health.service-eq'bell'){return}}catch{};Start-Sleep -Milliseconds 300}
throw "Bell health endpoint did not become ready: $BaseUrl"
}
function Invoke-BellJson {
param([string]$Method,[string]$Path,$Body=$null,[string]$Token='',[int]$ExpectedCode=200)
$headers=@{};if($Token){$headers.Authorization="Bearer $Token"}
$arguments=@{Method=$Method;Uri="$script:baseUrl$Path";Headers=$headers;TimeoutSec=20;NoProxy=$true}
if($null-ne$Body){$arguments.ContentType='application/json; charset=utf-8';$arguments.Body=$Body|ConvertTo-Json -Depth 12 -Compress}
try{$response=Invoke-RestMethod @arguments}catch{throw "Bell request failed for $Method $Path`: $($_.Exception.Message)"}
if([int]$response.code-ne$ExpectedCode){throw "Unexpected Bell code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"}
return $response
}
function Login([string]$Username,[string]$Password){$response=Invoke-BellJson POST '/api/v1/login' @{username=$Username;password=$Password;code='0';uuid='0'};if([string]::IsNullOrWhiteSpace($response.token)){throw "Login did not return a token for $Username"};return [string]$response.token}
function Get-VisibleMenuTitles($Menus,[bool]$AncestorsVisible=$true) {
foreach($menu in @($Menus)) {
if($null-eq$menu){continue}
$visible=$AncestorsVisible-and([string]$menu.visible-eq'0')
if($visible-and-not[string]::IsNullOrWhiteSpace([string]$menu.title)){[string]$menu.title}
if($menu.PSObject.Properties.Name-contains'children'){
Get-VisibleMenuTitles -Menus $menu.children -AncestorsVisible $visible
}
}
}
function Start-Package([string]$Root){
$script:launcher=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',"`"$(Join-Path $Root 'start-bell.bat')`"") -WorkingDirectory $Root -RedirectStandardOutput $runtimeOut -RedirectStandardError $runtimeErr -WindowStyle Hidden -PassThru
Wait-Health $script:baseUrl
}
function Stop-Package([string]$Root){
& (Join-Path $Root 'stop-bell.bat') | Out-Host
if($LASTEXITCODE-ne 0){throw 'Bell package stop failed'}
Wait-Tcp -Port $script:webPort -Open $false -Attempts 40
Wait-Tcp -Port $script:backendPort -Open $false -Attempts 40
if($script:launcher-and-not$script:launcher.HasExited){$script:launcher.WaitForExit(5000)|Out-Null}
$script:launcher=$null
}
function Stop-ProcessTree($Process){if($Process-and-not$Process.HasExited){& taskkill.exe /PID $Process.Id /T /F 2>$null|Out-Null}}
New-Item -ItemType Directory -Path $temporary | Out-Null
try {
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){$path=Join-Path $PostgresBin $name;if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Required PostgreSQL tool not found: $path"}}
if([string]::IsNullOrWhiteSpace($PreparedPackageRoot)){
& (Join-Path $bellRoot 'scripts\build\build-windows.ps1')
if($LASTEXITCODE-ne 0){throw 'Bell Windows package build failed'}
$preparedPackageRoot=Join-Path $bellRoot 'dist\bell-windows-amd64'
}else{$preparedPackageRoot=[IO.Path]::GetFullPath($PreparedPackageRoot)}
& (Join-Path $bellRoot 'scripts\build\test-package.ps1') -PackageRoot $preparedPackageRoot
& (Join-Path $bellRoot 'tests\compatibility\assert-go-admin-shell.ps1') -PackageRoot $preparedPackageRoot
$packageRoot=Join-Path $temporary 'package'
Copy-Item -LiteralPath $preparedPackageRoot -Destination $packageRoot -Recurse
# Production captcha behavior is covered by #138. The isolated business
# E2E uses a disposable package copy in dev mode so it never needs to
# expose or OCR a captcha answer.
$settingsPath=Join-Path $packageRoot 'config\settings.yml'
$settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8
$testSettings=[regex]::Replace($settings,'(?m)^(\s*mode:\s*)prod\s*$','$1dev')
if($testSettings-eq$settings){throw 'Packaged settings did not contain the expected production mode'}
[IO.File]::WriteAllText($settingsPath,$testSettings,(New-Object Text.UTF8Encoding($false)))
$pgPort,$script:backendPort,$script:webPort=Get-UniqueFreePorts 3
if($pgPort-eq 5432){throw 'E2E must not use the default PostgreSQL port'}
$script:baseUrl="http://127.0.0.1:$script:webPort"
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U bell_e2e -A trust --encoding=UTF8 --no-locale|Out-Null
if($LASTEXITCODE-ne 0){throw 'isolated PostgreSQL initdb failed'}
$pgArguments="-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput (Join-Path $temporary 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden|Out-Null
Wait-Tcp -Port $pgPort -Open $true;$pgStarted=$true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U bell_e2e bell_e2e
if($LASTEXITCODE-ne 0){throw 'isolated Bell database creation failed'}
$adminName='bell_e2e_admin_'+(New-RandomText 5).ToLowerInvariant();$adminPassword=New-RandomText 20
$operatorPassword=New-RandomText 20;$jwt=New-RandomText 48
$environment=@{
BELL_HOST='127.0.0.1';BELL_PORT="$script:backendPort";BELL_WEB_HOST='127.0.0.1';BELL_WEB_PORT="$script:webPort";
BELL_DATABASE_URL="host=127.0.0.1 port=$pgPort user=bell_e2e dbname=bell_e2e sslmode=disable";
BELL_JWT_SECRET=$jwt;BELL_BOOTSTRAP_USERNAME=$adminName;BELL_BOOTSTRAP_PASSWORD=$adminPassword;
BELL_AUTO_MIGRATE='true';BELL_SYNTHETIC_EVENTS_ENABLED='true'
}
foreach($item in $environment.GetEnumerator()){Set-TestEnvironment $item.Key $item.Value}
Start-Package $packageRoot
$anonymous=Invoke-BellJson GET '/api/v1/bell/alerts' $null '' 401
$adminToken=Login $adminName $adminPassword
$psql=Join-Path $PostgresBin 'psql.exe'
$operatorRole=[int]((&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select role_id from sys_role where role_key='operator';").Trim())
if($operatorRole-lt 1){throw 'operator role was not migrated'}
$operators=@(
@{username='bell_e2e_operator_a';nickName='处置员A'},
@{username='bell_e2e_operator_b';nickName='处置员B'}
)
foreach($operator in $operators){[void](Invoke-BellJson POST '/api/v1/sys-user' @{username=$operator.username;password=$operatorPassword;nickName=$operator.nickName;phone='13800000000';roleId=$operatorRole;sex='1';email="$($operator.username)@invalid.local";deptId=1;postId=1;status='2'} $adminToken)}
$tokenA=Login $operators[0].username $operatorPassword;$tokenB=Login $operators[1].username $operatorPassword
$adminMenu=Invoke-BellJson GET '/api/v1/menurole' $null $adminToken
$operatorMenu=Invoke-BellJson GET '/api/v1/menurole' $null $tokenA
$adminVisible=@(Get-VisibleMenuTitles $adminMenu.data)
$operatorVisible=@(Get-VisibleMenuTitles $operatorMenu.data)
foreach($label in @('预警管理','事件查询','规则配置')){if($adminVisible-notcontains$label){throw "administrator menu is missing $label; visible=$($adminVisible-join',')"}}
foreach($label in @('预警管理','事件查询')){if($operatorVisible-notcontains$label){throw "operator menu is missing $label; visible=$($operatorVisible-join',')"}}
foreach($label in @('开发工具','定时任务','系统监控')){if($adminVisible-contains$label-or$operatorVisible-contains$label){throw "unrelated menu is visible: $label"}}
$eventType='bell_e2e_danger';$ruleBody=@{code='bell-e2e-danger';name='E2E危险区域规则';eventType=$eventType;minimumSeverity='medium';locationContains='东门'}
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $tokenA 403)
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $adminToken)
$eventBody=Get-Content -LiteralPath (Join-Path $bellRoot 'tests\fixtures\synthetic-danger-event.json') -Raw -Encoding UTF8|ConvertFrom-Json
$eventBody.eventType=$eventType
$created=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
$replay=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
if($created.data.duplicate-ne$false-or$replay.data.duplicate-ne$true-or$created.data.event.id-ne$replay.data.event.id){throw 'synthetic Event idempotency failed'}
$eventId=[string]$created.data.event.id
$alerts=Invoke-BellJson GET '/api/v1/bell/alerts?status=open&pageIndex=1&pageSize=20' $null $tokenA
$alert=@($alerts.data.list)[0]
if(-not$alert){throw 'rule evaluation did not create an open Alert'}
$alertId=[string]$alert.id
$alertDetail=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId" $null $tokenA).data
if(@($alertDetail.events.id)-notcontains$eventId){throw 'created Alert is not linked to the synthetic Event'}
$requests=for($i=0;$i-lt 20;$i++){[pscustomobject]@{Token=$(if($i%2-eq0){$tokenA}else{$tokenB})}}
$acks=$requests|ForEach-Object -Parallel {
$headers=@{Authorization="Bearer $($_.Token)"}
$response=Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/alerts/$using:alertId/ack" -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20 -NoProxy
[pscustomobject]@{Token=$_.Token;Response=$response}
} -ThrottleLimit 20
$winners=@($acks|Where-Object{$_.Response.data.won-eq$true})
if($winners.Count-ne 1){throw "concurrent ack winners=$($winners.Count)"}
$lifecycle=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $tokenA).data.detail
if($lifecycle.timeline.Count-ne 1-or$lifecycle.projection.status-ne'acknowledged'){throw 'ack lifecycle projection is inconsistent'}
$winnerToken=[string]$winners[0].Token
$loserToken=$(if($winnerToken-eq$tokenA){$tokenB}else{$tokenA})
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal'} $loserToken 403)
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{} $winnerToken 400)
$closed=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
$closeReplay=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
if($closed.data.won-ne$true-or$closeReplay.data.idempotent-ne$true){throw 'close or idempotent replay failed'}
$final=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
if($final.timeline.Count-ne 2-or$final.projection.status-ne'closed'){throw 'closed timeline is incomplete'}
$facts=(&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select (select count(*) from bell_events),(select count(*) from bell_event_receipts),(select count(*) from bell_alert_lifecycle_facts where alert_id='$alertId');").Trim()
if($facts-ne'1|1|2'){throw "unexpected persisted fact counts: $facts"}
Stop-Package $packageRoot
Start-Package $packageRoot
$after=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
if($after.timeline.Count-ne 2-or$after.projection.closeOutcome-ne'site_normal'){throw 'cold restart lost lifecycle state'}
$rootPage=Invoke-WebRequest -Uri "$script:baseUrl/" -TimeoutSec 10 -NoProxy
if($rootPage.StatusCode-ne 200-or$rootPage.Content-notmatch'id=["'']app["'']'){throw 'packaged GoAdmin web shell is not available'}
if($BrowserHold){
$browserSession=Join-Path $temporary 'browser-session.json';$browserDone=Join-Path $temporary 'browser-done'
@{baseUrl=$script:baseUrl;username=$adminName;password=$adminPassword;alertId=$alertId}|ConvertTo-Json|Set-Content -LiteralPath $browserSession -Encoding UTF8
Write-Host "Bell browser session ready: $browserSession"
for($i=0;$i-lt 1200-and-not(Test-Path -LiteralPath $browserDone);$i++){Start-Sleep -Milliseconds 500}
if(-not(Test-Path -LiteralPath $browserDone)){throw 'Browser verification did not signal completion within 10 minutes'}
}
Stop-Package $packageRoot
foreach($log in @($runtimeOut,$runtimeErr,(Join-Path $packageRoot 'runtime\logs\bell.out.log'),(Join-Path $packageRoot 'runtime\logs\bell.err.log'))){if(Test-Path $log){$text=[string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue);foreach($secret in @($adminPassword,$operatorPassword,$jwt,$adminToken,$tokenA,$tokenB)){if($text.Contains($secret)){throw "runtime log exposed an E2E credential: $log"}}}}
Write-Host "Bell isolated E2E passed: health/login/RBAC, minimal menu, Event/Receipt idempotency, Rule/Alert, 20 concurrent ack, close authorization/idempotency, timeline, cold restart, package start/stop. base_url=$script:baseUrl"
} finally {
try { if($launcher){Stop-Package $packageRoot} } catch { Stop-ProcessTree $launcher }
if($pgStarted){Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$pgData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporary 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden|Out-Null;try{Wait-Tcp -Port $pgPort -Open $false -Attempts 40}catch{}}
foreach($item in $savedEnvironment.GetEnumerator()){[Environment]::SetEnvironmentVariable($item.Key,$item.Value,'Process')}
if(-not$KeepTemporary-and(Test-Path -LiteralPath $temporary)){$resolved=[IO.Path]::GetFullPath($temporary);if(-not$resolved.StartsWith([IO.Path]::GetTempPath(),[StringComparison]::OrdinalIgnoreCase)){throw "Unsafe temporary cleanup path: $resolved"};Remove-Item -LiteralPath $resolved -Recurse -Force}elseif($KeepTemporary){Write-Host "Kept Bell E2E directory: $temporary"}
}
+12
View File
@@ -0,0 +1,12 @@
{
"sourceEventId": "bell-e2e-danger-001",
"eventType": "danger_area_entered",
"occurredAt": "2026-08-29T00:00:00Z",
"location": "东门危险区域",
"severity": "high",
"evidenceRef": "e2e/evidence/bell-e2e-danger-001",
"attributes": {
"target": "anonymous",
"fixture": true
}
}
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getAlertLifecycle(id) { return request({ url: `/api/v1/bell/alerts/${id}/lifecycle`, method: 'get' }) }
export function acknowledgeAlert(id) { return request({ url: `/api/v1/bell/alerts/${id}/ack`, method: 'post' }) }
export function closeAlert(id, data) { return request({ url: `/api/v1/bell/alerts/${id}/close`, method: 'post', data }) }
+7
View File
@@ -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 }) }
+8
View File
@@ -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 }) }
@@ -15,7 +15,7 @@
<script>
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
import { mapGetters } from 'vuex'
export default {
@@ -29,7 +29,7 @@
import { mapGetters } from 'vuex'
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
export default {
components: { SidebarItem, Logo },
+1 -1
View File
@@ -20,7 +20,7 @@ import RightPanel from '@/components/RightPanel'
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
export default {
name: 'MainLayout',
+1 -1
View File
@@ -1,4 +1,4 @@
import variables from '@/styles/element-variables.scss'
import variables from '@/styles/element-variables.scss?module'
import defaultSettings from '@/settings'
const { showSettings, topNav, tagsView, fixedHeader, sidebarLogo, themeStyle } = defaultSettings
@@ -0,0 +1,28 @@
<template>
<div class="lifecycle-actions">
<el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" />
<el-button v-if="lifecycle.canAck" v-permisaction="['bell:alert:ack']" type="primary" :loading="loading" @click="ack">我已看到并开始处理</el-button>
<el-button v-if="lifecycle.canClose" v-permisaction="['bell:alert:close']" type="primary" :loading="loading" @click="dialog=true">记录现场结果并完成</el-button>
<el-dialog v-model="dialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" @closed="reset">
<el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false" class="form-alert" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="form.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item>
<el-form-item label="补充说明(可选)"><el-input v-model="form.note" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="loading" @click="finish">确认结果并完成</el-button></template>
</el-dialog>
</div>
</template>
<script>
import { acknowledgeAlert, closeAlert } from '@/api/bell/alert-lifecycle'
export default {
name: 'BellLifecycleActions', props: { alertId: { type: String, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed'],
data() { return { loading: false, error: '', dialog: false, form: { outcome: '', note: '' }, rules: { outcome: [{ required: true, message: '请选择现场结果', trigger: 'change' }] }} },
methods: {
async ack() { this.loading = true; this.error = ''; try { const r = await acknowledgeAlert(this.alertId); this.msgSuccess(r.data.idempotent ? '您已在处理此预警' : '已记录由您开始处理'); this.$emit('changed') } catch (e) { this.error = e.message || '开始处理失败'; this.$emit('changed') } finally { this.loading = false } },
async finish() { try { await this.$refs.formRef.validate(); this.loading = true; this.error = ''; const r = await closeAlert(this.alertId, this.form); this.msgSuccess(r.data.idempotent ? '该结果已记录' : '预警已完成'); this.dialog = false; this.$emit('changed') } catch (e) { if (e && e.message) this.error = e.message } finally { this.loading = false } },
reset() { this.form = { outcome: '', note: '' }; this.$refs.formRef && this.$refs.formRef.clearValidate() }
}
}
</script>
<style scoped>.lifecycle-actions{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}.lifecycle-actions .el-alert{flex-basis:100%}.form-alert{margin-bottom:16px}.outcome-group{display:grid;gap:10px}</style>
@@ -0,0 +1,2 @@
<template><el-timeline><el-timeline-item v-for="item in items" :key="item.id" :timestamp="parseTime(item.occurredAt)" :type="item.transition==='closed'?'success':'primary'"><strong>{{ item.transition === 'closed' ? '处理完成' : '开始处理' }}</strong> · {{ item.actorName }}<div v-if="item.outcome">现场结果:{{ outcomeName(item.outcome) }}<span v-if="item.note">;{{ item.note }}</span></div></el-timeline-item><el-empty v-if="!items.length" description="尚无处理记录" /></el-timeline></template>
<script>export default { name: 'BellLifecycleTimeline', props: { items: { type: Array, default: () => [] }}, methods: { outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
+3
View File
@@ -0,0 +1,3 @@
<template><div><el-descriptions :column="1" border><el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item><el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item><el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusName(lifecycle.projection.status || detail.alert.status) }}</el-descriptions-item><el-descriptions-item label="处理人">{{ lifecycle.projection.acknowledgedByName || '尚未开始处理' }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeOutcome" label="现场结果">{{ outcomeName(lifecycle.projection.closeOutcome) }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeNote" label="处理说明">{{ lifecycle.projection.closeNote }}</el-descriptions-item><el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item><el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item></el-descriptions><LifecycleActions :alert-id="detail.alert.id" :lifecycle="lifecycle" @changed="$emit('changed')" /><h3>关联事件</h3><el-table :data="detail.events" border row-key="id"><el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column><el-table-column prop="eventType" label="事件类型" min-width="150" /><el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="$emit('go-event',scope.row.id)">查看</el-button></template></el-table-column></el-table><h3>处理时间线</h3><LifecycleTimeline :items="lifecycle.timeline" /><h3>命中说明</h3><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">规则 v{{ match.ruleVersion }}:{{ match.explanation }}</el-timeline-item></el-timeline></div></template>
<script>import LifecycleActions from './components/LifecycleActions.vue'; import LifecycleTimeline from './components/LifecycleTimeline.vue'; export default { name: 'BellAlertDetail', components: { LifecycleActions, LifecycleTimeline }, props: { detail: { type: Object, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed', 'go-event'], methods: { statusName(v) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[v] || v }, severityName(v) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[v] || v }, outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
<style scoped>h3{font-size:16px;margin:22px 0 10px}</style>
+14 -29
View File
@@ -2,9 +2,9 @@
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><h2>预警管理</h2><p>查看待处理预警及其关联事件;开始处理和完成操作将在下一阶段提供。</p></div></template>
<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-select></el-form-item>
<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>
@@ -15,7 +15,7 @@
<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><el-tag type="danger">待处理</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>
@@ -26,28 +26,7 @@
<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" />
<template v-if="detail">
<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="状态">待处理</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>
<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="goEvent(scope.row.id)">查看</el-button></template></el-table-column>
</el-table>
<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>
</template>
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
</div>
</el-drawer>
</template>
@@ -56,13 +35,16 @@
<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,
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: '' }
}
@@ -76,12 +58,15 @@ export default {
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
try { const response = await getAlert(row.id); this.detail = response.data } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
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' }
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>
+21
View File
@@ -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
@@ -0,0 +1,14 @@
import request from '@/utils/request'
import { acknowledgeAlert, closeAlert, getAlertLifecycle } from '@/api/bell/alert-lifecycle'
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
describe('Bell alert lifecycle API', () => {
beforeEach(() => request.mockClear())
it('maps timeline, ack and close to protected Alert routes', async() => {
await getAlertLifecycle('a1'); await acknowledgeAlert('a1'); await closeAlert('a1', { outcome: 'site_normal' })
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/alerts/a1/lifecycle', method: 'get' },
{ url: '/api/v1/bell/alerts/a1/ack', method: 'post' },
{ url: '/api/v1/bell/alerts/a1/close', method: 'post', data: { outcome: 'site_normal' }}
])
})
})
@@ -0,0 +1,17 @@
jest.mock('@/api/bell/alert-lifecycle', () => ({
acknowledgeAlert: jest.fn(),
closeAlert: jest.fn(),
getAlertLifecycle: jest.fn()
}))
import AlertDetail from '@/views/bell/alerts/detail.vue'
import LifecycleTimeline from '@/views/bell/alerts/components/LifecycleTimeline.vue'
describe('Bell ordinary-user lifecycle wording', () => {
it('maps technical states and outcomes to familiar wording', () => {
expect(AlertDetail.methods.statusName('open')).toBe('待处理')
expect(AlertDetail.methods.statusName('acknowledged')).toBe('处理中')
expect(AlertDetail.methods.statusName('closed')).toBe('已完成')
expect(AlertDetail.methods.outcomeName('false_positive')).toBe('误报')
expect(LifecycleTimeline.methods.outcomeName('unable_to_confirm')).toBe('无法确认')
})
})
@@ -0,0 +1,16 @@
const fs = require('fs')
const path = require('path')
const valueImports = [
['src/store/modules/settings.js', "@/styles/element-variables.scss?module"],
['src/layout/index.vue', "@/styles/variables.scss?module"],
['src/layout/components/Sidebar/Logo.vue', "@/styles/variables.scss?module"],
['src/layout/components/Sidebar/index.vue', "@/styles/variables.scss?module"]
]
describe('GoAdmin shell Sass value imports', () => {
it.each(valueImports)('%s explicitly requests CSS Modules exports', (file, request) => {
const source = fs.readFileSync(path.join(__dirname, '../../..', file), 'utf8')
expect(source).toContain(`from '${request}'`)
})
})
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.1.0"
description = "Headless inference delivery unit for YoVision"
readme = "README.md"
requires-python = "==3.11.*"
dependencies = []
dependencies = ["cryptography==50.0.1"]
[project.optional-dependencies]
# The wheel backend is selected by the official PyTorch index documented in
+5 -2
View File
@@ -11,6 +11,7 @@ from yovision_brain.config import ConfigError
from yovision_brain.decode import DecoderError
from yovision_brain.events import JsonLinesSink
from yovision_brain.input import InputError
from yovision_brain.integration.event_export import build_event_export_sink
from yovision_brain.rules import RuleConfigError
from .runner import run_pipeline
@@ -38,14 +39,16 @@ def main(argv: list[str] | None = None) -> int:
stream = sys.stdout
owned_stream = None
try:
if args.output != "-":
export_sink = build_event_export_sink(raw.get("event_export"), base_dir=config_path.parent)
if export_sink is None and args.output != "-":
try:
owned_stream = Path(args.output).open("w", encoding="utf-8", newline="\n")
except OSError:
print(json.dumps({"status": "error", "message": "event output cannot be opened"}), file=sys.stderr)
return 2
stream = owned_stream
summary = run_pipeline(raw, JsonLinesSink(stream), base_dir=config_path.parent)
sink = export_sink if export_sink is not None else JsonLinesSink(stream)
summary = run_pipeline(raw, sink, base_dir=config_path.parent)
except (ConfigError, DecoderError, InputError, RuleConfigError, RuntimeError, ValueError) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 3
@@ -0,0 +1,30 @@
"""Map Brain-internal candidates to the frozen anonymous event contract."""
from .mapper import (
EVENT_SCHEMA_VERSION,
EVIDENCE_SCHEMA_VERSION,
EventExportError,
canonical_json,
canonical_json_bytes,
export_event,
payload_sha256,
)
from .replay import SQLiteReplayCache
from .client import DeliveryResult, EventDeliveryError, HTTPSMachineIdentitySender
from .runtime import EventExportSink, build_event_export_sink
__all__ = [
"EVENT_SCHEMA_VERSION",
"EVIDENCE_SCHEMA_VERSION",
"EventExportError",
"EventDeliveryError",
"DeliveryResult",
"EventExportSink",
"HTTPSMachineIdentitySender",
"SQLiteReplayCache",
"canonical_json",
"canonical_json_bytes",
"export_event",
"payload_sha256",
"build_event_export_sink",
]
@@ -0,0 +1,167 @@
"""Synchronous, request-bound HTTPS delivery for Brain event exports."""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Protocol
from urllib.parse import urlsplit
from yovision_brain.integration.machine_identity import Signer, TransportPolicy
EVENT_PATH = "/v1/events"
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
class EventDeliveryError(RuntimeError):
"""An event was not accepted; callers must retain or reproduce the fact."""
def __init__(self, code: str, *, terminal: bool) -> None:
super().__init__(code)
self.code = code
self.terminal = terminal
@dataclass(frozen=True, slots=True)
class DeliveryResult:
disposition: str
request_id: str
class _Headers(Protocol):
def get(self, name: str, default: str | None = None) -> str | None: ...
class _Response(Protocol):
status: int
headers: _Headers
def read(self, amount: int = -1) -> bytes: ...
def close(self) -> None: ...
class _Opener(Protocol):
def open(self, request: urllib.request.Request, timeout: float) -> _Response: ...
class HTTPSMachineIdentitySender:
def __init__(
self,
endpoint: str,
signer: Signer,
policy: TransportPolicy,
*,
opener: _Opener | None = None,
) -> None:
parsed = urlsplit(endpoint)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
raise ValueError("event export endpoint must be an HTTPS origin")
policy.validate()
self._endpoint = endpoint.rstrip("/")
self._signer = signer
self._policy = policy
self._opener = opener or urllib.request.build_opener(
urllib.request.HTTPSHandler(context=policy.ssl_context())
)
def send(self, body: bytes) -> DeliveryResult:
if len(body) > self._policy.max_request_bytes:
raise EventDeliveryError("event_request_too_large", terminal=True)
request_id = "req-" + secrets.token_urlsafe(16)
if not _REQUEST_ID.fullmatch(request_id): # pragma: no cover - defensive invariant
raise RuntimeError("generated request id is invalid")
token = self._signer.mint(
"yovision-sense", ("events:ingest",), "POST", EVENT_PATH, body
)
request = urllib.request.Request(
self._endpoint + EVENT_PATH,
data=body,
method="POST",
headers={
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
"X-Request-ID": request_id,
},
)
try:
response = self._opener.open(
request, timeout=self._policy.request_timeout_ms / 1000
)
except urllib.error.HTTPError as exc:
response_body = exc.read(64 * 1024 + 1)
code = _problem_code(response_body) or "event_delivery_rejected"
raise EventDeliveryError(
code,
terminal=400 <= exc.code < 500 and exc.code != 429,
) from None
except (OSError, TimeoutError, urllib.error.URLError):
raise EventDeliveryError("event_delivery_unavailable", terminal=False) from None
try:
response_body = response.read(64 * 1024 + 1)
if len(response_body) > 64 * 1024:
raise EventDeliveryError("event_response_invalid", terminal=False)
if response.status not in {200, 201, 202}:
raise EventDeliveryError(
"event_delivery_rejected",
terminal=400 <= response.status < 500 and response.status != 429,
)
response_request_id = response.headers.get("X-Request-ID")
if response_request_id != request_id:
raise EventDeliveryError("event_response_invalid", terminal=False)
disposition = _disposition(response_body, response.status, body)
return DeliveryResult(disposition=disposition, request_id=request_id)
finally:
response.close()
def _problem_code(body: bytes) -> str | None:
try:
value = json.loads(body)
except (UnicodeDecodeError, json.JSONDecodeError):
return None
code = value.get("code") if isinstance(value, dict) else None
return code if isinstance(code, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,63}", code) else None
def _disposition(body: bytes, status: int, request_body: bytes) -> str:
try:
value = json.loads(body)
except (UnicodeDecodeError, json.JSONDecodeError):
raise EventDeliveryError("event_response_invalid", terminal=False) from None
disposition = value.get("disposition") if isinstance(value, dict) else None
allowed = {"accepted", "created", "duplicate"}
if disposition not in allowed:
raise EventDeliveryError("event_response_invalid", terminal=False)
if status == 202 and disposition != "accepted":
raise EventDeliveryError("event_response_invalid", terminal=False)
try:
sent = json.loads(request_body)
response_identity = (
value["producer_id"],
value["source_event_id"],
value["payload_sha256"],
)
expected_identity = (
sent["producer_id"],
sent["source_event_id"],
hashlib.sha256(request_body).hexdigest(),
)
except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
raise EventDeliveryError("event_response_invalid", terminal=False) from None
if response_identity != expected_identity:
raise EventDeliveryError("event_response_invalid", terminal=False)
return disposition
@@ -0,0 +1,378 @@
"""Safe, deterministic Brain producer mapping for ``yovision.event/v1``."""
from __future__ import annotations
import hashlib
import json
import math
import re
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Mapping, Sequence
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
EVENT_SCHEMA_VERSION = "yovision.event/v1"
EVIDENCE_SCHEMA_VERSION = "yovision.evidence-reference/v1"
_REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
_SENSITIVE_NAMES = frozenset(
{
"path",
"url",
"uri",
"password",
"secret",
"token",
"credential",
"signed_url",
"camera_url",
"face",
"face_id",
"face_template",
"alert",
"ack",
"close",
"notification",
}
)
_EVIDENCE_FIELDS = frozenset(
{
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at",
"expires_at",
"content_type",
"integrity",
"failure",
}
)
class EventExportError(ValueError):
"""The internal candidate cannot safely satisfy the frozen contract."""
def export_event(
candidate: InternalEventCandidate,
*,
producer_id: str,
site_ref: str,
device_ref: str | None = None,
severity: str,
evidence: Sequence[Mapping[str, object]] = (),
region_ref: str | None = None,
crossing_direction: str | None = None,
category: str | None = None,
) -> dict[str, object]:
"""Return a new closed v1 payload without mutating the internal candidate.
The candidate's already stable ``event_id`` is the source identity. Callers
must persist and retry the returned payload unchanged; transport attempts do
not participate in either identity field.
"""
if candidate.schema != INTERNAL_EVENT_SCHEMA:
raise EventExportError("unsupported internal event candidate schema")
producer_id = _reference("producer_id", producer_id)
source_event_id = _reference("source_event_id", candidate.event_id)
site_ref = _reference("site_ref", site_ref)
device_ref = _reference("device_ref", device_ref or candidate.logical_input_id)
profile_ref = _reference("profile_ref", candidate.profile_id)
rule_id = _reference("rule.rule_id", candidate.rule_id)
region_id = _reference("region.region_id", region_ref or candidate.rule_id)
track_id = _reference("observation.track_id", candidate.track_id)
event_type = {
"danger_area_entered": "dangerous_area_entered",
"dangerous_area_entered": "dangerous_area_entered",
"directional_line_crossed": "directional_line_crossed",
}.get(candidate.event_type)
if event_type is None:
raise EventExportError("unsupported event type")
if severity not in {"low", "medium", "high", "critical"}:
raise EventExportError("unsupported severity")
observation = _observation(candidate, track_id=track_id, category=category)
region: dict[str, object] = {
"region_id": region_id,
"kind": "area" if event_type == "dangerous_area_entered" else "line",
}
if event_type == "directional_line_crossed":
if crossing_direction not in {"a_to_b", "b_to_a"}:
raise EventExportError("line events require a contract crossing_direction")
region["crossing_direction"] = crossing_direction
elif crossing_direction is not None:
raise EventExportError("area events cannot carry crossing_direction")
if len(evidence) > 8:
raise EventExportError("at most eight evidence references are allowed")
mapped_evidence = [_evidence_reference(item) for item in evidence]
if len({canonical_json_bytes(item) for item in mapped_evidence}) != len(mapped_evidence):
raise EventExportError("duplicate evidence references are not allowed")
payload: dict[str, object] = {
"schema_version": EVENT_SCHEMA_VERSION,
"producer_id": producer_id,
"source_event_id": source_event_id,
"site_ref": site_ref,
"device_ref": device_ref,
"profile_ref": profile_ref,
"event_type": event_type,
"occurred_at": _event_timestamp(candidate.occurred_at_ns),
"severity": severity,
"rule": {"rule_id": rule_id, "version": _bounded_text("rule.version", candidate.rule_version, 64)},
"model": {
"name": _bounded_text("model.name", candidate.model_name, 128),
"version": _bounded_text("model.version", candidate.model_version, 64),
},
"observation": observation,
"region": region,
"evidence": mapped_evidence,
}
_reject_unsafe(payload)
canonical_json_bytes(payload) # Reject non-finite or unsupported values now.
return payload
def canonical_json(value: object) -> str:
"""Serialize the closed event-domain JCS subset used by frozen fixtures.
Contract values use JSON strings, containers, booleans, integers and finite
ordinary decimals. Integer-valued floats are normalized to their JSON number
form; the checked-in RFC 8785 vector fixes cross-language digest behavior.
"""
return _encode_jcs(_normalize_numbers(value))
def canonical_json_bytes(value: object) -> bytes:
return canonical_json(value).encode("utf-8")
def payload_sha256(event: Mapping[str, object]) -> str:
return hashlib.sha256(canonical_json_bytes(event)).hexdigest()
def _observation(
candidate: InternalEventCandidate, *, track_id: str, category: str | None
) -> dict[str, object]:
internal = candidate.observation
confidence = internal.get("confidence")
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
raise EventExportError("observation confidence must be numeric")
confidence = float(confidence)
if not math.isfinite(confidence) or not 0 <= confidence <= 1:
raise EventExportError("observation confidence must be finite and between zero and one")
internal_category = internal.get("category")
exported_category = category or {
"anonymous_target": "person",
"person": "person",
"vehicle": "vehicle",
"other": "other",
}.get(internal_category)
if exported_category not in {"person", "vehicle", "other"}:
raise EventExportError("observation category requires an explicit anonymous contract mapping")
result: dict[str, object] = {
"track_id": track_id,
"category": exported_category,
"confidence": confidence,
}
box = internal.get("box")
if box is not None:
if not isinstance(box, Mapping) or set(box) != {"left", "top", "right", "bottom"}:
raise EventExportError("internal observation box is malformed")
if candidate.frame_width <= 0 or candidate.frame_height <= 0:
raise EventExportError("frame dimensions must be positive")
coordinates = (box["left"], box["top"], box["right"], box["bottom"])
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in coordinates):
raise EventExportError("box coordinates must be numeric")
normalized = [
float(coordinates[0]) / candidate.frame_width,
float(coordinates[1]) / candidate.frame_height,
float(coordinates[2]) / candidate.frame_width,
float(coordinates[3]) / candidate.frame_height,
]
if any(not math.isfinite(value) or not 0 <= value <= 1 for value in normalized):
raise EventExportError("normalized box coordinates must be finite and between zero and one")
result["bbox_normalized"] = normalized
return result
def _evidence_reference(source: Mapping[str, object]) -> dict[str, object]:
if not isinstance(source, Mapping):
raise EventExportError("evidence reference must be an object")
unknown = set(source) - _EVIDENCE_FIELDS
if unknown:
raise EventExportError(f"evidence reference contains forbidden fields: {sorted(unknown)!r}")
required = {
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at",
}
missing = required - set(source)
if missing:
raise EventExportError(f"evidence reference is missing fields: {sorted(missing)!r}")
result = dict(source)
if result["schema_version"] != EVIDENCE_SCHEMA_VERSION:
raise EventExportError("unsupported evidence schema version")
_reference("evidence.evidence_id", result["evidence_id"])
_reference("evidence.owner_id", result["owner_id"])
if result["type"] not in {"snapshot", "clip"}:
raise EventExportError("unsupported evidence type")
status = result["status"]
if status not in {"pending", "processing", "success", "failed"}:
raise EventExportError("unsupported evidence status")
for field in ("captured_at", "status_updated_at", "expires_at"):
if field in result:
_date_time(field, result[field])
if status in {"pending", "processing"}:
if any(field in result for field in ("content_type", "integrity", "failure")):
raise EventExportError(f"{status} evidence cannot claim content or failure")
elif status == "success":
if "failure" in result or "content_type" not in result or "integrity" not in result:
raise EventExportError("successful evidence requires content metadata and no failure")
if result["content_type"] not in {"image/jpeg", "image/png", "video/mp4"}:
raise EventExportError("unsupported evidence content type")
integrity = result["integrity"]
if not isinstance(integrity, Mapping) or set(integrity) != {"algorithm", "digest", "size_bytes"}:
raise EventExportError("evidence integrity is malformed")
if integrity["algorithm"] != "sha256" or not isinstance(integrity["digest"], str) or not _SHA256.fullmatch(integrity["digest"]):
raise EventExportError("evidence integrity must contain a SHA-256 digest")
if isinstance(integrity["size_bytes"], bool) or not isinstance(integrity["size_bytes"], int) or integrity["size_bytes"] < 0:
raise EventExportError("evidence size must be a non-negative integer")
else:
if "content_type" in result or "integrity" in result or "failure" not in result:
raise EventExportError("failed evidence requires only failure metadata")
failure = result["failure"]
if not isinstance(failure, Mapping) or set(failure) != {"code", "retryable"}:
raise EventExportError("evidence failure is malformed")
if failure["code"] not in {"capture_failed", "processing_failed", "expired", "unavailable"}:
raise EventExportError("unsupported evidence failure code")
if not isinstance(failure["retryable"], bool):
raise EventExportError("evidence retryable must be boolean")
_reject_unsafe(result)
return result
def _reference(name: str, value: object) -> str:
if not isinstance(value, str) or not _REFERENCE.fullmatch(value):
raise EventExportError(f"{name} is not a valid logical reference")
return value
def _bounded_text(name: str, value: object, maximum: int) -> str:
if not isinstance(value, str) or not 1 <= len(value) <= maximum:
raise EventExportError(f"{name} must be 1..{maximum} characters")
return value
def _event_timestamp(nanoseconds: int) -> str:
if isinstance(nanoseconds, bool) or not isinstance(nanoseconds, int) or nanoseconds < 0:
raise EventExportError("occurred_at_ns must be a non-negative integer")
seconds, remainder = divmod(nanoseconds, 1_000_000_000)
value = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
seconds=seconds, milliseconds=remainder // 1_000_000
)
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
def _date_time(name: str, value: object) -> None:
if not isinstance(value, str):
raise EventExportError(f"evidence {name} must be a date-time string")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise EventExportError(f"evidence {name} must be a valid date-time") from exc
if parsed.tzinfo is None:
raise EventExportError(f"evidence {name} must include a timezone")
def _reject_unsafe(value: object, *, key: str = "") -> None:
if isinstance(value, Mapping):
for child_key, child in value.items():
lowered = str(child_key).lower()
if lowered in _SENSITIVE_NAMES or lowered.endswith("_path") or lowered.endswith("_url"):
raise EventExportError(f"sensitive field {child_key!r} is forbidden")
_reject_unsafe(child, key=lowered)
elif isinstance(value, (list, tuple)):
for child in value:
_reject_unsafe(child, key=key)
elif isinstance(value, str):
if _URL.match(value) or _WINDOWS_PATH.match(value) or value.startswith(("/", "\\\\")):
raise EventExportError(f"path or URL value in {key or 'payload'} is forbidden")
def _normalize_numbers(value: object) -> object:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise EventExportError("canonical JSON rejects non-finite numbers")
if value == 0:
return 0
return value
if isinstance(value, Mapping):
if any(not isinstance(key, str) for key in value):
raise EventExportError("canonical JSON object keys must be strings")
return {key: _normalize_numbers(child) for key, child in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_numbers(child) for child in value]
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
def _encode_jcs(value: object) -> str:
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return _jcs_float(value)
if isinstance(value, list):
return "[" + ",".join(_encode_jcs(item) for item in value) + "]"
if isinstance(value, Mapping):
# Frozen contract keys are ASCII. Sorting them is therefore identical
# to RFC 8785's UTF-16 code-unit ordering without accepting extensions.
return "{" + ",".join(
_encode_jcs(key) + ":" + _encode_jcs(value[key]) for key in sorted(value)
) + "}"
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
def _jcs_float(value: float) -> str:
if not math.isfinite(value):
raise EventExportError("canonical JSON rejects non-finite numbers")
if value == 0:
return "0"
rendered = repr(value).lower()
absolute = abs(value)
if 1e-6 <= absolute < 1e21 and "e" in rendered:
return format(Decimal(rendered), "f")
if "e" in rendered:
mantissa, exponent = rendered.split("e", 1)
sign = ""
if exponent.startswith(("+", "-")):
sign, exponent = exponent[0], exponent[1:]
exponent = exponent.lstrip("0") or "0"
rendered = mantissa + "e" + sign + exponent
return rendered[:-2] if rendered.endswith(".0") else rendered
@@ -0,0 +1,69 @@
"""Durable atomic replay protection owned by the Brain connector."""
from __future__ import annotations
import sqlite3
from pathlib import Path
class SQLiteReplayCache:
"""SQLite implementation of the machine-identity ``ReplayCache`` protocol.
A primary key makes consumption atomic across threads and processes. Entries
remain durable across connector restarts until their verifier expiry passes.
"""
def __init__(self, database: str | Path, *, timeout_seconds: float = 5.0) -> None:
self._database = str(Path(database))
self._timeout_seconds = timeout_seconds
if timeout_seconds <= 0:
raise ValueError("SQLite replay timeout must be positive")
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS machine_token_replay (
principal TEXT NOT NULL,
token_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (principal, token_id)
) WITHOUT ROWID
"""
)
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
if not principal or not token_id:
raise ValueError("replay identity must be non-empty")
if any(isinstance(value, bool) or not isinstance(value, int) for value in (expires_at, now)):
raise ValueError("replay timestamps must be integers")
if expires_at <= now:
return False
connection = self._connect()
try:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM machine_token_replay WHERE expires_at <= ?", (now,))
try:
connection.execute(
"INSERT INTO machine_token_replay (principal, token_id, expires_at) VALUES (?, ?, ?)",
(principal, token_id, expires_at),
)
except sqlite3.IntegrityError:
connection.rollback()
return False
connection.commit()
return True
except BaseException:
connection.rollback()
raise
finally:
connection.close()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(
self._database,
timeout=self._timeout_seconds,
isolation_level=None,
)
connection.execute(f"PRAGMA busy_timeout={int(self._timeout_seconds * 1000)}")
return connection
@@ -0,0 +1,134 @@
"""Closed runtime configuration and event sink for Brain-to-Sense export."""
from __future__ import annotations
from pathlib import Path
from typing import Mapping
from yovision_brain.events import InternalEventCandidate
from yovision_brain.integration.machine_identity import (
Signer,
TransportPolicy,
load_private_key,
)
from .client import HTTPSMachineIdentitySender
from .mapper import canonical_json_bytes, export_event
class EventExportSink:
def __init__(
self,
sender: HTTPSMachineIdentitySender,
*,
producer_id: str,
site_ref: str,
severity: str,
region_refs: Mapping[str, str],
crossing_directions: Mapping[str, str],
) -> None:
self._sender = sender
self._producer_id = producer_id
self._site_ref = site_ref
self._severity = severity
self._region_refs = dict(region_refs)
self._crossing_directions = dict(crossing_directions)
def write(self, candidate: InternalEventCandidate) -> None:
event = export_event(
candidate,
producer_id=self._producer_id,
site_ref=self._site_ref,
severity=self._severity,
region_ref=self._region_refs.get(candidate.rule_id),
crossing_direction=self._crossing_directions.get(candidate.rule_id),
)
# Mapping and serialization happen before minting, so the exact bytes are
# bound to the machine token and remain unchanged for this delivery.
self._sender.send(canonical_json_bytes(event))
def build_event_export_sink(raw: object, *, base_dir: Path) -> EventExportSink | None:
if raw is None:
return None
config = _object("event_export", raw)
_closed(
"event_export",
config,
{
"enabled", "endpoint", "producer_id", "site_ref", "severity",
"region_refs", "crossing_directions", "machine_identity", "transport",
},
)
enabled = config.get("enabled", False)
if not isinstance(enabled, bool):
raise ValueError("event_export.enabled must be a boolean")
if not enabled:
if set(config) != {"enabled"}:
raise ValueError("disabled event_export may only contain enabled")
return None
identity = _object("event_export.machine_identity", config.get("machine_identity"))
_closed(
"event_export.machine_identity",
identity,
{"principal", "key_id", "private_key_path"},
)
transport_raw = _object("event_export.transport", config.get("transport"))
_closed(
"event_export.transport",
transport_raw,
{
"tls_min_version", "verify_certificate", "verify_hostname",
"connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms",
"max_request_bytes",
},
)
policy = TransportPolicy(**transport_raw) # type: ignore[arg-type]
policy.validate()
key_path = _string("private_key_path", identity.get("private_key_path"))
resolved_key_path = Path(key_path)
if not resolved_key_path.is_absolute():
resolved_key_path = base_dir / resolved_key_path
signer = Signer(
_string("principal", identity.get("principal")),
_string("key_id", identity.get("key_id")),
load_private_key(resolved_key_path),
)
return EventExportSink(
HTTPSMachineIdentitySender(
_string("endpoint", config.get("endpoint")), signer, policy
),
producer_id=_string("producer_id", config.get("producer_id")),
site_ref=_string("site_ref", config.get("site_ref")),
severity=_string("severity", config.get("severity")),
region_refs=_string_map("region_refs", config.get("region_refs", {})),
crossing_directions=_string_map(
"crossing_directions", config.get("crossing_directions", {})
),
)
def _object(name: str, value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
raise ValueError(f"{name} must be an object")
return value
def _closed(name: str, value: Mapping[str, object], allowed: set[str]) -> None:
unknown = set(value) - allowed
if unknown:
raise ValueError(f"{name} contains unsupported fields")
def _string(name: str, value: object) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"event_export.{name} must be a non-empty string")
return value
def _string_map(name: str, value: object) -> Mapping[str, str]:
mapping = _object(f"event_export.{name}", value)
if any(not isinstance(item, str) or not item for item in mapping.values()):
raise ValueError(f"event_export.{name} values must be non-empty strings")
return mapping # type: ignore[return-value]
@@ -0,0 +1,37 @@
"""Independent service-to-service machine identity for Brain connectors."""
from .token import (
ALLOWED_SKEW_SECONDS,
MAX_KEY_OVERLAP_SECONDS,
MAX_LIFETIME_SECONDS,
VERSION,
Claims,
KeyRecord,
MachineIdentityError,
Registry,
ReplayStore,
Signer,
Verifier,
load_private_key,
load_registry,
bearer_token,
)
from .transport import TransportPolicy
__all__ = [
"ALLOWED_SKEW_SECONDS",
"MAX_KEY_OVERLAP_SECONDS",
"MAX_LIFETIME_SECONDS",
"VERSION",
"Claims",
"KeyRecord",
"MachineIdentityError",
"Registry",
"ReplayStore",
"Signer",
"TransportPolicy",
"Verifier",
"load_private_key",
"load_registry",
"bearer_token",
]
@@ -0,0 +1,336 @@
"""Ed25519 request-bound machine tokens.
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
shared secrets. HTTP adapters must obtain the compact token exclusively from
the Authorization bearer header and pass the request body unchanged.
"""
from __future__ import annotations
import base64
import hashlib
import json
import posixpath
import re
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Protocol
from urllib.parse import urlsplit
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
VERSION = "yovision.machine-identity/v1"
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
MAX_LIFETIME_SECONDS = 300
ALLOWED_SKEW_SECONDS = 30
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
class MachineIdentityError(ValueError):
"""A stable, non-secret authentication failure."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def bearer_token(authorization: str) -> str:
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
raise MachineIdentityError("machine_token_missing")
return parts[1]
@dataclass(frozen=True)
class Claims:
ver: str
iss: str
sub: str
aud: str
scope: tuple[str, ...]
iat: int
nbf: int
exp: int
jti: str
htm: str
htu: str
body_sha256: str
@dataclass(frozen=True)
class KeyRecord:
principal: str
key_id: str
public_key: Ed25519PublicKey
audience: str
scopes: frozenset[str]
enabled: bool = True
revoked: bool = False
class Registry:
def __init__(self, records: Iterable[KeyRecord]) -> None:
self._lock = threading.RLock()
self._records: dict[str, KeyRecord] = {}
for record in records:
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
raise ValueError("invalid machine key record")
if record.key_id in self._records:
raise ValueError("duplicate machine key id")
self._records[record.key_id] = record
def lookup(self, key_id: str) -> KeyRecord | None:
with self._lock:
return self._records.get(key_id)
def revoke(self, key_id: str) -> bool:
with self._lock:
record = self._records.get(key_id)
if record is None:
return False
self._records[key_id] = KeyRecord(
principal=record.principal,
key_id=record.key_id,
public_key=record.public_key,
audience=record.audience,
scopes=record.scopes,
enabled=record.enabled,
revoked=True,
)
return True
class ReplayStore:
"""Process-local replay cache for tests or one uninterrupted process."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._used: dict[tuple[str, str], int] = {}
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
with self._lock:
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
key = (principal, token_id)
if key in self._used:
return False
self._used[key] = expires_at
return True
class ReplayCache(Protocol):
"""Connector implementations provide an atomic durable implementation."""
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
if not str(path).strip():
raise ValueError("machine private key path is required")
try:
raw = Path(path).read_bytes()
key = serialization.load_pem_private_key(raw, password=None)
except (OSError, ValueError, TypeError) as exc:
raise ValueError("invalid machine private key file") from exc
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("machine private key is not Ed25519")
return key
def load_registry(path: str | Path, expected_audience: str) -> Registry:
if not str(path).strip() or expected_audience not in _AUDIENCES:
raise ValueError("machine principal registry path and audience are required")
try:
document = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid machine principal registry") from exc
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
raise ValueError("invalid machine principal registry")
records: list[KeyRecord] = []
try:
for principal in document["principals"]:
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
raise ValueError
for key in principal["keys"]:
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
raise ValueError
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
except (KeyError, TypeError, ValueError):
raise ValueError("invalid machine principal registry") from None
return Registry(records)
class Signer:
def __init__(
self,
principal: str,
key_id: str,
private_key: Ed25519PrivateKey,
*,
clock: Callable[[], int] | None = None,
) -> None:
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("incomplete machine signer configuration")
self._principal = principal
self._key_id = key_id
self._private_key = private_key
self._clock = clock or (lambda: int(time.time()))
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
normalized_path = _normalize_path(request_path)
normalized_method = method.upper()
scope_values = tuple(scopes)
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
raise ValueError("invalid machine token request")
now = int(self._clock())
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
claims = {
"ver": VERSION,
"iss": self._principal,
"sub": self._principal,
"aud": audience,
"scope": list(scope_values),
"iat": now,
"nbf": now,
"exp": now + MAX_LIFETIME_SECONDS,
"jti": secrets.token_urlsafe(16),
"htm": normalized_method,
"htu": normalized_path,
"body_sha256": hashlib.sha256(body).hexdigest(),
}
encoded_header = _encode_json(header)
encoded_claims = _encode_json(claims)
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
signature = self._private_key.sign(signing_input)
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
class Verifier:
def __init__(
self,
registry: Registry,
replay_store: ReplayCache,
*,
clock: Callable[[], int] | None = None,
) -> None:
self._registry = registry
self._replay_store = replay_store
self._clock = clock or (lambda: int(time.time()))
def verify(
self,
token: str,
audience: str,
required_scope: str,
method: str,
request_path: str,
body: bytes,
) -> Claims:
parts = token.split(".")
if len(parts) != 3 or "=" in token:
raise MachineIdentityError("machine_token_invalid")
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
raise MachineIdentityError("machine_token_invalid")
record = self._registry.lookup(header["kid"])
if record is None:
raise MachineIdentityError("machine_token_invalid")
try:
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
except (InvalidSignature, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
if not record.enabled or record.revoked:
raise MachineIdentityError("machine_identity_revoked")
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
claims = _claims_from_object(raw)
if claims.iss != record.principal or claims.sub != record.principal:
raise MachineIdentityError("machine_token_invalid")
now = int(self._clock())
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_invalid")
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_expired")
if claims.aud != audience or record.audience != audience:
raise MachineIdentityError("machine_audience_denied")
if required_scope not in claims.scope or required_scope not in record.scopes:
raise MachineIdentityError("machine_scope_denied")
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
raise MachineIdentityError("machine_token_invalid")
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
raise MachineIdentityError("machine_token_replayed")
return claims
def _claims_from_object(value: dict[str, object]) -> Claims:
try:
scope = value["scope"]
if not isinstance(scope, list) or not _valid_scopes(scope):
raise ValueError
integer_fields = ("iat", "nbf", "exp")
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
raise ValueError
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
if any(not isinstance(value[field], str) for field in string_fields):
raise ValueError
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
raise ValueError
bytes.fromhex(claims.body_sha256)
_normalize_path(claims.htu)
return claims
except (KeyError, TypeError, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
def _normalize_path(value: str) -> str:
split = urlsplit(value)
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
raise ValueError("machine request path must be normalized and contain no query or fragment")
return split.path
def _valid_scopes(scopes: Iterable[str]) -> bool:
values = tuple(scopes)
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
def _encode_json(value: dict[str, object]) -> str:
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
try:
decoded = json.loads(_b64decode(value).decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
raise MachineIdentityError("machine_token_invalid") from None
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
raise MachineIdentityError("machine_token_invalid")
return decoded
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
if not value or "=" in value:
raise ValueError("invalid base64url")
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
if _b64encode(decoded) != value:
raise ValueError("non-canonical base64url")
return decoded
@@ -0,0 +1,37 @@
"""Fail-closed HTTPS transport policy for Brain connectors."""
from __future__ import annotations
import ssl
from dataclasses import dataclass
@dataclass(frozen=True)
class TransportPolicy:
tls_min_version: str
verify_certificate: bool
verify_hostname: bool
connect_timeout_ms: int
response_header_timeout_ms: int
request_timeout_ms: int
max_request_bytes: int
def validate(self) -> None:
if (
self.tls_min_version not in {"1.2", "1.3"}
or not self.verify_certificate
or not self.verify_hostname
or not 100 <= self.connect_timeout_ms <= 30_000
or not 100 <= self.response_header_timeout_ms <= 30_000
or not 100 <= self.request_timeout_ms <= 60_000
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
):
raise ValueError("machine transport policy is unsafe")
def ssl_context(self) -> ssl.SSLContext:
self.validate()
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
return context
@@ -0,0 +1,7 @@
"""Credential-free Sense control-plane connector for Brain."""
from .consumer import ApplyResult, SourceConfigConsumer, SourceConfigError
from .replay import SQLiteReplayStore
from .status import RuntimeStatusPublisher
__all__ = ["ApplyResult", "SourceConfigConsumer", "SourceConfigError", "SQLiteReplayStore", "RuntimeStatusPublisher"]
@@ -0,0 +1,57 @@
"""Machine-authenticated adapters with bounded timeout/backoff and a kill switch."""
from __future__ import annotations
import time
import re
from dataclasses import dataclass
from typing import Callable
from yovision_brain.integration.machine_identity.token import Signer, Verifier, bearer_token
from .consumer import ApplyResult, SourceConfigConsumer
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
@dataclass(frozen=True, slots=True)
class ConnectorResponse:
status: int
body: bytes
correlation_id: str
class SourceConfigEndpoint:
def __init__(self, consumer: SourceConfigConsumer, verifier: Verifier, *, enabled: bool = True, max_body_bytes: int = 10 * 1024 * 1024) -> None:
self._consumer, self._verifier, self._enabled, self._max = consumer, verifier, enabled, max_body_bytes
def receive(self, authorization: str, body: bytes, correlation_id: str) -> ApplyResult:
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
if len(body) > self._max: raise ValueError("REQUEST_TOO_LARGE")
token=bearer_token(authorization)
self._verifier.verify(token,"yovision-brain","source-config:write","POST","/machine/v1/source-config",body)
return self._consumer.apply(body)
class StatusSender:
def __init__(self, signer: Signer, send: Callable[[str, bytes, str, float], ConnectorResponse], *, enabled: bool = True, timeout_seconds: float = 5.0, max_attempts: int = 4, sleeper: Callable[[float], None] = time.sleep) -> None:
if timeout_seconds <= 0 or max_attempts < 1: raise ValueError("invalid connector retry policy")
self._signer,self._send,self._enabled,self._timeout,self._attempts,self._sleep=signer,send,enabled,timeout_seconds,max_attempts,sleeper
def publish(self, body: bytes, correlation_id: str) -> ConnectorResponse:
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
last: Exception|None=None
for attempt in range(self._attempts):
try:
# A retry gets a fresh jti: the previous request may have been
# accepted even when its response was lost.
token=self._signer.mint("yovision-sense",("runtime-status:write",),"POST","/machine/v1/runtime-status",body)
response=self._send("Bearer "+token,body,correlation_id,self._timeout)
if 200<=response.status<300:return response
if response.status<500:raise RuntimeError(f"STATUS_REJECTED_{response.status}")
last=RuntimeError(f"STATUS_REMOTE_{response.status}")
except (TimeoutError,ConnectionError) as exc:last=exc
if attempt+1<self._attempts:self._sleep(min(2**attempt,30))
raise RuntimeError("STATUS_DELIVERY_EXHAUSTED") from last
@@ -0,0 +1,250 @@
"""Strict source-config/v1 validation and atomic last-known-good application."""
from __future__ import annotations
import hashlib
import hmac
import json
import re
import sqlite3
import threading
import time
from contextlib import closing
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Mapping
from urllib.parse import urlsplit
from yovision_brain.rules.models import AreaDefinition, DirectionalLineDefinition, NormalizedPoint, RuleSet
VERSION = "yovision.source-config/v1"
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$")
_EXTENSION_NAMESPACE = re.compile(r"^[A-Za-z][A-Za-z0-9.-]{0,127}$")
_SECRET = re.compile(r"password|secret|credential|cookie|jwt|username|stream_uri", re.I)
class SourceConfigError(ValueError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
@dataclass(frozen=True, slots=True)
class AppliedConfig:
config_id: str
revision: int
logical_device_id: str
media_ref: str
profile_encoding: str
frame_rate: float
rule_state: str
rules: RuleSet | None
@dataclass(frozen=True, slots=True)
class ApplyResult:
config_id: str
revision: int
state: str
config: AppliedConfig | None
class SourceConfigConsumer:
"""Persists validated snapshots before atomically changing the active pointer."""
def __init__(self, state_path: str | Path, *, clock: Callable[[], float] | None = None) -> None:
self._path = str(state_path)
self._clock = clock or time.time
self._lock = threading.RLock()
with closing(self._connect()) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS source_snapshots (
config_id TEXT NOT NULL, revision INTEGER NOT NULL, effective_at INTEGER NOT NULL,
state TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(config_id, revision));
CREATE TABLE IF NOT EXISTS source_active (
config_id TEXT PRIMARY KEY, revision INTEGER NOT NULL,
FOREIGN KEY(config_id, revision) REFERENCES source_snapshots(config_id, revision));
"""
)
connection.commit()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self._path, timeout=5)
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA journal_mode=WAL")
return connection
def apply(self, body: bytes) -> ApplyResult:
document = _parse_and_validate(body)
config_id, revision = document["config_id"], document["revision"]
mapped = _map(document)
effective_at = int(_timestamp(document["effective_at"]))
with self._lock, closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
latest = connection.execute(
"SELECT revision, payload, state, effective_at FROM source_snapshots WHERE config_id=? ORDER BY revision DESC LIMIT 1",
(config_id,),
).fetchone()
if latest and revision < latest[0]:
connection.rollback()
raise SourceConfigError("STALE_REVISION")
canonical = body.decode("utf-8")
if latest and revision == latest[0]:
if json.loads(latest[1]) != document:
connection.rollback()
raise SourceConfigError("REVISION_CONFLICT")
connection.rollback()
return ApplyResult(config_id, revision, "idempotent", self.get_active(config_id))
connection.execute(
"INSERT INTO source_snapshots(config_id, revision, effective_at, state, payload) VALUES (?, ?, ?, ?, ?)",
(config_id, revision, effective_at, document["rule_set"]["state"], canonical),
)
if effective_at <= int(self._clock()):
connection.execute(
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
(config_id, revision),
)
state = "applied"
else:
state = "scheduled"
connection.commit()
return ApplyResult(config_id, revision, state, mapped if state == "applied" else self.get_active(config_id))
def activate_due(self) -> tuple[AppliedConfig, ...]:
now = int(self._clock())
activated: list[AppliedConfig] = []
with self._lock, closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"SELECT s.payload FROM source_snapshots s JOIN (SELECT config_id, MAX(revision) revision FROM source_snapshots WHERE effective_at<=? GROUP BY config_id) d ON d.config_id=s.config_id AND d.revision=s.revision",
(now,),
).fetchall()
for (payload,) in rows:
document = json.loads(payload)
connection.execute(
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
(document["config_id"], document["revision"]),
)
activated.append(_map(document))
connection.commit()
return tuple(activated)
def get_active(self, config_id: str) -> AppliedConfig | None:
with closing(self._connect()) as connection:
row = connection.execute(
"SELECT s.payload FROM source_active a JOIN source_snapshots s ON s.config_id=a.config_id AND s.revision=a.revision WHERE a.config_id=?",
(config_id,),
).fetchone()
return _map(json.loads(row[0])) if row else None
def _parse_and_validate(body: bytes) -> dict[str, object]:
try:
document = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise SourceConfigError("CONFIG_INVALID") from None
if not isinstance(document, dict):
raise SourceConfigError("CONFIG_INVALID")
if document.get("schema_version") != VERSION:
raise SourceConfigError("UNSUPPORTED_SCHEMA_VERSION")
if _contains_secret(document):
raise SourceConfigError("CONFIG_INVALID")
required = {"schema_version", "config_id", "revision", "published_at", "effective_at", "site", "logical_device", "profile", "media", "rule_set", "integrity"}
if set(document) - (required | {"extensions"}) or not required <= set(document):
raise SourceConfigError("CONFIG_INVALID")
extensions = document.get("extensions", {})
if not isinstance(extensions, dict) or any(
not isinstance(namespace, str)
or not _EXTENSION_NAMESPACE.fullmatch(namespace)
or not isinstance(value, dict)
for namespace, value in extensions.items()
):
raise SourceConfigError("CONFIG_INVALID")
integrity = document.get("integrity")
if not isinstance(integrity, dict) or set(integrity) != {"algorithm", "value"} or integrity.get("algorithm") != "sha256":
raise SourceConfigError("CONFIG_INVALID")
unsigned = dict(document); unsigned.pop("integrity")
digest = hashlib.sha256(json.dumps(unsigned, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest()
if not hmac.compare_digest(digest, str(integrity.get("value", ""))):
raise SourceConfigError("CONFIG_INVALID")
try:
if not _ID.fullmatch(document["config_id"]) or isinstance(document["revision"], bool) or document["revision"] < 1:
raise ValueError
published, effective = _timestamp(document["published_at"]), _timestamp(document["effective_at"])
if effective < published:
raise ValueError
for field in ("site", "logical_device"):
if not isinstance(document[field], dict) or set(document[field]) != {"id"} or not _ID.fullmatch(document[field]["id"]): raise ValueError
_validate_profile(document["profile"])
media = document["media"]
if not isinstance(media, dict) or set(media) != {"ref", "transport"} or media["transport"] != "rtsp" or not isinstance(media["ref"], str) or not media["ref"].startswith("media:") or any(marker in media["ref"] for marker in ("?", "#", "@", "\\", "://")): raise ValueError
_validate_rules(document["rule_set"], document["profile"])
except (KeyError, TypeError, ValueError, AttributeError):
raise SourceConfigError("CONFIG_INVALID") from None
return document
def _validate_profile(profile: object) -> None:
if not isinstance(profile, dict) or set(profile) != {"id", "width", "height", "encoding", "frame_rate"}: raise ValueError
if not _ID.fullmatch(profile["id"]) or profile["encoding"] not in {"H264", "H265", "MJPEG"}: raise ValueError
for field in ("width", "height"):
if isinstance(profile[field], bool) or not isinstance(profile[field], int) or profile[field] < 1: raise ValueError
if isinstance(profile["frame_rate"], bool) or not isinstance(profile["frame_rate"], (int, float)) or profile["frame_rate"] <= 0: raise ValueError
def _validate_rules(rules: object, profile: Mapping[str, object]) -> None:
if not isinstance(rules, dict) or set(rules) != {"version", "state", "profile_binding", "areas", "directional_lines"}: raise ValueError
if not _ID.fullmatch(rules["version"]) or rules["state"] not in {"active", "disabled", "recalibration_required"}: raise ValueError
binding = rules["profile_binding"]
if binding != {"profile_id": profile["id"], "width": profile["width"], "height": profile["height"]}: raise ValueError
if not isinstance(rules["areas"], list) or not isinstance(rules["directional_lines"], list) or len(rules["areas"]) > 1024 or len(rules["directional_lines"]) > 1024: raise ValueError
identifiers: set[str] = set()
for area in rules["areas"]:
if not isinstance(area, dict) or set(area) != {"id", "version", "kind", "enabled", "points"} or area["kind"] != "danger_area" or not isinstance(area["enabled"], bool) or not 3 <= len(area["points"]) <= 256: raise ValueError
_rule_identity(area, identifiers); points = tuple(_point(value) for value in area["points"])
polygon = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points, points[1:]+points[:1])) / 2
if abs(polygon) < 1e-9: raise ValueError
for line in rules["directional_lines"]:
if not isinstance(line, dict) or set(line) != {"id", "version", "kind", "enabled", "start", "end", "trigger_direction"} or line["kind"] != "directional_line" or not isinstance(line["enabled"], bool) or line["trigger_direction"] not in {"left_to_right", "right_to_left"}: raise ValueError
_rule_identity(line, identifiers)
if _point(line["start"]) == _point(line["end"]): raise ValueError
def _rule_identity(rule: Mapping[str, object], identifiers: set[str]) -> None:
if not isinstance(rule["id"], str) or not _ID.fullmatch(rule["id"]) or rule["id"] in identifiers or isinstance(rule["version"], bool) or not isinstance(rule["version"], int) or rule["version"] < 1: raise ValueError
identifiers.add(rule["id"])
def _point(value: object) -> tuple[float, float]:
if not isinstance(value, dict) or set(value) != {"x", "y"}: raise ValueError
x, y = value["x"], value["y"]
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int,float)) or not isinstance(y,(int,float)) or not 0 <= x <= 1 or not 0 <= y <= 1: raise ValueError
return float(x), float(y)
def _map(document: Mapping[str, object]) -> AppliedConfig:
profile, rules = document["profile"], document["rule_set"]
rule_set = None
if rules["state"] == "active":
rule_set = RuleSet(
version=rules["version"], profile_id=profile["id"], width=profile["width"], height=profile["height"],
areas=tuple(AreaDefinition(a["id"], tuple(NormalizedPoint(**p) for p in a["points"])) for a in rules["areas"] if a["enabled"]),
directional_lines=tuple(DirectionalLineDefinition(l["id"], NormalizedPoint(**l["start"]), NormalizedPoint(**l["end"]), l["trigger_direction"]) for l in rules["directional_lines"] if l["enabled"]),
)
return AppliedConfig(document["config_id"], document["revision"], document["logical_device"]["id"], document["media"]["ref"], profile["encoding"], float(profile["frame_rate"]), rules["state"], rule_set)
def _timestamp(value: object) -> float:
if not isinstance(value, str) or not value.endswith("Z"): raise ValueError
return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc).timestamp()
def _contains_secret(value: object) -> bool:
if isinstance(value, dict): return any(_SECRET.search(str(k)) or _contains_secret(v) for k,v in value.items())
if isinstance(value, list): return any(_contains_secret(item) for item in value)
if isinstance(value, str):
split=urlsplit(value)
return bool(split.username or split.password or value.startswith("file:") or re.match(r"^[A-Za-z]:[\\/]", value))
return False

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