Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4a8e0e1f1 | ||
|
|
86c3e79121 | ||
|
|
cabc29c18b | ||
|
|
452cd71035 | ||
|
|
f09a61e5fb | ||
|
|
afc58f7bf4 | ||
|
|
b01ca1fe09 | ||
|
|
689de560bb | ||
|
|
8f7d91310b | ||
|
|
96bd4ad2c8 | ||
|
|
adbd1c6aba | ||
|
|
6ffdbcce84 | ||
|
|
f6f561f2e2 | ||
|
|
ee9cfb0433 | ||
|
|
407ffa17b2 | ||
|
|
ba4ec28763 | ||
|
|
b9b067213f | ||
|
|
52b368068e | ||
|
|
7964d61cab | ||
|
|
4a605f6482 | ||
|
|
d61e6d5ee1 | ||
|
|
7274bd42f5 | ||
|
|
02a5af5e3b | ||
|
|
e06904272a | ||
|
|
d681fd1345 | ||
|
|
bdd78523b5 | ||
|
|
2db49fc955 | ||
|
|
a9fd8e8a47 | ||
|
|
12f5419f21 | ||
|
|
debf662138 | ||
|
|
184f101a86 | ||
|
|
4157d90e9f | ||
|
|
028519a710 | ||
|
|
44b60b5b46 | ||
|
|
ae19e59703 | ||
|
|
2e709aa4ff | ||
|
|
870cb4acec | ||
|
|
9c874734af | ||
|
|
966633d1cc | ||
|
|
dcbebdf01b | ||
|
|
f1c765f8cc | ||
|
|
14fda5395b | ||
|
|
e60d10b103 | ||
|
|
60036587a6 |
@@ -0,0 +1,190 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$server = $null
|
||||
$pgStarted = $false
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-131-' + [guid]::NewGuid().ToString('N'))
|
||||
$pgData = Join-Path $testRoot 'postgres'
|
||||
$pgLog = Join-Path $testRoot 'postgres.log'
|
||||
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
|
||||
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
|
||||
$serverOut = Join-Path $testRoot 'bell.out.log'
|
||||
$serverErr = Join-Path $testRoot 'bell.err.log'
|
||||
$serverExe = Join-Path $testRoot 'bell-server.exe'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\server')).Path
|
||||
|
||||
function Get-FreeTcpPort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
} catch {
|
||||
$connected = $false
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
if ($connected -eq $Open) { return }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "TCP port $Port did not reach open=$Open"
|
||||
}
|
||||
|
||||
function Wait-Health([string]$BaseUrl) {
|
||||
for ($attempt = 0; $attempt -lt 100; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
|
||||
if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw 'Bell health endpoint did not become ready'
|
||||
}
|
||||
|
||||
function Start-Bell([string]$BaseUrl, [string]$Config = 'config/settings.demo.yml') {
|
||||
$script:server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', $Config) -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $BaseUrl
|
||||
}
|
||||
|
||||
function Stop-Bell {
|
||||
if ($null -ne $script:server -and -not $script:server.HasExited) {
|
||||
Stop-Process -Id $script:server.Id -Force
|
||||
$script:server.WaitForExit(5000) | Out-Null
|
||||
}
|
||||
$script:server = $null
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreeTcpPort
|
||||
$bellPort = Get-FreeTcpPort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
$database = 'bell_131'
|
||||
$adminPassword = [guid]::NewGuid().ToString('N')
|
||||
|
||||
try {
|
||||
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
|
||||
$path = Join-Path $PostgresBin $required
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
|
||||
}
|
||||
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' }
|
||||
$pgArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput $pgCtlOut -RedirectStandardError $pgCtlErr -WindowStyle Hidden | Out-Null
|
||||
Wait-Tcp -Port $pgPort -Open $true
|
||||
$pgStarted = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres $database
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_131_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = $adminPassword
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = $bellPort.ToString()
|
||||
$env:BELL_SYNTHETIC_EVENTS_ENABLED = 'true'
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell migration failed' }
|
||||
go build -o $serverExe .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Start-Bell $baseUrl
|
||||
$unauthorized = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 5 -NoProxy
|
||||
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated request returned code $($unauthorized.code)" }
|
||||
|
||||
$loginBody = @{ username = 'bell_131_admin'; password = $adminPassword; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$login = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $loginBody -TimeoutSec 5 -NoProxy
|
||||
if ([int]$login.code -ne 200 -or [string]::IsNullOrWhiteSpace($login.token)) { throw 'Bell login failed' }
|
||||
$headers = @{ Authorization = "Bearer $($login.token)" }
|
||||
|
||||
$occurredAt = '2026-08-29T00:00:00Z'
|
||||
$eventBody = @{
|
||||
sourceEventId = 'acceptance-001'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
|
||||
location = '东门'; severity = 'high'; evidenceRef = 'evidence/acceptance-001'
|
||||
attributes = @{ rule = 'area-01'; target = 'anonymous' }
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
|
||||
if ([int]$created.code -ne 200 -or $created.data.duplicate -ne $false) { throw 'first synthetic event was not accepted as new' }
|
||||
$eventId = [string]$created.data.event.id
|
||||
$receiptId = [string]$created.data.receipt.id
|
||||
if ([string]::IsNullOrWhiteSpace($eventId) -or [string]::IsNullOrWhiteSpace($receiptId)) { throw 'event or receipt id missing' }
|
||||
|
||||
$replay = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
|
||||
if ([int]$replay.code -ne 200 -or $replay.data.duplicate -ne $true -or $replay.data.event.id -ne $eventId) { throw 'idempotent replay failed' }
|
||||
|
||||
$concurrentBody = @{
|
||||
sourceEventId = 'acceptance-concurrent'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
|
||||
location = '西门'; severity = 'medium'; evidenceRef = 'evidence/acceptance-concurrent'
|
||||
attributes = @{ rule = 'area-02'; target = 'anonymous' }
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
$authHeader = [string]$headers.Authorization
|
||||
$concurrent = 1..12 | ForEach-Object -Parallel {
|
||||
$requestHeaders = @{ Authorization = $using:authHeader }
|
||||
Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/synthetic-events" -Headers $requestHeaders -ContentType 'application/json; charset=utf-8' -Body $using:concurrentBody -TimeoutSec 20 -NoProxy
|
||||
} -ThrottleLimit 12
|
||||
$concurrentIds = @($concurrent | ForEach-Object { [string]$_.data.event.id } | Sort-Object -Unique)
|
||||
$newCount = @($concurrent | Where-Object { $_.data.duplicate -eq $false }).Count
|
||||
if ($concurrent.Count -ne 12 -or $concurrentIds.Count -ne 1 -or $newCount -ne 1) { throw 'concurrent idempotency did not converge to one Event' }
|
||||
|
||||
$conflictObject = $eventBody | ConvertFrom-Json
|
||||
$conflictObject.severity = 'critical'
|
||||
$conflict = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body ($conflictObject | ConvertTo-Json -Depth 6 -Compress) -TimeoutSec 10 -NoProxy
|
||||
if ([int]$conflict.code -ne 409) { throw "idempotency conflict returned code $($conflict.code)" }
|
||||
|
||||
$malformed = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body '{' -TimeoutSec 10 -NoProxy
|
||||
if ([int]$malformed.code -ne 400) { throw "malformed request returned code $($malformed.code)" }
|
||||
$oversized = @{ sourceEventId='too-large'; eventType='test'; occurredAt=$occurredAt; location='lab'; severity='low'; attributes=@{ blob=('x' * 70000) } } | ConvertTo-Json -Depth 5 -Compress
|
||||
$tooLarge = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body $oversized -TimeoutSec 10 -NoProxy
|
||||
if ([int]$tooLarge.code -ne 400) { throw "oversized request returned code $($tooLarge.code)" }
|
||||
|
||||
$psql = Join-Path $PostgresBin 'psql.exe'
|
||||
$counts = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from bell_events; select count(*) from bell_event_receipts; select count(*) from bell_event_ingest_audits where outcome='accepted'; select count(*) from bell_event_ingest_audits where outcome='replay'; select count(*) from bell_event_ingest_audits where outcome='conflict';"
|
||||
if (($counts -join ',') -ne '2,2,2,12,1') { throw "unexpected Bell fact counts: $($counts -join ',')" }
|
||||
$payloadAuditCount = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from sys_opera_log where oper_url='/api/v1/bell/synthetic-events' and (oper_param like '%sourceEventId%' or json_result like '%acceptance-001%');"
|
||||
if ([int]$payloadAuditCount -ne 0) { throw 'generic GoAdmin audit retained synthetic event payload' }
|
||||
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "update bell_events set severity='low' where id='$eventId'" 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { throw 'immutable Event update unexpectedly succeeded' }
|
||||
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "delete from bell_event_receipts where id='$receiptId'" 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { throw 'immutable Receipt delete unexpectedly succeeded' }
|
||||
|
||||
Stop-Bell
|
||||
Start-Bell $baseUrl
|
||||
$afterRestart = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/events/$eventId" -Headers $headers -TimeoutSec 10 -NoProxy
|
||||
if ([int]$afterRestart.code -ne 200 -or $afterRestart.data.id -ne $eventId) { throw 'Event was not readable after Bell restart' }
|
||||
|
||||
Stop-Bell
|
||||
Start-Bell $baseUrl 'config/settings.yml'
|
||||
$productionSynthetic = Invoke-WebRequest -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 10 -NoProxy -SkipHttpErrorCheck
|
||||
if ([int]$productionSynthetic.StatusCode -ne 404) { throw "production synthetic route returned HTTP $($productionSynthetic.StatusCode)" }
|
||||
|
||||
Write-Output "BELL_131_SMOKE events=2 receipts=2 concurrent=12 audit=redacted immutable=true restart=true production_synthetic=404"
|
||||
} finally {
|
||||
Stop-Bell
|
||||
if ($pgStarted) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT','BELL_SYNTHETIC_EVENTS_ENABLED')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Verbose "Bell #131 temporary artifacts: $testRoot"
|
||||
}
|
||||
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
|
||||
e.Error(500, err, "服务初始化失败!")
|
||||
return
|
||||
}
|
||||
id, b64s, answer, err := captcha.DriverDigitFunc()
|
||||
id, b64s, _, err := captcha.DriverDigitFunc()
|
||||
if err != nil {
|
||||
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
|
||||
e.Error(500, err, "验证码获取失败")
|
||||
return
|
||||
}
|
||||
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
|
||||
e.Custom(gin.H{
|
||||
"code": 200,
|
||||
"data": b64s,
|
||||
|
||||
@@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
}
|
||||
|
||||
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
systemAPI := apis.System{}
|
||||
v1.GET("/captcha", systemAPI.GenerateCaptchaHandler)
|
||||
|
||||
api := apis.SysMenu{}
|
||||
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
|
||||
"go-admin/app/bell/evaluation"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var query PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.Logger.Errorf("list Bell alerts failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
|
||||
return
|
||||
}
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
h.PageOK(items, int(count), page, size, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) Get(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
|
||||
return
|
||||
}
|
||||
h.Logger.Errorf("get Bell alert failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
|
||||
return
|
||||
}
|
||||
h.OK(detail, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) ListEvents(c *gin.Context) {
|
||||
var query EventPageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).ListEvents(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.Logger.Errorf("list Bell events failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
|
||||
return
|
||||
}
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
h.PageOK(items, int(count), page, size, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) EventResults(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
result, err := evaluation.NewService(h.Orm).ForEvent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, evaluation.ErrEventNotFound) {
|
||||
h.Error(http.StatusNotFound, evaluation.ErrEventNotFound, evaluation.ErrEventNotFound.Error())
|
||||
return
|
||||
}
|
||||
h.Logger.Errorf("get Bell event rule results failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取规则评估失败"), "读取规则评估失败")
|
||||
return
|
||||
}
|
||||
h.OK(result, "查询成功")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
PrimaryRuleID string `json:"primaryRuleId" gorm:"type:uuid;not null;index"`
|
||||
CorrelationKey string `json:"-" gorm:"size:384;not null"`
|
||||
Status string `json:"status" gorm:"size:24;not null;default:open;index"`
|
||||
Severity string `json:"severity" gorm:"size:16;not null;index"`
|
||||
Summary string `json:"summary" gorm:"size:256;not null"`
|
||||
Location string `json:"location" gorm:"size:256;not null;index"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Alert) TableName() string { return "bell_alerts" }
|
||||
|
||||
type AlertEvent struct {
|
||||
AlertID string `json:"alertId" gorm:"type:uuid;primaryKey"`
|
||||
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
|
||||
LinkedAt time.Time `json:"linkedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AlertEvent) TableName() string { return "bell_alert_events" }
|
||||
|
||||
type RuleMatch struct {
|
||||
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
|
||||
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
|
||||
AlertID string `json:"alertId" gorm:"type:uuid;not null;index"`
|
||||
RuleVersion int `json:"ruleVersion" gorm:"not null"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
|
||||
Explanation string `json:"explanation" gorm:"size:512;not null"`
|
||||
MatchedAt time.Time `json:"matchedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (RuleMatch) TableName() string { return "bell_rule_matches" }
|
||||
@@ -0,0 +1,136 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("预警不存在")
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Status string `form:"status"`
|
||||
Severity string `form:"severity"`
|
||||
Location string `form:"location"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Alert
|
||||
RuleName string `json:"ruleName"`
|
||||
EventCount int64 `json:"eventCount"`
|
||||
}
|
||||
|
||||
type LinkedEvent struct {
|
||||
event.Event
|
||||
LinkedAt time.Time `json:"linkedAt"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Alert Summary `json:"alert"`
|
||||
Events []LinkedEvent `json:"events"`
|
||||
Matches []RuleMatch `json:"matches"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]Summary, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
base := s.DB.WithContext(ctx).Table("bell_alerts a")
|
||||
if query.Status = strings.TrimSpace(query.Status); query.Status != "" {
|
||||
base = base.Where("a.status = ?", query.Status)
|
||||
}
|
||||
if query.Severity = strings.TrimSpace(query.Severity); query.Severity != "" {
|
||||
base = base.Where("a.severity = ?", query.Severity)
|
||||
}
|
||||
if query.Location = strings.TrimSpace(query.Location); query.Location != "" {
|
||||
base = base.Where("a.location ILIKE ?", "%"+query.Location+"%")
|
||||
}
|
||||
var count int64
|
||||
if err := base.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]Summary, 0)
|
||||
err := base.Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
|
||||
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").
|
||||
Order("a.created_at DESC, a.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
|
||||
return items, count, err
|
||||
}
|
||||
|
||||
func (s Service) Get(ctx context.Context, id string) (Detail, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
detail := Detail{Events: make([]LinkedEvent, 0), Matches: make([]RuleMatch, 0)}
|
||||
db := s.DB.WithContext(ctx)
|
||||
err := db.Table("bell_alerts a").
|
||||
Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
|
||||
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").Where("a.id = ?", id).Take(&detail.Alert).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if err = db.Table("bell_events e").Select("e.*, ae.linked_at").
|
||||
Joins("JOIN bell_alert_events ae ON ae.event_id = e.id").
|
||||
Where("ae.alert_id = ?", id).Order("e.occurred_at, e.id").Scan(&detail.Events).Error; err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
err = db.Where("alert_id = ?", id).Order("matched_at, event_id, rule_id").Find(&detail.Matches).Error
|
||||
return detail, err
|
||||
}
|
||||
|
||||
type EventPageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
EventType string `form:"eventType"`
|
||||
Severity string `form:"severity"`
|
||||
Location string `form:"location"`
|
||||
}
|
||||
|
||||
type EventSummary struct {
|
||||
event.Event
|
||||
AlertCount int64 `json:"alertCount"`
|
||||
}
|
||||
|
||||
func (s Service) ListEvents(ctx context.Context, query EventPageQuery) ([]EventSummary, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&event.Event{})
|
||||
if value := strings.TrimSpace(query.EventType); value != "" {
|
||||
db = db.Where("event_type ILIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(query.Severity); value != "" {
|
||||
db = db.Where("severity = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(query.Location); value != "" {
|
||||
db = db.Where("location ILIKE ?", "%"+value+"%")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]EventSummary, 0)
|
||||
err := db.Select("bell_events.*, (SELECT count(*) FROM bell_alert_events ae WHERE ae.event_id = bell_events.id) AS alert_count").
|
||||
Order("occurred_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
|
||||
return items, count, err
|
||||
}
|
||||
|
||||
func pageValues(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func (h Handler) Get(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
|
||||
if err == nil {
|
||||
current := actor(c)
|
||||
detail.CanAck = detail.Projection.Status == StatusOpen && (current.Role == "admin" || current.Role == "operator")
|
||||
detail.CanClose = detail.Projection.Status == StatusAcknowledged && (current.Role == "admin" || (detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == current.ID))
|
||||
}
|
||||
h.respond(c, Result{Detail: detail}, err)
|
||||
}
|
||||
|
||||
func (h Handler) Ack(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
result, err := NewService(h.Orm).Ack(c.Request.Context(), c.Param("id"), actor(c))
|
||||
h.respond(c, result, err)
|
||||
}
|
||||
|
||||
func (h Handler) Close(c *gin.Context) {
|
||||
if err := restoreCloseBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var input CloseInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
result, err := NewService(h.Orm).Close(c.Request.Context(), c.Param("id"), input, actor(c))
|
||||
h.respond(c, result, err)
|
||||
}
|
||||
|
||||
func (h Handler) respond(c *gin.Context, result Result, err error) {
|
||||
if err == nil {
|
||||
h.OK(result, "操作成功")
|
||||
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
|
||||
return
|
||||
}
|
||||
code := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
code = http.StatusNotFound
|
||||
case errors.Is(err, ErrOutcomeRequired):
|
||||
code = http.StatusBadRequest
|
||||
case errors.Is(err, ErrAlreadyHandled), errors.Is(err, ErrInvalidTransition):
|
||||
code = http.StatusConflict
|
||||
case errors.Is(err, ErrForbidden):
|
||||
code = http.StatusForbidden
|
||||
default:
|
||||
h.Logger.Errorf("Bell alert lifecycle failed: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "data": result})
|
||||
c.Set("result", gin.H{"code": code, "data": "<redacted>"})
|
||||
}
|
||||
|
||||
func actor(c *gin.Context) Actor {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return Actor{ID: user.GetUserId(c), Name: user.GetUserName(c), Role: role}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StatusOpen = "open"
|
||||
StatusAcknowledged = "acknowledged"
|
||||
StatusClosed = "closed"
|
||||
)
|
||||
|
||||
type Projection struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Status string `json:"status"`
|
||||
AcknowledgedBy *int `json:"acknowledgedBy,omitempty"`
|
||||
AcknowledgedByName *string `json:"acknowledgedByName,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
|
||||
ClosedBy *int `json:"closedBy,omitempty"`
|
||||
ClosedByName *string `json:"closedByName,omitempty"`
|
||||
ClosedAt *time.Time `json:"closedAt,omitempty"`
|
||||
CloseOutcome *string `json:"closeOutcome,omitempty"`
|
||||
CloseNote *string `json:"closeNote,omitempty"`
|
||||
}
|
||||
|
||||
func (Projection) TableName() string { return "bell_alerts" }
|
||||
|
||||
type Fact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
AlertID string `json:"alertId" gorm:"type:uuid;not null;uniqueIndex:bell_alert_transition"`
|
||||
Transition string `json:"transition" gorm:"size:24;not null;uniqueIndex:bell_alert_transition"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
ActorName string `json:"actorName" gorm:"size:128;not null"`
|
||||
Outcome *string `json:"outcome,omitempty" gorm:"size:32"`
|
||||
Note *string `json:"note,omitempty" gorm:"size:500"`
|
||||
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (Fact) TableName() string { return "bell_alert_lifecycle_facts" }
|
||||
|
||||
type RejectionAudit struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
AlertID *string `json:"alertId,omitempty" gorm:"type:uuid;index"`
|
||||
Action string `json:"action" gorm:"size:16;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null;index"`
|
||||
Reason string `json:"reason" gorm:"size:64;not null"`
|
||||
ObservedStatus *string `json:"observedStatus,omitempty" gorm:"size:24"`
|
||||
ObservedActor *int `json:"observedActor,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (RejectionAudit) TableName() string { return "bell_alert_lifecycle_rejections" }
|
||||
@@ -0,0 +1,47 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxCloseRequestBytes = 8 * 1024
|
||||
const closeBodyKey = "bell.lifecycle.close-body"
|
||||
const closeBodyErrorKey = "bell.lifecycle.close-body-error"
|
||||
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || !strings.HasPrefix(c.Request.URL.Path, "/api/v1/bell/alerts/") || !strings.HasSuffix(c.Request.URL.Path, "/close") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxCloseRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(closeBodyErrorKey, err)
|
||||
} else if len(body) > maxCloseRequestBytes {
|
||||
c.Set(closeBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(closeBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader([]byte(`{"redacted":true}`)))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreCloseBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(closeBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(closeBodyKey)
|
||||
if !ok {
|
||||
return errors.New("close request body was not captured")
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Actor struct {
|
||||
ID int
|
||||
Name, Role string
|
||||
}
|
||||
type Detail struct {
|
||||
Projection Projection `json:"projection"`
|
||||
Timeline []Fact `json:"timeline"`
|
||||
CanAck bool `json:"canAck"`
|
||||
CanClose bool `json:"canClose"`
|
||||
}
|
||||
type Result struct {
|
||||
Detail Detail `json:"detail"`
|
||||
Idempotent bool `json:"idempotent"`
|
||||
Won bool `json:"won"`
|
||||
}
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) Get(ctx context.Context, alertID string) (Detail, error) {
|
||||
if _, err := uuid.Parse(alertID); err != nil {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
var projection Projection
|
||||
if err := s.DB.WithContext(ctx).First(&projection, "id = ?", alertID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
return Detail{}, err
|
||||
}
|
||||
facts := make([]Fact, 0)
|
||||
if err := s.DB.WithContext(ctx).Where("alert_id = ?", alertID).Order("occurred_at, id").Find(&facts).Error; err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
return Detail{Projection: projection, Timeline: facts}, nil
|
||||
}
|
||||
|
||||
func (s Service) Ack(ctx context.Context, alertID string, actor Actor) (Result, error) {
|
||||
if _, err := uuid.Parse(alertID); err != nil {
|
||||
s.reject(ctx, nil, "ack", actor.ID, "not_found", nil)
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var projection Projection
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Raw(`UPDATE bell_alerts SET status='acknowledged', acknowledged_by=?, acknowledged_by_name=?, acknowledged_at=?, updated_at=? WHERE id=? AND status='open' RETURNING id,status,acknowledged_by,acknowledged_by_name,acknowledged_at,closed_by,closed_by_name,closed_at,close_outcome,close_note`, actor.ID, actor.Name, now, now, alertID).Scan(&projection)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusAcknowledged, ActorID: actor.ID, ActorName: actor.Name, OccurredAt: now}).Error
|
||||
})
|
||||
if err == nil {
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
return Result{Detail: detail, Won: true}, getErr
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
if getErr != nil {
|
||||
return Result{}, getErr
|
||||
}
|
||||
if detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == actor.ID {
|
||||
s.reject(ctx, &alertID, "ack", actor.ID, "duplicate", &detail.Projection)
|
||||
return Result{Detail: detail, Idempotent: true}, nil
|
||||
}
|
||||
s.reject(ctx, &alertID, "ack", actor.ID, "already_handled", &detail.Projection)
|
||||
return Result{Detail: detail}, ErrAlreadyHandled
|
||||
}
|
||||
|
||||
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor Actor) (Result, error) {
|
||||
normalized, err := normalizeClose(input)
|
||||
if err != nil {
|
||||
s.reject(ctx, validAlertID(alertID), "close", actor.ID, "invalid_outcome", nil)
|
||||
return Result{}, err
|
||||
}
|
||||
if _, err = uuid.Parse(alertID); err != nil {
|
||||
s.reject(ctx, nil, "close", actor.ID, "not_found", nil)
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
var projection Projection
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if lockErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&projection, "id = ?", alertID).Error; lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if projection.Status == StatusClosed {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
if projection.Status != StatusAcknowledged {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
if actor.Role != "admin" && (projection.AcknowledgedBy == nil || *projection.AcknowledgedBy != actor.ID) {
|
||||
return ErrForbidden
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var note *string
|
||||
if normalized.Note != "" {
|
||||
note = &normalized.Note
|
||||
}
|
||||
if updateErr := tx.Model(&projection).Updates(map[string]any{"status": StatusClosed, "closed_by": actor.ID, "closed_by_name": actor.Name, "closed_at": now, "close_outcome": normalized.Outcome, "close_note": note, "updated_at": now}).Error; updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusClosed, ActorID: actor.ID, ActorName: actor.Name, Outcome: &normalized.Outcome, Note: note, OccurredAt: now}).Error
|
||||
})
|
||||
if err == nil {
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
return Result{Detail: detail, Won: true}, getErr
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidTransition) && !errors.Is(err, ErrForbidden) && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
if getErr != nil {
|
||||
return Result{}, getErr
|
||||
}
|
||||
if detail.Projection.Status == StatusClosed && detail.Projection.ClosedBy != nil && *detail.Projection.ClosedBy == actor.ID && detail.Projection.CloseOutcome != nil && *detail.Projection.CloseOutcome == normalized.Outcome && equalOptional(detail.Projection.CloseNote, normalized.Note) {
|
||||
s.reject(ctx, &alertID, "close", actor.ID, "duplicate", &detail.Projection)
|
||||
return Result{Detail: detail, Idempotent: true}, nil
|
||||
}
|
||||
reason := "invalid_transition"
|
||||
publicErr := ErrInvalidTransition
|
||||
if errors.Is(err, ErrForbidden) {
|
||||
reason, publicErr = "forbidden", ErrForbidden
|
||||
} else if detail.Projection.Status == StatusClosed {
|
||||
reason = "conflicting_replay"
|
||||
}
|
||||
s.reject(ctx, &alertID, "close", actor.ID, reason, &detail.Projection)
|
||||
return Result{Detail: detail}, publicErr
|
||||
}
|
||||
|
||||
func (s Service) reject(ctx context.Context, alertID *string, action string, actorID int, reason string, projection *Projection) {
|
||||
audit := RejectionAudit{ID: uuid.NewString(), AlertID: alertID, Action: action, ActorID: actorID, Reason: reason, CreatedAt: time.Now().UTC()}
|
||||
if projection != nil {
|
||||
audit.ObservedStatus = &projection.Status
|
||||
audit.ObservedActor = projection.AcknowledgedBy
|
||||
}
|
||||
_ = s.DB.WithContext(ctx).Create(&audit).Error
|
||||
}
|
||||
func validAlertID(value string) *string {
|
||||
if _, err := uuid.Parse(value); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
func equalOptional(value *string, other string) bool {
|
||||
if value == nil {
|
||||
return other == ""
|
||||
}
|
||||
return *value == other
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("预警不存在")
|
||||
ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
|
||||
ErrInvalidTransition = errors.New("当前状态不能执行此操作")
|
||||
ErrOutcomeRequired = errors.New("请选择有效的现场结果")
|
||||
ErrForbidden = errors.New("您无权完成此预警")
|
||||
)
|
||||
|
||||
type CloseInput struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func normalizeClose(input CloseInput) (CloseInput, error) {
|
||||
input.Outcome = strings.TrimSpace(input.Outcome)
|
||||
input.Note = strings.TrimSpace(input.Note)
|
||||
switch input.Outcome {
|
||||
case "danger_confirmed", "false_positive", "site_normal", "unable_to_confirm":
|
||||
default:
|
||||
return CloseInput{}, ErrOutcomeRequired
|
||||
}
|
||||
if !utf8.ValidString(input.Note) || utf8.RuneCountInString(input.Note) > 500 || strings.ContainsAny(input.Note, "\x00\r") {
|
||||
return CloseInput{}, ErrOutcomeRequired
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package evaluation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Evaluation is an immutable explanation of one rule version evaluated
|
||||
// against one Event.
|
||||
type Evaluation struct {
|
||||
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
|
||||
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
|
||||
RuleVersion int `json:"ruleVersion" gorm:"not null"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
|
||||
Matched bool `json:"matched" gorm:"not null"`
|
||||
Explanation string `json:"explanation" gorm:"size:512;not null"`
|
||||
EvaluatedAt time.Time `json:"evaluatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Evaluation) TableName() string { return "bell_rule_evaluations" }
|
||||
@@ -0,0 +1,50 @@
|
||||
package evaluation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrEventNotFound = errors.New("事件不存在")
|
||||
|
||||
type EventResults struct {
|
||||
Evaluations []Evaluation `json:"evaluations"`
|
||||
Alerts []AlertLink `json:"alerts"`
|
||||
}
|
||||
|
||||
type AlertLink struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) ForEvent(ctx context.Context, eventID string) (EventResults, error) {
|
||||
if _, err := uuid.Parse(eventID); err != nil {
|
||||
return EventResults{}, ErrEventNotFound
|
||||
}
|
||||
var count int64
|
||||
if err := s.DB.WithContext(ctx).Table("bell_events").Where("id = ?", eventID).Count(&count).Error; err != nil {
|
||||
return EventResults{}, err
|
||||
}
|
||||
if count == 0 {
|
||||
return EventResults{}, ErrEventNotFound
|
||||
}
|
||||
result := EventResults{Evaluations: make([]Evaluation, 0), Alerts: make([]AlertLink, 0)}
|
||||
if err := s.DB.WithContext(ctx).Where("event_id = ?", eventID).Order("evaluated_at, rule_id").Find(&result.Evaluations).Error; err != nil {
|
||||
return EventResults{}, err
|
||||
}
|
||||
err := s.DB.WithContext(ctx).Table("bell_alerts a").
|
||||
Select("a.id, a.summary, a.status, a.severity, a.location").
|
||||
Joins("JOIN bell_alert_events ae ON ae.alert_id = a.id").
|
||||
Where("ae.event_id = ?", eventID).Order("a.created_at, a.id").Scan(&result.Alerts).Error
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package event
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("事件字段无效")
|
||||
ErrConflict = errors.New("幂等键已用于不同事件载荷")
|
||||
ErrNotFound = errors.New("事件不存在")
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is an immutable Bell business fact. It intentionally does not embed
|
||||
// GoAdmin's mutable/soft-delete model fields.
|
||||
type Event struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_event_key"`
|
||||
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_event_key"`
|
||||
EventType string `json:"eventType" gorm:"size:128;not null;index"`
|
||||
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
|
||||
Location string `json:"location" gorm:"size:256;not null;index"`
|
||||
Severity string `json:"severity" gorm:"size:16;not null;index"`
|
||||
EvidenceRef *string `json:"evidenceRef,omitempty" gorm:"size:512"`
|
||||
NormalizedPayload json.RawMessage `json:"normalizedPayload" gorm:"type:jsonb;not null"`
|
||||
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
|
||||
ReceivedAt time.Time `json:"receivedAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (Event) TableName() string { return "bell_events" }
|
||||
@@ -0,0 +1,105 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProducerID = 128
|
||||
maxSourceEventID = 256
|
||||
maxEventType = 128
|
||||
maxLocation = 256
|
||||
maxEvidenceRef = 512
|
||||
maxAttributes = 48 * 1024
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
ProducerID string `json:"producerId"`
|
||||
SourceEventID string `json:"sourceEventId"`
|
||||
EventType string `json:"eventType"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidenceRef,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
type Normalized struct {
|
||||
Command Command
|
||||
Payload []byte
|
||||
Digest string
|
||||
}
|
||||
|
||||
func Normalize(command Command) (Normalized, error) {
|
||||
command.ProducerID = strings.TrimSpace(command.ProducerID)
|
||||
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
|
||||
command.EventType = strings.TrimSpace(command.EventType)
|
||||
command.Location = strings.TrimSpace(command.Location)
|
||||
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
|
||||
command.OccurredAt = command.OccurredAt.UTC()
|
||||
|
||||
if !validText(command.ProducerID, maxProducerID) ||
|
||||
!validText(command.SourceEventID, maxSourceEventID) ||
|
||||
!validText(command.EventType, maxEventType) ||
|
||||
!validText(command.Location, maxLocation) || command.OccurredAt.IsZero() {
|
||||
return Normalized{}, ErrInvalid
|
||||
}
|
||||
switch command.Severity {
|
||||
case "low", "medium", "high", "critical":
|
||||
default:
|
||||
return Normalized{}, ErrInvalid
|
||||
}
|
||||
if command.EvidenceRef != nil {
|
||||
value := strings.TrimSpace(*command.EvidenceRef)
|
||||
lower := strings.ToLower(value)
|
||||
if !validText(value, maxEvidenceRef) || strings.Contains(value, "@") ||
|
||||
strings.Contains(value, "\\") || strings.HasPrefix(lower, "file:") {
|
||||
return Normalized{}, fmt.Errorf("%w: evidenceRef 必须是安全的逻辑引用", ErrInvalid)
|
||||
}
|
||||
command.EvidenceRef = &value
|
||||
}
|
||||
if command.Attributes == nil {
|
||||
command.Attributes = map[string]any{}
|
||||
}
|
||||
attributes, err := canonicalJSON(command.Attributes)
|
||||
if err != nil || len(attributes) > maxAttributes {
|
||||
return Normalized{}, fmt.Errorf("%w: attributes 无效或过大", ErrInvalid)
|
||||
}
|
||||
// Round trip the attributes so map ordering and nested values have one
|
||||
// deterministic representation before the complete command is hashed.
|
||||
if err = json.Unmarshal(attributes, &command.Attributes); err != nil {
|
||||
return Normalized{}, fmt.Errorf("%w: attributes 无效", ErrInvalid)
|
||||
}
|
||||
payload, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return Normalized{}, fmt.Errorf("normalize event: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
return Normalized{Command: command, Payload: payload, Digest: hex.EncodeToString(digest[:])}, nil
|
||||
}
|
||||
|
||||
func canonicalJSON(value any) ([]byte, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var normalized any
|
||||
if err = decoder.Decode(&normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(normalized)
|
||||
}
|
||||
|
||||
func validText(value string, max int) bool {
|
||||
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
|
||||
!strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Event Event `json:"event"`
|
||||
Receipt receipt.Receipt `json:"receipt"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, command Command, actorID int) (Result, error) {
|
||||
if s.DB == nil {
|
||||
return Result{}, errors.New("event database is unavailable")
|
||||
}
|
||||
normalized, err := Normalize(command)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var result Result
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockKey := fmt.Sprintf("%d:%s:%s", len(normalized.Command.ProducerID), normalized.Command.ProducerID, normalized.Command.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", lockKey).Error; err != nil {
|
||||
return fmt.Errorf("lock event idempotency key: %w", err)
|
||||
}
|
||||
|
||||
var existing receipt.Receipt
|
||||
err := tx.Where("producer_id = ? AND source_event_id = ?", normalized.Command.ProducerID, normalized.Command.SourceEventID).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != normalized.Digest {
|
||||
return ErrConflict
|
||||
}
|
||||
if err = tx.First(&result.Event, "id = ?", existing.EventID).Error; err != nil {
|
||||
return fmt.Errorf("load replay event: %w", err)
|
||||
}
|
||||
result.Receipt = existing
|
||||
result.Duplicate = true
|
||||
return tx.Create(&receipt.IngestAudit{
|
||||
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
|
||||
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeReplay, ActorID: actorID, CreatedAt: time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("load event receipt: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
result.Event = Event{
|
||||
ID: uuid.NewString(), ProducerID: normalized.Command.ProducerID,
|
||||
SourceEventID: normalized.Command.SourceEventID, EventType: normalized.Command.EventType,
|
||||
OccurredAt: normalized.Command.OccurredAt, Location: normalized.Command.Location,
|
||||
Severity: normalized.Command.Severity, EvidenceRef: normalized.Command.EvidenceRef,
|
||||
NormalizedPayload: normalized.Payload, PayloadSHA256: normalized.Digest, ReceivedAt: now,
|
||||
}
|
||||
result.Receipt = receipt.Receipt{
|
||||
ID: uuid.NewString(), EventID: result.Event.ID, ProducerID: result.Event.ProducerID,
|
||||
SourceEventID: result.Event.SourceEventID, PayloadSHA256: normalized.Digest, AcceptedAt: now,
|
||||
}
|
||||
if err = tx.Create(&result.Event).Error; err != nil {
|
||||
return fmt.Errorf("create event: %w", err)
|
||||
}
|
||||
if err = tx.Create(&result.Receipt).Error; err != nil {
|
||||
return fmt.Errorf("create event receipt: %w", err)
|
||||
}
|
||||
return tx.Create(&receipt.IngestAudit{
|
||||
ProducerID: result.Event.ProducerID, SourceEventID: result.Event.SourceEventID,
|
||||
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeAccepted, ActorID: actorID, CreatedAt: now,
|
||||
}).Error
|
||||
})
|
||||
if errors.Is(err, ErrConflict) {
|
||||
// The conflict audit must commit independently from the rejected ingest.
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{
|
||||
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
|
||||
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeConflict, ActorID: actorID, CreatedAt: time.Now().UTC(),
|
||||
}).Error
|
||||
if auditErr != nil {
|
||||
return Result{}, fmt.Errorf("record idempotency conflict audit: %w", auditErr)
|
||||
}
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s Service) Get(ctx context.Context, id string) (Event, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Event{}, ErrNotFound
|
||||
}
|
||||
var item Event
|
||||
if err := s.DB.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Event{}, ErrNotFound
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package receipt
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
OutcomeAccepted = "accepted"
|
||||
OutcomeReplay = "replay"
|
||||
OutcomeConflict = "conflict"
|
||||
)
|
||||
|
||||
// IngestAudit contains only identifiers, a digest and an outcome. Event payloads,
|
||||
// credentials and tokens must never be copied into this append-only audit table.
|
||||
type IngestAudit struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProducerID string `json:"producerId" gorm:"size:128;not null;index"`
|
||||
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;index"`
|
||||
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
|
||||
Outcome string `json:"outcome" gorm:"size:16;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (IngestAudit) TableName() string { return "bell_event_ingest_audits" }
|
||||
@@ -0,0 +1,16 @@
|
||||
package receipt
|
||||
|
||||
import "time"
|
||||
|
||||
// Receipt permanently binds an idempotency key to the accepted Event payload.
|
||||
// It deliberately has no UpdatedAt or soft-delete fields because it is immutable.
|
||||
type Receipt struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
EventID string `json:"eventId" gorm:"type:uuid;not null;uniqueIndex"`
|
||||
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_receipt_key"`
|
||||
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_receipt_key"`
|
||||
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
|
||||
AcceptedAt time.Time `json:"acceptedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Receipt) TableName() string { return "bell_event_receipts" }
|
||||
@@ -0,0 +1,21 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerAlertLifecycleRouter) }
|
||||
|
||||
func registerAlertLifecycleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
handler := alert_lifecycle.Handler{}
|
||||
routes := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
routes.GET("/alerts/:id/lifecycle", handler.Get)
|
||||
routes.POST("/alerts/:id/ack", handler.Ack)
|
||||
routes.POST("/alerts/:id/close", handler.Close)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
type eventHandler struct{ api.Api }
|
||||
|
||||
func registerEventRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
handler := eventHandler{}
|
||||
v1.GET("/events/:id", authMiddleware.MiddlewareFunc(), handler.Get)
|
||||
}
|
||||
|
||||
func (h eventHandler) Get(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
item, err := event.NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, event.ErrNotFound) {
|
||||
h.Error(http.StatusNotFound, event.ErrNotFound, event.ErrNotFound.Error())
|
||||
return
|
||||
}
|
||||
h.Logger.Errorf("load Bell event failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
|
||||
return
|
||||
}
|
||||
h.OK(item, "查询成功")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"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/synthetic"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
type registrar func(*gin.RouterGroup, *jwt.GinJWTMiddleware)
|
||||
|
||||
var registrars = []registrar{registerEventRouter}
|
||||
|
||||
func InitRouter() {
|
||||
engine, ok := sdk.Runtime.GetEngine().(*gin.Engine)
|
||||
if !ok || engine == nil {
|
||||
log.Error("Bell business router requires Gin engine")
|
||||
return
|
||||
}
|
||||
authMiddleware, err := middleware.AuthInit()
|
||||
if err != nil {
|
||||
log.Errorf("Bell business JWT init error: %v", err)
|
||||
return
|
||||
}
|
||||
v1 := engine.Group("/api/v1/bell")
|
||||
for _, register := range registrars {
|
||||
register(v1, authMiddleware)
|
||||
}
|
||||
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
|
||||
registerSyntheticRouter(v1, authMiddleware)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/rule"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrars = append(registrars, registerRuleAlertRouter)
|
||||
}
|
||||
|
||||
func registerRuleAlertRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
rules := rule.Handler{}
|
||||
alerts := alert.Handler{}
|
||||
secured := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
secured.GET("/rules", rules.List)
|
||||
secured.POST("/rules", rules.Create)
|
||||
secured.PUT("/rules/:id", rules.Update)
|
||||
secured.PUT("/rules/:id/enabled", rules.SetEnabled)
|
||||
|
||||
secured.GET("/alerts", alerts.List)
|
||||
secured.GET("/alerts/:id", alerts.Get)
|
||||
secured.GET("/events", alerts.ListEvents)
|
||||
secured.GET("/events/:id/rule-results", alerts.EventResults)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/synthetic"
|
||||
)
|
||||
|
||||
func registerSyntheticRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
handler := synthetic.Handler{}
|
||||
v1.POST("/synthetic-events", authMiddleware.MiddlewareFunc(), handler.Create)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
type enabledInput struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var query PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, "查询条件不正确")
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.Logger.Errorf("list Bell rules failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取规则失败"), "读取规则失败")
|
||||
return
|
||||
}
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
h.PageOK(items, int(count), page, size, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !isAdmin(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Create(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.writeResult(item, err)
|
||||
}
|
||||
|
||||
func (h Handler) Update(c *gin.Context) {
|
||||
if !isAdmin(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.writeResult(item, err)
|
||||
}
|
||||
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
if !isAdmin(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
|
||||
return
|
||||
}
|
||||
var input enabledInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil || input.Enabled == nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, user.GetUserId(c))
|
||||
h.writeResult(item, err)
|
||||
}
|
||||
|
||||
func (h Handler) writeResult(item Rule, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid):
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell rule failed: %v", err)
|
||||
h.Error(http.StatusConflict, errors.New("规则编码已存在或保存失败"), "规则编码已存在或保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package rule
|
||||
|
||||
import "time"
|
||||
|
||||
// Rule is the current editable rule definition. Historical evaluations keep a
|
||||
// complete versioned snapshot, so editing this row never rewrites history.
|
||||
type Rule struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Code string `json:"code" gorm:"size:128;not null;uniqueIndex"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
EventType *string `json:"eventType,omitempty" gorm:"size:128"`
|
||||
MinimumSeverity string `json:"minimumSeverity" gorm:"size:16;not null"`
|
||||
LocationContains *string `json:"locationContains,omitempty" gorm:"size:128"`
|
||||
Version int `json:"version" gorm:"not null;default:1"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
UpdatedBy int `json:"updatedBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Rule) TableName() string { return "bell_rules" }
|
||||
@@ -0,0 +1,124 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Name string `form:"name"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]Rule, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Rule{})
|
||||
if name := strings.TrimSpace(query.Name); name != "" {
|
||||
db = db.Where("name ILIKE ? OR code ILIKE ?", "%"+name+"%", "%"+name+"%")
|
||||
}
|
||||
if query.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *query.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]Rule, 0)
|
||||
err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||
return items, count, err
|
||||
}
|
||||
|
||||
func (s Service) Create(ctx context.Context, input WriteInput, actorID int) (Rule, error) {
|
||||
normalized, err := Normalize(input, true)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Rule{
|
||||
ID: uuid.NewString(), Code: normalized.Code, Name: normalized.Name, Enabled: true,
|
||||
EventType: normalized.EventType, MinimumSeverity: normalized.MinimumSeverity,
|
||||
LocationContains: normalized.LocationContains, Version: 1,
|
||||
CreatedBy: actorID, UpdatedBy: actorID, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err = s.DB.WithContext(ctx).Create(&item).Error; err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s Service) Update(ctx context.Context, id string, input WriteInput, actorID int) (Rule, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
normalized, err := Normalize(input, false)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
var item Rule
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": normalized.Name, "event_type": normalized.EventType,
|
||||
"minimum_severity": normalized.MinimumSeverity, "location_contains": normalized.LocationContains,
|
||||
"version": item.Version + 1, "updated_by": actorID, "updated_at": time.Now().UTC(),
|
||||
}
|
||||
if err := tx.Model(&item).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&item, "id = ?", id).Error
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, actorID int) (Rule, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
var item Rule
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if item.Enabled == enabled {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Model(&item).Updates(map[string]any{
|
||||
"enabled": enabled, "version": item.Version + 1,
|
||||
"updated_by": actorID, "updated_at": time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&item, "id = ?", id).Error
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
|
||||
func pageValues(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("规则内容不符合要求")
|
||||
ErrNotFound = errors.New("规则不存在")
|
||||
codePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,127}$`)
|
||||
)
|
||||
|
||||
type WriteInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType *string `json:"eventType"`
|
||||
MinimumSeverity string `json:"minimumSeverity"`
|
||||
LocationContains *string `json:"locationContains"`
|
||||
}
|
||||
|
||||
func Normalize(input WriteInput, requireCode bool) (WriteInput, error) {
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
|
||||
if requireCode && !codePattern.MatchString(input.Code) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
if !validText(input.Name, 128) || !validSeverity(input.MinimumSeverity) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
var err error
|
||||
if input.EventType, err = optionalText(input.EventType, 128); err != nil {
|
||||
return WriteInput{}, err
|
||||
}
|
||||
if input.LocationContains, err = optionalText(input.LocationContains, 128); err != nil {
|
||||
return WriteInput{}, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func validSeverity(value string) bool {
|
||||
switch value {
|
||||
case "low", "medium", "high", "critical":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func optionalText(value *string, max int) (*string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
normalized := strings.TrimSpace(*value)
|
||||
if normalized == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !validText(normalized, max) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func validText(value string, max int) bool {
|
||||
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
|
||||
!strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const EnabledEnv = "BELL_SYNTHETIC_EVENTS_ENABLED"
|
||||
|
||||
// Enabled requires both a non-production runtime and an explicit opt-in.
|
||||
func Enabled(mode string, getenv func(string) string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(mode), "prod") || strings.EqualFold(strings.TrimSpace(mode), "production") {
|
||||
return false
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(getenv(EnabledEnv)))
|
||||
return value == "true" || value == "1"
|
||||
}
|
||||
|
||||
func EnabledFromEnvironment(mode string) bool { return Enabled(mode, os.Getenv) }
|
||||
@@ -0,0 +1,58 @@
|
||||
package synthetic
|
||||
|
||||
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"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
const maxRequestBytes = 64 * 1024
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func IsAdministrator(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !IsAdministrator(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("无权使用合成事件入口"), "无权使用合成事件入口")
|
||||
return
|
||||
}
|
||||
if err := restoreRequestBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var request CreateRequest
|
||||
h.MakeContext(c).MakeOrm().Bind(&request, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
result, err := event.NewService(h.Orm).Ingest(c.Request.Context(), request.Command(), user.GetUserId(c))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, event.ErrInvalid):
|
||||
h.Error(http.StatusBadRequest, event.ErrInvalid, event.ErrInvalid.Error())
|
||||
case errors.Is(err, event.ErrConflict):
|
||||
h.Error(http.StatusConflict, event.ErrConflict, event.ErrConflict.Error())
|
||||
default:
|
||||
h.Logger.Errorf("synthetic event ingest failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("合成事件写入失败"), "合成事件写入失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
h.OK(result, "合成事件已接收")
|
||||
// The client already received the full response. Keep only a fixed marker
|
||||
// for GoAdmin's generic operation audit, which runs after the handler.
|
||||
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
requestBodyKey = "bell.synthetic.request-body"
|
||||
requestBodyErrorKey = "bell.synthetic.request-body-error"
|
||||
)
|
||||
|
||||
var redactedRequestBody = []byte(`{"redacted":true}`)
|
||||
|
||||
// RedactRequestBody runs before GoAdmin's operation logger. It keeps the real
|
||||
// body only in the request context for the handler and exposes a fixed marker
|
||||
// to the generic audit middleware so event payloads never enter sys_opera_log.
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || c.Request.URL.Path != "/api/v1/bell/synthetic-events" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(requestBodyErrorKey, err)
|
||||
} else if len(body) > maxRequestBytes {
|
||||
c.Set(requestBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(requestBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(redactedRequestBody))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreRequestBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(requestBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(requestBodyKey)
|
||||
if !ok {
|
||||
return errors.New("synthetic request body was not captured")
|
||||
}
|
||||
body := value.([]byte)
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
const ProducerID = "bell.synthetic"
|
||||
|
||||
type CreateRequest struct {
|
||||
SourceEventID string `json:"sourceEventId" binding:"required"`
|
||||
EventType string `json:"eventType" binding:"required"`
|
||||
OccurredAt time.Time `json:"occurredAt" binding:"required"`
|
||||
Location string `json:"location" binding:"required"`
|
||||
Severity string `json:"severity" binding:"required"`
|
||||
EvidenceRef *string `json:"evidenceRef,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
func (r CreateRequest) Command() event.Command {
|
||||
return event.Command{
|
||||
ProducerID: ProducerID, SourceEventID: r.SourceEventID, EventType: r.EventType,
|
||||
OccurredAt: r.OccurredAt, Location: r.Location, Severity: r.Severity,
|
||||
EvidenceRef: r.EvidenceRef, Attributes: r.Attributes,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/router"
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/bellconfig"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/global"
|
||||
@@ -54,6 +57,7 @@ func init() {
|
||||
|
||||
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
AppRouters = append(AppRouters, bellrouter.InitRouter)
|
||||
}
|
||||
|
||||
func setup() error {
|
||||
@@ -178,7 +182,9 @@ func initRouter() {
|
||||
//r.Use(middleware.Metrics())
|
||||
r.Use(common.Sentinel()).
|
||||
Use(common.RequestId(pkg.TrafficKey)).
|
||||
Use(api.SetRequestLogger)
|
||||
Use(api.SetRequestLogger).
|
||||
Use(synthetic.RedactRequestBody()).
|
||||
Use(alert_lifecycle.RedactRequestBody())
|
||||
|
||||
common.InitMiddleware(r)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventReceipt)
|
||||
}
|
||||
|
||||
func migrateBellEventReceipt(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(event.Event), new(receipt.Receipt), new(receipt.IngestAudit)); err != nil {
|
||||
return err
|
||||
}
|
||||
statements := []string{
|
||||
`ALTER TABLE bell_event_receipts ADD CONSTRAINT bell_event_receipts_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_events ADD CONSTRAINT bell_events_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
|
||||
`CREATE OR REPLACE FUNCTION bell_reject_immutable_fact() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Bell immutable fact cannot be changed' USING ERRCODE = '55000'; END $$`,
|
||||
`CREATE TRIGGER bell_events_immutable BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_event_receipts_immutable BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_event_ingest_audits_immutable BEFORE UPDATE OR DELETE ON bell_event_ingest_audits FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/evaluation"
|
||||
"go-admin/app/bell/rule"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellRuleAlert)
|
||||
}
|
||||
|
||||
func migrateBellRuleAlert(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(rule.Rule), new(evaluation.Evaluation), new(alert.Alert), new(alert.AlertEvent), new(alert.RuleMatch), new(runtimeCasbinRule)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range bellRuleAlertSchemaSQL {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedBellRuleAlertAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var bellRuleAlertSchemaSQL = []string{
|
||||
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_severity_check CHECK (minimum_severity IN ('low','medium','high','critical'))`,
|
||||
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_status_check CHECK (status IN ('open','acknowledged','closed'))`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
|
||||
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_rule_fk FOREIGN KEY (primary_rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id, correlation_key) WHERE status = 'open'`,
|
||||
`CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id, alert_id)`,
|
||||
`CREATE TRIGGER bell_rule_evaluations_immutable BEFORE UPDATE OR DELETE ON bell_rule_evaluations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_alert_events_immutable BEFORE UPDATE OR DELETE ON bell_alert_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_rule_matches_immutable BEFORE UPDATE OR DELETE ON bell_rule_matches FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE OR REPLACE FUNCTION bell_evaluate_new_event() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
current_rule bell_rules%ROWTYPE;
|
||||
is_match boolean;
|
||||
reasons text[];
|
||||
explanation_text text;
|
||||
snapshot jsonb;
|
||||
target_alert_id uuid;
|
||||
correlation text;
|
||||
BEGIN
|
||||
FOR current_rule IN SELECT * FROM bell_rules WHERE enabled = true ORDER BY id LOOP
|
||||
reasons := ARRAY[]::text[];
|
||||
IF current_rule.event_type IS NOT NULL AND current_rule.event_type <> NEW.event_type THEN
|
||||
reasons := array_append(reasons, '事件类型不匹配');
|
||||
END IF;
|
||||
IF array_position(ARRAY['low','medium','high','critical'], NEW.severity) <
|
||||
array_position(ARRAY['low','medium','high','critical'], current_rule.minimum_severity) THEN
|
||||
reasons := array_append(reasons, '风险等级低于阈值');
|
||||
END IF;
|
||||
IF current_rule.location_contains IS NOT NULL AND
|
||||
position(lower(current_rule.location_contains) in lower(NEW.location)) = 0 THEN
|
||||
reasons := array_append(reasons, '地点条件不匹配');
|
||||
END IF;
|
||||
is_match := cardinality(reasons) = 0;
|
||||
explanation_text := CASE WHEN is_match THEN '全部条件命中' ELSE array_to_string(reasons, ';') END;
|
||||
snapshot := jsonb_build_object(
|
||||
'id', current_rule.id, 'code', current_rule.code, 'name', current_rule.name,
|
||||
'enabled', current_rule.enabled, 'eventType', current_rule.event_type,
|
||||
'minimumSeverity', current_rule.minimum_severity,
|
||||
'locationContains', current_rule.location_contains, 'version', current_rule.version
|
||||
);
|
||||
INSERT INTO bell_rule_evaluations(event_id, rule_id, rule_version, rule_snapshot, matched, explanation, evaluated_at)
|
||||
VALUES(NEW.id, current_rule.id, current_rule.version, snapshot, is_match, explanation_text, now());
|
||||
IF NOT is_match THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
correlation := lower(trim(NEW.location));
|
||||
INSERT INTO bell_alerts(id, primary_rule_id, correlation_key, status, severity, summary, location, created_at, updated_at)
|
||||
VALUES(gen_random_uuid(), current_rule.id, correlation, 'open', NEW.severity,
|
||||
left(current_rule.name || ':' || NEW.event_type, 256), NEW.location, now(), now())
|
||||
ON CONFLICT(primary_rule_id, correlation_key) WHERE status = 'open'
|
||||
DO UPDATE SET
|
||||
updated_at = now(),
|
||||
severity = CASE
|
||||
WHEN array_position(ARRAY['low','medium','high','critical'], EXCLUDED.severity) >
|
||||
array_position(ARRAY['low','medium','high','critical'], bell_alerts.severity)
|
||||
THEN EXCLUDED.severity ELSE bell_alerts.severity END
|
||||
RETURNING id INTO target_alert_id;
|
||||
INSERT INTO bell_alert_events(alert_id, event_id, linked_at)
|
||||
VALUES(target_alert_id, NEW.id, now());
|
||||
INSERT INTO bell_rule_matches(event_id, rule_id, alert_id, rule_version, rule_snapshot, explanation, matched_at)
|
||||
VALUES(NEW.id, current_rule.id, target_alert_id, current_rule.version, snapshot, explanation_text, now());
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$`,
|
||||
`CREATE TRIGGER bell_events_evaluate_rules AFTER INSERT ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_evaluate_new_event()`,
|
||||
}
|
||||
|
||||
type menuSeed struct {
|
||||
ID int
|
||||
Permission string
|
||||
}
|
||||
|
||||
type apiSeed struct {
|
||||
ID int
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
// runtimeCasbinRule deliberately matches the table used by the frozen
|
||||
// go-admin-core gorm adapter. The legacy SysCasbinRule model is not the table
|
||||
// loaded by middleware.AuthCheckRole in this baseline.
|
||||
type runtimeCasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V0 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V1 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V2 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V3 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V4 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V5 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
}
|
||||
|
||||
func (runtimeCasbinRule) TableName() string { return "casbin_rule" }
|
||||
|
||||
func seedBellRuleAlertAccess(tx *gorm.DB) error {
|
||||
// db.sql contains explicit primary keys, so PostgreSQL sequences can lag
|
||||
// behind the imported baseline data. Align them before allocating any new
|
||||
// menu, API or role IDs.
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1));
|
||||
SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1));
|
||||
SELECT setval(pg_get_serial_sequence('sys_role','role_id'), GREATEST((SELECT max(role_id) FROM sys_role),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, err := insertMenu(tx, 0, "BellWarning", "预警中心", "warning", "/bell", "M", "", "", "Layout", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alerts, err := insertMenu(tx, root.ID, "BellAlerts", "预警管理", "bell", "alerts", "C", "bell:alert:list", "", "/bell/alerts/index", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events, err := insertMenu(tx, root.ID, "BellEvents", "事件查询", "list", "events", "C", "bell:event:list", "", "/bell/events/index", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := insertMenu(tx, root.ID, "BellRules", "规则配置", "guide", "rules", "C", "bell:rule:list", "", "/bell/rules/index", 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addRule, err := insertMenu(tx, rules.ID, "", "新增规则", "", "", "F", "bell:rule:add", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
editRule, err := insertMenu(tx, rules.ID, "", "修改规则", "", "", "F", "bell:rule:edit", "PUT", "", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiSpecs := []struct{ title, path, action string }{
|
||||
{"预警列表", "/api/v1/bell/alerts", "GET"}, {"预警详情", "/api/v1/bell/alerts/:id", "GET"},
|
||||
{"事件列表", "/api/v1/bell/events", "GET"}, {"事件详情", "/api/v1/bell/events/:id", "GET"},
|
||||
{"事件规则结果", "/api/v1/bell/events/:id/rule-results", "GET"},
|
||||
{"规则列表", "/api/v1/bell/rules", "GET"}, {"新增规则", "/api/v1/bell/rules", "POST"},
|
||||
{"修改规则", "/api/v1/bell/rules/:id", "PUT"}, {"启停规则", "/api/v1/bell/rules/:id/enabled", "PUT"},
|
||||
}
|
||||
apis := make([]apiSeed, 0, len(apiSpecs))
|
||||
for _, spec := range apiSpecs {
|
||||
seed, seedErr := insertAPI(tx, spec.title, spec.path, spec.action)
|
||||
if seedErr != nil {
|
||||
return seedErr
|
||||
}
|
||||
apis = append(apis, seed)
|
||||
}
|
||||
links := map[int][]apiSeed{
|
||||
alerts.ID: {apis[0], apis[1]}, events.ID: {apis[2], apis[3], apis[4]}, rules.ID: {apis[5]},
|
||||
addRule.ID: {apis[6]}, editRule.ID: {apis[7], apis[8]},
|
||||
}
|
||||
for menuID, menuAPIs := range links {
|
||||
for _, item := range menuAPIs {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id, sys_api_id) VALUES(?, ?) ON CONFLICT DO NOTHING", menuID, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var operatorRoleID int
|
||||
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key = 'operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operatorRoleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if operatorRoleID == 0 {
|
||||
if err := tx.Raw(`INSERT INTO sys_role(role_name,status,role_key,role_sort,flag,remark,admin,data_scope,create_by,update_by,created_at,updated_at)
|
||||
VALUES('处置员','2','operator',2,'','仅访问 Bell 预警处理入口',false,'',1,1,now(),now())
|
||||
RETURNING role_id`).Scan(&operatorRoleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, menu := range []menuSeed{root, alerts, events, rules} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id, menu_id) VALUES(?, ?) ON CONFLICT DO NOTHING", operatorRoleID, menu.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range apis {
|
||||
if item.Action != "GET" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertMenu(tx *gorm.DB, parentID int, name, title, icon, path, menuType, permission, action, component string, sort int) (menuSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`,
|
||||
name, title, icon, path, menuType, action, permission, parentID, component, sort).Scan(&id).Error
|
||||
if err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
paths := fmt.Sprintf("/0/%d", id)
|
||||
if parentID != 0 {
|
||||
var parentPaths string
|
||||
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id = ?", parentID).Scan(&parentPaths).Error; err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
paths = fmt.Sprintf("%s/%d", parentPaths, id)
|
||||
}
|
||||
if err = tx.Exec("UPDATE sys_menu SET paths = ? WHERE menu_id = ?", paths, id).Error; err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
return menuSeed{ID: id, Permission: permission}, nil
|
||||
}
|
||||
|
||||
func insertAPI(tx *gorm.DB, title, path, action string) (apiSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by)
|
||||
VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
|
||||
return apiSeed{ID: id, Path: path, Action: action}, err
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellAlertLifecycle)
|
||||
}
|
||||
|
||||
func migrateBellAlertLifecycle(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(alert_lifecycle.Fact), new(alert_lifecycle.RejectionAudit)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range alertLifecycleSQL {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedAlertLifecycleAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var alertLifecycleSQL = []string{
|
||||
`ALTER TABLE bell_alerts ADD COLUMN acknowledged_by bigint, ADD COLUMN acknowledged_by_name varchar(128), ADD COLUMN acknowledged_at timestamptz, ADD COLUMN closed_by bigint, ADD COLUMN closed_by_name varchar(128), ADD COLUMN closed_at timestamptz, ADD COLUMN close_outcome varchar(32), ADD COLUMN close_note varchar(500)`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_ack_user_fk FOREIGN KEY (acknowledged_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_user_fk FOREIGN KEY (closed_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_outcome_check CHECK (close_outcome IS NULL OR close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_actor_fk FOREIGN KEY (actor_id) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_transition_check CHECK (transition IN ('acknowledged','closed'))`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_outcome_check CHECK (outcome IS NULL OR outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
|
||||
`CREATE TRIGGER bell_alert_lifecycle_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_alert_lifecycle_rejections_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_rejections FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
|
||||
func seedAlertLifecycleAccess(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1)); SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var alertMenuID int
|
||||
if err := tx.Table("sys_menu").Select("menu_id").Where("permission = ?", "bell:alert:list").Scan(&alertMenuID).Error; err != nil || alertMenuID == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
ackMenu, err := insertMenu(tx, alertMenuID, "", "开始处理", "", "", "F", "bell:alert:ack", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closeMenu, err := insertMenu(tx, alertMenuID, "", "记录结果", "", "", "F", "bell:alert:close", "POST", "", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := []struct{ title, path, action string }{{"预警处理时间线", "/api/v1/bell/alerts/:id/lifecycle", "GET"}, {"开始处理预警", "/api/v1/bell/alerts/:id/ack", "POST"}, {"记录结果并完成", "/api/v1/bell/alerts/:id/close", "POST"}}
|
||||
apis := make([]apiSeed, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
item, itemErr := insertAPI(tx, spec.title, spec.path, spec.action)
|
||||
if itemErr != nil {
|
||||
return itemErr
|
||||
}
|
||||
apis = append(apis, item)
|
||||
}
|
||||
for _, link := range []struct {
|
||||
menu int
|
||||
api apiSeed
|
||||
}{{alertMenuID, apis[0]}, {ackMenu.ID, apis[1]}, {closeMenu.ID, apis[2]}} {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", link.menu, link.api.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var operatorRoleID int
|
||||
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&operatorRoleID).Error; err != nil || operatorRoleID == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
for _, menuID := range []int{ackMenu.ID, closeMenu.ID} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operatorRoleID, menuID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range apis {
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellMinimalMenu)
|
||||
}
|
||||
|
||||
func migrateBellMinimalMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ApplyBellMinimalMenuVisibility(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyBellMinimalMenuVisibility keeps the imported GoAdmin menu records for
|
||||
// rollback and upgrades, but exposes only Bell product entries and the three
|
||||
// RBAC administration pages required to maintain local accounts.
|
||||
func ApplyBellMinimalMenuVisibility(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE sys_menu
|
||||
SET visible = '1', updated_at = now()
|
||||
WHERE menu_type IN ('M', 'C')
|
||||
AND deleted_at IS NULL
|
||||
AND visible IS DISTINCT FROM '1'
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
UPDATE sys_menu
|
||||
SET visible = '0', updated_at = now()
|
||||
WHERE menu_type IN ('M', 'C')
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
path IN ('/admin', '/admin/sys-user', '/admin/sys-menu', '/admin/sys-role', '/bell')
|
||||
OR permission IN ('admin:sysUser:list', 'admin:sysMenu:list', 'admin:sysRole:list',
|
||||
'bell:alert:list', 'bell:event:list', 'bell:rule:list')
|
||||
)
|
||||
AND visible IS DISTINCT FROM '0'
|
||||
`).Error
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package bell_alert_lifecycle_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/rule"
|
||||
)
|
||||
|
||||
func TestConcurrentLifecycleAndPersistence(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("integration database not configured")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
actors := createActors(t, db)
|
||||
service := alert_lifecycle.NewService(db)
|
||||
alertID := createAlert(t, db, "main")
|
||||
const attempts = 20
|
||||
var won atomic.Int32
|
||||
results := make(chan alert_lifecycle.Result, attempts)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < attempts; i++ {
|
||||
wg.Add(1)
|
||||
actor := actors[i%2]
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, _ := service.Ack(ctx, alertID, actor)
|
||||
if result.Won {
|
||||
won.Add(1)
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
if won.Load() != 1 {
|
||||
t.Fatalf("ack winners=%d", won.Load())
|
||||
}
|
||||
detail, err := service.Get(ctx, alertID)
|
||||
if err != nil || detail.Projection.Status != alert_lifecycle.StatusAcknowledged || len(detail.Timeline) != 1 {
|
||||
t.Fatalf("ack projection=%#v err=%v", detail, err)
|
||||
}
|
||||
winner := actors[0]
|
||||
loser := actors[1]
|
||||
if detail.Projection.AcknowledgedBy == nil || *detail.Projection.AcknowledgedBy != winner.ID {
|
||||
winner, loser = loser, winner
|
||||
}
|
||||
for result := range results {
|
||||
if result.Detail.Projection.AcknowledgedBy != nil && *result.Detail.Projection.AcknowledgedBy != winner.ID {
|
||||
t.Fatal("later ack did not report the true winner")
|
||||
}
|
||||
}
|
||||
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal"}, loser); err == nil {
|
||||
t.Fatal("non-owner close succeeded")
|
||||
}
|
||||
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{}, winner); err == nil {
|
||||
t.Fatal("missing outcome succeeded")
|
||||
}
|
||||
closed, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
|
||||
if err != nil || !closed.Won {
|
||||
t.Fatalf("close failed: %#v %v", closed, err)
|
||||
}
|
||||
replay, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
|
||||
if err != nil || !replay.Idempotent {
|
||||
t.Fatalf("close replay=%#v %v", replay, err)
|
||||
}
|
||||
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "false_positive"}, winner); err == nil {
|
||||
t.Fatal("conflicting close replay succeeded")
|
||||
}
|
||||
if err = db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Update("actor_name", "tampered").Error; err == nil {
|
||||
t.Fatal("lifecycle fact update succeeded")
|
||||
}
|
||||
var facts, rejects int64
|
||||
db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Count(&facts)
|
||||
db.Model(&alert_lifecycle.RejectionAudit{}).Where("alert_id = ?", alertID).Count(&rejects)
|
||||
if facts != 2 || rejects < 20 {
|
||||
t.Fatalf("facts=%d rejects=%d", facts, rejects)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := alert_lifecycle.NewService(reopened).Get(ctx, alertID)
|
||||
if err != nil || after.Projection.Status != alert_lifecycle.StatusClosed || len(after.Timeline) != 2 {
|
||||
t.Fatalf("restart detail=%#v err=%v", after, err)
|
||||
}
|
||||
|
||||
rollbackID := createAlert(t, reopened, "rollback")
|
||||
if err = reopened.Exec(`CREATE FUNCTION bell_test_reject_lifecycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced lifecycle failure'; END $$`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = reopened.Exec(`CREATE TRIGGER bell_test_reject_lifecycle BEFORE INSERT ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, rollbackID, winner); err == nil {
|
||||
t.Fatal("forced lifecycle failure succeeded")
|
||||
}
|
||||
rollback, _ := alert_lifecycle.NewService(reopened).Get(ctx, rollbackID)
|
||||
if rollback.Projection.Status != alert_lifecycle.StatusOpen || len(rollback.Timeline) != 0 {
|
||||
t.Fatal("failed ack left partial projection")
|
||||
}
|
||||
if err = reopened.Exec(`DROP TRIGGER bell_test_reject_lifecycle ON bell_alert_lifecycle_facts; DROP FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminCloseID := createAlert(t, reopened, "admin-close")
|
||||
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, adminCloseID, winner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
administrator := loser
|
||||
administrator.Role = "admin"
|
||||
if result, closeErr := alert_lifecycle.NewService(reopened).Close(ctx, adminCloseID, alert_lifecycle.CloseInput{Outcome: "danger_confirmed"}, administrator); closeErr != nil || !result.Won {
|
||||
t.Fatalf("administrator close failed: %#v %v", result, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func createActors(t *testing.T, db *gorm.DB) []alert_lifecycle.Actor {
|
||||
t.Helper()
|
||||
var roleID int
|
||||
db.Table("sys_role").Select("role_id").Where("role_key='operator'").Scan(&roleID)
|
||||
result := make([]alert_lifecycle.Actor, 2)
|
||||
for i := range result {
|
||||
user := adminmodels.SysUser{Username: "bell_133_operator_" + string(rune('a'+i)), Password: "test-password-133", NickName: "处置员" + string(rune('A'+i)), RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result[i] = alert_lifecycle.Actor{ID: user.UserId, Name: user.NickName, Role: "operator"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func createAlert(t *testing.T, db *gorm.DB, suffix string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
eventType := "lifecycle_" + suffix
|
||||
createdRule, err := rule.NewService(db).Create(ctx, rule.WriteInput{Code: "lifecycle-" + suffix, Name: "生命周期规则", EventType: &eventType, MinimumSeverity: "low"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := event.NewService(db).Ingest(ctx, event.Command{ProducerID: "bell.lifecycle-test", SourceEventID: suffix, EventType: eventType, OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: "测试地点" + suffix, Severity: "high", Attributes: map[string]any{}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, _, err := alert.NewService(db).List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.PrimaryRuleID == createdRule.ID {
|
||||
return item.ID
|
||||
}
|
||||
}
|
||||
t.Fatal("alert not created")
|
||||
return created.Event.ID
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[CmdletBinding()] param([string]$PostgresBin='D:\pgsql17\bin')
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference='Stop'; $started=$false; $server=$null
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-133-'+[guid]::NewGuid().ToString('N'))
|
||||
$data=Join-Path $root 'postgres'; $log=Join-Path $root 'postgres.log'; $serverRoot=(Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path; $serverExe=Join-Path $root 'bell.exe'
|
||||
function FreePort { $l=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0); try{$l.Start();return ([Net.IPEndPoint]$l.LocalEndpoint).Port}finally{$l.Stop()} }
|
||||
function WaitPort([int]$port){for($i=0;$i -lt 120;$i++){try{$c=[Net.Sockets.TcpClient]::new();$ok=$c.ConnectAsync('127.0.0.1',$port).Wait(250)-and$c.Connected;$c.Dispose();if($ok){return}}catch{};Start-Sleep -Milliseconds 250};throw 'PostgreSQL did not start'}
|
||||
function Login([string]$base,[string]$username,[string]$password){$body=@{username=$username;password=$password;code='0';uuid='0'}|ConvertTo-Json -Compress; $result=Invoke-RestMethod -Method Post -Uri "$base/api/v1/login" -ContentType 'application/json' -Body $body -NoProxy; if([int]$result.code-ne 200){throw "login failed: $username"}; return @{Authorization="Bearer $($result.token)"}}
|
||||
New-Item -ItemType Directory -Path $root|Out-Null; $port=FreePort
|
||||
try {
|
||||
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){if(-not(Test-Path (Join-Path $PostgresBin $name))){throw "Missing $name"}}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale|Out-Null; if($LASTEXITCODE-ne 0){throw 'initdb failed'}
|
||||
$args="-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"; Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $args -WindowStyle Hidden|Out-Null; WaitPort $port; $started=$true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_133; if($LASTEXITCODE-ne 0){throw 'createdb failed'}
|
||||
$bellPort=FreePort; $base="http://127.0.0.1:$bellPort"; $env:GOTOOLCHAIN='go1.26.5'; $env:BELL_DATABASE_URL="host=127.0.0.1 port=$port user=postgres dbname=bell_133 sslmode=disable"; $env:BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL=$env:BELL_DATABASE_URL; $env:BELL_JWT_SECRET=[guid]::NewGuid().ToString('N')+[guid]::NewGuid().ToString('N'); $env:BELL_BOOTSTRAP_USERNAME='bell_133_admin'; $env:BELL_BOOTSTRAP_PASSWORD=[guid]::NewGuid().ToString('N'); $env:BELL_HOST='127.0.0.1'; $env:BELL_PORT=$bellPort.ToString()
|
||||
Push-Location $serverRoot; try { go run . migrate -c config/settings.demo.yml *> (Join-Path $root 'migrate.log'); if($LASTEXITCODE-ne 0){throw "migration failed: $root"}; go test ./tests/bell_alert_lifecycle -count=1 -v; if($LASTEXITCODE-ne 0){throw 'lifecycle test failed'}; go build -o $serverExe . } finally { Pop-Location }
|
||||
$server=Start-Process $serverExe -ArgumentList @('server','-c','config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput (Join-Path $root 'server.out') -RedirectStandardError (Join-Path $root 'server.err') -WindowStyle Hidden -PassThru; WaitPort $bellPort
|
||||
$a=Login $base 'bell_133_operator_a' 'test-password-133'; $b=Login $base 'bell_133_operator_b' 'test-password-133'; $list=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts?status=open" -Headers $a -NoProxy; $id=[string]$list.data.list[0].id; if([string]::IsNullOrWhiteSpace($id)){throw 'open alert missing'}
|
||||
$ack=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; $late=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $b -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$ack.code-ne 200-or[int]$late.code-ne 409){throw 'ack API semantics failed'}
|
||||
$forbidden=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $b -ContentType 'application/json' -Body '{"outcome":"site_normal"}' -NoProxy; $missing=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$forbidden.code-ne 403-or[int]$missing.code-ne 400){throw 'close rejection semantics failed'}
|
||||
$body='{"outcome":"site_normal","note":"现场正常"}'; $closed=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $replay=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $timeline=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts/$id/lifecycle" -Headers $a -NoProxy; if([int]$closed.code-ne 200-or-not$replay.data.idempotent-or$timeline.data.detail.timeline.Count-ne 2){throw 'close/timeline API semantics failed'}
|
||||
$leaks=& (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $port -U postgres -d bell_133 -Atc "select count(*) from sys_opera_log where oper_url like '%/bell/alerts/%/close' and ((oper_param <> '' and oper_param not like '%redacted%') or json_result not like '%redacted%');"; if($LASTEXITCODE-ne 0-or[int]$leaks-ne 0){throw 'lifecycle note leaked into GoAdmin operation log'}
|
||||
Write-Output 'BELL_133_HTTP ack=200 late_ack=409 forbidden_close=403 missing_outcome=400 close=200 replay=true timeline=2'
|
||||
} finally {
|
||||
if($null-ne$server-and-not$server.HasExited){Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000)|Out-Null}
|
||||
if($started){& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')}
|
||||
foreach($name in @('BELL_DATABASE_URL','BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')){Remove-Item "Env:$name" -ErrorAction SilentlyContinue}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package bell_alert_lifecycle_test
|
||||
|
||||
import "testing"
|
||||
|
||||
// Input and state validation are exercised through the PostgreSQL service test;
|
||||
// this sentinel keeps the package runnable without an integration database.
|
||||
func TestLifecyclePackageLoadsWithoutDatabase(t *testing.T) {}
|
||||
@@ -0,0 +1,61 @@
|
||||
package bell_event_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
func validCommand() event.Command {
|
||||
return event.Command{
|
||||
ProducerID: " bell.synthetic ", SourceEventID: " test-001 ", EventType: " danger_area_entered ",
|
||||
OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)),
|
||||
Location: " 东门 ", Severity: " HIGH ", Attributes: map[string]any{"z": 2, "a": "first"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIsDeterministicAndTrimsBusinessFields(t *testing.T) {
|
||||
first, err := event.Normalize(validCommand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondCommand := validCommand()
|
||||
secondCommand.Attributes = map[string]any{"a": "first", "z": 2}
|
||||
second, err := event.Normalize(secondCommand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Digest != second.Digest || string(first.Payload) != string(second.Payload) {
|
||||
t.Fatalf("normalized payload is not deterministic: %s != %s", first.Payload, second.Payload)
|
||||
}
|
||||
if first.Command.ProducerID != "bell.synthetic" || first.Command.Severity != "high" {
|
||||
t.Fatalf("fields were not normalized: %#v", first.Command)
|
||||
}
|
||||
if first.Command.OccurredAt.Location() != time.UTC {
|
||||
t.Fatalf("occurredAt was not converted to UTC: %v", first.Command.OccurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsUnsafeOrInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*event.Command)
|
||||
}{
|
||||
{name: "missing source id", mutate: func(c *event.Command) { c.SourceEventID = " " }},
|
||||
{name: "unknown severity", mutate: func(c *event.Command) { c.Severity = "urgent" }},
|
||||
{name: "newline in location", mutate: func(c *event.Command) { c.Location = "东门\nsecret" }},
|
||||
{name: "unsafe evidence path", mutate: func(c *event.Command) { value := `C:\\secret.jpg`; c.EvidenceRef = &value }},
|
||||
{name: "oversized attributes", mutate: func(c *event.Command) { c.Attributes = map[string]any{"blob": string(make([]byte, 49*1024))} }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
command := validCommand()
|
||||
test.mutate(&command)
|
||||
if _, err := event.Normalize(command); !errors.Is(err, event.ErrInvalid) {
|
||||
t.Fatalf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package bell_event_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/synthetic"
|
||||
)
|
||||
|
||||
func TestSyntheticEnabledRequiresExplicitNonProductionOptIn(t *testing.T) {
|
||||
getenv := func(name string) string {
|
||||
if name == synthetic.EnabledEnv {
|
||||
return "true"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if synthetic.Enabled("prod", getenv) || synthetic.Enabled("production", getenv) {
|
||||
t.Fatal("synthetic route must stay disabled in production even when the flag is set")
|
||||
}
|
||||
if !synthetic.Enabled("dev", getenv) {
|
||||
t.Fatal("explicit development opt-in should enable the synthetic route")
|
||||
}
|
||||
if synthetic.Enabled("dev", func(string) string { return "" }) {
|
||||
t.Fatal("synthetic route must default to disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyntheticAdministratorCheckUsesJWTClaims(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
admin, _ := gin.CreateTestContext(nil)
|
||||
admin.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "admin"})
|
||||
if !synthetic.IsAdministrator(admin) {
|
||||
t.Fatal("admin role should be authorized")
|
||||
}
|
||||
operator, _ := gin.CreateTestContext(nil)
|
||||
operator.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "operator"})
|
||||
if synthetic.IsAdministrator(operator) {
|
||||
t.Fatal("non-admin role must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package bell_minimal_menu_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
versionlocal "go-admin/cmd/migrate/migration/version-local"
|
||||
)
|
||||
|
||||
var expectedVisibleMenus = []string{
|
||||
"事件查询",
|
||||
"用户管理",
|
||||
"系统管理",
|
||||
"菜单管理",
|
||||
"角色管理",
|
||||
"规则配置",
|
||||
"预警中心",
|
||||
"预警管理",
|
||||
}
|
||||
|
||||
func TestBellMinimalMenuMigration(t *testing.T) {
|
||||
databaseURL := os.Getenv("BELL_MINIMAL_MENU_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("minimal menu database is not configured")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertVisibleMenus(t, db)
|
||||
assertUnusedMenusRetainedAndHidden(t, db)
|
||||
assertOperatorHasNoDefaultMenus(t, db)
|
||||
|
||||
var rowCountBefore int64
|
||||
if err = db.Table("sys_menu").Count(&rowCountBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`UPDATE sys_menu SET visible = '0' WHERE title IN ('开发工具','定时任务','系统工具')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`UPDATE sys_menu SET visible = '1' WHERE title IN ('系统管理','预警中心')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
|
||||
t.Fatalf("reapplying minimal menu policy failed: %v", err)
|
||||
}
|
||||
var rowCountAfter int64
|
||||
if err = db.Table("sys_menu").Count(&rowCountAfter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rowCountAfter != rowCountBefore {
|
||||
t.Fatalf("menu records changed during visibility migration: before=%d after=%d", rowCountBefore, rowCountAfter)
|
||||
}
|
||||
assertVisibleMenus(t, db)
|
||||
assertUnusedMenusRetainedAndHidden(t, db)
|
||||
}
|
||||
|
||||
func assertVisibleMenus(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var titles []string
|
||||
if err := db.Table("sys_menu").
|
||||
Where("menu_type IN ? AND deleted_at IS NULL AND visible = ?", []string{"M", "C"}, "0").
|
||||
Order("title").Pluck("title", &titles).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(titles)
|
||||
expected := append([]string(nil), expectedVisibleMenus...)
|
||||
sort.Strings(expected)
|
||||
if !reflect.DeepEqual(titles, expected) {
|
||||
t.Fatalf("visible menu mismatch\nwant: %v\n got: %v", expected, titles)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUnusedMenusRetainedAndHidden(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, title := range []string{"开发工具", "定时任务", "系统工具"} {
|
||||
var values []string
|
||||
if err := db.Table("sys_menu").Where("title = ? AND deleted_at IS NULL", title).Pluck("visible", &values).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
t.Fatalf("unused upstream menu %q was deleted", title)
|
||||
}
|
||||
for _, visible := range values {
|
||||
if visible != "1" {
|
||||
t.Fatalf("unused upstream menu %q remains visible=%q", title, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertOperatorHasNoDefaultMenus(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var titles []string
|
||||
err := db.Raw(`
|
||||
SELECT DISTINCT m.title
|
||||
FROM sys_role r
|
||||
JOIN sys_role_menu rm ON rm.role_id = r.role_id
|
||||
JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE r.role_key = 'operator'
|
||||
AND m.menu_type IN ('M', 'C')
|
||||
AND m.deleted_at IS NULL
|
||||
AND (m.path LIKE '/admin%' OR m.permission LIKE 'admin:%')
|
||||
`).Scan(&titles).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(titles) != 0 {
|
||||
t.Fatalf("operator retains default administration menus: %v", titles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$started = $false
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-140-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $root 'postgres'
|
||||
$log = Join-Path $root 'postgres.log'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForPort([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($connected) { return }
|
||||
} catch {
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw 'PostgreSQL did not start'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
$port = Get-FreePort
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { throw "Missing $name" }
|
||||
}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
|
||||
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
|
||||
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
|
||||
Wait-ForPort $port
|
||||
$started = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_140
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_140 sslmode=disable"
|
||||
$env:BELL_MINIMAL_MENU_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_140_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = (Get-FreePort).ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
|
||||
go test ./tests/bell_minimal_menu -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'minimal menu test failed' }
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate-repeat.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "repeat migration failed; evidence: $root" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Output 'BELL_140_MINIMAL_MENU fresh=true upgrade=true repeat=true admin_whitelist=true operator_default_menu=false'
|
||||
} finally {
|
||||
if ($started) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_MINIMAL_MENU_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package bell_production_login_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
sdkapi "github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
adminrouter "go-admin/app/admin/router"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/common/bellconfig"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/middleware"
|
||||
"go-admin/common/storage"
|
||||
ext "go-admin/config"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ID string `json:"id"`
|
||||
Msg string `json:"msg"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func TestProductionCaptchaLoginAndRouteBoundary(t *testing.T) {
|
||||
if os.Getenv("BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("production login database is not configured")
|
||||
}
|
||||
if err := os.MkdirAll("temp/logs", 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gin.SetMode(gin.TestMode)
|
||||
config.ExtendConfig = &ext.ExtConfig
|
||||
config.Setup(file.NewSource(file.WithPath("../../config/settings.yml")))
|
||||
if err := bellconfig.ApplyRequiredEnvironment(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.ApplicationConfig.Mode != "prod" {
|
||||
t.Fatalf("expected production mode, got %q", config.ApplicationConfig.Mode)
|
||||
}
|
||||
database.Setup()
|
||||
storage.Setup()
|
||||
|
||||
engine := gin.New()
|
||||
sdk.Runtime.SetEngine(engine)
|
||||
engine.Use(sdkapi.SetRequestLogger)
|
||||
engine.Use(middleware.WithContextDb)
|
||||
authMiddleware, err := middleware.AuthInit()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminrouter.InitSysRouter(engine, authMiddleware)
|
||||
adminrouter.InitExamplesRouter(engine, authMiddleware)
|
||||
bellrouter.InitRouter()
|
||||
|
||||
captchaResponse := requestJSON(t, engine, http.MethodGet, "/api/v1/captcha", nil, "")
|
||||
if captchaResponse.Code != 200 || captchaResponse.ID == "" || !bytes.Contains(captchaResponse.Data, []byte("data:image/")) {
|
||||
t.Fatalf("unexpected captcha response: code=%d id=%q data=%s", captchaResponse.Code, captchaResponse.ID, captchaResponse.Data)
|
||||
}
|
||||
|
||||
username := os.Getenv("BELL_BOOTSTRAP_USERNAME")
|
||||
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
|
||||
answer := "813907"
|
||||
validID := "bell-138-valid"
|
||||
if err := base64Captcha.DefaultMemStore.Set(validID, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !captcha.Verify(validID, answer, false) {
|
||||
t.Fatal("known captcha was not stored")
|
||||
}
|
||||
login := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
|
||||
if login.Code != 200 || login.Token == "" {
|
||||
t.Fatalf("valid captcha login failed: code=%d msg=%q", login.Code, login.Msg)
|
||||
}
|
||||
replay := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
|
||||
if replay.Code == 200 {
|
||||
t.Fatal("used captcha was accepted again")
|
||||
}
|
||||
|
||||
wrongID := "bell-138-wrong"
|
||||
if err := base64Captcha.DefaultMemStore.Set(wrongID, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, "000000"), "")
|
||||
if wrong.Code == 200 {
|
||||
t.Fatal("incorrect captcha was accepted")
|
||||
}
|
||||
consumed := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, answer), "")
|
||||
if consumed.Code == 200 {
|
||||
t.Fatal("captcha used by a failed attempt was not consumed")
|
||||
}
|
||||
|
||||
unauthenticated := requestJSON(t, engine, http.MethodGet, "/api/v1/bell/alerts", nil, "")
|
||||
if unauthenticated.Code == 200 {
|
||||
t.Fatal("unauthenticated Bell business API was accepted")
|
||||
}
|
||||
disabled := httptest.NewRecorder()
|
||||
engine.ServeHTTP(disabled, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil))
|
||||
if disabled.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled default route returned HTTP %d", disabled.Code)
|
||||
}
|
||||
|
||||
assertSecretsAbsentFromAudit(t, password, answer, login.Token)
|
||||
assertSecretsAbsentFromLogs(t, password, answer, login.Token)
|
||||
if captcha.Verify(captchaResponse.ID, "deliberately-wrong", true) {
|
||||
t.Fatal("generated captcha accepted a deliberately incorrect answer")
|
||||
}
|
||||
}
|
||||
|
||||
func loginBody(username, password, id, answer string) map[string]string {
|
||||
return map[string]string{"username": username, "password": password, "uuid": id, "code": answer}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, engine http.Handler, method, path string, body any, token string) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
var response apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode %s response (HTTP %d): %v: %s", path, recorder.Code, err, recorder.Body.String())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func assertSecretsAbsentFromAudit(t *testing.T, secrets ...string) {
|
||||
t.Helper()
|
||||
db := sdk.Runtime.GetDbByKey("").Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
for _, table := range []string{"sys_login_log", "sys_opera_log"} {
|
||||
for _, secret := range secrets {
|
||||
var count int64
|
||||
query := "SELECT count(*) FROM " + table + " WHERE row_to_json(" + table + ")::text LIKE ?"
|
||||
if err := db.Raw(query, "%"+secret+"%").Scan(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("secret leaked into %s", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecretsAbsentFromLogs(t *testing.T, secrets ...string) {
|
||||
t.Helper()
|
||||
patterns := append([]string{"DriverDigitFunc answer:"}, secrets...)
|
||||
err := filepath.WalkDir("temp", func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern != "" && strings.Contains(string(content), pattern) {
|
||||
t.Fatalf("sensitive value found in server log %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$started = $false
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-138-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $root 'postgres'
|
||||
$log = Join-Path $root 'postgres.log'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForPort([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($connected) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw 'PostgreSQL did not start'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
$port = Get-FreePort
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) {
|
||||
throw "Missing $name"
|
||||
}
|
||||
}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
|
||||
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
|
||||
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
|
||||
Wait-ForPort $port
|
||||
$started = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_138
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_138 sslmode=disable"
|
||||
$env:BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_138_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = (Get-FreePort).ToString()
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
|
||||
go test ./tests/bell_production_login -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'production login test failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Output 'BELL_138_PRODUCTION_LOGIN captcha=200 valid_login=200 wrong_rejected=true replay_rejected=true secrets_absent=true'
|
||||
} finally {
|
||||
if ($started) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package bell_rule_alert_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/evaluation"
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
"go-admin/app/bell/rule"
|
||||
)
|
||||
|
||||
func TestPostgresRuleEvaluationAndAlertProjection(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_RULE_ALERT_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_RULE_ALERT_TEST_DATABASE_URL to run the isolated PostgreSQL test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
rules := rule.NewService(db)
|
||||
events := event.NewService(db)
|
||||
evaluations := evaluation.NewService(db)
|
||||
alerts := alert.NewService(db)
|
||||
assertOperatorAccess(t, db)
|
||||
createOperatorUser(t, db)
|
||||
|
||||
eventType := "danger_area_entered"
|
||||
location := "东门"
|
||||
first, err := rules.Create(ctx, rule.WriteInput{Code: "area-high", Name: "高风险区域", EventType: &eventType, MinimumSeverity: "high", LocationContains: &location}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := rules.Create(ctx, rule.WriteInput{Code: "all-high", Name: "全局高风险", MinimumSeverity: "high"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherEventType := "fire_detected"
|
||||
_, err = rules.Create(ctx, rule.WriteInput{Code: "critical-fire", Name: "仅严重火情", EventType: &otherEventType, MinimumSeverity: "critical"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created := ingest(t, events, "event-001", "东门 A 区", "high")
|
||||
result, err := evaluations.ForEvent(ctx, created.Event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Evaluations) != 3 || len(result.Alerts) != 2 {
|
||||
t.Fatalf("expected 3 evaluations and 2 alerts, got %d and %d", len(result.Evaluations), len(result.Alerts))
|
||||
}
|
||||
matched, unmatched := 0, 0
|
||||
for _, item := range result.Evaluations {
|
||||
if item.Matched {
|
||||
matched++
|
||||
} else if item.Explanation != "" {
|
||||
unmatched++
|
||||
}
|
||||
}
|
||||
if matched != 2 || unmatched != 1 {
|
||||
t.Fatalf("unexpected match explanations: matched=%d unmatched=%d", matched, unmatched)
|
||||
}
|
||||
|
||||
replay := ingest(t, events, "event-001", "东门 A 区", "high")
|
||||
if !replay.Duplicate || replay.Event.ID != created.Event.ID {
|
||||
t.Fatalf("idempotent replay created another fact: %#v", replay)
|
||||
}
|
||||
assertCount(t, db, "bell_rule_evaluations", 3)
|
||||
assertCount(t, db, "bell_alerts", 2)
|
||||
|
||||
secondEvent := ingest(t, events, "event-002", "东门 A 区", "critical")
|
||||
assertCount(t, db, "bell_alerts", 2)
|
||||
items, total, err := alerts.List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 20, Status: "open"})
|
||||
if err != nil || total != 2 || len(items) != 2 {
|
||||
t.Fatalf("unexpected alert list: total=%d len=%d err=%v", total, len(items), err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.EventCount != 2 || item.Status != "open" || item.Severity != "critical" {
|
||||
t.Fatalf("open alert did not aggregate and escalate: %#v", item)
|
||||
}
|
||||
detail, detailErr := alerts.Get(ctx, item.ID)
|
||||
if detailErr != nil || len(detail.Events) != 2 || len(detail.Matches) != 2 {
|
||||
t.Fatalf("event-alert navigation is incomplete: events=%d matches=%d err=%v", len(detail.Events), len(detail.Matches), detailErr)
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := rules.Update(ctx, first.ID, rule.WriteInput{Name: "高风险区域(更新)", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location}, 1)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("rule version was not incremented: version=%d err=%v", updated.Version, err)
|
||||
}
|
||||
if _, err = rules.SetEnabled(ctx, second.ID, false, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thirdEvent := ingest(t, events, "event-003", "东门 B 区", "medium")
|
||||
thirdResults, err := evaluations.ForEvent(ctx, thirdEvent.Event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(thirdResults.Evaluations) != 2 || len(thirdResults.Alerts) != 1 {
|
||||
t.Fatalf("disabled rule was evaluated: evaluations=%d alerts=%d", len(thirdResults.Evaluations), len(thirdResults.Alerts))
|
||||
}
|
||||
var snapshot struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
for _, item := range thirdResults.Evaluations {
|
||||
if item.RuleID == first.ID {
|
||||
if err = json.Unmarshal(item.RuleSnapshot, &snapshot); err != nil || item.RuleVersion != 2 || snapshot.Version != 2 {
|
||||
t.Fatalf("versioned rule snapshot missing: item=%#v snapshot=%#v err=%v", item, snapshot, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Model(&evaluation.Evaluation{}).Where("event_id = ? AND rule_id = ?", created.Event.ID, first.ID).Update("explanation", "tampered").Error; err == nil {
|
||||
t.Fatal("immutable evaluation update unexpectedly succeeded")
|
||||
}
|
||||
|
||||
if err = db.Exec(`CREATE FUNCTION bell_test_reject_alert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced alert failure'; END $$`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TRIGGER bell_test_reject_alert BEFORE INSERT ON bell_alerts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_alert()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failedID := "event-rollback"
|
||||
_, ingestErr := events.Ingest(ctx, eventCommand(failedID, "东门 C 区", "high"), 1)
|
||||
if ingestErr == nil {
|
||||
t.Fatal("forced alert failure did not roll back Event ingest")
|
||||
}
|
||||
var eventCount, receiptCount int64
|
||||
db.Model(&event.Event{}).Where("source_event_id = ?", failedID).Count(&eventCount)
|
||||
db.Model(&receipt.Receipt{}).Where("source_event_id = ?", failedID).Count(&receiptCount)
|
||||
if eventCount != 0 || receiptCount != 0 {
|
||||
t.Fatalf("transaction failure left partial facts: events=%d receipts=%d", eventCount, receiptCount)
|
||||
}
|
||||
if err = db.Exec(`DROP TRIGGER bell_test_reject_alert ON bell_alerts; DROP FUNCTION bell_test_reject_alert()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if secondEvent.Event.ID == thirdEvent.Event.ID {
|
||||
t.Fatal("independent Events unexpectedly share an id")
|
||||
}
|
||||
}
|
||||
|
||||
func ingest(t *testing.T, service event.Service, sourceID, location, severity string) event.Result {
|
||||
t.Helper()
|
||||
result, err := service.Ingest(context.Background(), eventCommand(sourceID, location, severity), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func eventCommand(sourceID, location, severity string) event.Command {
|
||||
return event.Command{ProducerID: "bell.rule-test", SourceEventID: sourceID, EventType: "danger_area_entered", OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: location, Severity: severity, Attributes: map[string]any{"test": true}}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, db *gorm.DB, table string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := db.Table(table).Count(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatal(fmt.Sprintf("%s count: got %d want %d", table, got, want))
|
||||
}
|
||||
}
|
||||
|
||||
func assertOperatorAccess(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount int64
|
||||
if err := db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id = rm.role_id").
|
||||
Joins("JOIN sys_menu m ON m.menu_id = rm.menu_id").
|
||||
Where("r.role_key = ? AND m.path IN ?", "operator", []string{"/bell", "alerts", "events", "rules"}).Count(&menuCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ? AND v1 LIKE ?", "operator", "GET", "/api/v1/bell/rules%").Count(&ruleWritePolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ? AND v1 IN ?", "operator", "POST", []string{"/api/v1/bell/alerts/:id/ack", "/api/v1/bell/alerts/:id/close"}).Count(&lifecycleWritePolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if menuCount != 4 || readPolicyCount < 6 || ruleWritePolicyCount != 0 || lifecycleWritePolicyCount > 2 {
|
||||
t.Fatalf("operator access escaped Bell scope: menus=%d reads=%d rule_writes=%d lifecycle_writes=%d", menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func createOperatorUser(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
|
||||
if password == "" {
|
||||
t.Skip("set BELL_RULE_ALERT_OPERATOR_PASSWORD for the HTTP RBAC continuation")
|
||||
}
|
||||
var roleID int
|
||||
if err := db.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("load operator role: id=%d err=%v", roleID, err)
|
||||
}
|
||||
user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pgStarted = $false
|
||||
$server = $null
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-132-' + [guid]::NewGuid().ToString('N'))
|
||||
$pgData = Join-Path $testRoot 'postgres'
|
||||
$pgLog = Join-Path $testRoot 'postgres.log'
|
||||
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
|
||||
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
|
||||
$serverOut = Join-Path $testRoot 'bell.out.log'
|
||||
$serverErr = Join-Path $testRoot 'bell.err.log'
|
||||
$serverExe = Join-Path $testRoot 'bell-server.exe'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreeTcpPort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
} catch {
|
||||
$connected = $false
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
if ($connected -eq $Open) { return }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "TCP port $Port did not reach open=$Open"
|
||||
}
|
||||
|
||||
function Wait-Health([string]$BaseUrl) {
|
||||
for ($attempt = 0; $attempt -lt 100; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
|
||||
if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw 'Bell health endpoint did not become ready'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreeTcpPort
|
||||
$bellPort = Get-FreeTcpPort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
$database = 'bell_132'
|
||||
|
||||
try {
|
||||
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
$path = Join-Path $PostgresBin $required
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
|
||||
}
|
||||
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' }
|
||||
$pgArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput $pgCtlOut -RedirectStandardError $pgCtlErr -WindowStyle Hidden | Out-Null
|
||||
Wait-Tcp -Port $pgPort -Open $true
|
||||
$pgStarted = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres $database
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
|
||||
$env:BELL_RULE_ALERT_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_132_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = $bellPort.ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "Bell migration failed; see $(Join-Path $testRoot 'migrate.log')" }
|
||||
go test ./tests/bell_rule_alert -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell rule-alert integration test failed' }
|
||||
go build -o $serverExe .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $baseUrl
|
||||
|
||||
$unauthorized = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/rules" -TimeoutSec 5 -NoProxy
|
||||
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated rule list returned code $($unauthorized.code)" }
|
||||
|
||||
$adminLoginBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$adminLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminLoginBody -TimeoutSec 5 -NoProxy
|
||||
if ([int]$adminLogin.code -ne 200) { throw 'Bell administrator login failed' }
|
||||
$adminHeaders = @{ Authorization = "Bearer $($adminLogin.token)" }
|
||||
$adminRule = @{ code = 'http-admin'; name = '管理员 HTTP 规则'; eventType = $null; minimumSeverity = 'high'; locationContains = $null } | ConvertTo-Json -Compress
|
||||
$adminWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $adminRule -TimeoutSec 5 -NoProxy
|
||||
if ([int]$adminWrite.code -ne 200 -or [int]$adminWrite.data.version -ne 1) { throw 'administrator rule create failed' }
|
||||
|
||||
$operatorLoginBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$operatorLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorLoginBody -TimeoutSec 5 -NoProxy
|
||||
if ([int]$operatorLogin.code -ne 200) { throw 'Bell operator login failed' }
|
||||
$operatorHeaders = @{ Authorization = "Bearer $($operatorLogin.token)" }
|
||||
foreach ($path in @('/api/v1/bell/rules','/api/v1/bell/events','/api/v1/bell/alerts')) {
|
||||
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
|
||||
if ([int]$read.code -ne 200) { throw "operator read $path returned code $($read.code)" }
|
||||
}
|
||||
$operatorWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $operatorHeaders -ContentType 'application/json' -Body $adminRule -TimeoutSec 5 -NoProxy
|
||||
if ([int]$operatorWrite.code -ne 403) { throw "operator rule write returned code $($operatorWrite.code)" }
|
||||
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
|
||||
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
|
||||
foreach ($title in @('预警中心','预警管理','事件查询','规则配置')) {
|
||||
if (-not $menuJson.Contains($title)) { throw "operator menu is missing $title" }
|
||||
}
|
||||
if ($menuJson.Contains('系统管理') -or $menuJson.Contains('开发工具')) { throw 'operator menu exposed unrelated GoAdmin modules' }
|
||||
Write-Output 'BELL_132_HTTP unauthenticated=401 admin_write=200 operator_reads=200 operator_write=403 minimal_menu=true'
|
||||
} finally {
|
||||
if ($null -ne $server -and -not $server.HasExited) {
|
||||
Stop-Process -Id $server.Id -Force
|
||||
$server.WaitForExit(5000) | Out-Null
|
||||
}
|
||||
if ($pgStarted) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL','BELL_RULE_ALERT_TEST_DATABASE_URL','BELL_RULE_ALERT_OPERATOR_PASSWORD','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Verbose "Bell #132 temporary artifacts: $testRoot"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package bell_rule_alert_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/rule"
|
||||
)
|
||||
|
||||
func TestRuleNormalizeTrimsAndNormalizesFields(t *testing.T) {
|
||||
eventType := " danger_area_entered "
|
||||
location := " 东门 "
|
||||
got, err := rule.Normalize(rule.WriteInput{
|
||||
Code: " AREA_HIGH ", Name: " 高风险区域 ", EventType: &eventType,
|
||||
MinimumSeverity: " HIGH ", LocationContains: &location,
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Code != "area_high" || got.Name != "高风险区域" || got.MinimumSeverity != "high" {
|
||||
t.Fatalf("unexpected normalized rule: %#v", got)
|
||||
}
|
||||
if got.EventType == nil || *got.EventType != "danger_area_entered" || got.LocationContains == nil || *got.LocationContains != "东门" {
|
||||
t.Fatalf("optional fields were not normalized: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleNormalizeRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input rule.WriteInput
|
||||
}{
|
||||
{name: "invalid code", input: rule.WriteInput{Code: "Bad Code", Name: "规则", MinimumSeverity: "low"}},
|
||||
{name: "missing name", input: rule.WriteInput{Code: "valid-code", Name: " ", MinimumSeverity: "low"}},
|
||||
{name: "unknown severity", input: rule.WriteInput{Code: "valid-code", Name: "规则", MinimumSeverity: "urgent"}},
|
||||
{name: "control character", input: rule.WriteInput{Code: "valid-code", Name: "规则\n泄露", MinimumSeverity: "low"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := rule.Normalize(test.input, true); !errors.Is(err, rule.ErrInvalid) {
|
||||
t.Fatalf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleUpdateKeepsImmutableCodeOutsideInput(t *testing.T) {
|
||||
got, err := rule.Normalize(rule.WriteInput{Code: "ignored invalid code", Name: "更新后规则", MinimumSeverity: "medium"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "更新后规则" || got.MinimumSeverity != "medium" {
|
||||
t.Fatalf("unexpected update normalization: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getAlertLifecycle(id) { return request({ url: `/api/v1/bell/alerts/${id}/lifecycle`, method: 'get' }) }
|
||||
export function acknowledgeAlert(id) { return request({ url: `/api/v1/bell/alerts/${id}/ack`, method: 'post' }) }
|
||||
export function closeAlert(id, data) { return request({ url: `/api/v1/bell/alerts/${id}/close`, method: 'post', data }) }
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listAlerts(query) {
|
||||
return request({ url: '/api/v1/bell/alerts', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getAlert(id) {
|
||||
return request({ url: `/api/v1/bell/alerts/${id}`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listEvents(query) {
|
||||
return request({ url: '/api/v1/bell/events', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getEvent(id) {
|
||||
return request({ url: `/api/v1/bell/events/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function getEventRuleResults(id) {
|
||||
return request({ url: `/api/v1/bell/events/${id}/rule-results`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listRules(query) {
|
||||
return request({ url: '/api/v1/bell/rules', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function createRule(data) {
|
||||
return request({ url: '/api/v1/bell/rules', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateRule(id, data) {
|
||||
return request({ url: `/api/v1/bell/rules/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function setRuleEnabled(id, enabled) {
|
||||
return request({ url: `/api/v1/bell/rules/${id}/enabled`, method: 'put', data: { enabled }})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="lifecycle-actions">
|
||||
<el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" />
|
||||
<el-button v-if="lifecycle.canAck" v-permisaction="['bell:alert:ack']" type="primary" :loading="loading" @click="ack">我已看到并开始处理</el-button>
|
||||
<el-button v-if="lifecycle.canClose" v-permisaction="['bell:alert:close']" type="primary" :loading="loading" @click="dialog=true">记录现场结果并完成</el-button>
|
||||
<el-dialog v-model="dialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" @closed="reset">
|
||||
<el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false" class="form-alert" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="form.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="补充说明(可选)"><el-input v-model="form.note" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="loading" @click="finish">确认结果并完成</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { acknowledgeAlert, closeAlert } from '@/api/bell/alert-lifecycle'
|
||||
export default {
|
||||
name: 'BellLifecycleActions', props: { alertId: { type: String, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed'],
|
||||
data() { return { loading: false, error: '', dialog: false, form: { outcome: '', note: '' }, rules: { outcome: [{ required: true, message: '请选择现场结果', trigger: 'change' }] }} },
|
||||
methods: {
|
||||
async ack() { this.loading = true; this.error = ''; try { const r = await acknowledgeAlert(this.alertId); this.msgSuccess(r.data.idempotent ? '您已在处理此预警' : '已记录由您开始处理'); this.$emit('changed') } catch (e) { this.error = e.message || '开始处理失败'; this.$emit('changed') } finally { this.loading = false } },
|
||||
async finish() { try { await this.$refs.formRef.validate(); this.loading = true; this.error = ''; const r = await closeAlert(this.alertId, this.form); this.msgSuccess(r.data.idempotent ? '该结果已记录' : '预警已完成'); this.dialog = false; this.$emit('changed') } catch (e) { if (e && e.message) this.error = e.message } finally { this.loading = false } },
|
||||
reset() { this.form = { outcome: '', note: '' }; this.$refs.formRef && this.$refs.formRef.clearValidate() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>.lifecycle-actions{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}.lifecycle-actions .el-alert{flex-basis:100%}.form-alert{margin-bottom:16px}.outcome-group{display:grid;gap:10px}</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
<template><el-timeline><el-timeline-item v-for="item in items" :key="item.id" :timestamp="parseTime(item.occurredAt)" :type="item.transition==='closed'?'success':'primary'"><strong>{{ item.transition === 'closed' ? '处理完成' : '开始处理' }}</strong> · {{ item.actorName }}<div v-if="item.outcome">现场结果:{{ outcomeName(item.outcome) }}<span v-if="item.note">;{{ item.note }}</span></div></el-timeline-item><el-empty v-if="!items.length" description="尚无处理记录" /></el-timeline></template>
|
||||
<script>export default { name: 'BellLifecycleTimeline', props: { items: { type: Array, default: () => [] }}, methods: { outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template><div><el-descriptions :column="1" border><el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item><el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item><el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusName(lifecycle.projection.status || detail.alert.status) }}</el-descriptions-item><el-descriptions-item label="处理人">{{ lifecycle.projection.acknowledgedByName || '尚未开始处理' }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeOutcome" label="现场结果">{{ outcomeName(lifecycle.projection.closeOutcome) }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeNote" label="处理说明">{{ lifecycle.projection.closeNote }}</el-descriptions-item><el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item><el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item></el-descriptions><LifecycleActions :alert-id="detail.alert.id" :lifecycle="lifecycle" @changed="$emit('changed')" /><h3>关联事件</h3><el-table :data="detail.events" border row-key="id"><el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column><el-table-column prop="eventType" label="事件类型" min-width="150" /><el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="$emit('go-event',scope.row.id)">查看</el-button></template></el-table-column></el-table><h3>处理时间线</h3><LifecycleTimeline :items="lifecycle.timeline" /><h3>命中说明</h3><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">规则 v{{ match.ruleVersion }}:{{ match.explanation }}</el-timeline-item></el-timeline></div></template>
|
||||
<script>import LifecycleActions from './components/LifecycleActions.vue'; import LifecycleTimeline from './components/LifecycleTimeline.vue'; export default { name: 'BellAlertDetail', components: { LifecycleActions, LifecycleTimeline }, props: { detail: { type: Object, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed', 'go-event'], methods: { statusName(v) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[v] || v }, severityName(v) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[v] || v }, outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
|
||||
<style scoped>h3{font-size:16px;margin:22px 0 10px}</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<template #header><div class="page-heading"><h2>预警管理</h2><p>先开始处理,再记录现场结果完成预警。</p></div></template>
|
||||
<el-form ref="queryForm" :model="query" :inline="true">
|
||||
<el-form-item label="状态" prop="status"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:130px"><el-option label="待处理" value="open" /><el-option label="处理中" value="acknowledged" /><el-option label="已完成" value="closed" /></el-select></el-form-item>
|
||||
<el-form-item label="风险" prop="severity"><el-select v-model="query.severity" clearable placeholder="全部风险" style="width:130px"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
|
||||
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="summary" label="预警事项" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="location" label="地点" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusName(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="eventCount" label="关联事件" width="100" />
|
||||
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
|
||||
<template #empty><el-empty description="暂无预警" /></template>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="drawer" title="预警详情" size="min(720px, 100%)">
|
||||
<div v-loading="detailLoading">
|
||||
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
|
||||
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAlert, listAlerts } from '@/api/bell/alert'
|
||||
import { getAlertLifecycle } from '@/api/bell/alert-lifecycle'
|
||||
import BellAlertDetail from './detail.vue'
|
||||
|
||||
export default {
|
||||
name: 'BellAlerts',
|
||||
components: { BellAlertDetail },
|
||||
data() {
|
||||
return {
|
||||
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
|
||||
drawer: false, detail: null, lifecycle: null, activeId: '',
|
||||
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
|
||||
query: { pageIndex: 1, pageSize: 10, status: '', severity: '', location: '' }
|
||||
}
|
||||
},
|
||||
created() { this.load().then(() => { if (this.$route.query.alertId) this.openDetail({ id: this.$route.query.alertId }) }) },
|
||||
methods: {
|
||||
async load() {
|
||||
this.loading = true; this.error = ''
|
||||
try { const response = await listAlerts(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '预警加载失败' } finally { this.loading = false }
|
||||
},
|
||||
search() { this.query.pageIndex = 1; this.load() },
|
||||
reset() { this.$refs.queryForm.resetFields(); this.search() },
|
||||
async openDetail(row) {
|
||||
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.detail = null; this.lifecycle = null; this.activeId = row.id
|
||||
try { const [detailResponse, lifecycleResponse] = await Promise.all([getAlert(row.id), getAlertLifecycle(row.id)]); this.detail = detailResponse.data; this.lifecycle = lifecycleResponse.data.detail } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
|
||||
},
|
||||
async reloadDetail() { await this.openDetail({ id: this.activeId }); await this.load() },
|
||||
goEvent(id) { this.drawer = false; this.$router.push({ path: '/bell/events', query: { eventId: id }}) },
|
||||
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
|
||||
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' },
|
||||
statusName(value) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[value] || value },
|
||||
statusType(value) { return { open: 'danger', acknowledged: 'warning', closed: 'success' }[value] || 'info' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="page-heading">
|
||||
<div><h2>事件查询</h2><p>原始事件只读保存,规则评估和预警不会改写事件。</p></div>
|
||||
</div>
|
||||
</template>
|
||||
<el-form ref="queryForm" :model="query" :inline="true">
|
||||
<el-form-item label="事件类型" prop="eventType"><el-input v-model="query.eventType" clearable placeholder="请输入事件类型" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="风险" prop="severity">
|
||||
<el-select v-model="query.severity" clearable placeholder="全部风险" style="width: 130px">
|
||||
<el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
|
||||
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
|
||||
<el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column>
|
||||
<el-table-column prop="eventType" label="事件类型" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="location" label="地点" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="alertCount" label="关联预警" width="100" />
|
||||
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
|
||||
<template #empty><el-empty description="暂无事件" /></template>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="drawer" title="事件详情" size="min(680px, 100%)">
|
||||
<div v-loading="detailLoading">
|
||||
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
|
||||
<template v-if="selected">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="发生事项">{{ selected.eventType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地点">{{ selected.location }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="紧急程度">{{ severityName(selected.severity) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="事件来源">{{ selected.producerId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源事件编号">{{ selected.sourceEventId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证据引用">{{ selected.evidenceRef || '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3>关联预警</h3>
|
||||
<el-table :data="results.alerts" border row-key="id">
|
||||
<el-table-column prop="summary" label="预警事项" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="90"><template #default><el-tag type="danger">待处理</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="goAlert(scope.row.id)">查看</el-button></template></el-table-column>
|
||||
<template #empty><el-empty description="该事件未生成预警" /></template>
|
||||
</el-table>
|
||||
<h3>规则评估</h3>
|
||||
<el-table :data="results.evaluations" border row-key="ruleId">
|
||||
<el-table-column label="规则" min-width="160"><template #default="scope">{{ scope.row.ruleSnapshot && scope.row.ruleSnapshot.name }}</template></el-table-column>
|
||||
<el-table-column prop="ruleVersion" label="版本" width="70" />
|
||||
<el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.matched ? 'success' : 'info'">{{ scope.row.matched ? '命中' : '未命中' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="explanation" label="说明" min-width="180" />
|
||||
<template #empty><el-empty description="接收时没有启用规则" /></template>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
|
||||
|
||||
export default {
|
||||
name: 'BellEvents',
|
||||
data() {
|
||||
return {
|
||||
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
|
||||
drawer: false, selected: null, results: { alerts: [], evaluations: [] },
|
||||
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
|
||||
query: { pageIndex: 1, pageSize: 10, eventType: '', location: '', severity: '' }
|
||||
}
|
||||
},
|
||||
created() { this.load().then(() => { if (this.$route.query.eventId) this.openDetail({ id: this.$route.query.eventId }) }) },
|
||||
methods: {
|
||||
async load() {
|
||||
this.loading = true; this.error = ''
|
||||
try { const response = await listEvents(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '事件加载失败' } finally { this.loading = false }
|
||||
},
|
||||
search() { this.query.pageIndex = 1; this.load() },
|
||||
reset() { this.$refs.queryForm.resetFields(); this.search() },
|
||||
async openDetail(row) {
|
||||
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.selected = null
|
||||
try { const [eventResponse, resultResponse] = await Promise.all([getEvent(row.id), getEventRuleResults(row.id)]); this.selected = eventResponse.data; this.results = resultResponse.data } catch (error) { this.detailError = error.message || '事件详情加载失败' } finally { this.detailLoading = false }
|
||||
},
|
||||
goAlert(id) { this.drawer = false; this.$router.push({ path: '/bell/alerts', query: { alertId: id }}) },
|
||||
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
|
||||
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<template #header><div class="page-heading"><div><h2>规则配置</h2><p>规则修改会产生新版本,已有事件的命中快照不会改变。</p></div><el-button v-permisaction="['bell:rule:add']" type="primary" @click="openCreate">新增规则</el-button></div></template>
|
||||
<el-form ref="queryForm" :model="query" :inline="true">
|
||||
<el-form-item label="规则" prop="name"><el-input v-model="query.name" clearable placeholder="名称或编码" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="状态" prop="enabled"><el-select v-model="query.enabled" clearable placeholder="全部状态" style="width:130px"><el-option label="已启用" :value="true" /><el-option label="已停用" :value="false" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
|
||||
<el-table v-loading="loading" :data="items" border row-key="id">
|
||||
<el-table-column prop="name" label="规则名称" min-width="170" />
|
||||
<el-table-column prop="code" label="规则编码" min-width="150" />
|
||||
<el-table-column label="事件类型" min-width="150"><template #default="scope">{{ scope.row.eventType || '全部' }}</template></el-table-column>
|
||||
<el-table-column label="最低风险" width="100"><template #default="scope"><el-tag :type="severityType(scope.row.minimumSeverity)">{{ severityName(scope.row.minimumSeverity) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="地点包含" min-width="130"><template #default="scope">{{ scope.row.locationContains || '不限' }}</template></el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="70" />
|
||||
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canEdit" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="90"><template #default="scope"><el-button v-permisaction="['bell:rule:edit']" link type="primary" @click="openEdit(scope.row)">编辑</el-button></template></el-table-column>
|
||||
<template #empty><el-empty description="暂无规则" /></template>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialog" :title="editing ? '编辑规则' : '新增规则'" width="min(560px, calc(100vw - 32px))" :close-on-click-modal="false" @closed="clearForm">
|
||||
<el-alert title="保存后仅影响随后接收的事件,历史命中记录保持原样。" type="info" :closable="false" class="state-alert" />
|
||||
<el-form ref="ruleForm" :model="form" :rules="formRules" label-position="top">
|
||||
<el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" :disabled="editing" placeholder="如 gate-danger" /></el-form-item>
|
||||
<el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name" maxlength="128" show-word-limit /></el-form-item>
|
||||
<el-form-item label="事件类型"><el-input v-model.trim="form.eventType" placeholder="留空表示全部类型" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="最低风险" prop="minimumSeverity"><el-select v-model="form.minimumSeverity" style="width:100%"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="地点包含"><el-input v-model.trim="form.locationContains" placeholder="留空表示不限地点" maxlength="128" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
|
||||
|
||||
export default {
|
||||
name: 'BellRules',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false, error: '', items: [], total: 0, dialog: false, editing: false, editingId: '',
|
||||
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
|
||||
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null },
|
||||
form: { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' },
|
||||
formRules: {
|
||||
code: [{ required: true, pattern: /^[a-z0-9][a-z0-9_-]{1,127}$/, message: '请输入至少2位小写字母、数字、下划线或短横线', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
|
||||
minimumSeverity: [{ required: true, message: '请选择最低风险', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canEdit() { const values = this.$store.getters.permisaction || []; return values.includes('*:*:*') || values.includes('bell:rule:edit') }
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
async load() {
|
||||
this.loading = true; this.error = ''
|
||||
try { const response = await listRules(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '规则加载失败' } finally { this.loading = false }
|
||||
},
|
||||
search() { this.query.pageIndex = 1; this.load() },
|
||||
reset() { this.$refs.queryForm.resetFields(); this.query.enabled = null; this.search() },
|
||||
openCreate() { this.editing = false; this.editingId = ''; this.dialog = true },
|
||||
openEdit(row) {
|
||||
this.editing = true; this.editingId = row.id
|
||||
this.form = { code: row.code, name: row.name, eventType: row.eventType || '', minimumSeverity: row.minimumSeverity, locationContains: row.locationContains || '' }
|
||||
this.dialog = true
|
||||
},
|
||||
async save() {
|
||||
try {
|
||||
await this.$refs.ruleForm.validate(); this.saving = true
|
||||
const payload = { ...this.form, eventType: this.form.eventType || null, locationContains: this.form.locationContains || null }
|
||||
if (this.editing) await updateRule(this.editingId, payload); else await createRule(payload)
|
||||
this.msgSuccess(this.editing ? '规则已更新并生成新版本' : '规则已创建'); this.dialog = false; await this.load()
|
||||
} catch (error) { if (error && error.message) this.error = error.message } finally { this.saving = false }
|
||||
},
|
||||
async toggle(row) {
|
||||
try { await setRuleEnabled(row.id, row.enabled); this.msgSuccess(row.enabled ? '规则已启用' : '规则已停用'); await this.load() } catch (error) { row.enabled = !row.enabled; this.error = error.message || '规则状态更新失败' }
|
||||
},
|
||||
clearForm() { this.$refs.ruleForm && this.$refs.ruleForm.clearValidate(); this.form = { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' } },
|
||||
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
|
||||
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
import request from '@/utils/request'
|
||||
import { acknowledgeAlert, closeAlert, getAlertLifecycle } from '@/api/bell/alert-lifecycle'
|
||||
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
|
||||
describe('Bell alert lifecycle API', () => {
|
||||
beforeEach(() => request.mockClear())
|
||||
it('maps timeline, ack and close to protected Alert routes', async() => {
|
||||
await getAlertLifecycle('a1'); await acknowledgeAlert('a1'); await closeAlert('a1', { outcome: 'site_normal' })
|
||||
expect(request.mock.calls.map(call => call[0])).toEqual([
|
||||
{ url: '/api/v1/bell/alerts/a1/lifecycle', method: 'get' },
|
||||
{ url: '/api/v1/bell/alerts/a1/ack', method: 'post' },
|
||||
{ url: '/api/v1/bell/alerts/a1/close', method: 'post', data: { outcome: 'site_normal' }}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
jest.mock('@/api/bell/alert-lifecycle', () => ({
|
||||
acknowledgeAlert: jest.fn(),
|
||||
closeAlert: jest.fn(),
|
||||
getAlertLifecycle: jest.fn()
|
||||
}))
|
||||
|
||||
import AlertDetail from '@/views/bell/alerts/detail.vue'
|
||||
import LifecycleTimeline from '@/views/bell/alerts/components/LifecycleTimeline.vue'
|
||||
describe('Bell ordinary-user lifecycle wording', () => {
|
||||
it('maps technical states and outcomes to familiar wording', () => {
|
||||
expect(AlertDetail.methods.statusName('open')).toBe('待处理')
|
||||
expect(AlertDetail.methods.statusName('acknowledged')).toBe('处理中')
|
||||
expect(AlertDetail.methods.statusName('closed')).toBe('已完成')
|
||||
expect(AlertDetail.methods.outcomeName('false_positive')).toBe('误报')
|
||||
expect(LifecycleTimeline.methods.outcomeName('unable_to_confirm')).toBe('无法确认')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import request from '@/utils/request'
|
||||
import { getAlert, listAlerts } from '@/api/bell/alert'
|
||||
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
|
||||
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
|
||||
|
||||
describe('Bell rule-alert API adapters', () => {
|
||||
beforeEach(() => request.mockClear())
|
||||
|
||||
it('maps read operations to the versioned Bell routes', async() => {
|
||||
await listAlerts({ status: 'open' })
|
||||
await getAlert('alert-1')
|
||||
await listEvents({ severity: 'high' })
|
||||
await getEvent('event-1')
|
||||
await getEventRuleResults('event-1')
|
||||
await listRules({ enabled: true })
|
||||
|
||||
expect(request.mock.calls.map(call => call[0])).toEqual([
|
||||
{ url: '/api/v1/bell/alerts', method: 'get', params: { status: 'open' }},
|
||||
{ url: '/api/v1/bell/alerts/alert-1', method: 'get' },
|
||||
{ url: '/api/v1/bell/events', method: 'get', params: { severity: 'high' }},
|
||||
{ url: '/api/v1/bell/events/event-1', method: 'get' },
|
||||
{ url: '/api/v1/bell/events/event-1/rule-results', method: 'get' },
|
||||
{ url: '/api/v1/bell/rules', method: 'get', params: { enabled: true }}
|
||||
])
|
||||
})
|
||||
|
||||
it('maps administrator writes without exposing unrelated operations', async() => {
|
||||
const payload = { name: '区域规则', minimumSeverity: 'high' }
|
||||
await createRule(payload)
|
||||
await updateRule('rule-1', payload)
|
||||
await setRuleEnabled('rule-1', false)
|
||||
|
||||
expect(request.mock.calls.map(call => call[0])).toEqual([
|
||||
{ url: '/api/v1/bell/rules', method: 'post', data: payload },
|
||||
{ url: '/api/v1/bell/rules/rule-1', method: 'put', data: payload },
|
||||
{ url: '/api/v1/bell/rules/rule-1/enabled', method: 'put', data: { enabled: false }}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import RulesPage from '@/views/bell/rules/index.vue'
|
||||
import { listRules } from '@/api/bell/rule'
|
||||
|
||||
jest.mock('@/api/bell/rule', () => ({
|
||||
createRule: jest.fn(),
|
||||
listRules: jest.fn(),
|
||||
setRuleEnabled: jest.fn(),
|
||||
updateRule: jest.fn()
|
||||
}))
|
||||
|
||||
function pageContext() {
|
||||
return {
|
||||
loading: false,
|
||||
error: '',
|
||||
items: [],
|
||||
total: 0,
|
||||
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }
|
||||
}
|
||||
}
|
||||
|
||||
describe('Bell ordinary warning rule page', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('only enables rule changes for the GoAdmin edit permission', () => {
|
||||
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:list'] }}})).toBe(false)
|
||||
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:edit'] }}})).toBe(true)
|
||||
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['*:*:*'] }}})).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an empty list as an intentional page state', async() => {
|
||||
listRules.mockResolvedValue({ data: { list: [], count: 0 }})
|
||||
const context = pageContext()
|
||||
await RulesPage.methods.load.call(context)
|
||||
expect(context).toMatchObject({ loading: false, error: '', items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('surfaces a failed list request instead of leaving a blank page', async() => {
|
||||
listRules.mockRejectedValue(new Error('网络不可用'))
|
||||
const context = pageContext()
|
||||
await RulesPage.methods.load.call(context)
|
||||
expect(context.loading).toBe(false)
|
||||
expect(context.error).toBe('网络不可用')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
# Safe local defaults. Do not add credentials, customer data, or production paths.
|
||||
BRAIN_DEVICE=auto
|
||||
BRAIN_LOG_LEVEL=INFO
|
||||
@@ -0,0 +1 @@
|
||||
3.11.15
|
||||
@@ -0,0 +1,90 @@
|
||||
# YoVision Brain
|
||||
|
||||
Brain 是无界面的独立推理交付单元。本骨架只提供可安装 Python 包、命令入口和运行时探测;尚不包含视频、模型、规则、事件或部署能力,也不依赖 Sense、Bell 在线。
|
||||
|
||||
## 冻结基线
|
||||
|
||||
| 项目 | 版本 / 选择 |
|
||||
|---|---|
|
||||
| Python | CPython `3.11.15`(`.python-version`;本机由 uv 隔离管理) |
|
||||
| 环境与包管理 | Python `venv` + pip `26.2.1`;可用 uv `0.11.6` 取得固定 Python |
|
||||
| 构建后端 | setuptools `80.9.0` |
|
||||
| 测试 | pytest `8.4.2` |
|
||||
| 数组运行时 | NumPy `2.3.3` |
|
||||
| 推理运行时 | PyTorch `2.12.1` |
|
||||
| CPU wheel | PyTorch 官方 `https://download.pytorch.org/whl/cpu` |
|
||||
| NVIDIA wheel | PyTorch 官方 CUDA 12.6 `https://download.pytorch.org/whl/cu126` |
|
||||
|
||||
选择 Python 3.11 是因为 PyTorch 的 Windows 支持范围包含 Python 3.9–3.12,并且本机已有隔离的 CPython 3.11.15。选择 `torch 2.12.1 + cu126` 是因为 PyTorch 官方为 Linux/Windows 同时发布该固定组合;NVIDIA 的 CUDA 12.x 兼容表要求 Windows 驱动至少为 528.33,本机驱动 566.24 满足运行 CUDA 12.6 wheel 的驱动前提。
|
||||
|
||||
本机安装的 CUDA Toolkit 11.2 不参与 PyTorch wheel 构建,也不因本项目而修改。驱动满足最低版本只是兼容前提,不等于 GPU 已验证;必须以 `--smoke cuda` 的真实结果为准。
|
||||
|
||||
官方依据:
|
||||
|
||||
- [PyTorch - Start Locally](https://docs.pytorch.org/get-started/locally/)
|
||||
- [PyTorch - Previous Versions](https://pytorch.org/get-started/previous-versions/)
|
||||
- [NVIDIA CUDA 12.6 Release Notes](https://docs.nvidia.com/cuda/archive/12.6.0/cuda-toolkit-release-notes/index.html)
|
||||
- [Python 3.11.15](https://www.python.org/downloads/release/python-31115/)
|
||||
|
||||
## 创建隔离环境
|
||||
|
||||
从仓库根目录执行。无需激活虚拟环境,也不需要更改 PowerShell 执行策略:
|
||||
|
||||
```powershell
|
||||
uv python install 3.11.15
|
||||
uv venv --python 3.11.15 Brain/.venv
|
||||
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
|
||||
```
|
||||
|
||||
若不使用 uv,也可以让已安装的 Python 3.11.15 创建环境:
|
||||
|
||||
```powershell
|
||||
py -V:3.11 -m venv Brain/.venv
|
||||
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
|
||||
```
|
||||
|
||||
## 安装运行时
|
||||
|
||||
CPU 环境使用 PyTorch 官方 CPU 索引:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cpu
|
||||
```
|
||||
|
||||
目标 NVIDIA 环境使用官方 CUDA 12.6 wheel。该 wheel 自带所需 CUDA 用户态运行库,不要求把系统 Toolkit 改成 12.6:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cu126
|
||||
```
|
||||
|
||||
不要在同一环境混装 CPU 与 CUDA wheel;切换后端时重建 `.venv`。
|
||||
|
||||
## 运行和验证
|
||||
|
||||
入口的帮助与版本查询不导入 PyTorch,因此未安装运行时时也可用:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --help
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --version
|
||||
```
|
||||
|
||||
安装相应运行时后执行:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/test_package.py -q
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --runtime-info
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cpu
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cuda
|
||||
```
|
||||
|
||||
`--runtime-info` 只输出 Python、PyTorch 和设备能力,不读取或显示环境变量值。`--smoke cuda` 在 CUDA wheel、驱动或设备不可用时以非零状态退出,不会回退 CPU 后伪称成功。
|
||||
|
||||
## 配置与安全边界
|
||||
|
||||
`.env.example` 只有无秘密默认值。Brain 不接收用户会话,不持有账户、RBAC、Alert 或通知状态。不得把 token、摄像头凭据、客户数据、内部文件路径或未经授权的人脸信息写入配置、日志或事件。
|
||||
|
||||
第三方许可证与来源见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
||||
@@ -0,0 +1,17 @@
|
||||
# Third-party notices
|
||||
|
||||
本文件记录 Brain 骨架直接固定或明确依赖的第三方软件。具体安装包内许可证文本仍是最终依据。
|
||||
|
||||
| 组件 | 固定版本 | 许可证 | 来源 |
|
||||
|---|---:|---|---|
|
||||
| CPython | 3.11.15 | Python Software Foundation License | https://www.python.org/downloads/release/python-31115/ |
|
||||
| pip | 26.2.1 | MIT | https://github.com/pypa/pip/tree/26.2.1 |
|
||||
| uv(可选 Python 获取工具,不随 Brain 分发) | 0.11.6 | Apache-2.0 OR MIT | https://github.com/astral-sh/uv/tree/0.11.6 |
|
||||
| python-build-standalone(uv 管理的 Python 分发来源,不随 Brain 分发) | 2026 系列 | MPL-2.0;分发包内另含 CPython 与组件许可证 | https://github.com/astral-sh/python-build-standalone |
|
||||
| setuptools | 80.9.0 | MIT | https://github.com/pypa/setuptools/tree/v80.9.0 |
|
||||
| pytest | 8.4.2 | MIT | https://github.com/pytest-dev/pytest/tree/8.4.2 |
|
||||
| NumPy | 2.3.3 | BSD-3-Clause | https://github.com/numpy/numpy/tree/v2.3.3 |
|
||||
| PyTorch | 2.12.1 | BSD-3-Clause | https://github.com/pytorch/pytorch/tree/v2.12.1 |
|
||||
| NVIDIA CUDA runtime(随官方 PyTorch CUDA wheel 分发) | 12.6 系列 | NVIDIA CUDA Toolkit End User License Agreement | https://docs.nvidia.com/cuda/eula/index.html |
|
||||
|
||||
PyTorch 及后续模型可能带来额外第三方依赖与模型许可。本骨架未选择或分发任何模型;引入模型前必须另行核对商用、再分发、数据和输出限制,不能把框架许可证视为模型许可证。
|
||||
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools==80.9.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "yovision-brain"
|
||||
version = "0.1.0"
|
||||
description = "Headless inference delivery unit for YoVision"
|
||||
readme = "README.md"
|
||||
requires-python = "==3.11.*"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The wheel backend is selected by the official PyTorch index documented in
|
||||
# README.md. Keeping one pinned requirement here prevents CPU/CUDA drift.
|
||||
runtime = ["numpy==2.3.3", "torch==2.12.1"]
|
||||
dev = ["pytest==8.4.2"]
|
||||
|
||||
[project.scripts]
|
||||
yovision-brain = "yovision_brain.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "--strict-markers"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""YoVision Brain package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Command-line entry point for safe Brain runtime diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import platform
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from yovision_brain import __version__
|
||||
|
||||
|
||||
def _load_torch() -> Any:
|
||||
try:
|
||||
import torch
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"PyTorch runtime is not installed; install the pinned CPU or CUDA wheel "
|
||||
"from Brain/README.md"
|
||||
) from exc
|
||||
return torch
|
||||
|
||||
|
||||
def runtime_info() -> dict[str, object]:
|
||||
"""Return non-sensitive interpreter and compute-runtime facts."""
|
||||
info: dict[str, object] = {
|
||||
"python": platform.python_version(),
|
||||
"torch_installed": False,
|
||||
"torch_version": None,
|
||||
"cuda_build": None,
|
||||
"cuda_available": False,
|
||||
"cuda_device_count": 0,
|
||||
}
|
||||
try:
|
||||
torch = _load_torch()
|
||||
except RuntimeError:
|
||||
return info
|
||||
|
||||
cuda_available = bool(torch.cuda.is_available())
|
||||
info.update(
|
||||
{
|
||||
"torch_installed": True,
|
||||
"torch_version": torch.__version__,
|
||||
"cuda_build": torch.version.cuda,
|
||||
"cuda_available": cuda_available,
|
||||
"cuda_device_count": torch.cuda.device_count() if cuda_available else 0,
|
||||
}
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def smoke(device: str) -> dict[str, object]:
|
||||
"""Run a deterministic tensor operation on exactly the requested device."""
|
||||
torch = _load_torch()
|
||||
if device == "cuda" and not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA was requested but PyTorch reports no available CUDA device")
|
||||
|
||||
tensor = torch.tensor([[1.0, 2.0], [3.0, 4.0]], device=device)
|
||||
result = tensor @ tensor
|
||||
expected = torch.tensor([[7.0, 10.0], [15.0, 22.0]], device=device)
|
||||
if not torch.equal(result, expected):
|
||||
raise RuntimeError("tensor smoke result did not match the expected value")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"device": device,
|
||||
"torch_version": torch.__version__,
|
||||
"cuda_build": torch.version.cuda,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="yovision-brain",
|
||||
description="YoVision Brain runtime diagnostics",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
action = parser.add_mutually_exclusive_group()
|
||||
action.add_argument(
|
||||
"--runtime-info",
|
||||
action="store_true",
|
||||
help="print non-sensitive Python/PyTorch/CUDA capability information",
|
||||
)
|
||||
action.add_argument(
|
||||
"--smoke",
|
||||
choices=("cpu", "cuda"),
|
||||
help="run a tensor smoke test on exactly the selected device",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.runtime_info:
|
||||
payload = runtime_info()
|
||||
elif args.smoke:
|
||||
payload = smoke(args.smoke)
|
||||
else:
|
||||
build_parser().print_help()
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Brain independent vertical-slice application."""
|
||||
|
||||
from .runner import RunSummary, run_pipeline
|
||||
|
||||
__all__ = ["RunSummary", "run_pipeline"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""CLI for the isolated Brain local-event vertical slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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.rules import RuleConfigError
|
||||
|
||||
from .runner import run_pipeline
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run the isolated Brain internal-event pipeline")
|
||||
parser.add_argument("--config", required=True, help="explicit Brain-internal JSON configuration")
|
||||
parser.add_argument("--output", default="-", help="JSON Lines output file, or - for stdout")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
config_path = Path(args.config)
|
||||
try:
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
print(json.dumps({"status": "error", "message": "Brain internal config cannot be read"}), file=sys.stderr)
|
||||
return 2
|
||||
if not isinstance(raw, dict):
|
||||
print(json.dumps({"status": "error", "message": "Brain internal config must be an object"}), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
stream = sys.stdout
|
||||
owned_stream = None
|
||||
try:
|
||||
if 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)
|
||||
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
|
||||
except KeyboardInterrupt:
|
||||
print(json.dumps({"status": "cancelled"}), file=sys.stderr)
|
||||
return 130
|
||||
finally:
|
||||
if owned_stream is not None:
|
||||
owned_stream.close()
|
||||
print(json.dumps({"status": summary.status, "frames": summary.frames, "detections": summary.detections, "events": summary.events}, sort_keys=True), file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Compose input, decode, anonymous vision, rules and local event output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from yovision_brain.config import BrainInputConfig, parse_input_config
|
||||
from yovision_brain.decode import decode_packets
|
||||
from yovision_brain.events import EventSink, candidate_from_decision
|
||||
from yovision_brain.input import CancellationToken, build_input_source
|
||||
from yovision_brain.rules import (
|
||||
AreaDefinition,
|
||||
DirectionalLineDefinition,
|
||||
NormalizedPoint,
|
||||
RuleEngine,
|
||||
RuleSet,
|
||||
)
|
||||
from yovision_brain.vision import LumaBlobDetector, SingleStreamTracker, TorchLumaBlobDetector
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunSummary:
|
||||
status: str
|
||||
frames: int
|
||||
detections: int
|
||||
events: int
|
||||
|
||||
|
||||
def _rules(config: BrainInputConfig, version: str) -> RuleSet:
|
||||
return RuleSet(
|
||||
version=version,
|
||||
profile_id=config.profile.profile_id,
|
||||
width=config.profile.width,
|
||||
height=config.profile.height,
|
||||
areas=tuple(
|
||||
AreaDefinition(area.rule_id, tuple(NormalizedPoint(point.x, point.y) for point in area.points))
|
||||
for area in config.areas
|
||||
),
|
||||
directional_lines=tuple(
|
||||
DirectionalLineDefinition(
|
||||
line.rule_id,
|
||||
NormalizedPoint(line.start.x, line.start.y),
|
||||
NormalizedPoint(line.end.x, line.end.y),
|
||||
line.trigger_direction,
|
||||
)
|
||||
for line in config.directional_lines
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
raw: Mapping[str, Any],
|
||||
sink: EventSink,
|
||||
*,
|
||||
base_dir: Path | None = None,
|
||||
cancellation: CancellationToken | None = None,
|
||||
) -> RunSummary:
|
||||
input_raw = raw.get("input")
|
||||
if not isinstance(input_raw, Mapping):
|
||||
raise ValueError("input must be an object")
|
||||
config = parse_input_config(input_raw, base_dir=base_dir)
|
||||
version = raw.get("rules_version")
|
||||
if not isinstance(version, str) or not version:
|
||||
raise ValueError("rules_version must be a non-empty string")
|
||||
detector_raw = raw.get("detector", {})
|
||||
if not isinstance(detector_raw, Mapping):
|
||||
raise ValueError("detector must be an object")
|
||||
backend = detector_raw.get("backend", "python")
|
||||
threshold = detector_raw.get("threshold", 200)
|
||||
minimum_area = detector_raw.get("minimum_area", 1)
|
||||
if not isinstance(threshold, int) or not isinstance(minimum_area, int):
|
||||
raise ValueError("detector threshold and minimum_area must be integers")
|
||||
if backend == "python":
|
||||
detector = LumaBlobDetector(threshold=threshold, minimum_area=minimum_area)
|
||||
elif backend == "torch_cpu":
|
||||
detector = TorchLumaBlobDetector(threshold=threshold, minimum_area=minimum_area, device="cpu")
|
||||
else:
|
||||
raise ValueError("detector.backend must be python or torch_cpu")
|
||||
|
||||
source = build_input_source(config)
|
||||
tracker = SingleStreamTracker()
|
||||
engine = RuleEngine(_rules(config, version))
|
||||
frame_count = detection_count = event_count = 0
|
||||
for frame in decode_packets(source.packets(cancellation), cancellation):
|
||||
frame_count += 1
|
||||
detections = detector.detect(frame)
|
||||
detection_count += len(detections)
|
||||
tracks = tracker.update(detections, frame_sequence=frame.sequence, timestamp_ns=frame.timestamp_ns)
|
||||
decisions = engine.evaluate(
|
||||
tracks,
|
||||
profile_id=frame.profile_id,
|
||||
width=frame.width,
|
||||
height=frame.height,
|
||||
)
|
||||
tracks_by_id = {track.track_id: track for track in tracks}
|
||||
for decision in decisions:
|
||||
if not decision.triggered:
|
||||
continue
|
||||
sink.write(candidate_from_decision(
|
||||
decision,
|
||||
tracks_by_id[decision.track_id],
|
||||
logical_input_id=config.logical_device_id,
|
||||
detector=detector.metadata,
|
||||
))
|
||||
event_count += 1
|
||||
tracker.finish()
|
||||
status = "cancelled" if cancellation is not None and cancellation.cancelled else "completed"
|
||||
return RunSummary(status, frame_count, detection_count, event_count)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Brain-internal configuration models.
|
||||
|
||||
These types are deliberately not a cross-project contract. Adapters for a
|
||||
future versioned Sense/Brain contract belong in a coordination task.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
AreaRule,
|
||||
BrainInputConfig,
|
||||
ConfigError,
|
||||
DirectionalLineRule,
|
||||
Point,
|
||||
SourceConfig,
|
||||
VideoProfile,
|
||||
parse_input_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AreaRule",
|
||||
"BrainInputConfig",
|
||||
"ConfigError",
|
||||
"DirectionalLineRule",
|
||||
"Point",
|
||||
"SourceConfig",
|
||||
"VideoProfile",
|
||||
"parse_input_config",
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Validated project-internal input configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
INTERNAL_SCHEMA = "brain.internal.input/v1"
|
||||
_SECRET_KEYS = {"credential", "password", "secret", "token", "username"}
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when Brain's project-internal test/runtime config is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Point:
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AreaRule:
|
||||
rule_id: str
|
||||
points: tuple[Point, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectionalLineRule:
|
||||
rule_id: str
|
||||
start: Point
|
||||
end: Point
|
||||
trigger_direction: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VideoProfile:
|
||||
profile_id: str
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourceConfig:
|
||||
kind: str
|
||||
seed: int | None = None
|
||||
frame_count: int | None = None
|
||||
path: Path | None = None
|
||||
chunk_size: int = 64 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BrainInputConfig:
|
||||
schema: str
|
||||
logical_device_id: str
|
||||
profile: VideoProfile
|
||||
source: SourceConfig
|
||||
areas: tuple[AreaRule, ...] = ()
|
||||
directional_lines: tuple[DirectionalLineRule, ...] = ()
|
||||
|
||||
|
||||
def _mapping(value: object, field: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ConfigError(f"{field} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ConfigError(f"{field} must be a non-empty string")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _integer(value: object, field: str, *, minimum: int = 1) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise ConfigError(f"{field} must be an integer >= {minimum}")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, field: str, *, minimum: float = 0.0) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ConfigError(f"{field} must be a number")
|
||||
result = float(value)
|
||||
if result <= minimum:
|
||||
raise ConfigError(f"{field} must be greater than {minimum}")
|
||||
return result
|
||||
|
||||
|
||||
def _reject_secrets(value: object, field: str = "config") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
normalized = str(key).strip().lower()
|
||||
if normalized in _SECRET_KEYS or any(
|
||||
marker in normalized for marker in ("password", "secret", "token")
|
||||
):
|
||||
raise ConfigError(f"{field} must not contain credential field {key!r}")
|
||||
_reject_secrets(child, f"{field}.{key}")
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for index, child in enumerate(value):
|
||||
_reject_secrets(child, f"{field}[{index}]")
|
||||
|
||||
|
||||
def _point(value: object, field: str) -> Point:
|
||||
if (
|
||||
not isinstance(value, Sequence)
|
||||
or isinstance(value, (str, bytes, bytearray))
|
||||
or len(value) != 2
|
||||
):
|
||||
raise ConfigError(f"{field} must be [x, y]")
|
||||
x, y = value
|
||||
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
|
||||
raise ConfigError(f"{field} coordinates must be numbers")
|
||||
point = Point(float(x), float(y))
|
||||
if not 0.0 <= point.x <= 1.0 or not 0.0 <= point.y <= 1.0:
|
||||
raise ConfigError(f"{field} coordinates must be normalized to 0..1")
|
||||
return point
|
||||
|
||||
|
||||
def parse_input_config(raw: Mapping[str, Any], *, base_dir: Path | None = None) -> BrainInputConfig:
|
||||
"""Parse the explicitly versioned Brain-internal input configuration."""
|
||||
_reject_secrets(raw)
|
||||
schema = _text(raw.get("schema"), "schema")
|
||||
if schema != INTERNAL_SCHEMA:
|
||||
raise ConfigError(f"schema must be {INTERNAL_SCHEMA!r}")
|
||||
|
||||
profile_raw = _mapping(raw.get("profile"), "profile")
|
||||
profile = VideoProfile(
|
||||
profile_id=_text(profile_raw.get("id"), "profile.id"),
|
||||
width=_integer(profile_raw.get("width"), "profile.width"),
|
||||
height=_integer(profile_raw.get("height"), "profile.height"),
|
||||
fps=_number(profile_raw.get("fps"), "profile.fps"),
|
||||
)
|
||||
|
||||
source_raw = _mapping(raw.get("source"), "source")
|
||||
kind = _text(source_raw.get("kind"), "source.kind")
|
||||
if kind == "synthetic":
|
||||
seed = source_raw.get("seed", 0)
|
||||
if isinstance(seed, bool) or not isinstance(seed, int):
|
||||
raise ConfigError("source.seed must be an integer")
|
||||
source = SourceConfig(
|
||||
kind=kind,
|
||||
seed=seed,
|
||||
frame_count=_integer(source_raw.get("frame_count"), "source.frame_count"),
|
||||
)
|
||||
elif kind == "local_file":
|
||||
configured_path = Path(_text(source_raw.get("path"), "source.path"))
|
||||
if not configured_path.is_absolute() and base_dir is not None:
|
||||
configured_path = base_dir / configured_path
|
||||
source = SourceConfig(
|
||||
kind=kind,
|
||||
path=configured_path,
|
||||
chunk_size=_integer(source_raw.get("chunk_size", 64 * 1024), "source.chunk_size"),
|
||||
)
|
||||
else:
|
||||
raise ConfigError("source.kind must be 'synthetic' or 'local_file'")
|
||||
|
||||
areas_raw = raw.get("areas", [])
|
||||
if not isinstance(areas_raw, list):
|
||||
raise ConfigError("areas must be an array")
|
||||
areas: list[AreaRule] = []
|
||||
for index, item in enumerate(areas_raw):
|
||||
area = _mapping(item, f"areas[{index}]")
|
||||
points_raw = area.get("points")
|
||||
if not isinstance(points_raw, list) or len(points_raw) < 3:
|
||||
raise ConfigError(f"areas[{index}].points must contain at least three points")
|
||||
areas.append(
|
||||
AreaRule(
|
||||
rule_id=_text(area.get("id"), f"areas[{index}].id"),
|
||||
points=tuple(_point(point, f"areas[{index}].points[{point_index}]") for point_index, point in enumerate(points_raw)),
|
||||
)
|
||||
)
|
||||
|
||||
lines_raw = raw.get("directional_lines", [])
|
||||
if not isinstance(lines_raw, list):
|
||||
raise ConfigError("directional_lines must be an array")
|
||||
lines: list[DirectionalLineRule] = []
|
||||
for index, item in enumerate(lines_raw):
|
||||
line = _mapping(item, f"directional_lines[{index}]")
|
||||
direction = _text(line.get("trigger_direction"), f"directional_lines[{index}].trigger_direction")
|
||||
if direction not in {"left_to_right", "right_to_left"}:
|
||||
raise ConfigError(
|
||||
f"directional_lines[{index}].trigger_direction must be left_to_right or right_to_left"
|
||||
)
|
||||
lines.append(
|
||||
DirectionalLineRule(
|
||||
rule_id=_text(line.get("id"), f"directional_lines[{index}].id"),
|
||||
start=_point(line.get("start"), f"directional_lines[{index}].start"),
|
||||
end=_point(line.get("end"), f"directional_lines[{index}].end"),
|
||||
trigger_direction=direction,
|
||||
)
|
||||
)
|
||||
|
||||
identifiers = [area.rule_id for area in areas] + [line.rule_id for line in lines]
|
||||
if len(set(identifiers)) != len(identifiers):
|
||||
raise ConfigError("rule ids must be unique")
|
||||
|
||||
return BrainInputConfig(
|
||||
schema=schema,
|
||||
logical_device_id=_text(raw.get("logical_device_id"), "logical_device_id"),
|
||||
profile=profile,
|
||||
source=source,
|
||||
areas=tuple(areas),
|
||||
directional_lines=tuple(lines),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Replaceable Brain-internal video decode pipeline."""
|
||||
|
||||
from .models import DecodedFrame, DecoderBackend, DecoderError
|
||||
from .pipeline import DecoderPipeline, decode_packets
|
||||
from .raw_rgb import RawRGBDecoder
|
||||
from .y4m import Y4MDecoder
|
||||
|
||||
__all__ = [
|
||||
"DecodedFrame",
|
||||
"DecoderBackend",
|
||||
"DecoderError",
|
||||
"DecoderPipeline",
|
||||
"RawRGBDecoder",
|
||||
"Y4MDecoder",
|
||||
"decode_packets",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Decode-layer ports and frame model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Iterator, Protocol
|
||||
|
||||
from yovision_brain.input import CancellationToken, InputPacket
|
||||
|
||||
|
||||
class DecoderError(RuntimeError):
|
||||
"""A safe and actionable decode failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedFrame:
|
||||
sequence: int
|
||||
timestamp_ns: int
|
||||
logical_device_id: str
|
||||
profile_id: str
|
||||
width: int
|
||||
height: int
|
||||
pixel_format: str
|
||||
payload: bytes
|
||||
dimensions_changed: bool = False
|
||||
|
||||
|
||||
class DecoderBackend(Protocol):
|
||||
media_formats: frozenset[str]
|
||||
|
||||
def decode(
|
||||
self,
|
||||
packets: Iterable[InputPacket],
|
||||
cancellation: CancellationToken | None = None,
|
||||
) -> Iterator[DecodedFrame]: ...
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Decoder selection independent of concrete codec libraries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from itertools import chain
|
||||
|
||||
from yovision_brain.input import CancellationToken, InputPacket
|
||||
|
||||
from .models import DecodedFrame, DecoderBackend, DecoderError
|
||||
from .raw_rgb import RawRGBDecoder
|
||||
from .y4m import Y4MDecoder
|
||||
|
||||
|
||||
class DecoderPipeline:
|
||||
def __init__(self, backends: Iterable[DecoderBackend] | None = None) -> None:
|
||||
selected = tuple(backends) if backends is not None else (RawRGBDecoder(), Y4MDecoder())
|
||||
self._backends: dict[str, DecoderBackend] = {}
|
||||
for backend in selected:
|
||||
for media_format in backend.media_formats:
|
||||
if media_format in self._backends:
|
||||
raise ValueError(f"duplicate decoder for media format {media_format!r}")
|
||||
self._backends[media_format] = backend
|
||||
|
||||
def decode(
|
||||
self,
|
||||
packets: Iterable[InputPacket],
|
||||
cancellation: CancellationToken | None = None,
|
||||
) -> Iterator[DecodedFrame]:
|
||||
iterator = iter(packets)
|
||||
if cancellation is not None and cancellation.cancelled:
|
||||
return
|
||||
try:
|
||||
first = next(iterator)
|
||||
except StopIteration:
|
||||
return
|
||||
backend = self._backends.get(first.media_format)
|
||||
if backend is None:
|
||||
raise DecoderError(f"no decoder registered for media format {first.media_format!r}")
|
||||
yield from backend.decode(chain((first,), iterator), cancellation)
|
||||
|
||||
|
||||
def decode_packets(
|
||||
packets: Iterable[InputPacket],
|
||||
cancellation: CancellationToken | None = None,
|
||||
) -> Iterator[DecodedFrame]:
|
||||
return DecoderPipeline().decode(packets, cancellation)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pass-through decoder for deterministic RGB24 synthetic frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
from yovision_brain.input import CancellationToken, InputPacket
|
||||
|
||||
from .models import DecodedFrame, DecoderError
|
||||
|
||||
|
||||
class RawRGBDecoder:
|
||||
media_formats = frozenset({"rgb24"})
|
||||
|
||||
def decode(
|
||||
self,
|
||||
packets: Iterable[InputPacket],
|
||||
cancellation: CancellationToken | None = None,
|
||||
) -> Iterator[DecodedFrame]:
|
||||
previous_dimensions: tuple[int, int] | None = None
|
||||
for packet in packets:
|
||||
if cancellation is not None and cancellation.cancelled:
|
||||
return
|
||||
if packet.media_format != "rgb24":
|
||||
raise DecoderError(f"raw RGB decoder does not support {packet.media_format!r}")
|
||||
expected = packet.width * packet.height * 3
|
||||
if len(packet.payload) != expected:
|
||||
raise DecoderError(
|
||||
f"RGB24 frame {packet.sequence} has {len(packet.payload)} bytes; expected {expected}"
|
||||
)
|
||||
if packet.timestamp_ns is None:
|
||||
raise DecoderError(f"RGB24 frame {packet.sequence} has no source timestamp")
|
||||
dimensions = (packet.width, packet.height)
|
||||
yield DecodedFrame(
|
||||
sequence=packet.sequence,
|
||||
timestamp_ns=packet.timestamp_ns,
|
||||
logical_device_id=packet.logical_device_id,
|
||||
profile_id=packet.profile_id,
|
||||
width=packet.width,
|
||||
height=packet.height,
|
||||
pixel_format="rgb24",
|
||||
payload=packet.payload,
|
||||
dimensions_changed=previous_dimensions is not None and dimensions != previous_dimensions,
|
||||
)
|
||||
previous_dimensions = dimensions
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Minimal streaming YUV4MPEG2 decoder for anonymous local fixtures.
|
||||
|
||||
The backend intentionally supports only uncompressed C444 streams. Production
|
||||
codecs and RTSP belong behind the same decoder port in later tasks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from yovision_brain.input import CancellationToken, InputPacket
|
||||
|
||||
from .models import DecodedFrame, DecoderError
|
||||
|
||||
_MAX_HEADER_BYTES = 4096
|
||||
_MAX_FRAME_BYTES = 256 * 1024 * 1024
|
||||
|
||||
|
||||
class _Cancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _PacketReader:
|
||||
def __init__(
|
||||
self,
|
||||
packets: Iterable[InputPacket],
|
||||
cancellation: CancellationToken | None,
|
||||
) -> None:
|
||||
self._packets = iter(packets)
|
||||
self._cancellation = cancellation
|
||||
self._buffer = bytearray()
|
||||
self._ended = False
|
||||
self.first_packet: InputPacket | None = None
|
||||
|
||||
def _fill(self) -> bool:
|
||||
if self._cancellation is not None and self._cancellation.cancelled:
|
||||
raise _Cancelled
|
||||
if self._ended:
|
||||
return False
|
||||
try:
|
||||
packet = next(self._packets)
|
||||
except StopIteration:
|
||||
self._ended = True
|
||||
return False
|
||||
if packet.media_format != "container-bytes":
|
||||
raise DecoderError(f"Y4M decoder does not support {packet.media_format!r}")
|
||||
if self.first_packet is None:
|
||||
self.first_packet = packet
|
||||
else:
|
||||
first = self.first_packet
|
||||
if (packet.logical_device_id, packet.profile_id) != (
|
||||
first.logical_device_id,
|
||||
first.profile_id,
|
||||
):
|
||||
raise DecoderError("input identity changed inside one local video stream")
|
||||
self._buffer.extend(packet.payload)
|
||||
return True
|
||||
|
||||
def line(self, *, allow_clean_eof: bool = False) -> bytes | None:
|
||||
while True:
|
||||
newline = self._buffer.find(b"\n")
|
||||
if newline >= 0:
|
||||
result = bytes(self._buffer[:newline])
|
||||
del self._buffer[: newline + 1]
|
||||
return result
|
||||
if len(self._buffer) > _MAX_HEADER_BYTES:
|
||||
raise DecoderError("Y4M header exceeds the safe size limit")
|
||||
if not self._fill():
|
||||
if not self._buffer and allow_clean_eof:
|
||||
return None
|
||||
raise DecoderError("truncated Y4M header")
|
||||
|
||||
def exact(self, size: int) -> bytes:
|
||||
while len(self._buffer) < size:
|
||||
if not self._fill():
|
||||
raise DecoderError("truncated Y4M frame payload")
|
||||
result = bytes(self._buffer[:size])
|
||||
del self._buffer[:size]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Header:
|
||||
width: int
|
||||
height: int
|
||||
fps_numerator: int
|
||||
fps_denominator: int
|
||||
|
||||
|
||||
def _positive_int(value: bytes, field: str) -> int:
|
||||
try:
|
||||
result = int(value)
|
||||
except ValueError as exc:
|
||||
raise DecoderError(f"invalid Y4M {field}") from exc
|
||||
if result <= 0:
|
||||
raise DecoderError(f"invalid Y4M {field}")
|
||||
return result
|
||||
|
||||
|
||||
def _parse_header(line: bytes) -> _Header:
|
||||
parts = line.split()
|
||||
if not parts or parts[0] != b"YUV4MPEG2":
|
||||
raise DecoderError("unsupported local video format; expected YUV4MPEG2")
|
||||
fields = {part[:1]: part[1:] for part in parts[1:] if len(part) > 1}
|
||||
if fields.get(b"C", b"444") not in {b"444", b"444jpeg"}:
|
||||
raise DecoderError("unsupported Y4M chroma; only C444 is supported")
|
||||
width = _positive_int(fields.get(b"W", b""), "width")
|
||||
height = _positive_int(fields.get(b"H", b""), "height")
|
||||
fps_parts = fields.get(b"F", b"").split(b":", 1)
|
||||
if len(fps_parts) != 2:
|
||||
raise DecoderError("invalid Y4M frame rate")
|
||||
header = _Header(
|
||||
width=width,
|
||||
height=height,
|
||||
fps_numerator=_positive_int(fps_parts[0], "frame rate numerator"),
|
||||
fps_denominator=_positive_int(fps_parts[1], "frame rate denominator"),
|
||||
)
|
||||
if header.width * header.height * 3 > _MAX_FRAME_BYTES:
|
||||
raise DecoderError("Y4M frame exceeds the safe size limit")
|
||||
return header
|
||||
|
||||
|
||||
class Y4MDecoder:
|
||||
media_formats = frozenset({"container-bytes"})
|
||||
|
||||
def decode(
|
||||
self,
|
||||
packets: Iterable[InputPacket],
|
||||
cancellation: CancellationToken | None = None,
|
||||
) -> Iterator[DecodedFrame]:
|
||||
reader = _PacketReader(packets, cancellation)
|
||||
try:
|
||||
header_line = reader.line()
|
||||
assert header_line is not None
|
||||
header = _parse_header(header_line)
|
||||
first = reader.first_packet
|
||||
if first is None:
|
||||
raise DecoderError("local video input is empty")
|
||||
if (first.width, first.height) != (header.width, header.height):
|
||||
raise DecoderError(
|
||||
"Y4M dimensions do not match the configured input profile "
|
||||
f"({header.width}x{header.height} != {first.width}x{first.height})"
|
||||
)
|
||||
interval_ns = round(1_000_000_000 * header.fps_denominator / header.fps_numerator)
|
||||
frame_size = header.width * header.height * 3
|
||||
sequence = 0
|
||||
while True:
|
||||
frame_header = reader.line(allow_clean_eof=True)
|
||||
if frame_header is None:
|
||||
return
|
||||
if frame_header != b"FRAME":
|
||||
raise DecoderError(f"invalid Y4M frame header at frame {sequence}")
|
||||
payload = reader.exact(frame_size)
|
||||
yield DecodedFrame(
|
||||
sequence=sequence,
|
||||
timestamp_ns=sequence * interval_ns,
|
||||
logical_device_id=first.logical_device_id,
|
||||
profile_id=first.profile_id,
|
||||
width=header.width,
|
||||
height=header.height,
|
||||
pixel_format="yuv444p",
|
||||
payload=payload,
|
||||
)
|
||||
sequence += 1
|
||||
except _Cancelled:
|
||||
return
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Brain-internal event candidates and local sinks."""
|
||||
|
||||
from .mapper import candidate_from_decision
|
||||
from .models import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
from .sink import EventSink, JsonLinesSink
|
||||
|
||||
__all__ = [
|
||||
"EventSink",
|
||||
"INTERNAL_EVENT_SCHEMA",
|
||||
"InternalEventCandidate",
|
||||
"JsonLinesSink",
|
||||
"candidate_from_decision",
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Stable mapping from an internal rule hit to an internal event candidate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from yovision_brain.rules import RuleDecision
|
||||
from yovision_brain.vision import DetectorMetadata, TrackedObject
|
||||
|
||||
from .models import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
|
||||
|
||||
def candidate_from_decision(
|
||||
decision: RuleDecision,
|
||||
track: TrackedObject,
|
||||
*,
|
||||
logical_input_id: str,
|
||||
detector: DetectorMetadata,
|
||||
) -> InternalEventCandidate:
|
||||
if not decision.triggered or decision.track_id != track.track_id:
|
||||
raise ValueError("only a triggered decision for the same anonymous track can become an event")
|
||||
event_type = "danger_area_entered" if decision.rule_type == "danger_area" else "directional_line_crossed"
|
||||
observation: dict[str, object] = {
|
||||
"category": track.category,
|
||||
"confidence": track.confidence,
|
||||
"box": {
|
||||
"left": track.box.left,
|
||||
"top": track.box.top,
|
||||
"right": track.box.right,
|
||||
"bottom": track.box.bottom,
|
||||
},
|
||||
"anchor": {"x": decision.anchor.x, "y": decision.anchor.y},
|
||||
}
|
||||
fact = {
|
||||
"schema": INTERNAL_EVENT_SCHEMA,
|
||||
"logical_input_id": logical_input_id,
|
||||
"event_type": event_type,
|
||||
"occurred_at_ns": decision.timestamp_ns,
|
||||
"rule_id": decision.rule_id,
|
||||
"rule_version": decision.config_version,
|
||||
"model_name": detector.name,
|
||||
"model_version": detector.version,
|
||||
"profile_id": decision.profile_id,
|
||||
"frame_width": decision.width,
|
||||
"frame_height": decision.height,
|
||||
"track_id": track.track_id,
|
||||
"observation": observation,
|
||||
"reason": decision.reason,
|
||||
}
|
||||
canonical = json.dumps(fact, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
event_id = "brain-local-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
return InternalEventCandidate(event_id=event_id, **fact)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Explicitly internal event candidate model; not a shared contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
INTERNAL_EVENT_SCHEMA = "brain.internal.event-candidate/v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InternalEventCandidate:
|
||||
schema: str
|
||||
event_id: str
|
||||
logical_input_id: str
|
||||
event_type: str
|
||||
occurred_at_ns: int
|
||||
rule_id: str
|
||||
rule_version: str
|
||||
model_name: str
|
||||
model_version: str
|
||||
profile_id: str
|
||||
frame_width: int
|
||||
frame_height: int
|
||||
track_id: str
|
||||
observation: dict[str, object]
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Replaceable local event sinks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Protocol, TextIO
|
||||
|
||||
from .models import InternalEventCandidate
|
||||
|
||||
|
||||
class EventSink(Protocol):
|
||||
def write(self, candidate: InternalEventCandidate) -> None: ...
|
||||
|
||||
|
||||
class JsonLinesSink:
|
||||
def __init__(self, stream: TextIO) -> None:
|
||||
self._stream = stream
|
||||
|
||||
def write(self, candidate: InternalEventCandidate) -> None:
|
||||
self._stream.write(json.dumps(candidate.to_dict(), ensure_ascii=False, sort_keys=True) + "\n")
|
||||
self._stream.flush()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Brain-internal video input adapters."""
|
||||
|
||||
from .factory import build_input_source
|
||||
from .local_file import LocalFileInput
|
||||
from .models import CancellationToken, InputError, InputPacket, InputSource
|
||||
from .synthetic import SyntheticInput
|
||||
|
||||
__all__ = [
|
||||
"CancellationToken",
|
||||
"InputError",
|
||||
"InputPacket",
|
||||
"InputSource",
|
||||
"LocalFileInput",
|
||||
"SyntheticInput",
|
||||
"build_input_source",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Construct the configured Brain-internal input adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from yovision_brain.config import BrainInputConfig
|
||||
|
||||
from .local_file import LocalFileInput
|
||||
from .models import InputSource
|
||||
from .synthetic import SyntheticInput
|
||||
|
||||
|
||||
def build_input_source(config: BrainInputConfig) -> InputSource:
|
||||
if config.source.kind == "synthetic":
|
||||
return SyntheticInput(config)
|
||||
if config.source.kind == "local_file":
|
||||
return LocalFileInput(config)
|
||||
raise ValueError(f"unsupported Brain input source kind: {config.source.kind}")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Explicit local-file input adapter with safe error reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from yovision_brain.config import BrainInputConfig
|
||||
|
||||
from .models import CancellationToken, InputError, InputPacket
|
||||
|
||||
|
||||
class LocalFileInput:
|
||||
def __init__(self, config: BrainInputConfig) -> None:
|
||||
if config.source.kind != "local_file" or config.source.path is None:
|
||||
raise ValueError("LocalFileInput requires a local_file source config")
|
||||
self._config = config
|
||||
self._path = config.source.path
|
||||
|
||||
@property
|
||||
def source_label(self) -> str:
|
||||
"""Return a safe label rather than exposing the internal absolute path."""
|
||||
return self._path.name
|
||||
|
||||
def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]:
|
||||
profile = self._config.profile
|
||||
source = self._config.source
|
||||
try:
|
||||
stream = self._path.open("rb")
|
||||
except FileNotFoundError as exc:
|
||||
raise InputError(f"local video source not found: {self.source_label}") from exc
|
||||
except OSError as exc:
|
||||
raise InputError(f"local video source cannot be opened: {self.source_label}: {exc.strerror}") from exc
|
||||
|
||||
with stream:
|
||||
sequence = 0
|
||||
while cancellation is None or not cancellation.cancelled:
|
||||
try:
|
||||
payload = stream.read(source.chunk_size)
|
||||
except OSError as exc:
|
||||
raise InputError(f"local video source read failed: {self.source_label}: {exc.strerror}") from exc
|
||||
if not payload:
|
||||
return
|
||||
yield InputPacket(
|
||||
sequence=sequence,
|
||||
timestamp_ns=None,
|
||||
logical_device_id=self._config.logical_device_id,
|
||||
profile_id=profile.profile_id,
|
||||
width=profile.width,
|
||||
height=profile.height,
|
||||
media_format="container-bytes",
|
||||
payload=payload,
|
||||
)
|
||||
sequence += 1
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Common project-internal input types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from threading import Event
|
||||
from typing import Iterator, Protocol
|
||||
|
||||
|
||||
class InputError(RuntimeError):
|
||||
"""A safe, actionable input adapter error."""
|
||||
|
||||
|
||||
class CancellationToken:
|
||||
"""Thread-safe cooperative cancellation without platform dependencies."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event = Event()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InputPacket:
|
||||
sequence: int
|
||||
timestamp_ns: int | None
|
||||
logical_device_id: str
|
||||
profile_id: str
|
||||
width: int
|
||||
height: int
|
||||
media_format: str
|
||||
payload: bytes
|
||||
|
||||
|
||||
class InputSource(Protocol):
|
||||
"""Replaceable source boundary consumed by the future decode layer."""
|
||||
|
||||
def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]: ...
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Deterministic synthetic RGB input for isolated tests and smoke runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Iterator
|
||||
|
||||
from yovision_brain.config import BrainInputConfig
|
||||
|
||||
from .models import CancellationToken, InputPacket
|
||||
|
||||
|
||||
class SyntheticInput:
|
||||
def __init__(self, config: BrainInputConfig) -> None:
|
||||
if config.source.kind != "synthetic":
|
||||
raise ValueError("SyntheticInput requires a synthetic source config")
|
||||
self._config = config
|
||||
|
||||
def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]:
|
||||
source = self._config.source
|
||||
assert source.seed is not None and source.frame_count is not None
|
||||
randomizer = random.Random(source.seed)
|
||||
profile = self._config.profile
|
||||
frame_size = profile.width * profile.height * 3
|
||||
interval_ns = round(1_000_000_000 / profile.fps)
|
||||
for sequence in range(source.frame_count):
|
||||
if cancellation is not None and cancellation.cancelled:
|
||||
return
|
||||
yield InputPacket(
|
||||
sequence=sequence,
|
||||
timestamp_ns=sequence * interval_ns,
|
||||
logical_device_id=self._config.logical_device_id,
|
||||
profile_id=profile.profile_id,
|
||||
width=profile.width,
|
||||
height=profile.height,
|
||||
media_format="rgb24",
|
||||
payload=randomizer.randbytes(frame_size),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Brain-internal anonymous area and directional-line rules."""
|
||||
|
||||
from .engine import RuleEngine
|
||||
from .models import (
|
||||
AreaDefinition,
|
||||
DirectionalLineDefinition,
|
||||
NormalizedPoint,
|
||||
RuleConfigError,
|
||||
RuleDecision,
|
||||
RuleSet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AreaDefinition",
|
||||
"DirectionalLineDefinition",
|
||||
"NormalizedPoint",
|
||||
"RuleConfigError",
|
||||
"RuleDecision",
|
||||
"RuleEngine",
|
||||
"RuleSet",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Stateful, explainable area and directional-line evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from yovision_brain.vision import TrackedObject
|
||||
|
||||
from .models import NormalizedPoint, RuleConfigError, RuleDecision, RuleSet
|
||||
|
||||
_EPSILON = 1e-9
|
||||
|
||||
|
||||
def _anchor(track: TrackedObject, width: int, height: int) -> NormalizedPoint:
|
||||
x = (track.box.left + track.box.right) / (2.0 * width)
|
||||
y = track.box.bottom / height
|
||||
try:
|
||||
return NormalizedPoint(x, y)
|
||||
except RuleConfigError as exc:
|
||||
raise RuleConfigError(f"track {track.track_id!r} anchor is outside the configured frame") from exc
|
||||
|
||||
|
||||
def _on_segment(point: NormalizedPoint, first: NormalizedPoint, second: NormalizedPoint) -> bool:
|
||||
cross = (second.x - first.x) * (point.y - first.y) - (second.y - first.y) * (point.x - first.x)
|
||||
return abs(cross) <= _EPSILON and min(first.x, second.x) - _EPSILON <= point.x <= max(first.x, second.x) + _EPSILON and min(first.y, second.y) - _EPSILON <= point.y <= max(first.y, second.y) + _EPSILON
|
||||
|
||||
|
||||
def _inside(point: NormalizedPoint, polygon: tuple[NormalizedPoint, ...]) -> bool:
|
||||
inside = False
|
||||
previous = polygon[-1]
|
||||
for current in polygon:
|
||||
if _on_segment(point, previous, current):
|
||||
return True
|
||||
if (current.y > point.y) != (previous.y > point.y):
|
||||
crossing_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
|
||||
if point.x < crossing_x:
|
||||
inside = not inside
|
||||
previous = current
|
||||
return inside
|
||||
|
||||
|
||||
def _side(point: NormalizedPoint, start: NormalizedPoint, end: NormalizedPoint) -> float:
|
||||
return (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)
|
||||
|
||||
|
||||
class RuleEngine:
|
||||
"""Evaluates one versioned rule set against one stream session."""
|
||||
|
||||
def __init__(self, rules: RuleSet) -> None:
|
||||
self._rules = rules
|
||||
self._area_inside: dict[tuple[str, str], bool] = {}
|
||||
self._line_side: dict[tuple[str, str], int] = {}
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
tracks: tuple[TrackedObject, ...],
|
||||
*,
|
||||
profile_id: str,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[RuleDecision, ...]:
|
||||
if (profile_id, width, height) != (self._rules.profile_id, self._rules.width, self._rules.height):
|
||||
raise RuleConfigError("track Profile/resolution does not match the versioned rule configuration")
|
||||
decisions: list[RuleDecision] = []
|
||||
for track in tracks:
|
||||
anchor = _anchor(track, width, height)
|
||||
common = dict(
|
||||
track_id=track.track_id,
|
||||
config_version=self._rules.version,
|
||||
profile_id=profile_id,
|
||||
width=width,
|
||||
height=height,
|
||||
anchor=anchor,
|
||||
timestamp_ns=track.timestamp_ns,
|
||||
)
|
||||
for area in self._rules.areas:
|
||||
key = (track.track_id, area.rule_id)
|
||||
current = _inside(anchor, area.points)
|
||||
previous = self._area_inside.get(key, False)
|
||||
state = "entered" if current and not previous else "inside" if current else "outside"
|
||||
self._area_inside[key] = current
|
||||
decisions.append(RuleDecision(
|
||||
rule_id=area.rule_id,
|
||||
rule_type="danger_area",
|
||||
state=state,
|
||||
triggered=state == "entered",
|
||||
reason=f"bottom-center anchor is {state} the configured polygon",
|
||||
**common,
|
||||
))
|
||||
for line in self._rules.directional_lines:
|
||||
key = (track.track_id, line.rule_id)
|
||||
value = _side(anchor, line.start, line.end)
|
||||
if abs(value) <= line.deadband:
|
||||
decisions.append(RuleDecision(
|
||||
rule_id=line.rule_id, rule_type="directional_line", state="on_line",
|
||||
triggered=False, reason="anchor is inside the line deadband; previous significant side is retained",
|
||||
**common,
|
||||
))
|
||||
continue
|
||||
current_side = 1 if value > 0 else -1
|
||||
previous_side = self._line_side.get(key)
|
||||
self._line_side[key] = current_side
|
||||
wanted = (previous_side, current_side) == ((1, -1) if line.trigger_direction == "left_to_right" else (-1, 1))
|
||||
crossed = previous_side is not None and previous_side != current_side
|
||||
state = "triggered" if wanted else "reverse_crossing" if crossed else "same_side"
|
||||
decisions.append(RuleDecision(
|
||||
rule_id=line.rule_id,
|
||||
rule_type="directional_line",
|
||||
state=state,
|
||||
triggered=wanted,
|
||||
reason=f"directed side transition {previous_side!r}->{current_side}; expected {line.trigger_direction}",
|
||||
**common,
|
||||
))
|
||||
return tuple(decisions)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Versioned Brain-internal rule configuration and decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class RuleConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedPoint:
|
||||
x: float
|
||||
y: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.x <= 1.0 or not 0.0 <= self.y <= 1.0:
|
||||
raise RuleConfigError("rule coordinates must be normalized to 0..1")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AreaDefinition:
|
||||
rule_id: str
|
||||
points: tuple[NormalizedPoint, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectionalLineDefinition:
|
||||
rule_id: str
|
||||
start: NormalizedPoint
|
||||
end: NormalizedPoint
|
||||
trigger_direction: str
|
||||
deadband: float = 0.005
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuleSet:
|
||||
version: str
|
||||
profile_id: str
|
||||
width: int
|
||||
height: int
|
||||
areas: tuple[AreaDefinition, ...] = ()
|
||||
directional_lines: tuple[DirectionalLineDefinition, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.version or not self.profile_id or self.width <= 0 or self.height <= 0:
|
||||
raise RuleConfigError("rule version, profile and dimensions are required")
|
||||
identifiers = [rule.rule_id for rule in self.areas] + [rule.rule_id for rule in self.directional_lines]
|
||||
if any(not identifier for identifier in identifiers) or len(set(identifiers)) != len(identifiers):
|
||||
raise RuleConfigError("rule ids must be non-empty and unique")
|
||||
for area in self.areas:
|
||||
if len(area.points) < 3 or abs(_polygon_area(area.points)) < 1e-9:
|
||||
raise RuleConfigError(f"area {area.rule_id!r} must be a non-degenerate polygon")
|
||||
for line in self.directional_lines:
|
||||
if line.start == line.end:
|
||||
raise RuleConfigError(f"line {line.rule_id!r} must have distinct endpoints")
|
||||
if line.trigger_direction not in {"left_to_right", "right_to_left"}:
|
||||
raise RuleConfigError(f"line {line.rule_id!r} has invalid trigger direction")
|
||||
if not 0.0 <= line.deadband < 0.5:
|
||||
raise RuleConfigError(f"line {line.rule_id!r} has invalid deadband")
|
||||
|
||||
|
||||
def _polygon_area(points: tuple[NormalizedPoint, ...]) -> float:
|
||||
return sum(
|
||||
first.x * second.y - second.x * first.y
|
||||
for first, second in zip(points, points[1:] + points[:1])
|
||||
) / 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuleDecision:
|
||||
rule_id: str
|
||||
rule_type: str
|
||||
track_id: str
|
||||
state: str
|
||||
triggered: bool
|
||||
reason: str
|
||||
config_version: str
|
||||
profile_id: str
|
||||
width: int
|
||||
height: int
|
||||
anchor: NormalizedPoint
|
||||
timestamp_ns: int
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Anonymous detection and single-stream tracking."""
|
||||
|
||||
from .detector import LumaBlobDetector, TorchLumaBlobDetector
|
||||
from .models import BoundingBox, Detection, Detector, DetectorMetadata, TrackedObject
|
||||
from .tracker import SingleStreamTracker
|
||||
|
||||
__all__ = [
|
||||
"BoundingBox",
|
||||
"Detection",
|
||||
"Detector",
|
||||
"DetectorMetadata",
|
||||
"LumaBlobDetector",
|
||||
"SingleStreamTracker",
|
||||
"TorchLumaBlobDetector",
|
||||
"TrackedObject",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Deterministic anonymous blob detectors with no biometric semantics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from yovision_brain.decode import DecodedFrame, DecoderError
|
||||
|
||||
from .models import BoundingBox, Detection, DetectorMetadata
|
||||
|
||||
_METADATA = DetectorMetadata(
|
||||
name="yovision-luma-blob",
|
||||
version="1.0.0",
|
||||
source="YoVision Brain first-party deterministic algorithm",
|
||||
license="No external model license; no learned weights are distributed",
|
||||
weights="none",
|
||||
)
|
||||
|
||||
|
||||
def _components(mask: Sequence[Sequence[bool]], minimum_area: int) -> tuple[BoundingBox, ...]:
|
||||
height = len(mask)
|
||||
width = len(mask[0]) if height else 0
|
||||
visited: set[tuple[int, int]] = set()
|
||||
boxes: list[BoundingBox] = []
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if not mask[y][x] or (x, y) in visited:
|
||||
continue
|
||||
pending = [(x, y)]
|
||||
visited.add((x, y))
|
||||
points: list[tuple[int, int]] = []
|
||||
while pending:
|
||||
current_x, current_y = pending.pop()
|
||||
points.append((current_x, current_y))
|
||||
for neighbor in (
|
||||
(current_x - 1, current_y),
|
||||
(current_x + 1, current_y),
|
||||
(current_x, current_y - 1),
|
||||
(current_x, current_y + 1),
|
||||
):
|
||||
nx, ny = neighbor
|
||||
if 0 <= nx < width and 0 <= ny < height and mask[ny][nx] and neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
pending.append(neighbor)
|
||||
if len(points) >= minimum_area:
|
||||
xs, ys = zip(*points)
|
||||
boxes.append(BoundingBox(min(xs), min(ys), max(xs) + 1, max(ys) + 1))
|
||||
return tuple(sorted(boxes, key=lambda box: (box.top, box.left, box.bottom, box.right)))
|
||||
|
||||
|
||||
def _validate_frame(frame: DecodedFrame) -> None:
|
||||
if frame.pixel_format not in {"rgb24", "yuv444p"}:
|
||||
raise DecoderError(f"anonymous detector does not support pixel format {frame.pixel_format!r}")
|
||||
expected = frame.width * frame.height * 3
|
||||
if len(frame.payload) != expected:
|
||||
raise DecoderError(f"vision frame has {len(frame.payload)} bytes; expected {expected}")
|
||||
|
||||
|
||||
class LumaBlobDetector:
|
||||
"""Small CPU reference detector used for deterministic integration tests."""
|
||||
|
||||
metadata = _METADATA
|
||||
|
||||
def __init__(self, *, threshold: int = 200, minimum_area: int = 1) -> None:
|
||||
if not 0 <= threshold <= 255 or minimum_area < 1:
|
||||
raise ValueError("invalid luma detector threshold or minimum area")
|
||||
self._threshold = threshold
|
||||
self._minimum_area = minimum_area
|
||||
|
||||
def detect(self, frame: DecodedFrame) -> tuple[Detection, ...]:
|
||||
_validate_frame(frame)
|
||||
if frame.pixel_format == "rgb24":
|
||||
pixels = [
|
||||
max(frame.payload[index : index + 3])
|
||||
for index in range(0, len(frame.payload), 3)
|
||||
]
|
||||
else:
|
||||
pixels = list(frame.payload[: frame.width * frame.height])
|
||||
mask = [
|
||||
[pixels[y * frame.width + x] >= self._threshold for x in range(frame.width)]
|
||||
for y in range(frame.height)
|
||||
]
|
||||
return tuple(
|
||||
Detection(box=box, category="anonymous_target", confidence=1.0)
|
||||
for box in _components(mask, self._minimum_area)
|
||||
)
|
||||
|
||||
|
||||
class TorchLumaBlobDetector:
|
||||
"""PyTorch CPU/GPU smoke backend; it contains no external model weights."""
|
||||
|
||||
metadata = DetectorMetadata(
|
||||
name="yovision-torch-luma-blob",
|
||||
version="1.0.0",
|
||||
source="YoVision Brain first-party PyTorch tensor implementation",
|
||||
license="PyTorch BSD-3-Clause; no external model weights",
|
||||
weights="none",
|
||||
)
|
||||
|
||||
def __init__(self, *, threshold: int = 200, minimum_area: int = 1, device: str = "cpu") -> None:
|
||||
self._threshold = threshold
|
||||
self._minimum_area = minimum_area
|
||||
self._device = device
|
||||
|
||||
def detect(self, frame: DecodedFrame) -> tuple[Detection, ...]:
|
||||
_validate_frame(frame)
|
||||
try:
|
||||
import torch
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("PyTorch runtime is required for TorchLumaBlobDetector") from exc
|
||||
values = torch.tensor(list(frame.payload), dtype=torch.uint8, device=self._device)
|
||||
if frame.pixel_format == "rgb24":
|
||||
luma = values.reshape(frame.height, frame.width, 3).amax(dim=2)
|
||||
else:
|
||||
luma = values[: frame.width * frame.height].reshape(frame.height, frame.width)
|
||||
mask = (luma >= self._threshold).cpu().tolist()
|
||||
return tuple(
|
||||
Detection(box=box, category="anonymous_target", confidence=1.0)
|
||||
for box in _components(mask, self._minimum_area)
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Privacy-preserving vision ports and observations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from yovision_brain.decode import DecodedFrame
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DetectorMetadata:
|
||||
name: str
|
||||
version: str
|
||||
source: str
|
||||
license: str
|
||||
weights: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BoundingBox:
|
||||
left: int
|
||||
top: int
|
||||
right: int
|
||||
bottom: int
|
||||
|
||||
@property
|
||||
def area(self) -> int:
|
||||
return max(0, self.right - self.left) * max(0, self.bottom - self.top)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Detection:
|
||||
box: BoundingBox
|
||||
category: str
|
||||
confidence: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackedObject:
|
||||
track_id: str
|
||||
box: BoundingBox
|
||||
category: str
|
||||
confidence: float
|
||||
frame_sequence: int
|
||||
timestamp_ns: int
|
||||
|
||||
|
||||
class Detector(Protocol):
|
||||
metadata: DetectorMetadata
|
||||
|
||||
def detect(self, frame: DecodedFrame) -> tuple[Detection, ...]: ...
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Session-local single-stream IoU tracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import BoundingBox, Detection, TrackedObject
|
||||
|
||||
|
||||
def _iou(first: BoundingBox, second: BoundingBox) -> float:
|
||||
intersection = BoundingBox(
|
||||
max(first.left, second.left),
|
||||
max(first.top, second.top),
|
||||
min(first.right, second.right),
|
||||
min(first.bottom, second.bottom),
|
||||
).area
|
||||
union = first.area + second.area - intersection
|
||||
return intersection / union if union else 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Track:
|
||||
track_id: str
|
||||
detection: Detection
|
||||
missed: int = 0
|
||||
|
||||
|
||||
class SingleStreamTracker:
|
||||
"""Tracks anonymous boxes only within one process session and one stream."""
|
||||
|
||||
def __init__(self, *, iou_threshold: float = 0.2, max_missed: int = 2) -> None:
|
||||
if not 0.0 <= iou_threshold <= 1.0 or max_missed < 0:
|
||||
raise ValueError("invalid tracker threshold or missed-frame limit")
|
||||
self._iou_threshold = iou_threshold
|
||||
self._max_missed = max_missed
|
||||
self._tracks: dict[str, _Track] = {}
|
||||
self._next_id = 1
|
||||
|
||||
def update(
|
||||
self,
|
||||
detections: tuple[Detection, ...],
|
||||
*,
|
||||
frame_sequence: int,
|
||||
timestamp_ns: int,
|
||||
) -> tuple[TrackedObject, ...]:
|
||||
unmatched_tracks = set(self._tracks)
|
||||
results: list[TrackedObject] = []
|
||||
for detection in detections:
|
||||
candidates = [
|
||||
(track_id, _iou(self._tracks[track_id].detection.box, detection.box))
|
||||
for track_id in unmatched_tracks
|
||||
if self._tracks[track_id].detection.category == detection.category
|
||||
]
|
||||
track_id, score = max(candidates, key=lambda item: item[1], default=("", -1.0))
|
||||
if score < self._iou_threshold:
|
||||
track_id = f"track-{self._next_id:06d}"
|
||||
self._next_id += 1
|
||||
self._tracks[track_id] = _Track(track_id, detection)
|
||||
else:
|
||||
unmatched_tracks.remove(track_id)
|
||||
self._tracks[track_id].detection = detection
|
||||
self._tracks[track_id].missed = 0
|
||||
results.append(
|
||||
TrackedObject(
|
||||
track_id=track_id,
|
||||
box=detection.box,
|
||||
category=detection.category,
|
||||
confidence=detection.confidence,
|
||||
frame_sequence=frame_sequence,
|
||||
timestamp_ns=timestamp_ns,
|
||||
)
|
||||
)
|
||||
for track_id in unmatched_tracks:
|
||||
track = self._tracks[track_id]
|
||||
track.missed += 1
|
||||
if track.missed > self._max_missed:
|
||||
del self._tracks[track_id]
|
||||
return tuple(results)
|
||||
|
||||
def finish(self) -> tuple[str, ...]:
|
||||
ended = tuple(sorted(self._tracks))
|
||||
self._tracks.clear()
|
||||
return ended
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from yovision_brain.app import run_pipeline
|
||||
from yovision_brain.events import JsonLinesSink
|
||||
from yovision_brain.input import CancellationToken
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "events" / "area.json"
|
||||
|
||||
|
||||
def test_pipeline_generates_stable_internal_event() -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
first_stream, second_stream = io.StringIO(), io.StringIO()
|
||||
first = run_pipeline(raw, JsonLinesSink(first_stream), base_dir=FIXTURE.parent)
|
||||
second = run_pipeline(raw, JsonLinesSink(second_stream), base_dir=FIXTURE.parent)
|
||||
assert first.events == second.events == 1
|
||||
assert first_stream.getvalue() == second_stream.getvalue()
|
||||
event = json.loads(first_stream.getvalue())
|
||||
assert event["event_type"] == "danger_area_entered"
|
||||
assert event["schema"] == "brain.internal.event-candidate/v1"
|
||||
|
||||
|
||||
def test_no_hit_has_explicit_zero_event_summary() -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
raw["detector"]["threshold"] = 255
|
||||
raw["detector"]["minimum_area"] = 1000
|
||||
stream = io.StringIO()
|
||||
summary = run_pipeline(raw, JsonLinesSink(stream))
|
||||
assert (summary.status, summary.events, stream.getvalue()) == ("completed", 0, "")
|
||||
|
||||
|
||||
def test_cli_runs_without_sense_or_bell() -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "yovision_brain.app", "--config", str(FIXTURE), "--output", "-"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert json.loads(result.stdout)["schema"] == "brain.internal.event-candidate/v1"
|
||||
assert json.loads(result.stderr)["events"] == 1
|
||||
|
||||
|
||||
def test_pre_cancelled_run_is_explicit() -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
token = CancellationToken()
|
||||
token.cancel()
|
||||
summary = run_pipeline(raw, JsonLinesSink(io.StringIO()), cancellation=token)
|
||||
assert (summary.status, summary.frames, summary.events) == ("cancelled", 0, 0)
|
||||
|
||||
|
||||
def test_cli_config_failure_is_nonzero_and_does_not_echo_path(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "private-machine-path.json"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "yovision_brain.app", "--config", str(missing)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert json.loads(result.stderr)["status"] == "error"
|
||||
assert str(tmp_path) not in result.stderr
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yovision_brain.config import ConfigError, parse_input_config
|
||||
|
||||
|
||||
def valid_config() -> dict[str, object]:
|
||||
return {
|
||||
"schema": "brain.internal.input/v1",
|
||||
"logical_device_id": "synthetic-camera-01",
|
||||
"profile": {"id": "main", "width": 4, "height": 3, "fps": 5},
|
||||
"source": {"kind": "synthetic", "seed": 17, "frame_count": 3},
|
||||
"areas": [{"id": "danger-yard", "points": [[0.1, 0.1], [0.9, 0.1], [0.5, 0.8]]}],
|
||||
"directional_lines": [
|
||||
{
|
||||
"id": "gate-line",
|
||||
"start": [0.2, 0.5],
|
||||
"end": [0.8, 0.5],
|
||||
"trigger_direction": "left_to_right",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_versioned_internal_config() -> None:
|
||||
parsed = parse_input_config(valid_config())
|
||||
assert parsed.schema == "brain.internal.input/v1"
|
||||
assert parsed.logical_device_id == "synthetic-camera-01"
|
||||
assert parsed.profile.width == 4
|
||||
assert parsed.areas[0].rule_id == "danger-yard"
|
||||
assert parsed.directional_lines[0].trigger_direction == "left_to_right"
|
||||
|
||||
|
||||
def test_relative_local_path_is_bound_to_explicit_base(tmp_path: Path) -> None:
|
||||
raw = valid_config()
|
||||
raw["source"] = {"kind": "local_file", "path": "fixture.bin", "chunk_size": 8}
|
||||
parsed = parse_input_config(raw, base_dir=tmp_path)
|
||||
assert parsed.source.path == tmp_path / "fixture.bin"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "message"),
|
||||
[
|
||||
({"schema": "shared.source/v1"}, "schema must be"),
|
||||
({"logical_device_id": ""}, "logical_device_id"),
|
||||
({"source": {"kind": "synthetic", "seed": 1, "frame_count": 0}}, "frame_count"),
|
||||
({"password": "must-not-be-accepted"}, "credential field"),
|
||||
],
|
||||
)
|
||||
def test_invalid_or_secret_config_is_rejected(change: dict[str, object], message: str) -> None:
|
||||
raw = valid_config()
|
||||
raw.update(change)
|
||||
with pytest.raises(ConfigError, match=message):
|
||||
parse_input_config(raw)
|
||||
|
||||
|
||||
def test_coordinates_and_rule_ids_are_validated() -> None:
|
||||
raw = valid_config()
|
||||
raw["areas"] = [{"id": "same", "points": [[0, 0], [2, 0], [0, 1]]}]
|
||||
with pytest.raises(ConfigError, match="normalized"):
|
||||
parse_input_config(raw)
|
||||
|
||||
raw = valid_config()
|
||||
raw["areas"] = [{"id": "same", "points": [[0, 0], [1, 0], [0, 1]]}]
|
||||
raw["directional_lines"] = [
|
||||
{"id": "same", "start": [0, 0], "end": [1, 1], "trigger_direction": "left_to_right"}
|
||||
]
|
||||
with pytest.raises(ConfigError, match="unique"):
|
||||
parse_input_config(raw)
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yovision_brain.config import parse_input_config
|
||||
from yovision_brain.decode import DecoderError, DecoderPipeline, decode_packets
|
||||
from yovision_brain.input import CancellationToken, InputPacket, LocalFileInput, SyntheticInput
|
||||
|
||||
|
||||
def synthetic_packets():
|
||||
config = parse_input_config(
|
||||
{
|
||||
"schema": "brain.internal.input/v1",
|
||||
"logical_device_id": "synthetic-01",
|
||||
"profile": {"id": "main", "width": 2, "height": 1, "fps": 5},
|
||||
"source": {"kind": "synthetic", "seed": 3, "frame_count": 2},
|
||||
}
|
||||
)
|
||||
return SyntheticInput(config).packets()
|
||||
|
||||
|
||||
def local_packets(path: Path, *, width: int = 2, height: int = 1, chunk_size: int = 5):
|
||||
config = parse_input_config(
|
||||
{
|
||||
"schema": "brain.internal.input/v1",
|
||||
"logical_device_id": "local-01",
|
||||
"profile": {"id": "archive", "width": width, "height": height, "fps": 25},
|
||||
"source": {"kind": "local_file", "path": str(path), "chunk_size": chunk_size},
|
||||
}
|
||||
)
|
||||
return LocalFileInput(config).packets()
|
||||
|
||||
|
||||
def test_rgb24_pipeline_preserves_order_timestamps_and_metadata() -> None:
|
||||
frames = list(decode_packets(synthetic_packets()))
|
||||
assert [frame.sequence for frame in frames] == [0, 1]
|
||||
assert [frame.timestamp_ns for frame in frames] == [0, 200_000_000]
|
||||
assert all(frame.logical_device_id == "synthetic-01" for frame in frames)
|
||||
assert all(frame.profile_id == "main" for frame in frames)
|
||||
assert all((frame.width, frame.height, frame.pixel_format) == (2, 1, "rgb24") for frame in frames)
|
||||
|
||||
|
||||
def test_rgb24_dimension_change_is_explicit() -> None:
|
||||
packets = [
|
||||
InputPacket(0, 0, "camera", "main", 1, 1, "rgb24", b"abc"),
|
||||
InputPacket(1, 1, "camera", "main", 2, 1, "rgb24", b"abcdef"),
|
||||
]
|
||||
frames = list(decode_packets(packets))
|
||||
assert [frame.dimensions_changed for frame in frames] == [False, True]
|
||||
|
||||
|
||||
def test_invalid_rgb_payload_and_unsupported_format_are_clear() -> None:
|
||||
bad = [InputPacket(0, 0, "camera", "main", 2, 2, "rgb24", b"short")]
|
||||
with pytest.raises(DecoderError, match="expected 12"):
|
||||
list(decode_packets(bad))
|
||||
unknown = [InputPacket(0, 0, "camera", "main", 1, 1, "opaque", b"data")]
|
||||
with pytest.raises(DecoderError, match="no decoder registered"):
|
||||
list(DecoderPipeline().decode(unknown))
|
||||
|
||||
|
||||
def test_y4m_local_video_decodes_across_input_chunks(tmp_path: Path) -> None:
|
||||
video = tmp_path / "anonymous.y4m"
|
||||
first, second = b"abcdef", b"ghijkl"
|
||||
video.write_bytes(b"YUV4MPEG2 W2 H1 F25:1 C444\nFRAME\n" + first + b"FRAME\n" + second)
|
||||
frames = list(decode_packets(local_packets(video)))
|
||||
assert [frame.payload for frame in frames] == [first, second]
|
||||
assert [frame.timestamp_ns for frame in frames] == [0, 40_000_000]
|
||||
assert all(frame.pixel_format == "yuv444p" for frame in frames)
|
||||
assert all((frame.width, frame.height) == (2, 1) for frame in frames)
|
||||
assert all(frame.profile_id == "archive" for frame in frames)
|
||||
|
||||
|
||||
def test_y4m_clean_eof_and_cancellation_are_normal(tmp_path: Path) -> None:
|
||||
video = tmp_path / "empty.y4m"
|
||||
video.write_bytes(b"YUV4MPEG2 W2 H1 F25:1 C444\n")
|
||||
assert list(decode_packets(local_packets(video))) == []
|
||||
|
||||
token = CancellationToken()
|
||||
token.cancel()
|
||||
assert list(decode_packets(local_packets(video), token)) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "message"),
|
||||
[
|
||||
(b"not-video\n", "expected YUV4MPEG2"),
|
||||
(b"YUV4MPEG2 W2 H1 F25:1 C420\n", "only C444"),
|
||||
(b"YUV4MPEG2 W2 H1 F25:1 C444\nFRAME\nabc", "truncated Y4M frame"),
|
||||
],
|
||||
)
|
||||
def test_y4m_damage_and_unsupported_content_are_clear(tmp_path: Path, payload: bytes, message: str) -> None:
|
||||
video = tmp_path / "broken.y4m"
|
||||
video.write_bytes(payload)
|
||||
with pytest.raises(DecoderError, match=message):
|
||||
list(decode_packets(local_packets(video)))
|
||||
|
||||
|
||||
def test_y4m_profile_dimension_mismatch_is_rejected(tmp_path: Path) -> None:
|
||||
video = tmp_path / "mismatch.y4m"
|
||||
video.write_bytes(b"YUV4MPEG2 W2 H1 F25:1 C444\n")
|
||||
with pytest.raises(DecoderError, match="do not match"):
|
||||
list(decode_packets(local_packets(video, width=3)))
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
from yovision_brain.events import JsonLinesSink, candidate_from_decision
|
||||
from yovision_brain.rules import NormalizedPoint, RuleDecision
|
||||
from yovision_brain.vision import BoundingBox, DetectorMetadata, TrackedObject
|
||||
|
||||
|
||||
def test_internal_event_id_is_stable_and_payload_is_safe() -> None:
|
||||
track = TrackedObject("track-000001", BoundingBox(1, 2, 3, 4), "anonymous_target", 1.0, 7, 123)
|
||||
decision = RuleDecision("yard", "danger_area", track.track_id, "entered", True, "entered polygon", "rules-v1", "main", 10, 10, NormalizedPoint(0.2, 0.4), 123)
|
||||
metadata = DetectorMetadata("detector", "1", "first-party", "no external weights", "none")
|
||||
first = candidate_from_decision(decision, track, logical_input_id="camera-01", detector=metadata)
|
||||
second = candidate_from_decision(decision, track, logical_input_id="camera-01", detector=metadata)
|
||||
assert first == second
|
||||
assert first.event_id.startswith("brain-local-")
|
||||
payload = json.dumps(first.to_dict())
|
||||
for forbidden in ("password", "rtsp://", "evidence", "face"):
|
||||
assert forbidden not in payload.lower()
|
||||
|
||||
|
||||
def test_json_lines_sink_writes_one_canonical_line() -> None:
|
||||
track = TrackedObject("track-000001", BoundingBox(0, 0, 1, 1), "anonymous_target", 1.0, 0, 0)
|
||||
decision = RuleDecision("yard", "danger_area", track.track_id, "entered", True, "entered", "v1", "main", 2, 2, NormalizedPoint(0.25, 0.5), 0)
|
||||
candidate = candidate_from_decision(decision, track, logical_input_id="synthetic", detector=DetectorMetadata("d", "1", "first", "none", "none"))
|
||||
stream = io.StringIO()
|
||||
JsonLinesSink(stream).write(candidate)
|
||||
assert json.loads(stream.getvalue())["schema"] == "brain.internal.event-candidate/v1"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user