Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e20e6aed | ||
|
|
19c0868c5d | ||
|
|
1c6b30fac0 | ||
|
|
6702b8a5b9 | ||
|
|
a35f1d6770 | ||
|
|
6194b664ee |
@@ -0,0 +1,59 @@
|
||||
# Bell 独立纵切验收
|
||||
|
||||
本验收只使用 Bell 自身、临时 PostgreSQL 和项目内合成事件,不启动或调用 Sense、Brain,不连接默认 5432、生产数据库或客户数据。
|
||||
|
||||
## 固定工具链
|
||||
|
||||
```powershell
|
||||
$env:GOTOOLCHAIN='go1.26.5'
|
||||
go version
|
||||
node --version
|
||||
corepack pnpm@9.15.1 --version
|
||||
```
|
||||
|
||||
预期分别为 Go 1.26.5、Node 22.22.1、pnpm 9.15.1。
|
||||
|
||||
## 源码验证
|
||||
|
||||
```powershell
|
||||
Set-Location Bell\server
|
||||
$env:GOTOOLCHAIN='go1.26.5'
|
||||
go test ./... -count=1
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
Set-Location ..\ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit --runInBand
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
## Windows 包和隔离 E2E
|
||||
|
||||
```powershell
|
||||
Set-Location <仓库根目录>
|
||||
Bell\scripts\build\build-windows.bat
|
||||
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
|
||||
pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64
|
||||
```
|
||||
|
||||
E2E 自动完成并清理:临时 PostgreSQL、随机数据库/HTTP 端口、随机管理员/处置员凭据、迁移、健康检查、登录/RBAC、最小 Bell 菜单、规则、合成 Event/Receipt 幂等、Alert、20 路并发 ack、越权/缺参拒绝、close 重放幂等、两条生命周期时间线、冷重启、Windows stop 和日志泄密检查。原始包保持生产配置并先通过审计;业务自动化只把临时包副本切换为 `dev` 测试模式。生产验证码的获取、正确登录、错误及重放拒绝由 #138 的 `Bell/server/tests/bell_production_login/run-postgres.ps1` 覆盖,不暴露或识别验证码答案。
|
||||
|
||||
浏览器验收打开脚本输出的临时 `base_url`,检查:
|
||||
|
||||
- 匿名访问跳转登录页,并显示验证码输入;测试模式可填写任意非空验证码,生产验证码行为由 #138 回归覆盖;
|
||||
- 登录后保留 GoAdmin 侧栏、顶部导航和标签页;
|
||||
- 管理员显示 Bell 必要业务菜单,包括预警管理、事件查询、规则配置;处置员仅显示预警处理所需入口;
|
||||
- 预警详情可显示关联事件、处理人、现场结果和两条处理时间线;
|
||||
- 不显示开发工具、定时任务、系统监控等无关入口。
|
||||
|
||||
## 仓库闭环
|
||||
|
||||
```powershell
|
||||
python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
浏览器人工/工具检查、真实生产数据库、客户网络和长期负载不由 API 单测替代;未执行的项目必须在工单证据中明确说明。
|
||||
@@ -0,0 +1,59 @@
|
||||
# Bell Windows 运行说明
|
||||
|
||||
Bell Windows 包包含独立后端、GoAdmin 管理端静态资源和启动、停止、检查脚本。正式运行需要独立 PostgreSQL;包内不提供默认账号、密码、JWT secret 或数据库。
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config\bell.env`:
|
||||
|
||||
```text
|
||||
BELL_HOST=127.0.0.1
|
||||
BELL_PORT=18090
|
||||
BELL_WEB_HOST=127.0.0.1
|
||||
BELL_WEB_PORT=18091
|
||||
BELL_DATABASE_URL=host=127.0.0.1 port=5432 user=bell dbname=bell sslmode=disable
|
||||
BELL_JWT_SECRET=<至少 32 字符的独立随机值>
|
||||
BELL_BOOTSTRAP_USERNAME=<仅首次迁移使用>
|
||||
BELL_BOOTSTRAP_PASSWORD=<仅首次迁移使用,至少 8 字符>
|
||||
BELL_AUTO_MIGRATE=true
|
||||
BELL_SYNTHETIC_EVENTS_ENABLED=false
|
||||
```
|
||||
|
||||
不要把真实配置提交到 Git。首次迁移成功后,建议从进程环境中移除 `BELL_BOOTSTRAP_PASSWORD`;它不会写入明文数据库。
|
||||
|
||||
## 启动、检查和停止
|
||||
|
||||
```bat
|
||||
check-bell.bat
|
||||
start-bell.bat
|
||||
check-bell.bat -Running
|
||||
stop-bell.bat
|
||||
```
|
||||
|
||||
浏览器访问 `http://127.0.0.1:18091/`。`BELL_PORT` 是仅供本机 Web 网关访问的后端端口;`BELL_WEB_PORT` 是用户访问入口。启动脚本默认先执行幂等数据库迁移,再启动后端和 Web 网关;任一步失败都会返回非零退出码。
|
||||
|
||||
`stop-bell.bat` 只按包内 PID 文件和启动命令行核对后停止本包进程树,不按端口终止未知进程。运行日志位于 `runtime\logs`,不得包含密码、JWT 或登录 token。
|
||||
|
||||
## 构建和包审计
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
Bell\scripts\build\build-windows.bat
|
||||
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
- `Bell\dist\bell-windows-amd64\`
|
||||
- `Bell\dist\bell-windows-amd64.zip`
|
||||
|
||||
包内 `VERSION.txt`、`MANIFEST.sha256` 和 `LICENSES\` 分别记录源码提交、工具链、文件摘要、GoAdmin 来源及 MIT 许可证。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `BELL_DATABASE_URL is required`:设置独立 PostgreSQL 连接串。
|
||||
- `PostgreSQL is unreachable`:启动 PostgreSQL,并检查地址和端口。
|
||||
- `BELL_JWT_SECRET must contain...`:生成至少 32 字符、只供 Bell 使用的随机值。
|
||||
- `port ... is already in use`:停止已有 Bell,或修改后端/Web 端口。
|
||||
- `Bell database migration failed`:检查数据库是否存在、用户权限及迁移日志;不要删除已有 Event、Alert 或生命周期事实。
|
||||
@@ -37,3 +37,12 @@ corepack pnpm@9.15.1 dev
|
||||
```
|
||||
|
||||
生产构建使用 `corepack pnpm@9.15.1 build:prod`。生产环境不会生成或接受仓库默认管理员、默认 JWT secret 或默认数据库连接串。
|
||||
|
||||
## Windows 交付与独立验收
|
||||
|
||||
- Windows 构建:`Bell\scripts\build\build-windows.bat`
|
||||
- 包审计:`pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64`
|
||||
- 隔离 E2E:`pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64`
|
||||
- 包内启动、检查和停止:`start-bell.bat`、`check-bell.bat -Running`、`stop-bell.bat`
|
||||
|
||||
完整配置、排错和验收标准见 `README-WINDOWS.md` 与 `ACCEPTANCE.md`。隔离 E2E 使用临时 PostgreSQL、随机端口和随机凭据,不启动或调用 Sense、Brain。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Bell production environment. Copy values into process environment or this file.
|
||||
BELL_HOST=127.0.0.1
|
||||
BELL_PORT=18090
|
||||
BELL_WEB_HOST=127.0.0.1
|
||||
BELL_WEB_PORT=18091
|
||||
BELL_DATABASE_URL=
|
||||
BELL_JWT_SECRET=
|
||||
BELL_BOOTSTRAP_USERNAME=
|
||||
BELL_BOOTSTRAP_PASSWORD=
|
||||
BELL_AUTO_MIGRATE=true
|
||||
BELL_SYNTHETIC_EVENTS_ENABLED=false
|
||||
@@ -0,0 +1,13 @@
|
||||
param([Parameter(Mandatory = $true)][string]$WebRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath($WebRoot)
|
||||
$index = Join-Path $root 'index.html'
|
||||
if (-not (Test-Path -LiteralPath $index -PathType Leaf)) { throw 'web/index.html is missing.' }
|
||||
$html = Get-Content -LiteralPath $index -Raw -Encoding UTF8
|
||||
$references = [regex]::Matches($html, '(?:src|href)=["''](?<path>/[^"''?#]+)') | ForEach-Object { $_.Groups['path'].Value.TrimStart('/').Replace('/', '\') }
|
||||
foreach ($relative in $references | Sort-Object -Unique) {
|
||||
if ($relative -match '^https?:') { continue }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $root $relative) -PathType Leaf)) { throw "web asset referenced by index.html is missing: $relative" }
|
||||
}
|
||||
Write-Host "Bell web asset check passed: $root"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0build-windows.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0build-windows.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,88 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$bellRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$repositoryRoot = Split-Path $bellRoot -Parent
|
||||
$serverRoot = Join-Path $bellRoot 'server'
|
||||
$uiRoot = Join-Path $bellRoot 'ui'
|
||||
$distRoot = Join-Path $bellRoot 'dist'
|
||||
$target = Join-Path $distRoot 'bell-windows-amd64'
|
||||
$archive = Join-Path $distRoot 'bell-windows-amd64.zip'
|
||||
$staging = Join-Path $distRoot ('.bell-windows-amd64.staging-' + $PID)
|
||||
|
||||
function Assert-ChildPath([string]$Parent,[string]$Child) {
|
||||
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
|
||||
$childPath = [IO.Path]::GetFullPath($Child)
|
||||
if (-not $childPath.StartsWith($parentPath,[StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe build path outside $Parent`: $Child" }
|
||||
}
|
||||
function Get-FileSha256([string]$Path) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create(); $stream = [IO.File]::OpenRead($Path)
|
||||
try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','') } finally { $stream.Dispose(); $sha.Dispose() }
|
||||
}
|
||||
Assert-ChildPath $bellRoot $distRoot; Assert-ChildPath $distRoot $target; Assert-ChildPath $distRoot $archive; Assert-ChildPath $distRoot $staging
|
||||
|
||||
$savedToolchain = $env:GOTOOLCHAIN
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
try {
|
||||
Push-Location $serverRoot
|
||||
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
|
||||
$nodeVersion = (& node --version).Trim().TrimStart('v')
|
||||
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
|
||||
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; found $goVersion." }
|
||||
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
|
||||
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; found $pnpmVersion." }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
|
||||
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||
Push-Location $uiRoot
|
||||
try {
|
||||
& corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
|
||||
& corepack pnpm@9.15.1 run build:prod
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell UI production build failed.' }
|
||||
# The frozen Vue CLI differential build references a module runtime
|
||||
# that ScriptExt removes from disk. The complete legacy bundle is
|
||||
# present, so make that reproducible bundle the package entry point.
|
||||
$builtIndex = Join-Path $uiRoot 'dist\index.html'
|
||||
$html = Get-Content -LiteralPath $builtIndex -Raw -Encoding UTF8
|
||||
$html = [regex]::Replace($html, '<script[^>]+type="module"[^>]*></script>', '')
|
||||
$html = $html.Replace(' nomodule', '')
|
||||
[IO.File]::WriteAllText($builtIndex, $html, (New-Object Text.UTF8Encoding($false)))
|
||||
} finally { Pop-Location }
|
||||
|
||||
$oldGOOS,$oldGOARCH,$oldCGO = $env:GOOS,$env:GOARCH,$env:CGO_ENABLED
|
||||
try {
|
||||
$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
|
||||
Push-Location $serverRoot
|
||||
try { & go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'bell.exe') .; if ($LASTEXITCODE -ne 0) { throw 'Bell server Windows build failed.' } } finally { Pop-Location }
|
||||
} finally { $env:GOOS,$env:GOARCH,$env:CGO_ENABLED=$oldGOOS,$oldGOARCH,$oldCGO }
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
|
||||
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'),(Join-Path $staging 'config'),(Join-Path $staging 'LICENSES') | Out-Null
|
||||
Copy-Item -Path (Join-Path $bellRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
|
||||
foreach ($name in @('start-bell','stop-bell','check-bell')) { Copy-Item -LiteralPath (Join-Path $bellRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") }
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env.example')
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\settings.yml') -Destination (Join-Path $staging 'config\settings.yml')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'LICENSES') -Destination $staging -Recurse -Force
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'LICENSE.md') -Destination (Join-Path $staging 'LICENSES\Bell-server-LICENSE.md')
|
||||
Copy-Item -LiteralPath (Join-Path $uiRoot 'LICENSE') -Destination (Join-Path $staging 'LICENSES\Bell-ui-LICENSE')
|
||||
$commit = (& git -C $repositoryRoot rev-parse HEAD).Trim()
|
||||
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'),@("source_commit=$commit",'go=1.26.5','node=22.22.1','pnpm=9.15.1'),(New-Object Text.UTF8Encoding($false)))
|
||||
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell package audit failed.' }
|
||||
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { "$(Get-FileSha256 $file.FullName) $($file.FullName.Substring($staging.Length+1).Replace('\','/'))" }
|
||||
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'),$manifest,(New-Object Text.UTF8Encoding($false)))
|
||||
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
|
||||
Move-Item -LiteralPath $staging -Destination $target
|
||||
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
|
||||
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
|
||||
Write-Host "Bell Windows package: $target"
|
||||
Write-Host "Bell Windows archive: $archive"
|
||||
} finally {
|
||||
$env:GOTOOLCHAIN = $savedToolchain
|
||||
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath($PackageRoot)
|
||||
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
|
||||
$required = @(
|
||||
'bell.exe','start-bell.bat','stop-bell.bat','check-bell.bat','README-WINDOWS.md',
|
||||
'config\bell.env','config\bell.env.example','config\settings.yml','config\db.sql','config\pg.sql','web\index.html',
|
||||
'scripts\runtime\bell-common.ps1','scripts\runtime\bell-web.ps1',
|
||||
'LICENSES\SOURCES.md','LICENSES\go-admin-LICENSE.md','LICENSES\go-admin-ui-LICENSE',
|
||||
'VERSION.txt'
|
||||
)
|
||||
foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } }
|
||||
& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web')
|
||||
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules','.git','dist','.cache') }
|
||||
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
|
||||
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db','.sqlite','.sqlite3','.dump','.bak') }
|
||||
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
|
||||
$config = Get-Content -LiteralPath (Join-Path $root 'config\bell.env') -Raw -Encoding UTF8
|
||||
foreach ($secret in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD')) {
|
||||
if ($config -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty credential field: $secret" }
|
||||
}
|
||||
$sources = Get-Content -LiteralPath (Join-Path $root 'LICENSES\SOURCES.md') -Raw -Encoding UTF8
|
||||
foreach ($commit in @('f06540883b41d03782bb6b2c4150f298f328c6b6','67d393d713877572fab0b897296a4c1d525fc81d','424855aacf6905f3fde860c3331385cb25529a0d')) {
|
||||
if (-not $sources.Contains($commit)) { throw "Package source evidence is missing commit $commit" }
|
||||
}
|
||||
$version = Get-Content -LiteralPath (Join-Path $root 'VERSION.txt') -Raw -Encoding UTF8
|
||||
foreach ($entry in @('go=1.26.5','node=22.22.1','pnpm=9.15.1')) { if (-not $version.Contains($entry)) { throw "Package version evidence is missing $entry" } }
|
||||
$textExtensions = @('.md','.txt','.env','.example','.ps1','.bat','.yml','.yaml','.json','.html','.js','.css')
|
||||
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
|
||||
$content = [string](Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue)
|
||||
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') { throw "Package contains a forbidden default credential or private key marker: $($file.FullName)" }
|
||||
}
|
||||
Write-Host "Bell package audit passed: $root"
|
||||
@@ -0,0 +1,117 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:BellAllowedEnvironment = @(
|
||||
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
|
||||
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
|
||||
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
|
||||
'BELL_SYNTHETIC_EVENTS_ENABLED'
|
||||
)
|
||||
|
||||
function Get-BellPackageRoot {
|
||||
param([string]$ScriptDirectory = $PSScriptRoot)
|
||||
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
|
||||
}
|
||||
function Import-BellEnvironment {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" }
|
||||
$lineNumber = 0
|
||||
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$line = $rawLine.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
|
||||
$separator = $line.IndexOf('=')
|
||||
if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." }
|
||||
$name = $line.Substring(0, $separator).Trim()
|
||||
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" }
|
||||
$value = $line.Substring($separator + 1)
|
||||
if ($value.Length -ge 2) {
|
||||
$first, $last = $value[0], $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BellEnvironmentValue {
|
||||
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
|
||||
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
|
||||
return $value
|
||||
}
|
||||
|
||||
function Test-BellTcpEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
|
||||
}
|
||||
|
||||
function Test-BellListenPortAvailable {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
|
||||
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
|
||||
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
|
||||
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
|
||||
}
|
||||
|
||||
function Get-BellDatabaseEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$Connection)
|
||||
if ($Connection -match '^postgres(?:ql)?://') {
|
||||
$uri = [Uri]$Connection
|
||||
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
|
||||
}
|
||||
$values = @{}
|
||||
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
|
||||
$value = $match.Groups['value'].Value.Trim("'", '"')
|
||||
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
|
||||
}
|
||||
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
|
||||
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
|
||||
}
|
||||
|
||||
function Get-BellPort {
|
||||
param([string]$Name, [int]$Default)
|
||||
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
|
||||
$port = 0
|
||||
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
|
||||
return $port
|
||||
}
|
||||
|
||||
function Initialize-BellRuntime {
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
|
||||
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
|
||||
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
|
||||
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
|
||||
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
|
||||
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
|
||||
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
|
||||
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
|
||||
if (-not $AllowOccupiedPorts) {
|
||||
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
|
||||
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
|
||||
}
|
||||
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
|
||||
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
|
||||
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
|
||||
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
|
||||
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
|
||||
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
|
||||
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
|
||||
$webRoot = Join-Path $PackageRoot 'web'
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
|
||||
return [pscustomobject]@{
|
||||
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
|
||||
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
|
||||
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-BellHealth {
|
||||
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw "Bell health check timed out: $BaseUrl/healthz"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$WebRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ListenHost,
|
||||
[Parameter(Mandatory = $true)][int]$ListenPort,
|
||||
[Parameter(Mandatory = $true)][string]$BackendUrl
|
||||
)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
|
||||
$listener = [Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$handler.UseProxy = $false
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
|
||||
|
||||
try {
|
||||
$listener.Start()
|
||||
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
|
||||
while ($listener.IsListening) {
|
||||
$context = $listener.GetContext()
|
||||
try {
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
$path = $request.Url.AbsolutePath
|
||||
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
|
||||
$target = "$BackendUrl$($request.Url.PathAndQuery)"
|
||||
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
|
||||
if ($request.HasEntityBody) {
|
||||
$memory = [IO.MemoryStream]::new()
|
||||
$request.InputStream.CopyTo($memory)
|
||||
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
|
||||
$memory.Dispose()
|
||||
}
|
||||
foreach ($key in $request.Headers.AllKeys) {
|
||||
if ($key -in @('Host','Content-Length')) { continue }
|
||||
$values = $request.Headers.GetValues($key)
|
||||
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
|
||||
}
|
||||
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
|
||||
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
|
||||
$response.StatusCode = [int]$upstream.StatusCode
|
||||
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$message.Dispose(); $upstream.Dispose()
|
||||
} else {
|
||||
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
|
||||
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
|
||||
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
|
||||
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
|
||||
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
|
||||
$file = Join-Path $root 'index.html'
|
||||
}
|
||||
if ($response.StatusCode -ne 403) {
|
||||
$bytes = [IO.File]::ReadAllBytes($file)
|
||||
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
|
||||
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
|
||||
} finally {
|
||||
try { $context.Response.OutputStream.Close() } catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,12 @@
|
||||
param([switch]$Running)
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
|
||||
if ($Running) {
|
||||
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
|
||||
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
|
||||
}
|
||||
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
|
||||
exit 0
|
||||
} catch { Write-Error $_.Exception.Message; exit 1 }
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,41 @@
|
||||
param([switch]$SkipMigration)
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
|
||||
$backend = $null
|
||||
$pidFile = $null
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$state = Initialize-BellRuntime -PackageRoot $root
|
||||
$bell = Join-Path $root 'bell.exe'
|
||||
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
|
||||
$runtime = Join-Path $root 'runtime'
|
||||
$logs = Join-Path $runtime 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
|
||||
$pidFile = Join-Path $runtime 'bell.pid'
|
||||
if (Test-Path -LiteralPath $pidFile) {
|
||||
$oldPid = 0
|
||||
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
|
||||
Remove-Item -LiteralPath $pidFile -Force
|
||||
}
|
||||
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
|
||||
Push-Location $root
|
||||
try {
|
||||
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
|
||||
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
|
||||
Write-Host 'Applying pending Bell database migrations...'
|
||||
& $bell migrate -c $state.SettingsPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
|
||||
}
|
||||
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
|
||||
Wait-BellHealth -BaseUrl $state.BackendUrl
|
||||
Write-Host "Bell is available at $($state.WebUrl)/"
|
||||
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
|
||||
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
|
||||
} finally { Pop-Location }
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
} finally {
|
||||
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
|
||||
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,17 @@
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$pidFile = Join-Path $root 'runtime\bell.pid'
|
||||
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
|
||||
$processId = 0
|
||||
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
|
||||
$rootPattern = [regex]::Escape($root)
|
||||
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
|
||||
& taskkill.exe /PID $processId /T /F | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
|
||||
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host 'Bell backend and web process tree stopped.'
|
||||
exit 0
|
||||
} catch { Write-Error $_.Exception.Message; exit 1 }
|
||||
@@ -0,0 +1,7 @@
|
||||
param([string]$PostgresBin='D:\pgsql17\bin',[string]$PreparedPackageRoot='',[switch]$KeepTemporary,[switch]$BrowserHold)
|
||||
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot '..\tests\e2e\run-isolated-e2e.ps1'),'-PostgresBin',$PostgresBin)
|
||||
if(-not[string]::IsNullOrWhiteSpace($PreparedPackageRoot)){$arguments+=@('-PreparedPackageRoot',$PreparedPackageRoot)}
|
||||
if($KeepTemporary){$arguments+='-KeepTemporary'}
|
||||
if($BrowserHold){$arguments+='-BrowserHold'}
|
||||
& pwsh.exe @arguments
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,8 @@
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$web = Join-Path ([IO.Path]::GetFullPath($PackageRoot)) 'web'
|
||||
& (Join-Path $PSScriptRoot '..\..\scripts\build\assert-web-assets.ps1') -WebRoot $web
|
||||
$index = Get-Content -LiteralPath (Join-Path $web 'index.html') -Raw -Encoding UTF8
|
||||
if ($index -notmatch 'id=["'']app["'']') { throw 'Bell package does not contain the GoAdmin Vue application mount.' }
|
||||
Write-Host 'Bell GoAdmin shell compatibility check passed.'
|
||||
@@ -0,0 +1,204 @@
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin',
|
||||
[string]$PreparedPackageRoot = '',
|
||||
[switch]$KeepTemporary,
|
||||
[switch]$BrowserHold
|
||||
)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
||||
$bellRoot = Join-Path $repositoryRoot 'Bell'
|
||||
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('bell-e2e-' + [guid]::NewGuid().ToString('N'))
|
||||
$pgData = Join-Path $temporary 'postgres'
|
||||
$pgLog = Join-Path $temporary 'postgres.log'
|
||||
$runtimeOut = Join-Path $temporary 'bell-launcher.out.log'
|
||||
$runtimeErr = Join-Path $temporary 'bell-launcher.err.log'
|
||||
$launcher = $null
|
||||
$pgStarted = $false
|
||||
$savedEnvironment = @{}
|
||||
|
||||
function Get-FreeTcpPort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0)
|
||||
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
function Get-UniqueFreePorts([int]$Count) {
|
||||
$ports = [Collections.Generic.List[int]]::new()
|
||||
while ($ports.Count -lt $Count) { $port=Get-FreeTcpPort; if (-not $ports.Contains($port)) { $ports.Add($port) } }
|
||||
return $ports.ToArray()
|
||||
}
|
||||
function New-RandomText([int]$Bytes=32) {
|
||||
$buffer=New-Object byte[] $Bytes; $generator=[Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
|
||||
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+','A').Replace('/','B')
|
||||
}
|
||||
function Set-TestEnvironment([string]$Name,[string]$Value) {
|
||||
if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name]=[Environment]::GetEnvironmentVariable($Name,'Process') }
|
||||
[Environment]::SetEnvironmentVariable($Name,$Value,'Process')
|
||||
}
|
||||
function Wait-Tcp([int]$Port,[bool]$Open,[int]$Attempts=120) {
|
||||
for($i=0;$i -lt $Attempts;$i++) {
|
||||
$client=[Net.Sockets.TcpClient]::new()
|
||||
try { $connected=$client.ConnectAsync('127.0.0.1',$Port).Wait(250)-and$client.Connected } catch { $connected=$false } finally { $client.Dispose() }
|
||||
if($connected -eq $Open){return}; Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "TCP port $Port did not reach open=$Open"
|
||||
}
|
||||
function Wait-Health([string]$BaseUrl) {
|
||||
for($i=0;$i -lt 120;$i++){try{$health=Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy;if($health.status-eq'ok'-and$health.service-eq'bell'){return}}catch{};Start-Sleep -Milliseconds 300}
|
||||
throw "Bell health endpoint did not become ready: $BaseUrl"
|
||||
}
|
||||
function Invoke-BellJson {
|
||||
param([string]$Method,[string]$Path,$Body=$null,[string]$Token='',[int]$ExpectedCode=200)
|
||||
$headers=@{};if($Token){$headers.Authorization="Bearer $Token"}
|
||||
$arguments=@{Method=$Method;Uri="$script:baseUrl$Path";Headers=$headers;TimeoutSec=20;NoProxy=$true}
|
||||
if($null-ne$Body){$arguments.ContentType='application/json; charset=utf-8';$arguments.Body=$Body|ConvertTo-Json -Depth 12 -Compress}
|
||||
try{$response=Invoke-RestMethod @arguments}catch{throw "Bell request failed for $Method $Path`: $($_.Exception.Message)"}
|
||||
if([int]$response.code-ne$ExpectedCode){throw "Unexpected Bell code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"}
|
||||
return $response
|
||||
}
|
||||
function Login([string]$Username,[string]$Password){$response=Invoke-BellJson POST '/api/v1/login' @{username=$Username;password=$Password;code='0';uuid='0'};if([string]::IsNullOrWhiteSpace($response.token)){throw "Login did not return a token for $Username"};return [string]$response.token}
|
||||
function Get-VisibleMenuTitles($Menus,[bool]$AncestorsVisible=$true) {
|
||||
foreach($menu in @($Menus)) {
|
||||
if($null-eq$menu){continue}
|
||||
$visible=$AncestorsVisible-and([string]$menu.visible-eq'0')
|
||||
if($visible-and-not[string]::IsNullOrWhiteSpace([string]$menu.title)){[string]$menu.title}
|
||||
if($menu.PSObject.Properties.Name-contains'children'){
|
||||
Get-VisibleMenuTitles -Menus $menu.children -AncestorsVisible $visible
|
||||
}
|
||||
}
|
||||
}
|
||||
function Start-Package([string]$Root){
|
||||
$script:launcher=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',"`"$(Join-Path $Root 'start-bell.bat')`"") -WorkingDirectory $Root -RedirectStandardOutput $runtimeOut -RedirectStandardError $runtimeErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $script:baseUrl
|
||||
}
|
||||
function Stop-Package([string]$Root){
|
||||
& (Join-Path $Root 'stop-bell.bat') | Out-Host
|
||||
if($LASTEXITCODE-ne 0){throw 'Bell package stop failed'}
|
||||
Wait-Tcp -Port $script:webPort -Open $false -Attempts 40
|
||||
Wait-Tcp -Port $script:backendPort -Open $false -Attempts 40
|
||||
if($script:launcher-and-not$script:launcher.HasExited){$script:launcher.WaitForExit(5000)|Out-Null}
|
||||
$script:launcher=$null
|
||||
}
|
||||
function Stop-ProcessTree($Process){if($Process-and-not$Process.HasExited){& taskkill.exe /PID $Process.Id /T /F 2>$null|Out-Null}}
|
||||
|
||||
New-Item -ItemType Directory -Path $temporary | Out-Null
|
||||
try {
|
||||
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){$path=Join-Path $PostgresBin $name;if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Required PostgreSQL tool not found: $path"}}
|
||||
if([string]::IsNullOrWhiteSpace($PreparedPackageRoot)){
|
||||
& (Join-Path $bellRoot 'scripts\build\build-windows.ps1')
|
||||
if($LASTEXITCODE-ne 0){throw 'Bell Windows package build failed'}
|
||||
$preparedPackageRoot=Join-Path $bellRoot 'dist\bell-windows-amd64'
|
||||
}else{$preparedPackageRoot=[IO.Path]::GetFullPath($PreparedPackageRoot)}
|
||||
& (Join-Path $bellRoot 'scripts\build\test-package.ps1') -PackageRoot $preparedPackageRoot
|
||||
& (Join-Path $bellRoot 'tests\compatibility\assert-go-admin-shell.ps1') -PackageRoot $preparedPackageRoot
|
||||
$packageRoot=Join-Path $temporary 'package'
|
||||
Copy-Item -LiteralPath $preparedPackageRoot -Destination $packageRoot -Recurse
|
||||
# Production captcha behavior is covered by #138. The isolated business
|
||||
# E2E uses a disposable package copy in dev mode so it never needs to
|
||||
# expose or OCR a captcha answer.
|
||||
$settingsPath=Join-Path $packageRoot 'config\settings.yml'
|
||||
$settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8
|
||||
$testSettings=[regex]::Replace($settings,'(?m)^(\s*mode:\s*)prod\s*$','$1dev')
|
||||
if($testSettings-eq$settings){throw 'Packaged settings did not contain the expected production mode'}
|
||||
[IO.File]::WriteAllText($settingsPath,$testSettings,(New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
$pgPort,$script:backendPort,$script:webPort=Get-UniqueFreePorts 3
|
||||
if($pgPort-eq 5432){throw 'E2E must not use the default PostgreSQL port'}
|
||||
$script:baseUrl="http://127.0.0.1:$script:webPort"
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U bell_e2e -A trust --encoding=UTF8 --no-locale|Out-Null
|
||||
if($LASTEXITCODE-ne 0){throw 'isolated PostgreSQL initdb failed'}
|
||||
$pgArguments="-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput (Join-Path $temporary 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden|Out-Null
|
||||
Wait-Tcp -Port $pgPort -Open $true;$pgStarted=$true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U bell_e2e bell_e2e
|
||||
if($LASTEXITCODE-ne 0){throw 'isolated Bell database creation failed'}
|
||||
|
||||
$adminName='bell_e2e_admin_'+(New-RandomText 5).ToLowerInvariant();$adminPassword=New-RandomText 20
|
||||
$operatorPassword=New-RandomText 20;$jwt=New-RandomText 48
|
||||
$environment=@{
|
||||
BELL_HOST='127.0.0.1';BELL_PORT="$script:backendPort";BELL_WEB_HOST='127.0.0.1';BELL_WEB_PORT="$script:webPort";
|
||||
BELL_DATABASE_URL="host=127.0.0.1 port=$pgPort user=bell_e2e dbname=bell_e2e sslmode=disable";
|
||||
BELL_JWT_SECRET=$jwt;BELL_BOOTSTRAP_USERNAME=$adminName;BELL_BOOTSTRAP_PASSWORD=$adminPassword;
|
||||
BELL_AUTO_MIGRATE='true';BELL_SYNTHETIC_EVENTS_ENABLED='true'
|
||||
}
|
||||
foreach($item in $environment.GetEnumerator()){Set-TestEnvironment $item.Key $item.Value}
|
||||
Start-Package $packageRoot
|
||||
$anonymous=Invoke-BellJson GET '/api/v1/bell/alerts' $null '' 401
|
||||
$adminToken=Login $adminName $adminPassword
|
||||
$psql=Join-Path $PostgresBin 'psql.exe'
|
||||
$operatorRole=[int]((&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select role_id from sys_role where role_key='operator';").Trim())
|
||||
if($operatorRole-lt 1){throw 'operator role was not migrated'}
|
||||
$operators=@(
|
||||
@{username='bell_e2e_operator_a';nickName='处置员A'},
|
||||
@{username='bell_e2e_operator_b';nickName='处置员B'}
|
||||
)
|
||||
foreach($operator in $operators){[void](Invoke-BellJson POST '/api/v1/sys-user' @{username=$operator.username;password=$operatorPassword;nickName=$operator.nickName;phone='13800000000';roleId=$operatorRole;sex='1';email="$($operator.username)@invalid.local";deptId=1;postId=1;status='2'} $adminToken)}
|
||||
$tokenA=Login $operators[0].username $operatorPassword;$tokenB=Login $operators[1].username $operatorPassword
|
||||
$adminMenu=Invoke-BellJson GET '/api/v1/menurole' $null $adminToken
|
||||
$operatorMenu=Invoke-BellJson GET '/api/v1/menurole' $null $tokenA
|
||||
$adminVisible=@(Get-VisibleMenuTitles $adminMenu.data)
|
||||
$operatorVisible=@(Get-VisibleMenuTitles $operatorMenu.data)
|
||||
foreach($label in @('预警管理','事件查询','规则配置')){if($adminVisible-notcontains$label){throw "administrator menu is missing $label; visible=$($adminVisible-join',')"}}
|
||||
foreach($label in @('预警管理','事件查询')){if($operatorVisible-notcontains$label){throw "operator menu is missing $label; visible=$($operatorVisible-join',')"}}
|
||||
foreach($label in @('开发工具','定时任务','系统监控')){if($adminVisible-contains$label-or$operatorVisible-contains$label){throw "unrelated menu is visible: $label"}}
|
||||
|
||||
$eventType='bell_e2e_danger';$ruleBody=@{code='bell-e2e-danger';name='E2E危险区域规则';eventType=$eventType;minimumSeverity='medium';locationContains='东门'}
|
||||
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $tokenA 403)
|
||||
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $adminToken)
|
||||
$eventBody=Get-Content -LiteralPath (Join-Path $bellRoot 'tests\fixtures\synthetic-danger-event.json') -Raw -Encoding UTF8|ConvertFrom-Json
|
||||
$eventBody.eventType=$eventType
|
||||
$created=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
|
||||
$replay=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
|
||||
if($created.data.duplicate-ne$false-or$replay.data.duplicate-ne$true-or$created.data.event.id-ne$replay.data.event.id){throw 'synthetic Event idempotency failed'}
|
||||
$eventId=[string]$created.data.event.id
|
||||
$alerts=Invoke-BellJson GET '/api/v1/bell/alerts?status=open&pageIndex=1&pageSize=20' $null $tokenA
|
||||
$alert=@($alerts.data.list)[0]
|
||||
if(-not$alert){throw 'rule evaluation did not create an open Alert'}
|
||||
$alertId=[string]$alert.id
|
||||
$alertDetail=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId" $null $tokenA).data
|
||||
if(@($alertDetail.events.id)-notcontains$eventId){throw 'created Alert is not linked to the synthetic Event'}
|
||||
|
||||
$requests=for($i=0;$i-lt 20;$i++){[pscustomobject]@{Token=$(if($i%2-eq0){$tokenA}else{$tokenB})}}
|
||||
$acks=$requests|ForEach-Object -Parallel {
|
||||
$headers=@{Authorization="Bearer $($_.Token)"}
|
||||
$response=Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/alerts/$using:alertId/ack" -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20 -NoProxy
|
||||
[pscustomobject]@{Token=$_.Token;Response=$response}
|
||||
} -ThrottleLimit 20
|
||||
$winners=@($acks|Where-Object{$_.Response.data.won-eq$true})
|
||||
if($winners.Count-ne 1){throw "concurrent ack winners=$($winners.Count)"}
|
||||
$lifecycle=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $tokenA).data.detail
|
||||
if($lifecycle.timeline.Count-ne 1-or$lifecycle.projection.status-ne'acknowledged'){throw 'ack lifecycle projection is inconsistent'}
|
||||
$winnerToken=[string]$winners[0].Token
|
||||
$loserToken=$(if($winnerToken-eq$tokenA){$tokenB}else{$tokenA})
|
||||
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal'} $loserToken 403)
|
||||
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{} $winnerToken 400)
|
||||
$closed=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
|
||||
$closeReplay=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
|
||||
if($closed.data.won-ne$true-or$closeReplay.data.idempotent-ne$true){throw 'close or idempotent replay failed'}
|
||||
$final=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
|
||||
if($final.timeline.Count-ne 2-or$final.projection.status-ne'closed'){throw 'closed timeline is incomplete'}
|
||||
$facts=(&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select (select count(*) from bell_events),(select count(*) from bell_event_receipts),(select count(*) from bell_alert_lifecycle_facts where alert_id='$alertId');").Trim()
|
||||
if($facts-ne'1|1|2'){throw "unexpected persisted fact counts: $facts"}
|
||||
|
||||
Stop-Package $packageRoot
|
||||
Start-Package $packageRoot
|
||||
$after=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
|
||||
if($after.timeline.Count-ne 2-or$after.projection.closeOutcome-ne'site_normal'){throw 'cold restart lost lifecycle state'}
|
||||
$rootPage=Invoke-WebRequest -Uri "$script:baseUrl/" -TimeoutSec 10 -NoProxy
|
||||
if($rootPage.StatusCode-ne 200-or$rootPage.Content-notmatch'id=["'']app["'']'){throw 'packaged GoAdmin web shell is not available'}
|
||||
if($BrowserHold){
|
||||
$browserSession=Join-Path $temporary 'browser-session.json';$browserDone=Join-Path $temporary 'browser-done'
|
||||
@{baseUrl=$script:baseUrl;username=$adminName;password=$adminPassword;alertId=$alertId}|ConvertTo-Json|Set-Content -LiteralPath $browserSession -Encoding UTF8
|
||||
Write-Host "Bell browser session ready: $browserSession"
|
||||
for($i=0;$i-lt 1200-and-not(Test-Path -LiteralPath $browserDone);$i++){Start-Sleep -Milliseconds 500}
|
||||
if(-not(Test-Path -LiteralPath $browserDone)){throw 'Browser verification did not signal completion within 10 minutes'}
|
||||
}
|
||||
Stop-Package $packageRoot
|
||||
foreach($log in @($runtimeOut,$runtimeErr,(Join-Path $packageRoot 'runtime\logs\bell.out.log'),(Join-Path $packageRoot 'runtime\logs\bell.err.log'))){if(Test-Path $log){$text=[string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue);foreach($secret in @($adminPassword,$operatorPassword,$jwt,$adminToken,$tokenA,$tokenB)){if($text.Contains($secret)){throw "runtime log exposed an E2E credential: $log"}}}}
|
||||
Write-Host "Bell isolated E2E passed: health/login/RBAC, minimal menu, Event/Receipt idempotency, Rule/Alert, 20 concurrent ack, close authorization/idempotency, timeline, cold restart, package start/stop. base_url=$script:baseUrl"
|
||||
} finally {
|
||||
try { if($launcher){Stop-Package $packageRoot} } catch { Stop-ProcessTree $launcher }
|
||||
if($pgStarted){Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$pgData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporary 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden|Out-Null;try{Wait-Tcp -Port $pgPort -Open $false -Attempts 40}catch{}}
|
||||
foreach($item in $savedEnvironment.GetEnumerator()){[Environment]::SetEnvironmentVariable($item.Key,$item.Value,'Process')}
|
||||
if(-not$KeepTemporary-and(Test-Path -LiteralPath $temporary)){$resolved=[IO.Path]::GetFullPath($temporary);if(-not$resolved.StartsWith([IO.Path]::GetTempPath(),[StringComparison]::OrdinalIgnoreCase)){throw "Unsafe temporary cleanup path: $resolved"};Remove-Item -LiteralPath $resolved -Recurse -Force}elseif($KeepTemporary){Write-Host "Kept Bell E2E directory: $temporary"}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"sourceEventId": "bell-e2e-danger-001",
|
||||
"eventType": "danger_area_entered",
|
||||
"occurredAt": "2026-08-29T00:00:00Z",
|
||||
"location": "东门危险区域",
|
||||
"severity": "high",
|
||||
"evidenceRef": "e2e/evidence/bell-e2e-danger-001",
|
||||
"attributes": {
|
||||
"target": "anonymous",
|
||||
"fixture": true
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,14 @@
|
||||
|
||||
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
|
||||
|
||||
默认入口只复制 Git 已跟踪的 `Sense/` 源码到系统临时目录,因此正常开发工作区中已有的 `node_modules`、`dist`、本地配置、日志和其他未跟踪文件不会进入验收副本。`-PreparedPackageRoot` 也会先把指定包复制到本次临时目录,运行时配置、浏览器脚本、截图和日志不会写回原包或源码树。
|
||||
|
||||
Sense 进程提前退出或 HTTP 就绪超时时,脚本返回非零并输出阶段、退出状态、临时日志位置和经过过滤、截断的日志摘要;数据库连接、密码、token、Cookie、JWT 和 credential key 不得出现在诊断中。默认无论成功或失败都会清理所属进程和临时目录;`-KeepTemporary` 仅用于排错,仍会停止进程,但保留目录可能包含随机运行时秘密,必须限制访问并在排错后安全删除。
|
||||
|
||||
为避免 Windows 首次扫描临时复制的 MediaMTX 二进制占用产品固定的就绪窗口,E2E 会先在同一动态端口和临时配置上启动一次包内 MediaMTX,确认 Control API 可用并完全停止,再由 Sense 以 managed 模式启动并完成生命周期验收。预检失败会单独报告 `MediaMTX preflight` 阶段,不会被误报为 Sense HTTP 超时。
|
||||
|
||||
HTTP、RTSP、HLS、Control API 和 ONVIF 动态端口使用 TCP 绑定探测;WebRTC 本地 UDP 端口必须使用 UDP socket 实际绑定探测,不得用 TCP 空闲结果代替,避免落入 Windows 的 UDP 排除或占用范围。
|
||||
|
||||
## 回归矩阵
|
||||
|
||||
| 范围 | 自动化证据 | 判定 |
|
||||
|
||||
@@ -49,4 +49,18 @@ foreach ($file in $fixtureFiles) {
|
||||
if ($file.Extension -eq '.json') { [void]($content | ConvertFrom-Json); $passed++ }
|
||||
}
|
||||
|
||||
$e2eScript = Read-Utf8 (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1')
|
||||
Assert-True ($e2eScript.Contains('Copy-TrackedSenseSource $repositoryRoot $senseCopy')) 'Sense E2E no longer copies only tracked source'
|
||||
Assert-True (-not $e2eScript.Contains('Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse')) 'Sense E2E regressed to recursive source-tree copying'
|
||||
Assert-True ($e2eScript.Contains("`$browserScript = Join-Path `$PSScriptRoot 'browser-smoke.cjs'")) 'browser smoke script no longer runs from its tracked location'
|
||||
Assert-True ($e2eScript.Contains("`$packageRoot = Join-Path `$temporary 'prepared-package'")) 'prepared package no longer runs from an isolated temporary copy'
|
||||
Assert-True ($e2eScript.Contains("-Process `$process -Stage 'Sense HTTP'")) 'Sense HTTP readiness no longer observes the package process'
|
||||
Assert-True ($e2eScript.Contains('Get-SafeLogSummary')) 'Sense readiness diagnostics no longer use log redaction'
|
||||
Assert-True ($e2eScript.Contains("-Stage 'MediaMTX preflight'")) 'Sense E2E no longer preflights the copied MediaMTX package and config'
|
||||
Assert-True ($e2eScript.Contains('function Get-FreeUdpPort')) 'Sense E2E no longer probes WebRTC UDP ports with the UDP protocol'
|
||||
Assert-True ($e2eScript.Contains('do { $webrtcUDPort = Get-FreeUdpPort }')) 'Sense E2E WebRTC UDP port regressed to TCP-only discovery'
|
||||
|
||||
& powershell.exe -NoProfile -File (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1') -HarnessSelfTest
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense E2E harness self-test failed' }
|
||||
|
||||
Write-Host "Sense compatibility regression passed: $passed assertions."
|
||||
|
||||
@@ -3,11 +3,13 @@ param(
|
||||
[string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe',
|
||||
[string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe',
|
||||
[string]$PreparedPackageRoot = '',
|
||||
[switch]$HarnessSelfTest,
|
||||
[switch]$KeepTemporary
|
||||
)
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
$legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser)
|
||||
if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) }
|
||||
if ($HarnessSelfTest) { $legacyArguments += '-HarnessSelfTest' }
|
||||
if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' }
|
||||
& powershell.exe @legacyArguments
|
||||
exit $LASTEXITCODE
|
||||
@@ -34,8 +36,10 @@ $fixtureStatus = Join-Path $temporary 'fixture-status.json'
|
||||
$server = $null
|
||||
$fixture = $null
|
||||
$publisher = $null
|
||||
$preflightMedia = $null
|
||||
$pgStarted = $false
|
||||
$savedEnvironment = @{}
|
||||
$sensitiveValues = @()
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
@@ -61,15 +65,68 @@ function Set-TestEnvironment([string]$Name, [string]$Value) {
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
|
||||
}
|
||||
function Wait-Http([string]$Uri, [int]$Attempts = 120) {
|
||||
function Get-FreeUdpPort {
|
||||
$client = [Net.Sockets.UdpClient]::new([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, 0))
|
||||
try { return ([Net.IPEndPoint]$client.Client.LocalEndPoint).Port } finally { $client.Dispose() }
|
||||
}
|
||||
function Copy-TrackedSenseSource([string]$RepositoryRoot, [string]$Destination) {
|
||||
$tracked = @(& git -C $RepositoryRoot ls-files -- 'Sense')
|
||||
if ($LASTEXITCODE -ne 0 -or $tracked.Count -eq 0) { throw 'Could not enumerate tracked Sense source files' }
|
||||
$sensePrefix = 'Sense\'
|
||||
foreach ($relative in $tracked) {
|
||||
$normalized = ([string]$relative).Replace('/', '\')
|
||||
if (-not $normalized.StartsWith($sensePrefix, [StringComparison]::Ordinal)) {
|
||||
throw "Unexpected tracked path outside Sense: $relative"
|
||||
}
|
||||
$source = Join-Path $RepositoryRoot $normalized
|
||||
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Tracked Sense source is missing: $relative" }
|
||||
$target = Join-Path $Destination $normalized.Substring($sensePrefix.Length)
|
||||
$parent = Split-Path -Parent $target
|
||||
if (-not (Test-Path -LiteralPath $parent)) { [void](New-Item -ItemType Directory -Path $parent -Force) }
|
||||
Copy-Item -LiteralPath $source -Destination $target
|
||||
}
|
||||
}
|
||||
function Get-SafeLogSummary([string[]]$Paths, [int]$MaximumCharacters = 2000) {
|
||||
$parts = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($path in @($Paths)) {
|
||||
if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
|
||||
$text = [string](@(Get-Content -LiteralPath $path -Tail 20 -ErrorAction SilentlyContinue) -join ' | ')
|
||||
foreach ($secret in @($script:sensitiveValues)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($secret) -and $secret.Length -ge 4) { $text = $text.Replace($secret, '<redacted>') }
|
||||
}
|
||||
$text = $text -replace '(?i)((?:password|token|secret|credential(?:_key)?|database(?:_url)?|cookie|authorization)["'']?\s*[:=]\s*["'']?)[^\s,;"'']+', '$1<redacted>'
|
||||
$text = $text -replace '(?i)(postgres(?:ql)?://)[^\s]+', '$1<redacted>'
|
||||
if ($text.Length -gt $MaximumCharacters) { $text = $text.Substring($text.Length - $MaximumCharacters) }
|
||||
if (-not [string]::IsNullOrWhiteSpace($text)) { $parts.Add("$([IO.Path]::GetFileName($path)): $text") }
|
||||
}
|
||||
if ($parts.Count -eq 0) { return '<no log output>' }
|
||||
return ($parts -join ' || ')
|
||||
}
|
||||
function Wait-Http {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[int]$Attempts = 120,
|
||||
$Process = $null,
|
||||
[string]$Stage = 'HTTP endpoint',
|
||||
[string[]]$LogPaths = @()
|
||||
)
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
if ($null -ne $Process -and $Process.HasExited) {
|
||||
try { $Process.WaitForExit(); $Process.Refresh() } catch {}
|
||||
$exitCode = try { [string]$Process.ExitCode } catch { 'unknown' }
|
||||
if ([string]::IsNullOrWhiteSpace($exitCode)) { $exitCode = 'unknown' }
|
||||
$summary = Get-SafeLogSummary $LogPaths
|
||||
throw "$Stage process exited before readiness: exit_code=$exitCode; log_files=$($LogPaths -join ','); summary=$summary"
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1
|
||||
if ($response.StatusCode -eq 200) { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
throw "HTTP endpoint did not become ready: $Uri"
|
||||
$processState = if ($null -eq $Process) { 'not-observed' } elseif ($Process.HasExited) { "exited:$($Process.ExitCode)" } else { 'running' }
|
||||
$summary = Get-SafeLogSummary $LogPaths
|
||||
throw "$Stage did not become ready: uri=$Uri; process_state=$processState; log_files=$($LogPaths -join ','); summary=$summary"
|
||||
}
|
||||
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
@@ -104,13 +161,62 @@ function Invoke-SenseJson {
|
||||
function Start-SensePackage([string]$PackageRoot) {
|
||||
$launcher = Join-Path $PackageRoot 'start-sense.bat'
|
||||
$process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru
|
||||
Wait-Http "$script:baseUrl/"
|
||||
Wait-Http -Uri "$script:baseUrl/" -Process $process -Stage 'Sense HTTP' -LogPaths @($runtimeLog, $runtimeError)
|
||||
return $process
|
||||
}
|
||||
function Stop-ProcessTree($Process) {
|
||||
if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null }
|
||||
}
|
||||
|
||||
function Invoke-HarnessSelfTest {
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-selftest-' + [guid]::NewGuid().ToString('N'))
|
||||
$originalSensitiveValues = @($script:sensitiveValues)
|
||||
try {
|
||||
$fixtureRepository = Join-Path $root 'repository'
|
||||
$trackedSource = Join-Path $fixtureRepository 'Sense\tracked.txt'
|
||||
$ignoredSource = Join-Path $fixtureRepository 'Sense\ui\node_modules\ignored.txt'
|
||||
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $trackedSource) -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $ignoredSource) -Force)
|
||||
[IO.File]::WriteAllText($trackedSource, 'tracked', (New-Object Text.UTF8Encoding($false)))
|
||||
[IO.File]::WriteAllText($ignoredSource, 'ignored', (New-Object Text.UTF8Encoding($false)))
|
||||
& git -C $fixtureRepository init --quiet
|
||||
& git -C $fixtureRepository add -- 'Sense/tracked.txt'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Harness self-test could not prepare tracked source' }
|
||||
$copy = Join-Path $root 'copy'
|
||||
Copy-TrackedSenseSource $fixtureRepository $copy
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $copy 'tracked.txt'))) { throw 'Harness self-test did not copy tracked source' }
|
||||
if (Test-Path -LiteralPath (Join-Path $copy 'ui\node_modules\ignored.txt')) { throw 'Harness self-test copied ignored node_modules content' }
|
||||
|
||||
$udpPort = Get-FreeUdpPort
|
||||
$udpProbe = [Net.Sockets.UdpClient]::new()
|
||||
try { $udpProbe.Client.Bind([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, $udpPort)) } finally { $udpProbe.Dispose() }
|
||||
|
||||
$diagnosticLog = Join-Path $root 'sense.err.log'
|
||||
[IO.File]::WriteAllText($diagnosticLog, 'SENSE_JWT_SECRET=unit-secret-value', (New-Object Text.UTF8Encoding($false)))
|
||||
$script:sensitiveValues = @('unit-secret-value')
|
||||
$exited = [pscustomobject]@{ HasExited = $true; ExitCode = 23 }
|
||||
$earlyFailure = ''
|
||||
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 3 -Process $exited -Stage 'Self-test early exit' -LogPaths @($diagnosticLog) } catch { $earlyFailure = $_.Exception.Message }
|
||||
if ($earlyFailure -notmatch 'exit_code=23' -or $earlyFailure.Contains('unit-secret-value') -or $earlyFailure -notmatch '<redacted>') {
|
||||
throw "Harness self-test early-exit diagnostic was unsafe or incomplete: $earlyFailure"
|
||||
}
|
||||
$timeoutFailure = ''
|
||||
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 1 -Stage 'Self-test timeout' -LogPaths @($diagnosticLog) } catch { $timeoutFailure = $_.Exception.Message }
|
||||
if ($timeoutFailure -notmatch 'process_state=not-observed' -or $timeoutFailure.Contains('unit-secret-value') -or $timeoutFailure -notmatch '<redacted>') {
|
||||
throw "Harness self-test timeout diagnostic was unsafe or incomplete: $timeoutFailure"
|
||||
}
|
||||
Write-Host 'Sense E2E harness self-test passed: tracked copy, UDP bind, early exit, timeout and redaction.'
|
||||
} finally {
|
||||
$script:sensitiveValues = $originalSensitiveValues
|
||||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
|
||||
}
|
||||
}
|
||||
|
||||
if ($HarnessSelfTest) {
|
||||
Invoke-HarnessSelfTest
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($required in @(
|
||||
(Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'),
|
||||
@@ -121,25 +227,32 @@ try {
|
||||
}
|
||||
$ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source
|
||||
if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) {
|
||||
New-Item -ItemType Directory -Path $repoCopy | Out-Null
|
||||
Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse
|
||||
New-Item -ItemType Directory -Path $senseCopy -Force | Out-Null
|
||||
Copy-TrackedSenseSource $repositoryRoot $senseCopy
|
||||
& git -C $repoCopy init --quiet
|
||||
& git -C $repoCopy config user.name 'Sense E2E'
|
||||
& git -C $repoCopy config user.email 'sense-e2e@invalid.local'
|
||||
& git -C $repoCopy commit --allow-empty --quiet -m 'temporary acceptance source'
|
||||
& git -C $repoCopy config core.autocrlf false
|
||||
& git -C $repoCopy add -- Sense
|
||||
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source staging failed' }
|
||||
& git -C $repoCopy commit --quiet -m 'temporary acceptance source'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source commit failed' }
|
||||
|
||||
Write-Host 'Building Sense Windows package in an isolated temporary copy...'
|
||||
& pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' }
|
||||
$packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64'
|
||||
} else {
|
||||
$packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' }
|
||||
$senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..'))
|
||||
Write-Host "Using prepared isolated package: $packageRoot"
|
||||
$preparedInput = [IO.Path]::GetFullPath($PreparedPackageRoot)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $preparedInput 'sense.exe'))) { throw 'prepared Sense package is invalid' }
|
||||
$packageRoot = Join-Path $temporary 'prepared-package'
|
||||
Copy-Item -LiteralPath $preparedInput -Destination $packageRoot -Recurse
|
||||
Write-Host "Using temporary copy of prepared package: $packageRoot"
|
||||
}
|
||||
|
||||
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8
|
||||
$tcpPorts = @(Get-UniqueFreePorts 7)
|
||||
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $mediaAPIPort, $onvifPort = $tcpPorts
|
||||
do { $webrtcUDPort = Get-FreeUdpPort } while ($tcpPorts -contains $webrtcUDPort)
|
||||
$script:baseUrl = "http://127.0.0.1:$sensePort"
|
||||
Write-Host "Initializing isolated PostgreSQL on port $pgPort..."
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
@@ -162,6 +275,16 @@ try {
|
||||
'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:'
|
||||
) -join "`n"
|
||||
[IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false)))
|
||||
$preflightOut = Join-Path $temporary 'mediamtx-preflight.out.log'
|
||||
$preflightError = Join-Path $temporary 'mediamtx-preflight.err.log'
|
||||
$preflightBinary = Join-Path $packageRoot 'bin\mediamtx.exe'
|
||||
$preflightConfig = Join-Path $packageRoot 'config\mediamtx.yml'
|
||||
$preflightMedia = Start-Process -FilePath $preflightBinary -ArgumentList $preflightConfig -WorkingDirectory (Split-Path -Parent $preflightConfig) -RedirectStandardOutput $preflightOut -RedirectStandardError $preflightError -WindowStyle Hidden -PassThru
|
||||
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $preflightMedia -Stage 'MediaMTX preflight' -LogPaths @($preflightOut, $preflightError) -Attempts 60
|
||||
Stop-ProcessTree $preflightMedia
|
||||
Wait-Tcp -Port $mediaAPIPort -Open $false -Attempts 40
|
||||
$preflightMedia = $null
|
||||
Write-Host 'MediaMTX package/config preflight passed before managed Sense startup.'
|
||||
|
||||
$jwt = New-RandomText 48
|
||||
$bootstrap = New-RandomText 48
|
||||
@@ -173,6 +296,7 @@ try {
|
||||
$cameraUser = 'fixture_' + (New-RandomText 8)
|
||||
$cameraPassword = New-RandomText 24
|
||||
$database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable"
|
||||
$script:sensitiveValues = @($jwt, $bootstrap, $adminPassword, $credentialKey, $cameraUser, $cameraPassword, $database)
|
||||
$environment = @{
|
||||
SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database;
|
||||
SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap;
|
||||
@@ -198,7 +322,7 @@ try {
|
||||
if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' }
|
||||
|
||||
$server = Start-SensePackage $packageRoot
|
||||
Wait-Http "http://127.0.0.1:$mediaAPIPort/v3/config/global/get"
|
||||
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $server -Stage 'MediaMTX API' -LogPaths @($runtimeLog, $runtimeError)
|
||||
$publisherArguments = @(
|
||||
'-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10',
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp',
|
||||
@@ -214,6 +338,7 @@ try {
|
||||
if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' }
|
||||
$login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword }
|
||||
$token = [string]$login.token
|
||||
$script:sensitiveValues += $token
|
||||
if ($token.Length -lt 20) { throw 'login did not return a usable token' }
|
||||
$unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401
|
||||
|
||||
@@ -265,13 +390,12 @@ try {
|
||||
$area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0]
|
||||
if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' }
|
||||
|
||||
$browserScript = Join-Path $senseCopy 'ui\sense-browser-smoke.cjs'
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript
|
||||
$browserScript = Join-Path $PSScriptRoot 'browser-smoke.cjs'
|
||||
foreach ($item in @{
|
||||
SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser;
|
||||
SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png')
|
||||
}.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
|
||||
Push-Location (Join-Path $senseCopy 'ui')
|
||||
Push-Location $temporary
|
||||
try { & node.exe $browserScript } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' }
|
||||
|
||||
@@ -299,7 +423,7 @@ try {
|
||||
$plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim()
|
||||
if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' }
|
||||
|
||||
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError)) {
|
||||
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError, $preflightOut, $preflightError)) {
|
||||
if (Test-Path $log) {
|
||||
$text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $text) { $text = '' }
|
||||
@@ -311,6 +435,7 @@ try {
|
||||
Stop-ProcessTree $publisher
|
||||
Stop-ProcessTree $fixture
|
||||
Stop-ProcessTree $server
|
||||
Stop-ProcessTree $preflightMedia
|
||||
if ($pgStarted) {
|
||||
$pgStopArguments = "-D `"$pgData`" -m fast stop"
|
||||
[void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
|
||||
wiki_revision: 5894b3f4e3152420bd9addd63c1ce80205a6fd80
|
||||
synchronized_at: 2026-08-27T15:22:01Z
|
||||
wiki_revision: 3ec1fe54504a9c5eabb76dc19f0e46eb6c58ba08
|
||||
synchronized_at: 2026-08-29T12:37:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -19,7 +19,7 @@ synchronized_at: 2026-08-27T15:22:01Z
|
||||
| 首期客户场景 | 民办寄宿学校,默认 16 路高风险点位 |
|
||||
| 首期规则 | 越线、危险区域、聚集等匿名安全规则;不启用人脸 |
|
||||
| 产品形态 | Sense 与 Bell 两个独立销售产品,Brain 为独立推理交付单元 |
|
||||
| 当前阶段 | Sense 独立纵切、Brain Python 骨架、Bell GoAdmin 产品骨架已通过用户验收并合入 `dev`;旧实现归档于 `explore`,`main` 仍为审核基线 |
|
||||
| 当前阶段 | MVP #8 三项目首个独立纵切已于 2026-08-29 通过用户验收并合入 `dev`:Sense 完成摄像头接入到区域配置,Brain 完成合成输入到匿名本地事件,Bell 完成合成事件到 Alert ack/close;旧实现归档于 `explore`,`main` 仍为审核基线 |
|
||||
| 历史来源 | `D:\OPC\yovision_old`,只读追溯 |
|
||||
|
||||
## DevHarness 来源与基线
|
||||
@@ -54,7 +54,7 @@ YoVision 采用 DevHarness 的共同工作流、统一 `harness.py` 命令、Git
|
||||
- 证据:客户侧 MinIO/S3 兼容对象存储;常态录像优先留在客户已有 NVR。
|
||||
- 首期验证平台:NVIDIA x86/Jetson;M1-M3 不承诺 GB/T 28181、信创或原生 App。
|
||||
|
||||
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。当前 `dev` 中的 Sense、Bell 已分别从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时仍必须核对冻结 go-admin-doc。Brain 已建立独立 Python/PyTorch 包骨架,但尚未包含推理业务能力。
|
||||
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。当前 `dev` 中的 Sense、Bell 已分别从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时仍必须核对冻结 go-admin-doc。Brain 已在独立 Python/PyTorch 包骨架上完成合成/本地输入、解码、匿名检测与单路跟踪、区域/方向越线判定和项目内匿名事件输出;真实 GPU、生产模型和跨项目契约仍属后续范围。
|
||||
|
||||
## 阅读入口
|
||||
|
||||
@@ -108,20 +108,20 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
|
||||
<!-- sense-runtime:start -->
|
||||
## Sense 重建状态
|
||||
|
||||
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;当前成果已合入 `dev`,并于 2026-08-27 通过用户验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
|
||||
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;#145 又修复默认验收入口的受控源码复制、UDP 端口探测、临时清理和脱敏诊断。当前成果已合入 `dev`,并随 MVP #8 于 2026-08-29 通过三项目独立纵切验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
<!-- bell-runtime:start -->
|
||||
## Bell 重建状态
|
||||
|
||||
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。当前最小启用骨架已通过后端、前端和隔离 PostgreSQL smoke,并于 2026-08-27 通过用户验收、合入 `dev`;事件、规则、Alert 等业务能力继续按独立工单迁移。
|
||||
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。#131–#134 已完成 Event/Receipt、合成事件、规则匹配、Alert ack/close、审计时间线、Windows 交付和独立 E2E;生产验证码、最小菜单和 GoAdmin 外壳缺陷也已闭环。当前成果已合入 `dev`,并随 MVP #8 于 2026-08-29 通过三项目独立纵切验收。
|
||||
<!-- bell-runtime:end -->
|
||||
|
||||
<!-- brain-runtime:start -->
|
||||
## Brain 初始化状态
|
||||
|
||||
Brain 已建立 CPython 3.11.15 / PyTorch 2.12.1 的无界面包骨架,提供安装、版本、runtime-info 与 CPU/CUDA smoke 入口。CPU wheel、包测试和 CPU tensor smoke 已通过,并于 2026-08-27 通过用户验收、合入 `dev`;CUDA wheel、真实 GPU、视频、模型、规则、事件与部署尚未验证或实现。
|
||||
Brain 已在 CPython 3.11.15 / PyTorch 2.12.1 无界面包骨架上完成合成与本地视频输入、可替换解码、匿名检测与单路跟踪、危险区域与方向越线判定,以及项目内匿名事件输出。独立验收中 43 项测试通过,CLI 合成输入实际生成 `brain.internal.event-candidate/v1` 匿名事件;当前成果已合入 `dev`,并随 MVP #8 于 2026-08-29 通过用户验收。CUDA wheel、真实 GPU、生产模型、容量和跨项目事件契约仍未验证。
|
||||
<!-- brain-runtime:end -->
|
||||
|
||||
## 分支治理
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 578ddbaae3d7e846037d958085e40609cd398bef
|
||||
synchronized_at: 2026-08-28T08:02:32Z
|
||||
wiki_revision: 14b961599d6954357142713a5667fb37d38e86b7
|
||||
synchronized_at: 2026-08-29T12:37:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -236,3 +236,63 @@ PostgreSQL 表 `sense_provisioning_batches` 保存幂等键、配额快照和汇
|
||||
- GoAdmin 路由位于 `Sense/server/app/admin/router/sense_media_shard.go`,只开放列表、详情和迁移预检三个 GET 接口。go-admin-ui 页面位于 `Sense/ui/src/views/sense/media-shard/`,复用 BasicLayout、Element Plus 表格、进度、Dialog、Tag、Alert 和权限指令。
|
||||
- 迁移 `2026082814000_media_shard.go` 建表、注册动态菜单并为 implementation_operator、site_admin、viewer 建立只读权限;生产迁移和启动不写入合成分片。
|
||||
<!-- sense-media-shards:end -->
|
||||
|
||||
<!-- sense-outbox:start -->
|
||||
## Sense 内部可靠投递入口
|
||||
|
||||
工单 #78 在 `Sense/server/app/sense/outbox/` 建立内部事务 Outbox。业务写入通过同一 GORM 事务创建领域记录与 outbox;`Sense/server/app/sense/local_event/outbox.go` 是当前首个原子写入入口。GoAdmin 路由位于 `Sense/server/app/admin/router/sense_outbox.go`,迁移与菜单/RBAC 位于 `Sense/server/cmd/migrate/migration/version/2026082815000_outbox.go`,前端页面位于 `Sense/ui/src/views/sense/outbox/index.vue`。
|
||||
|
||||
内部状态为 pending、processing、retry、dead、delivered。relay 使用数据库 claim、lease 和版本号避免并发重复领取;失败按退避进入 retry,超过上限进入 dead,租约过期可恢复。成功投递写入永久幂等收据。当前模块不定义 Brain/Bell 正式 schema、connector 或机器身份,内部 payload 也不通过管理 API 暴露。
|
||||
<!-- sense-outbox:end -->
|
||||
|
||||
<!-- sense-ops-alerts:start -->
|
||||
## Sense 运维告警代码路径
|
||||
|
||||
工单 #79 在 `Sense/server/app/sense/ops_alert/` 建立持久化运维告警:`sense_ops_alerts` 以“告警类型 + 对象类型 + 对象 ID”唯一指纹保存当前生命周期,`sense_ops_alert_transitions` 追加发现、确认、健康恢复、恢复确认、恢复失败和再次发生历史。健康事实只读取既有设备接入、媒体路由、媒体分片和边缘节点投影,不建立第二套设备或媒体状态事实源。
|
||||
|
||||
GoAdmin 路由位于 `Sense/server/app/admin/router/sense_ops_alert.go`,API 为 `GET /api/v1/ops-alerts`、`GET /api/v1/ops-alerts/:id`、`POST /api/v1/ops-alerts/evaluate`、`POST /api/v1/ops-alerts/:id/acknowledge` 和 `POST /api/v1/ops-alerts/:id/recover`。前端入口为 `Sense/ui/src/views/sense/ops-alert/index.vue`,继续复用 GoAdmin BasicLayout、动态菜单、Axios、Element Plus 表格/表单/分页/Dialog/Tag/Alert 和权限指令。
|
||||
|
||||
本模块只写 Sense 运维告警和 GoAdmin 操作审计,不导入或写入本地安全事件、Brain、Bell、Outbox 或共享契约模型。viewer 只读;implementation_operator 与 site_admin 可刷新健康事实、确认和恢复。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
<!-- brain-input-v1:start -->
|
||||
## Brain 内部输入与配置边界
|
||||
|
||||
Brain 的首个独立输入边界位于 `Brain/src/yovision_brain/input/`,项目内配置模型位于 `Brain/src/yovision_brain/config/`。配置显式标记为 `brain.internal.input/v1`,只用于 Brain 独立开发与测试,不是 Sense→Brain 共享契约。
|
||||
|
||||
输入端口当前提供确定性 RGB 合成源和显式本地文件源。两者携带逻辑设备、Profile 与分辨率元数据;合成源提供固定种子、帧序列和确定性时间基准,本地文件源提供可替换解码器消费的容器字节、EOF 和协作取消边界。错误只暴露安全文件标签,不把机器绝对路径、凭据或客户数据写入日志/事件。
|
||||
|
||||
正式 RTSP、Sense 源配置、共享区域契约和跨项目投递仍由协调工单建立版本化 `contracts/` 适配器,不得把本内部模型直接发布给 Sense 或 Bell。
|
||||
<!-- brain-input-v1:end -->
|
||||
|
||||
<!-- brain-decode-v1:start -->
|
||||
## Brain 可替换解码边界
|
||||
|
||||
Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内部 `InputPacket` 端口,向后续视觉模块输出顺序、纳秒时间戳、逻辑设备、Profile、分辨率、像素格式和尺寸变化标记明确的 `DecodedFrame`。具体后端通过 `DecoderBackend` 注册,不要求检测、跟踪或规则层依赖某个编解码 SDK。
|
||||
|
||||
当前独立纵切支持确定性 RGB24 合成帧,以及标准库实现的最小 YUV4MPEG2 C444 本地视频流。Y4M 只用于匿名本地/合成验证;生产 RTSP、FFmpeg/PyAV、NVIDIA 硬件解码、重连和多路调度仍是后续范围。损坏输入、不支持格式、Profile 尺寸不匹配和安全大小上限均产生明确错误;正常 EOF 与主动取消不伪装成失败。
|
||||
<!-- brain-decode-v1:end -->
|
||||
|
||||
<!-- brain-vision-v1:start -->
|
||||
## Brain 匿名检测与单路跟踪边界
|
||||
|
||||
`Brain/src/yovision_brain/vision/` 定义可替换 Detector、匿名边界框观测和会话内单路 IoU 跟踪。输出仅包含类别 `anonymous_target`、置信度、边界框、帧时间和当前进程内轨迹 ID;轨迹 ID 不跨进程、不跨摄像头,也不是自然人身份。
|
||||
|
||||
当前基线是版本 `1.0.0` 的 YoVision first-party 亮度连通区域算法,并提供 PyTorch 2.12.1 张量实现;不分发外部模型权重,PyTorch 许可已在 Brain 第三方清单记录。它用于验证匿名检测/跟踪链路,不代表人员检测效果,不承诺召回率或误报率。人脸、生物特征和跨摄像头 ReID 均未启用。
|
||||
<!-- brain-vision-v1:end -->
|
||||
|
||||
<!-- brain-rules-v1:start -->
|
||||
## Brain 区域与方向越线规则边界
|
||||
|
||||
`Brain/src/yovision_brain/rules/` 只消费匿名轨迹。轨迹框底边中心是归一化规则锚点;多边形边界视为区域内,状态区分 outside、entered、inside。有向警戒线按起点→终点的左右侧定义 `left_to_right` / `right_to_left`,deadband 内不触发且保留上一次显著侧。
|
||||
|
||||
每个结果绑定规则配置版本、Profile、分辨率、锚点和可解释原因。结果是 Brain 内部候选,不是标准事件或 Bell Alert;时段、持续时间、冷却、聚集和正式 Sense 配置契约不在本阶段。
|
||||
<!-- brain-rules-v1:end -->
|
||||
|
||||
<!-- brain-local-events-v1:start -->
|
||||
## Brain 独立纵切与内部事件边界
|
||||
|
||||
`Brain/src/yovision_brain/app/` 编排输入、解码、匿名检测/跟踪和规则端口;`Brain/src/yovision_brain/events/` 将触发结果映射为 `brain.internal.event-candidate/v1` 并写入可替换 JSON Lines sink。事件 ID 基于规范化输入事实与版本的 SHA-256,同一输入、配置和实现版本重复运行保持稳定。
|
||||
|
||||
内部候选包含逻辑输入引用、规则/模型版本、发生时间、匿名框和解释原因,不包含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或证据引用。该格式不是 Brain→Bell 共享契约;Bell API、Outbox、机器身份、证据和跨项目投递必须由协调工单另行实现。
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 999eb1aee3558ff75cfa929acac77841af20b742
|
||||
synchronized_at: 2026-08-28T08:02:42Z
|
||||
wiki_revision: 27749cbf699093d997284afc277ea53e73a5876f
|
||||
synchronized_at: 2026-08-29T12:37:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -210,3 +210,26 @@ synchronized_at: 2026-08-28T08:02:42Z
|
||||
- 跨分片迁移预检是只读操作,只检查源状态、全部受影响路径和候选目标容量;即使预检通过也不授予执行权限。实际迁移必须另建高风险工单并取得人工确认。
|
||||
- 额外分片只能配置为 external,Control API 必须使用无用户信息、无查询参数、无路径的本机回环 HTTP 地址。Sense 不停止外部实例。
|
||||
<!-- sense-media-shards:end -->
|
||||
|
||||
<!-- sense-outbox:start -->
|
||||
## Sense 内部 Outbox 业务规则
|
||||
|
||||
- 领域记录与 outbox 必须在同一 PostgreSQL 事务中提交;任一写入失败时两者一起回滚。
|
||||
- 幂等键在消息表唯一,成功后还保留永久投递收据;重试和人工恢复沿用原业务记录与幂等键。
|
||||
- worker 只能领取到期的 pending/retry 或租约已过期的 processing 记录;同一记录不能被两个 worker 同时成功领取。
|
||||
- 失败保留脱敏错误与尝试历史,按退避等待;达到最大次数进入 dead。人工重新排队必须填写原因并记录操作者,不删除历史。
|
||||
- implementation_operator、site_admin、viewer 可查看;只有 implementation_operator、site_admin 可重新排队。
|
||||
- 未配置外部 connector 时保留内部记录且不阻断 Sense 核心功能。测试 sink 在 prod/production 模式禁止启用。
|
||||
- 管理 API 不返回内部 payload、外部凭据或机器身份;Brain/Bell 正式协议属于后续协调工单。
|
||||
<!-- sense-outbox:end -->
|
||||
|
||||
<!-- sense-ops-alerts:start -->
|
||||
## Sense 运维告警规则
|
||||
|
||||
- 六类运维告警固定为:设备/边缘节点离线、设备认证失败、设备时间漂移、媒体状态对账失败、媒体分片异常、控制隧道异常。
|
||||
- 每个“告警类型 + 对象类型 + 对象 ID”只有一条记录;同一源版本重复刷新不增加发现次数,也不产生第二条活动告警。恢复后再次异常复用原记录、递增处理周期并保留全部历史。
|
||||
- 状态为 `unacknowledged`(待确认)、`acknowledged`(已确认)、`recovering`(恢复观察)、`recovered`(已恢复)。人工确认只表示已接手,不表示故障恢复。
|
||||
- 健康事实恢复后先进入固定 5 分钟观察窗口;只有 `recovering` 且观察窗口结束后才能人工确认恢复。观察期再次异常返回原处理状态并追加恢复失败历史。
|
||||
- 确认和恢复都要求 6–256 字符原因、当前版本和允许的状态;旧版本或错误状态返回冲突。所有动作写入独立流转历史和 GoAdmin 操作审计。
|
||||
- 运维告警永远设置为 Sense 内部运维记录,不创建本地安全事件或 Bell Alert,不进入跨项目 Outbox,也不实现通知升级。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 05435d53528271a866c525655486689afc198762
|
||||
synchronized_at: 2026-08-28T08:02:52Z
|
||||
wiki_revision: e6068ff0e42765d32ff4e0ee0e8e51cf7d79b7da
|
||||
synchronized_at: 2026-08-29T12:37:58Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -515,3 +515,112 @@ go test ./cmd/migrate/migration/version -run TestMediaShardMigrationOnPostgres -
|
||||
|
||||
验证应覆盖任意配置容量、稳定重复分配、容量耗尽、分片故障后归属不变、设备/Profile/路径影响范围、只读迁移预检、只读 RBAC、Control API 不出现在响应,以及 Brain/Bell 均不运行。没有专用 PostgreSQL 连接时必须记录真库迁移测试未执行。
|
||||
<!-- sense-media-shards:end -->
|
||||
|
||||
<!-- sense-outbox:start -->
|
||||
## Sense Outbox 本地验证
|
||||
|
||||
从 `Sense/server` 运行完整后端测试:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
PostgreSQL 多 worker 集成测试必须使用专用隔离数据库,不得指向开发或生产库:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_OUTBOX_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./app/sense/outbox -run TestPostgresConcurrentWorkersDoNotClaimSameMessage -count=1 -v
|
||||
```
|
||||
|
||||
前端从 `Sense/ui` 运行:
|
||||
|
||||
```powershell
|
||||
pnpm lint
|
||||
pnpm test:unit -- --runInBand
|
||||
pnpm build:prod
|
||||
```
|
||||
|
||||
验证至少覆盖:领域记录与 outbox 原子回滚、并发 claim/lease、租约恢复、退避与 dead、人工重新排队及操作人、永久幂等收据、production 禁用测试 sink、只读/恢复权限、API 不泄露 payload,以及 Brain/Bell 均不运行时页面可观察。无专用 PostgreSQL 连接时必须明确记录真库并发测试未执行。
|
||||
<!-- sense-outbox:end -->
|
||||
|
||||
<!-- sense-ops-alerts:start -->
|
||||
## Sense 运维告警验证
|
||||
|
||||
从后端目录运行:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
go test ./app/sense/ops_alert ./app/admin/router ./cmd/migrate/migration/version
|
||||
go test ./...
|
||||
```
|
||||
|
||||
从前端目录运行:
|
||||
|
||||
```powershell
|
||||
cd Sense/ui
|
||||
pnpm lint
|
||||
pnpm test:unit -- --runInBand
|
||||
pnpm build:prod
|
||||
```
|
||||
|
||||
故障注入至少覆盖六类来源、相同源版本重复刷新、健康恢复、5 分钟观察门槛、观察期复发、恢复后再次发生、旧版本并发冲突、viewer 只读权限和脱敏操作审计。Brain/Bell 不启动。隔离启动 smoke 必须验证 `2026082816000_ops_alert.go` 迁移、菜单和 API 注册;不得把开发或生产数据库当作破坏性故障注入库。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
<!-- brain-input-v1:start -->
|
||||
## Brain 合成与本地输入验证
|
||||
|
||||
从仓库根目录使用 Brain 的隔离 CPython 3.11 环境执行:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/input Brain/tests/config -q
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
|
||||
```
|
||||
|
||||
定向测试覆盖固定种子与时间基准、Profile/分辨率和规则配置、EOF、取消、文件不存在、非法配置、凭据字段拒绝及安全错误文本。测试只使用运行时生成的小型匿名字节文件,不启动 Sense/Bell,不连接摄像头或网络服务。
|
||||
<!-- brain-input-v1:end -->
|
||||
|
||||
<!-- brain-decode-v1:start -->
|
||||
## Brain 视频解码验证
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/decode -q
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
|
||||
```
|
||||
|
||||
定向测试使用运行时生成的匿名 YUV4MPEG2 字节流,覆盖跨输入分块解码、顺序与时间戳、Profile/分辨率、RGB24 尺寸变化、正常 EOF、主动取消、截断帧、不支持格式/色度和配置尺寸不匹配。该结果不证明生产 RTSP、硬件解码、GPU 或多路性能。
|
||||
<!-- brain-decode-v1:end -->
|
||||
|
||||
<!-- brain-vision-v1:start -->
|
||||
## Brain 匿名检测与跟踪验证
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/vision -q
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cpu
|
||||
```
|
||||
|
||||
定向测试覆盖空帧、目标出现/移动、短暂遮挡、消失、轨迹结束、会话 ID 边界及 PyTorch CPU 后端。合成几何帧不含人脸或客户数据;结果只证明链路可运行,不是效果评估。
|
||||
<!-- brain-vision-v1:end -->
|
||||
|
||||
<!-- brain-rules-v1:start -->
|
||||
## Brain 区域与方向越线验证
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/rules -q
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
|
||||
```
|
||||
|
||||
定向测试覆盖区域外/进入/内部、边界点、正反方向、贴线 deadband、无效多边形/警戒线、重复 ID 和 Profile/分辨率不匹配;只使用合成归一化几何与匿名轨迹。
|
||||
<!-- brain-rules-v1:end -->
|
||||
|
||||
<!-- brain-local-events-v1:start -->
|
||||
## Brain 独立纵切运行与验证
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/events Brain/tests/app -q
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests -q
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain.app --config Brain\tests\fixtures\events\area.json --output -
|
||||
```
|
||||
|
||||
CLI 将内部事件 JSON Lines 写入 stdout,并把 completed/cancelled、帧数、检测数和事件数摘要写入 stderr。配置文件必须显式提供,当前使用 JSON;无命中正常返回零事件,读取/配置/模块失败返回非零且不回显机器路径。命令不启动 Sense/Bell、不连接摄像头或网络。
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
|
||||
wiki_revision: dcbdcf563017a1749fa76ad5f78c74a2c3cc6be1
|
||||
synchronized_at: 2026-08-28T06:16:00Z
|
||||
wiki_revision: eac307b5aa55ff770ff034c53a65b70dc01cb00d
|
||||
synchronized_at: 2026-08-29T12:39:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -232,3 +232,43 @@ Sense 为网管和非技术运维人员提供只读的“边缘节点”页面
|
||||
|
||||
本能力只管理 Sense 自有投影,不建立 Brain/Bell 共享身份、控制协议或跨项目回填执行;Brain、Bell 未运行时仍可独立查看。合成节点仅供显式开发和测试,不由生产启动或迁移自动写入。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
<!-- brain-input-delivery:start -->
|
||||
## BRN-001 独立输入适配交付边界
|
||||
|
||||
BRN-001 的首个独立实现已通过工单 #11 验收。Brain 可在 Sense、Bell 均未启动时使用固定种子和时间基准生成可重复的 RGB 合成帧,也可从显式本地路径读取容器字节供后续解码层消费;两种输入都携带 Brain 内部逻辑设备、Profile 和分辨率信息,并支持 EOF、协作取消及安全可定位错误。
|
||||
|
||||
项目内配置版本为 `brain.internal.input/v1`,可承载测试用区域和方向线,但它不是 Sense→Brain 共享契约。正式 RTSP、Sense 源/区域配置和跨项目机器身份仍须由协调工单在版本化 `contracts/` 中冻结;不得让 Sense 或 Bell 直接依赖此内部模型。配置和测试不得包含摄像头凭据、客户视频、个人数据或机器绝对路径。
|
||||
<!-- brain-input-delivery:end -->
|
||||
|
||||
<!-- brain-decode-delivery:start -->
|
||||
## BRN-002 独立解码交付边界
|
||||
|
||||
BRN-002 的首个解码阶段已通过工单 #13 验收。Brain 通过可替换 `DecoderBackend` 把内部输入转换为顺序、纳秒时间戳、逻辑设备、Profile、分辨率和像素格式明确的帧;当前独立路径支持确定性 RGB24 与匿名本地 YUV4MPEG2 C444。正常 EOF、主动取消、损坏或不支持格式、尺寸变化/不匹配均有明确结果。
|
||||
|
||||
该验收不包括生产 RTSP、FFmpeg/PyAV、NVIDIA 硬件解码、多路性能或客户视频,不得据此声明 GPU/生产编解码能力。
|
||||
<!-- brain-decode-delivery:end -->
|
||||
|
||||
<!-- brain-vision-delivery:start -->
|
||||
## BRN-002 匿名检测与跟踪交付边界
|
||||
|
||||
工单 #14 已验收匿名目标检测和会话内单路跟踪。输出只包含匿名类别、置信度、边界框、帧时间和当前进程内轨迹 ID;不包含姓名、人脸模板、生物特征、摄像头凭据或跨摄像头身份。
|
||||
|
||||
当前版本化基线是无外部权重的 first-party 亮度目标算法及 PyTorch 2.12.1 张量后端,只证明匿名检测/跟踪接口与链路可运行。真实人员检测效果、GPU、召回率、误报率和 ReID 均未验证或启用。
|
||||
<!-- brain-vision-delivery:end -->
|
||||
|
||||
<!-- brain-rules-delivery:start -->
|
||||
## BRN-003/BRN-004 区域与方向规则交付边界
|
||||
|
||||
工单 #15 已验收 Brain 内部危险区域与方向越线判定。轨迹框底边中心为归一化锚点;多边形边界视为区域内,状态区分 outside、entered、inside;有向线按起点→终点区分左右方向,并使用 deadband 抑制贴线抖动。
|
||||
|
||||
每个结果绑定规则配置版本、Profile、分辨率和解释原因。结果仍是 Brain 内部候选,不是 Bell Alert 或正式共享事件;聚集、完整时段/持续/冷却和正式 Sense 配置契约仍是后续范围。
|
||||
<!-- brain-rules-delivery:end -->
|
||||
|
||||
<!-- brain-local-events-delivery:start -->
|
||||
## BRN-005 独立内部事件候选交付边界
|
||||
|
||||
工单 #16 已验收 Brain 首个独立纵切:合成/本地输入经过解码、匿名检测/单路跟踪和区域/方向规则后,可输出 `brain.internal.event-candidate/v1` JSON Lines 候选。事件 ID 基于规范化输入事实与版本生成稳定 SHA-256;相同输入、配置和版本重复运行不制造不同 ID。
|
||||
|
||||
内部候选只含逻辑输入引用、规则/模型版本、发生时间、匿名观测和解释原因,不含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或伪造证据。该格式不是正式 Brain→Bell 契约;证据、机器身份、Outbox/可靠投递和跨项目 E2E 仍须协调工单实现。
|
||||
<!-- brain-local-events-delivery:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Roadmap
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Roadmap.-
|
||||
wiki_revision: 2d5b8550109a8ff0795ad46dd0f96c85929fb506
|
||||
synchronized_at: 2026-08-11T10:31:04Z
|
||||
wiki_revision: 5142de162b4665bd7c9ff201168cb0d4a7552f35
|
||||
synchronized_at: 2026-08-29T12:40:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品路线图
|
||||
@@ -84,3 +84,40 @@ Sense/Brain Event → 持久 Outbox/可靠投递
|
||||
- 契约未冻结却尝试共享数据库、用户会话或内部文件;
|
||||
- 新纵切尚未验收却删除或覆盖旧仓库;
|
||||
- 许可证、隐私或客户/法务门禁未满足却进入生产试点。
|
||||
|
||||
<!-- brain-input-delivery:start -->
|
||||
## Brain 独立纵切进度
|
||||
|
||||
- 工单 #11 已验收:确定性合成输入、本地文件输入和 Brain 内部版本化配置已合入 `dev`。
|
||||
- 下一项按真实依赖进入 #13 视频解码流水线;#14 检测/跟踪、#15 区域/越线和 #16 项目内匿名事件仍需依次完成。
|
||||
- 当前输入模型只用于 Brain 独立纵切,不代替阶段 2 的 Sense→Brain 正式契约。
|
||||
<!-- brain-input-delivery:end -->
|
||||
|
||||
<!-- brain-decode-delivery:start -->
|
||||
## Brain 解码进度
|
||||
|
||||
- 工单 #13 已验收:可替换解码端口、RGB24 和匿名本地 YUV4MPEG2 路径已合入 `dev`。
|
||||
- 下一项进入 #14 匿名检测与单路跟踪;#15、#16 仍按依赖顺序推进。
|
||||
<!-- brain-decode-delivery:end -->
|
||||
|
||||
<!-- brain-vision-delivery:start -->
|
||||
## Brain 匿名视觉进度
|
||||
|
||||
- 工单 #14 已验收并合入 `dev`;下一项进入 #15 区域与方向越线规则。
|
||||
- 当前基线不代表生产模型效果,#16 项目内事件仍未完成。
|
||||
<!-- brain-vision-delivery:end -->
|
||||
|
||||
<!-- brain-rules-delivery:start -->
|
||||
## Brain 规则进度
|
||||
|
||||
- 工单 #15 已验收并合入 `dev`;下一项进入 #16 独立纵切与内部匿名事件。
|
||||
- #16 完成前,Brain 首个独立纵切仍未闭环。
|
||||
<!-- brain-rules-delivery:end -->
|
||||
|
||||
<!-- brain-local-events-delivery:start -->
|
||||
## Brain 首个独立纵切完成状态
|
||||
|
||||
- #10、#11、#13、#14、#15、#16 已全部通过用户验收。
|
||||
- Brain 可在 Sense/Bell 未启动时,以合成输入产生稳定的项目内匿名区域事件。
|
||||
- 下一步是 MVP #8 三项目独立纵切集成验收;正式跨项目契约与投递不属于该 MVP。
|
||||
<!-- brain-local-events-delivery:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: ce246054849b5b797dc4da0a55116238a4c00d7e
|
||||
synchronized_at: 2026-08-28T08:04:52Z
|
||||
wiki_revision: b21bbc64f323f465b78b557537c38e334de31f45
|
||||
synchronized_at: 2026-08-29T12:41:01Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# YoVision 部署与运维
|
||||
@@ -112,3 +112,25 @@ Sense\start_sense.bat
|
||||
|
||||
页面“运行异常”表示最近一次 Control API 探测失败;“状态已陈旧”表示运行循环已超过 30 秒没有更新探测结果。故障时先在详情定位设备/Profile/路径,不要手工改数据库归属。迁移预检不会执行迁移;任何实际跨分片迁移都必须另建高风险工单和回退方案。
|
||||
<!-- sense-media-shards:end -->
|
||||
|
||||
<!-- sense-outbox:start -->
|
||||
## Sense 可靠投递运维与排错
|
||||
|
||||
升级后应执行包含 `2026082815000_outbox.go` 的数据库迁移。看不到“可靠投递”菜单时,先确认迁移成功,再重新登录或刷新动态菜单。页面提供等待投递、重试、处理中/租约和死信数量;未配置正式 connector 时队列保留,不影响 Sense 设备接入、实时监看和其他核心能力。
|
||||
|
||||
积压时先查看状态、可用时间、租约、尝试次数和最近脱敏错误。processing 长时间不恢复时检查 worker 是否仍运行、数据库时间与租约是否过期;不要手工清空租约或删除消息。dead 只能由 implementation_operator 或 site_admin 在排除根因后填写恢复原因重新排队,原业务记录、幂等键和失败历史必须保留。
|
||||
|
||||
日志、页面和 API 不得输出内部 payload、外部凭据或机器身份。production 配置不得启用测试 sink。正式 Brain/Bell connector、机器身份、共享 schema 和跨项目 E2E 必须通过后续协调工单交付;停用 relay 可以作为回退,但不得删除未投递记录或永久幂等收据。
|
||||
<!-- sense-outbox:end -->
|
||||
|
||||
<!-- sense-ops-alerts:start -->
|
||||
## Sense 运维告警运行与排错
|
||||
|
||||
升级后必须执行包含 `2026082816000_ops_alert.go` 的数据库迁移。看不到“运维告警”菜单时,先确认迁移成功,再重新登录或刷新动态菜单。viewer 只能查看列表和详情;implementation_operator、site_admin 可使用“刷新状态”、确认和恢复。
|
||||
|
||||
“刷新状态”只读取 Sense 数据库中已有的设备接入、媒体路由、媒体分片和边缘节点健康投影。没有对应健康投影时不会伪造演示告警;先检查上游模块是否已完成探测或心跳入库。分片超过 30 秒没有探测、节点超过 90 秒没有心跳会被判定异常。
|
||||
|
||||
确认后仍显示活动告警是正常行为:确认只代表有人处理。源状态健康后进入“恢复观察”,稳定满 5 分钟才能确认恢复;期间复发会返回待确认或已确认。恢复操作被拒绝时先刷新列表,检查健康状态、观察起始时间和页面版本,不要手工改表或删除历史。
|
||||
|
||||
运维告警排错不得粘贴设备地址、Stream URI、摄像头凭据、JWT、Cookie 或数据库连接。需要回退时可停止使用刷新/处置入口,但不得删除 `sense_ops_alerts` 或 `sense_ops_alert_transitions` 历史;规则语义变化必须另建工单。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
Reference in New Issue
Block a user