Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde84c8687 | ||
|
|
64b9ea8462 | ||
|
|
15340507eb | ||
|
|
e83b359cc5 | ||
|
|
86b4dd7a07 | ||
|
|
f6fda99aa2 | ||
|
|
ea9a89f851 | ||
|
|
f07952cb98 | ||
|
|
be1b479f68 | ||
|
|
20066172c3 | ||
|
|
18a4b8379c | ||
|
|
3297937e53 | ||
|
|
a8a01d9a0a | ||
|
|
da5318f8f0 | ||
|
|
4042011345 | ||
|
|
136caf189e | ||
|
|
6075433402 | ||
|
|
65798d01b6 | ||
|
|
b383f0a8d8 | ||
|
|
74070aa0f9 | ||
|
|
d0e947ca02 | ||
|
|
c29ce45971 | ||
|
|
ff93592001 | ||
|
|
5b466b4ae6 | ||
|
|
c8cf53291f | ||
|
|
850ca4fead | ||
|
|
05100c4f37 | ||
|
|
523fe81fb2 | ||
|
|
2591517c76 | ||
|
|
440831fb4d | ||
|
|
ef85943ef4 | ||
|
|
6bbaa07962 | ||
|
|
5a178986e5 | ||
|
|
5e5aa48cf9 | ||
|
|
21624f6c55 | ||
|
|
c4a799c99c | ||
|
|
62ed5cc1ce | ||
|
|
048a6ec969 | ||
|
|
cb3015a5b1 | ||
|
|
245d38d471 | ||
|
|
88dcebc3fb | ||
|
|
8091fbb89c | ||
|
|
5e6b5fa0d9 | ||
|
|
4c4b11470d | ||
|
|
2d501eabae | ||
|
|
f841ef1913 | ||
|
|
8b3d814628 | ||
|
|
6d35fa7514 | ||
|
|
efb48b4030 | ||
|
|
e80180e2b6 |
@@ -53,3 +53,18 @@ corepack pnpm@9.15.1 build
|
||||
The frontend automatically discovers project-local route and store modules.
|
||||
Feature tickets add one route module without exposing unrelated GoAdmin demo
|
||||
pages.
|
||||
|
||||
## Windows package
|
||||
|
||||
With the frozen Go toolchain first in `PATH`, build the backend, frontend and
|
||||
ZIP archive with one command from the repository root:
|
||||
|
||||
```powershell
|
||||
cmd /c .\Sense\scripts\package-windows.bat
|
||||
```
|
||||
|
||||
The ignored local artifact is `Sense\dist\sense-windows-amd64.zip`. It contains
|
||||
the Windows amd64 server, compiled UI, empty example configuration, upstream
|
||||
licenses, a start script and an operations note. Run `start-sense.bat demo` only
|
||||
for a temporary local preview; production startup requires externally supplied
|
||||
PostgreSQL and secret environment variables.
|
||||
|
||||
@@ -4,4 +4,12 @@ SENSE_DATABASE_MODE=postgres
|
||||
SENSE_DATABASE_URL=
|
||||
SENSE_LOG_LEVEL=info
|
||||
SENSE_UI_STATIC_DIR=
|
||||
|
||||
SENSE_IDENTITY_SIGNING_KEY=
|
||||
SENSE_BOOTSTRAP_TOKEN=
|
||||
SENSE_COOKIE_SECURE=true
|
||||
SENSE_CREDENTIAL_KEY=
|
||||
SENSE_ONVIF_DISCOVERY_IP=
|
||||
SENSE_MEDIAMTX_BINARY=
|
||||
SENSE_MEDIAMTX_CONFIG=
|
||||
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||
SENSE_MEDIAMTX_WEBRTC_BASE=http://127.0.0.1:8889
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..") do set "SENSE_ROOT=%%~fI"
|
||||
for %%I in ("%SENSE_ROOT%\dist") do set "OUTPUT_ROOT=%%~fI"
|
||||
for %%I in ("%OUTPUT_ROOT%\sense-windows-amd64") do set "PACKAGE_DIR=%%~fI"
|
||||
for %%I in ("%OUTPUT_ROOT%\sense-windows-amd64.zip") do set "ZIP_PATH=%%~fI"
|
||||
for %%I in ("%OUTPUT_ROOT%\.sense.env.preserve") do set "PRESERVED_ENV=%%~fI"
|
||||
for %%I in ("%SENSE_ROOT%\ui\dist") do set "UI_DIST=%%~fI"
|
||||
|
||||
if /I not "%PACKAGE_DIR%"=="%SENSE_ROOT%\dist\sense-windows-amd64" (
|
||||
echo [ERROR] Unsafe package directory: %PACKAGE_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%ZIP_PATH%"=="%SENSE_ROOT%\dist\sense-windows-amd64.zip" (
|
||||
echo [ERROR] Unsafe ZIP path: %ZIP_PATH%
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%PRESERVED_ENV%"=="%SENSE_ROOT%\dist\.sense.env.preserve" (
|
||||
echo [ERROR] Unsafe preserved configuration path: %PRESERVED_ENV%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where go >nul 2>nul || goto :missing_go
|
||||
for /f "tokens=3" %%V in ('go version') do set "GO_VERSION=%%V"
|
||||
if not "%GO_VERSION%"=="go1.26.5" (
|
||||
echo [ERROR] Go 1.26.5 is required, found %GO_VERSION%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where node >nul 2>nul || goto :missing_node
|
||||
for /f "delims=" %%V in ('node --version') do set "NODE_VERSION=%%V"
|
||||
if not "%NODE_VERSION%"=="v22.22.1" (
|
||||
echo [ERROR] Node.js 22.22.1 is required, found %NODE_VERSION%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where corepack >nul 2>nul || goto :missing_corepack
|
||||
for /f "delims=" %%V in ('corepack pnpm@9.15.1 --version') do set "PNPM_VERSION=%%V"
|
||||
if not "%PNPM_VERSION%"=="9.15.1" (
|
||||
echo [ERROR] pnpm 9.15.1 is required, found %PNPM_VERSION%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/5] Installing frozen frontend dependencies...
|
||||
pushd "%SENSE_ROOT%\ui" || goto :failed
|
||||
call corepack pnpm@9.15.1 install --frozen-lockfile || goto :failed_popd
|
||||
|
||||
echo [2/5] Building frontend...
|
||||
call corepack pnpm@9.15.1 build || goto :failed_popd
|
||||
popd
|
||||
|
||||
if exist "%PRESERVED_ENV%" (
|
||||
echo [ERROR] Preserved configuration already exists: %PRESERVED_ENV%
|
||||
echo Move it back to config\sense.env or remove it after confirming it is obsolete.
|
||||
exit /b 1
|
||||
)
|
||||
if exist "%PACKAGE_DIR%\config\sense.env" (
|
||||
copy /y "%PACKAGE_DIR%\config\sense.env" "%PRESERVED_ENV%" >nul || goto :failed
|
||||
)
|
||||
if exist "%PACKAGE_DIR%" rmdir /s /q "%PACKAGE_DIR%"
|
||||
if exist "%ZIP_PATH%" del /q "%ZIP_PATH%"
|
||||
mkdir "%PACKAGE_DIR%\config" || goto :failed
|
||||
|
||||
echo [3/5] Building Windows backend...
|
||||
set "CGO_ENABLED=0"
|
||||
set "GOOS=windows"
|
||||
set "GOARCH=amd64"
|
||||
pushd "%SENSE_ROOT%\server" || goto :failed
|
||||
go build -trimpath -ldflags "-s -w" -o "%PACKAGE_DIR%\sense-server.exe" . || goto :failed_popd
|
||||
popd
|
||||
|
||||
echo [4/5] Assembling package...
|
||||
robocopy "%UI_DIST%" "%PACKAGE_DIR%\ui" /E /NFL /NDL /NJH /NJS /NP >nul
|
||||
if errorlevel 8 goto :failed
|
||||
robocopy "%SENSE_ROOT%\LICENSES" "%PACKAGE_DIR%\LICENSES" /E /NFL /NDL /NJH /NJS /NP >nul
|
||||
if errorlevel 8 goto :failed
|
||||
copy /y "%SENSE_ROOT%\config\sense.env.example" "%PACKAGE_DIR%\config\sense.env.example" >nul || goto :failed
|
||||
copy /y "%SENSE_ROOT%\scripts\runtime\start-sense.bat" "%PACKAGE_DIR%\start-sense.bat" >nul || goto :failed
|
||||
copy /y "%SENSE_ROOT%\scripts\runtime\start-sense.ps1" "%PACKAGE_DIR%\start-sense.ps1" >nul || goto :failed
|
||||
copy /y "%SENSE_ROOT%\scripts\runtime\README-WINDOWS.md" "%PACKAGE_DIR%\README-WINDOWS.md" >nul || goto :failed
|
||||
|
||||
echo [5/5] Creating ZIP...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path '%PACKAGE_DIR%' -DestinationPath '%ZIP_PATH%' -Force" || goto :failed
|
||||
if exist "%PRESERVED_ENV%" (
|
||||
move /y "%PRESERVED_ENV%" "%PACKAGE_DIR%\config\sense.env" >nul || goto :failed
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Package directory: %PACKAGE_DIR%
|
||||
echo ZIP archive: %ZIP_PATH%
|
||||
exit /b 0
|
||||
|
||||
:failed_popd
|
||||
popd
|
||||
:failed
|
||||
echo [ERROR] Packaging failed.
|
||||
exit /b 1
|
||||
|
||||
:missing_go
|
||||
echo [ERROR] Go was not found in PATH.
|
||||
exit /b 1
|
||||
|
||||
:missing_node
|
||||
echo [ERROR] Node.js was not found in PATH.
|
||||
exit /b 1
|
||||
|
||||
:missing_corepack
|
||||
echo [ERROR] Corepack was not found in PATH.
|
||||
exit /b 1
|
||||
@@ -0,0 +1,38 @@
|
||||
# Sense Windows 运行包
|
||||
|
||||
此目录同时包含 Sense 后端和已经构建好的前端静态页面。Brain、Bell 无需启动。
|
||||
|
||||
## 快速查看
|
||||
|
||||
在命令提示符中运行:
|
||||
|
||||
```bat
|
||||
start-sense.bat demo
|
||||
```
|
||||
|
||||
然后访问 <http://127.0.0.1:18080>。`demo` 使用内存数据,进程退出后数据会丢失,仅用于本机查看,不能用于生产。
|
||||
|
||||
## 生产启动
|
||||
|
||||
生产环境必须先安装并准备独立 PostgreSQL。把配置写入运行目录的 `config\sense.env`,或在 Windows 进程环境中提供;已存在的非空进程环境变量优先于文件:
|
||||
|
||||
- `SENSE_DATABASE_URL`:PostgreSQL 连接地址。
|
||||
- `SENSE_IDENTITY_SIGNING_KEY`:至少 32 个字符的会话签名密钥。
|
||||
- `SENSE_BOOTSTRAP_TOKEN`:首次初始化使用的引导令牌。
|
||||
- `SENSE_CREDENTIAL_KEY`:Base64 编码的 32 字节摄像头凭据加密密钥。
|
||||
|
||||
按需要设置 `SENSE_HTTP_ADDRESS`、MediaMTX 和 ONVIF 参数后运行:
|
||||
|
||||
```bat
|
||||
start-sense.bat
|
||||
```
|
||||
|
||||
启动前可只检查配置,不连接数据库也不启动服务:
|
||||
|
||||
```bat
|
||||
start-sense.bat check
|
||||
```
|
||||
|
||||
完整变量名可参考 `config\sense.env.example`。复制为 `config\sense.env` 后填写真实值;该文件不会进入 ZIP 或 Git。本机在同一运行目录重新打包时会保留该文件,但交付 ZIP 始终不包含它。启动器只读取 `SENSE_*` 键,忽略空行与 `#` 注释,且不会打印配置值。生产环境仍建议使用 Windows 环境变量或外部秘密管理工具注入真实值。
|
||||
|
||||
健康检查为 `GET /healthz` 和 `GET /readyz`。MediaMTX 仍是独立程序,本运行包不会安装 PostgreSQL、MediaMTX 或 Windows 服务。
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-sense.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,91 @@
|
||||
param(
|
||||
[ValidateSet('production', 'demo', 'check')]
|
||||
[string]$Mode = 'production'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Import-SenseEnvironment {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
return
|
||||
}
|
||||
|
||||
$lineNumber = 0
|
||||
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$trimmed = $line.Trim()
|
||||
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
if ($line -notmatch '^\s*(SENSE_[A-Z0-9_]+)\s*=(.*)$') {
|
||||
throw "config\sense.env line $lineNumber must use SENSE_NAME=value format"
|
||||
}
|
||||
|
||||
$name = $Matches[1]
|
||||
$value = $Matches[2].Trim()
|
||||
if ($value.Length -ge 2) {
|
||||
$first = $value[0]
|
||||
$last = $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
}
|
||||
|
||||
$current = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
if ([string]::IsNullOrEmpty($current)) {
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RequiredEnvironment {
|
||||
param([string[]]$Names)
|
||||
|
||||
foreach ($name in $Names) {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
|
||||
throw "$name is required in production mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Import-SenseEnvironment -Path (Join-Path $runtimeRoot 'config\sense.env')
|
||||
|
||||
[Environment]::SetEnvironmentVariable('SENSE_UI_STATIC_DIR', (Join-Path $runtimeRoot 'ui'), 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($env:SENSE_HTTP_ADDRESS)) {
|
||||
$env:SENSE_HTTP_ADDRESS = '127.0.0.1:18080'
|
||||
}
|
||||
|
||||
if ($Mode -eq 'demo') {
|
||||
$env:SENSE_DATABASE_MODE = 'memory'
|
||||
Write-Host "Starting Sense in temporary demo mode at http://$($env:SENSE_HTTP_ADDRESS) ..."
|
||||
Write-Host 'Demo data is discarded when the process stops. Do not use this mode in production.'
|
||||
} else {
|
||||
$env:SENSE_DATABASE_MODE = 'postgres'
|
||||
Assert-RequiredEnvironment -Names @(
|
||||
'SENSE_DATABASE_URL',
|
||||
'SENSE_IDENTITY_SIGNING_KEY',
|
||||
'SENSE_BOOTSTRAP_TOKEN',
|
||||
'SENSE_CREDENTIAL_KEY'
|
||||
)
|
||||
if ($Mode -eq 'check') {
|
||||
Write-Host 'Sense production configuration check passed.'
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Starting Sense in production mode at $($env:SENSE_HTTP_ADDRESS) ..."
|
||||
}
|
||||
|
||||
$server = Join-Path $runtimeRoot 'sense-server.exe'
|
||||
if (-not (Test-Path -LiteralPath $server -PathType Leaf)) {
|
||||
throw 'sense-server.exe was not found beside the start script'
|
||||
}
|
||||
& $server
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
Write-Host "[ERROR] $($_.Exception.Message)"
|
||||
Write-Host 'See README-WINDOWS.md and config\sense.env.example.'
|
||||
exit 1
|
||||
}
|
||||
@@ -3,11 +3,16 @@ package onvif
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -34,10 +39,28 @@ func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
return &HTTPClient{client: &http.Client{Timeout: timeout}}
|
||||
return &HTTPClient{client: &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}}
|
||||
}
|
||||
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
|
||||
endpoint, err := validateEndpoint(address)
|
||||
deviceEndpoint, err := validateEndpoint(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capabilitiesBody := `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetCapabilities xmlns="http://www.onvif.org/ver10/device/wsdl"><Category>All</Category></GetCapabilities></s:Body></s:Envelope>`
|
||||
capabilities, err := c.soap(ctx, deviceEndpoint, credential, capabilitiesBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaAddress, err := ParseMediaServiceAddress(capabilities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint, err := normalizeServiceEndpoint(deviceEndpoint, mediaAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,16 +83,45 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles[i].StreamURI, err = normalizeStreamURI(deviceEndpoint, profiles[i].StreamURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func normalizeStreamURI(deviceEndpoint, streamURI string) (string, error) {
|
||||
device, err := url.Parse(deviceEndpoint)
|
||||
if err != nil || device.Hostname() == "" {
|
||||
return "", fmt.Errorf("invalid ONVIF address")
|
||||
}
|
||||
stream, err := url.Parse(streamURI)
|
||||
if err != nil || stream.Scheme != "rtsp" || stream.Host == "" || stream.User != nil {
|
||||
return "", fmt.Errorf("invalid RTSP stream URI")
|
||||
}
|
||||
if !strings.EqualFold(stream.Hostname(), device.Hostname()) {
|
||||
port := stream.Port()
|
||||
stream.Host = device.Hostname()
|
||||
if port != "" {
|
||||
stream.Host = net.JoinHostPort(device.Hostname(), port)
|
||||
}
|
||||
}
|
||||
return stream.String(), nil
|
||||
}
|
||||
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
|
||||
return c.soapAttempt(ctx, endpoint, credential, body, "")
|
||||
}
|
||||
|
||||
func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
if credential.Username != "" {
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
} else if credential.Username != "" {
|
||||
req.SetBasicAuth(credential.Username, credential.Password)
|
||||
}
|
||||
res, err := c.client.Do(req)
|
||||
@@ -82,6 +134,16 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
if authorization == "" && credential.Username != "" {
|
||||
challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate"))
|
||||
if challengeErr == nil {
|
||||
digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge)
|
||||
if digestErr != nil {
|
||||
return nil, digestErr
|
||||
}
|
||||
return c.soapAttempt(ctx, endpoint, credential, body, digest)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("authentication_failed")
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
@@ -89,6 +151,157 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type digestChallenge struct {
|
||||
realm string
|
||||
nonce string
|
||||
opaque string
|
||||
algorithm string
|
||||
qop string
|
||||
}
|
||||
|
||||
func parseDigestChallenge(values []string) (digestChallenge, error) {
|
||||
for _, value := range values {
|
||||
if !strings.EqualFold(strings.TrimSpace(strings.SplitN(value, " ", 2)[0]), "Digest") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
|
||||
if len(parts) != 2 {
|
||||
break
|
||||
}
|
||||
params, err := parseAuthParameters(parts[1])
|
||||
if err != nil {
|
||||
return digestChallenge{}, err
|
||||
}
|
||||
challenge := digestChallenge{
|
||||
realm: strings.TrimSpace(params["realm"]), nonce: strings.TrimSpace(params["nonce"]),
|
||||
opaque: strings.TrimSpace(params["opaque"]), algorithm: strings.ToUpper(strings.TrimSpace(params["algorithm"])),
|
||||
}
|
||||
if challenge.realm == "" || challenge.nonce == "" {
|
||||
return digestChallenge{}, fmt.Errorf("invalid_digest_challenge")
|
||||
}
|
||||
if challenge.algorithm == "" {
|
||||
challenge.algorithm = "MD5"
|
||||
}
|
||||
if challenge.algorithm != "MD5" && challenge.algorithm != "SHA-256" {
|
||||
return digestChallenge{}, fmt.Errorf("unsupported_digest_algorithm")
|
||||
}
|
||||
qops := strings.Split(params["qop"], ",")
|
||||
for _, qop := range qops {
|
||||
if strings.EqualFold(strings.TrimSpace(qop), "auth") {
|
||||
challenge.qop = "auth"
|
||||
break
|
||||
}
|
||||
}
|
||||
if params["qop"] != "" && challenge.qop == "" {
|
||||
return digestChallenge{}, fmt.Errorf("unsupported_digest_qop")
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
return digestChallenge{}, fmt.Errorf("digest_challenge_not_found")
|
||||
}
|
||||
|
||||
func parseAuthParameters(value string) (map[string]string, error) {
|
||||
result := map[string]string{}
|
||||
for position := 0; position < len(value); {
|
||||
for position < len(value) && (value[position] == ' ' || value[position] == ',') {
|
||||
position++
|
||||
}
|
||||
start := position
|
||||
for position < len(value) && value[position] != '=' && value[position] != ',' {
|
||||
position++
|
||||
}
|
||||
if position == start || position >= len(value) || value[position] != '=' {
|
||||
return nil, fmt.Errorf("invalid_digest_challenge")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(value[start:position]))
|
||||
position++
|
||||
var parameter string
|
||||
if position < len(value) && value[position] == '"' {
|
||||
position++
|
||||
var builder strings.Builder
|
||||
closed := false
|
||||
for position < len(value) {
|
||||
if value[position] == '"' {
|
||||
position++
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
if value[position] == '\\' && position+1 < len(value) {
|
||||
position++
|
||||
}
|
||||
builder.WriteByte(value[position])
|
||||
position++
|
||||
}
|
||||
if !closed {
|
||||
return nil, fmt.Errorf("invalid_digest_challenge")
|
||||
}
|
||||
parameter = builder.String()
|
||||
} else {
|
||||
start = position
|
||||
for position < len(value) && value[position] != ',' {
|
||||
position++
|
||||
}
|
||||
parameter = strings.TrimSpace(value[start:position])
|
||||
}
|
||||
result[name] = parameter
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func digestAuthorization(method, uri string, credential Credential, challenge digestChallenge) (string, error) {
|
||||
cnonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(cnonceBytes); err != nil {
|
||||
return "", fmt.Errorf("generate_digest_cnonce: %w", err)
|
||||
}
|
||||
cnonce := fmt.Sprintf("%x", cnonceBytes)
|
||||
hash := func(value string) string {
|
||||
if challenge.algorithm == "SHA-256" {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
sum := md5.Sum([]byte(value))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
ha1 := hash(credential.Username + ":" + challenge.realm + ":" + credential.Password)
|
||||
ha2 := hash(method + ":" + uri)
|
||||
nonceCount := "00000001"
|
||||
response := hash(ha1 + ":" + challenge.nonce + ":" + ha2)
|
||||
if challenge.qop != "" {
|
||||
response = hash(ha1 + ":" + challenge.nonce + ":" + nonceCount + ":" + cnonce + ":" + challenge.qop + ":" + ha2)
|
||||
}
|
||||
values := []string{
|
||||
`username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(challenge.realm),
|
||||
`nonce=` + strconv.Quote(challenge.nonce), `uri=` + strconv.Quote(uri),
|
||||
`response=` + strconv.Quote(response), `algorithm=` + challenge.algorithm,
|
||||
}
|
||||
if challenge.opaque != "" {
|
||||
values = append(values, `opaque=`+strconv.Quote(challenge.opaque))
|
||||
}
|
||||
if challenge.qop != "" {
|
||||
values = append(values, `qop=`+challenge.qop, `nc=`+nonceCount, `cnonce=`+strconv.Quote(cnonce))
|
||||
}
|
||||
return "Digest " + strings.Join(values, ", "), nil
|
||||
}
|
||||
|
||||
func normalizeServiceEndpoint(deviceEndpoint, advertisedEndpoint string) (string, error) {
|
||||
device, err := url.Parse(deviceEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid ONVIF address")
|
||||
}
|
||||
advertised, err := url.Parse(advertisedEndpoint)
|
||||
if err != nil || advertised.Scheme == "" || advertised.Host == "" || advertised.User != nil {
|
||||
return "", fmt.Errorf("invalid ONVIF media address")
|
||||
}
|
||||
if advertised.Scheme != "http" && advertised.Scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported ONVIF media scheme")
|
||||
}
|
||||
if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
|
||||
advertised.Scheme = device.Scheme
|
||||
advertised.Host = device.Host
|
||||
}
|
||||
return advertised.String(), nil
|
||||
}
|
||||
func validateEndpoint(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
@@ -107,3 +320,4 @@ func xmlEscape(value string) string {
|
||||
_ = xml.EscapeText(&b, []byte(value))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProfilesDiscoversMediaServiceAndUsesDigest(t *testing.T) {
|
||||
var digestRequests atomic.Int32
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/onvif/device_service":
|
||||
fmt.Fprintf(w, `<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>%s/onvif/media_service</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`, server.URL)
|
||||
case "/onvif/media_service":
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authorization, "Digest ") {
|
||||
w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="nonce-1", algorithm=MD5, qop="auth"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
digestRequests.Add(1)
|
||||
if strings.Contains(readRequestBody(t, r), "GetProfiles") {
|
||||
fmt.Fprint(w, `<Envelope><Body><GetProfilesResponse><Profiles token="main"><Name>Main</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></Body></Envelope>`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `<Envelope><Body><GetStreamUriResponse><MediaUri><Uri>rtsp://camera.invalid/live</Uri></MediaUri></GetStreamUriResponse></Body></Envelope>`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profiles, err := NewHTTPClient(2*time.Second).Profiles(context.Background(), server.URL+"/onvif/device_service", Credential{Username: "operator", Password: "secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://"+serverURL.Hostname()+"/live" {
|
||||
t.Fatalf("profiles=%#v", profiles)
|
||||
}
|
||||
if digestRequests.Load() != 2 {
|
||||
t.Fatalf("digest requests=%d", digestRequests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServiceEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device string
|
||||
advertised string
|
||||
want string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "same host keeps media port", device: "http://camera.local:80/device", advertised: "http://camera.local:8000/media", want: "http://camera.local:8000/media"},
|
||||
{name: "different host uses authorized origin", device: "http://192.0.2.10:8080/device", advertised: "http://unusable.local:9000/media?profile=1", want: "http://192.0.2.10:8080/media?profile=1"},
|
||||
{name: "reject credentials", device: "http://camera.local/device", advertised: "http://user:pass@camera.local/media", wantError: true},
|
||||
{name: "reject scheme", device: "http://camera.local/device", advertised: "ftp://camera.local/media", wantError: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := normalizeServiceEndpoint(test.device, test.advertised)
|
||||
if test.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStreamURI(t *testing.T) {
|
||||
got, err := normalizeStreamURI("http://192.0.2.10:80/onvif/device_service", "rtsp://unusable.local:8554/live/main?channel=1")
|
||||
if err != nil || got != "rtsp://192.0.2.10:8554/live/main?channel=1" {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
if _, err := normalizeStreamURI("http://camera.local/onvif", "rtsp://user:pass@camera.local/live"); err == nil {
|
||||
t.Fatal("credential stream URI accepted")
|
||||
}
|
||||
if _, err := normalizeStreamURI("http://camera.local/onvif", "http://camera.local/live"); err == nil {
|
||||
t.Fatal("non-RTSP URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsupportedDigestChallenge(t *testing.T) {
|
||||
for _, challenge := range []string{
|
||||
`Digest realm="camera", nonce="n", algorithm=SHA-512, qop="auth"`,
|
||||
`Digest realm="camera", nonce="n", algorithm=MD5, qop="auth-int"`,
|
||||
`Digest realm="camera"`,
|
||||
} {
|
||||
if _, err := parseDigestChallenge([]string{challenge}); err == nil {
|
||||
t.Fatalf("challenge accepted: %s", challenge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpointRejectsCredentials(t *testing.T) {
|
||||
if _, err := validateEndpoint("http://user:pass@camera.invalid/onvif"); err == nil {
|
||||
t.Fatal("credential endpoint accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOAPDoesNotFollowRedirect(t *testing.T) {
|
||||
redirectTargetCalled := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
redirectTargetCalled = true
|
||||
}))
|
||||
defer target.Close()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := NewHTTPClient(time.Second).soap(context.Background(), server.URL, Credential{Username: "operator", Password: "secret"}, "<Envelope />")
|
||||
if err == nil || redirectTargetCalled {
|
||||
t.Fatalf("err=%v redirect_target_called=%v", err, redirectTargetCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func readRequestBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
defer r.Body.Close()
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestDigestAuthorizationUsesRequestURI(t *testing.T) {
|
||||
header, err := digestAuthorization(http.MethodPost, "/media?profile=1", Credential{Username: "operator", Password: "secret"}, digestChallenge{realm: "camera", nonce: "n", algorithm: "SHA-256", qop: "auth"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := parseAuthParameters(strings.TrimPrefix(header, "Digest "))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed["uri"] != "/media?profile=1" || parsed["username"] != "operator" || parsed["response"] == "" {
|
||||
t.Fatalf("invalid digest fields: %#v", parsed)
|
||||
}
|
||||
if _, err := url.Parse(parsed["uri"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +54,33 @@ func ParseStreamURI(data []byte) (string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMediaServiceAddress(data []byte) (string, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||
mediaDepth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("media_service_not_found")
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if value.Name.Local == "Media" {
|
||||
mediaDepth++
|
||||
continue
|
||||
}
|
||||
if mediaDepth > 0 && value.Name.Local == "XAddr" {
|
||||
var address string
|
||||
if err := decoder.DecodeElement(&address, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(address), nil
|
||||
}
|
||||
case xml.EndElement:
|
||||
if value.Name.Local == "Media" && mediaDepth > 0 {
|
||||
mediaDepth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,3 +29,18 @@ func TestRejectCredentialInStreamURI(t *testing.T) {
|
||||
t.Fatal("credential URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaServiceAddress(t *testing.T) {
|
||||
data := []byte(`<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>http://camera.invalid:8000/onvif/media_service</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`)
|
||||
address, err := ParseMediaServiceAddress(data)
|
||||
if err != nil || address != "http://camera.invalid:8000/onvif/media_service" {
|
||||
t.Fatalf("address=%q err=%v", address, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaServiceAddressRejectsMissingMedia(t *testing.T) {
|
||||
if _, err := ParseMediaServiceAddress([]byte(`<Envelope><Body /></Envelope>`)); err == nil {
|
||||
t.Fatal("missing media service accepted")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package admission
|
||||
|
||||
const MigrationSQL = `
|
||||
CREATE TABLE IF NOT EXISTS sense_admission_results (
|
||||
device_id TEXT PRIMARY KEY REFERENCES sense_devices(id),
|
||||
address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
checked_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sense_admission_profiles (
|
||||
device_id TEXT NOT NULL REFERENCES sense_devices(id),
|
||||
token TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
stream_uri TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
verification_status TEXT NOT NULL,
|
||||
verification_latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
verification_detail TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY(device_id, token)
|
||||
);
|
||||
`
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/adapters/onvif"
|
||||
@@ -37,13 +36,16 @@ type Service struct {
|
||||
rtsp rtsp.Verifier
|
||||
discoveryIP string
|
||||
discoveryTimeout time.Duration
|
||||
mu sync.RWMutex
|
||||
results map[string]Result
|
||||
store Store
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string) *Service {
|
||||
return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, results: map[string]Result{}, now: time.Now}
|
||||
func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string, stores ...Store) *Service {
|
||||
var store Store = NewMemoryStore()
|
||||
if len(stores) > 0 && stores[0] != nil {
|
||||
store = stores[0]
|
||||
}
|
||||
return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, store: store, now: time.Now}
|
||||
}
|
||||
func (s *Service) Discover(ctx context.Context) ([]string, error) {
|
||||
if strings.TrimSpace(s.discoveryIP) == "" {
|
||||
@@ -52,21 +54,25 @@ func (s *Service) Discover(ctx context.Context) ([]string, error) {
|
||||
return onvif.Discover(ctx, s.discoveryIP, s.discoveryTimeout)
|
||||
}
|
||||
func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID, address string) (Result, error) {
|
||||
credential, err := device.ReadCredential(ctx, deviceID)
|
||||
onvifCredential, err := device.ReadONVIFCredential(ctx, deviceID)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("credential_required")
|
||||
}
|
||||
profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: credential.Username, Password: credential.Password})
|
||||
profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: onvifCredential.Username, Password: onvifCredential.Password})
|
||||
if err != nil {
|
||||
status, detail := classify(err)
|
||||
result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, CheckedAt: s.now().UTC()}
|
||||
s.save(result)
|
||||
_ = s.store.Save(ctx, result)
|
||||
identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "failure", map[string]any{"status": status})
|
||||
return result, nil
|
||||
}
|
||||
rtspCredential, err := device.ReadRTSPCredential(ctx, deviceID)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("rtsp_credential_required")
|
||||
}
|
||||
items := make([]Profile, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: credential.Username, Password: credential.Password})
|
||||
verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: rtspCredential.Username, Password: rtspCredential.Password})
|
||||
if verifyErr != nil {
|
||||
verification = rtsp.Result{Status: "failed", Detail: "视频地址格式不正确"}
|
||||
}
|
||||
@@ -89,20 +95,23 @@ func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID,
|
||||
}
|
||||
}
|
||||
result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, Profiles: items, CheckedAt: s.now().UTC()}
|
||||
s.save(result)
|
||||
if err := s.store.Save(ctx, result); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Verification.Status == "ready" {
|
||||
if err := device.MarkActive(ctx, deviceID); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "success", map[string]any{"profile_count": len(items), "status": status})
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Get(deviceID string) (Result, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
value, ok := s.results[deviceID]
|
||||
return value, ok
|
||||
}
|
||||
func (s *Service) save(result Result) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.results[result.DeviceID] = result
|
||||
value, err := s.store.Get(context.Background(), deviceID)
|
||||
return value, err == nil
|
||||
}
|
||||
func classify(err error) (string, string) {
|
||||
value := strings.ToLower(err.Error())
|
||||
@@ -117,3 +126,4 @@ func classify(err error) (string, string) {
|
||||
return "unreachable", "无法读取设备信息,请检查地址和网络"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,20 @@ type fakeRTSP struct{}
|
||||
func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
|
||||
return rtsp.Result{Status: "ready"}, nil
|
||||
}
|
||||
|
||||
type credentialCapturingONVIF struct{ got onvif.Credential }
|
||||
|
||||
func (f *credentialCapturingONVIF) Profiles(_ context.Context, _ string, credential onvif.Credential) ([]onvif.Profile, error) {
|
||||
f.got = credential
|
||||
return fakeONVIF{}.Profiles(context.Background(), "", credential)
|
||||
}
|
||||
|
||||
type credentialCapturingRTSP struct{ got rtsp.Credential }
|
||||
|
||||
func (f *credentialCapturingRTSP) Verify(_ context.Context, _ string, credential rtsp.Credential) (rtsp.Result, error) {
|
||||
f.got = credential
|
||||
return rtsp.Result{Status: "ready"}, nil
|
||||
}
|
||||
func TestProbeProfilesWithoutCredentialURI(t *testing.T) {
|
||||
vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
@@ -44,3 +58,41 @@ func TestProbeProfilesWithoutCredentialURI(t *testing.T) {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUsesSeparateCredentialsPersistsProfilesAndActivatesDevice(t *testing.T) {
|
||||
vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deviceStore := device.NewMemoryStore()
|
||||
deviceService := device.NewService(deviceStore, vault)
|
||||
device.NewModule(deviceService).Register(platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, nil))
|
||||
item, err := deviceService.Create(context.Background(), identity.Principal{}, "camera", "gate", device.ModalityVideo, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = deviceService.SetCredentials(context.Background(), identity.Principal{}, item.ID, "onvif-user", "onvif-password", false, "rtsp-user", "rtsp-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
onvifClient := &credentialCapturingONVIF{}
|
||||
rtspVerifier := &credentialCapturingRTSP{}
|
||||
store := NewMemoryStore()
|
||||
service := NewService(onvifClient, rtspVerifier, "", store)
|
||||
result, err := service.Probe(context.Background(), identity.Principal{}, item.ID, "http://camera.invalid/onvif/device_service")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if onvifClient.got.Username != "onvif-user" || rtspVerifier.got.Username != "rtsp-user" {
|
||||
t.Fatal("credentials were not separated")
|
||||
}
|
||||
restarted := NewService(onvifClient, rtspVerifier, "", store)
|
||||
persisted, ok := restarted.Get(item.ID)
|
||||
if !ok || len(persisted.Profiles) != len(result.Profiles) {
|
||||
t.Fatalf("persisted=%#v", persisted)
|
||||
}
|
||||
updated, err := deviceService.Get(context.Background(), item.ID)
|
||||
if err != nil || updated.Status != device.StatusActive {
|
||||
t.Fatalf("device=%#v err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("admission result not found")
|
||||
|
||||
type Store interface {
|
||||
Save(context.Context, Result) error
|
||||
Get(context.Context, string) (Result, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
results map[string]Result
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{results: map[string]Result{}} }
|
||||
func (s *MemoryStore) Save(_ context.Context, result Result) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.results[result.DeviceID] = result
|
||||
return nil
|
||||
}
|
||||
func (s *MemoryStore) Get(_ context.Context, deviceID string) (Result, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result, ok := s.results[deviceID]
|
||||
if !ok {
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
|
||||
func (s *PostgresStore) Save(ctx context.Context, result Result) error {
|
||||
tx, err := s.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense_admission_results(device_id,address,status,detail,checked_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(device_id) DO UPDATE SET address=EXCLUDED.address,status=EXCLUDED.status,detail=EXCLUDED.detail,checked_at=EXCLUDED.checked_at`, result.DeviceID, result.Address, result.Status, result.Detail, result.CheckedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM sense_admission_profiles WHERE device_id=$1`, result.DeviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, profile := range result.Profiles {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense_admission_profiles(device_id,token,name,width,height,encoding,stream_uri,kind,verification_status,verification_latency_ms,verification_detail) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, result.DeviceID, profile.Token, profile.Name, profile.Width, profile.Height, profile.Encoding, profile.StreamURI, profile.Kind, profile.Verification.Status, profile.Verification.LatencyMS, profile.Verification.Detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, deviceID string) (Result, error) {
|
||||
var result Result
|
||||
err := s.database.QueryRowContext(ctx, `SELECT device_id,address,status,detail,checked_at FROM sense_admission_results WHERE device_id=$1`, deviceID).Scan(&result.DeviceID, &result.Address, &result.Status, &result.Detail, &result.CheckedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT token,name,width,height,encoding,stream_uri,kind,verification_status,verification_latency_ms,verification_detail FROM sense_admission_profiles WHERE device_id=$1 ORDER BY width*height DESC`, deviceID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var profile Profile
|
||||
if err := rows.Scan(&profile.Token, &profile.Name, &profile.Width, &profile.Height, &profile.Encoding, &profile.StreamURI, &profile.Kind, &profile.Verification.Status, &profile.Verification.LatencyMS, &profile.Verification.Detail); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.Profiles = append(result.Profiles, profile)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ var activeService atomic.Pointer[Service]
|
||||
// ReadCredential is an internal adapter port. Credentials must never be
|
||||
// returned from HTTP handlers, logged, or placed in a URL.
|
||||
func ReadCredential(ctx context.Context, id string) (Credential, error) {
|
||||
return ReadONVIFCredential(ctx, id)
|
||||
}
|
||||
func ReadONVIFCredential(ctx context.Context, id string) (Credential, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return Credential{}, fmt.Errorf("device service is not ready")
|
||||
@@ -30,3 +33,39 @@ func ReadCredential(ctx context.Context, id string) (Credential, error) {
|
||||
username, password, err := service.vault.Decrypt(item.CredentialCiphertext)
|
||||
return Credential{Username: username, Password: password}, err
|
||||
}
|
||||
|
||||
func ReadRTSPCredential(ctx context.Context, id string) (Credential, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return Credential{}, fmt.Errorf("device service is not ready")
|
||||
}
|
||||
item, err := service.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
if len(item.RTSPCredentialCiphertext) == 0 {
|
||||
return Credential{}, fmt.Errorf("RTSP credential is not configured")
|
||||
}
|
||||
username, password, err := service.vault.Decrypt(item.RTSPCredentialCiphertext)
|
||||
return Credential{Username: username, Password: password}, err
|
||||
}
|
||||
|
||||
func MarkActive(ctx context.Context, id string) error {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return fmt.Errorf("device service is not ready")
|
||||
}
|
||||
item, err := service.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Status == StatusDisabled || item.Status == StatusActive {
|
||||
return nil
|
||||
}
|
||||
expected := item.Version
|
||||
item.Status = StatusActive
|
||||
item.Version++
|
||||
item.UpdatedAt = service.now().UTC()
|
||||
return service.store.Update(ctx, item, expected)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,15 +78,30 @@ func (m *Module) update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
func (m *Module) credential(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ONVIFUsername string `json:"onvif_username"`
|
||||
ONVIFPassword string `json:"onvif_password"`
|
||||
RTSPSameAsONVIF *bool `json:"rtsp_same_as_onvif"`
|
||||
RTSPUsername string `json:"rtsp_username"`
|
||||
RTSPPassword string `json:"rtsp_password"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.SetCredential(r.Context(), actor, r.PathValue("id"), req.Username, req.Password)
|
||||
if req.ONVIFUsername == "" {
|
||||
req.ONVIFUsername = req.Username
|
||||
}
|
||||
if req.ONVIFPassword == "" {
|
||||
req.ONVIFPassword = req.Password
|
||||
}
|
||||
rtspSame := true
|
||||
if req.RTSPSameAsONVIF != nil {
|
||||
rtspSame = *req.RTSPSameAsONVIF
|
||||
}
|
||||
item, err := m.service.SetCredentials(r.Context(), actor, r.PathValue("id"), req.ONVIFUsername, req.ONVIFPassword, rtspSame, req.RTSPUsername, req.RTSPPassword)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
@@ -122,3 +137,4 @@ func writeDeviceError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
package device
|
||||
|
||||
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC);`
|
||||
|
||||
const SplitCredentialMigrationSQL = `ALTER TABLE sense_devices ADD COLUMN IF NOT EXISTS rtsp_credential_ciphertext BYTEA NULL;ALTER TABLE sense_devices ADD COLUMN IF NOT EXISTS rtsp_credential_same_as_onvif BOOLEAN NOT NULL DEFAULT TRUE;UPDATE sense_devices SET rtsp_credential_ciphertext=credential_ciphertext WHERE rtsp_credential_ciphertext IS NULL AND credential_ciphertext IS NOT NULL AND rtsp_credential_same_as_onvif=TRUE;`
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Service) Create(ctx context.Context, actor identity.Principal, name, lo
|
||||
adapter = AdapterReady
|
||||
}
|
||||
now := s.now().UTC()
|
||||
item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, RTSPCredentialSameAsONVIF: true, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.Create(ctx, item); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
@@ -45,7 +45,9 @@ func (s *Service) Get(ctx context.Context, id string) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err == nil {
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -53,6 +55,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) (Page, error) {
|
||||
page, err := s.store.List(ctx, filter)
|
||||
for i := range page.Items {
|
||||
page.Items[i].CredentialCiphertext = nil
|
||||
page.Items[i].RTSPCredentialCiphertext = nil
|
||||
}
|
||||
return page, err
|
||||
}
|
||||
@@ -74,31 +77,50 @@ func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version})
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) SetCredential(ctx context.Context, actor identity.Principal, id, username, password string) (Device, error) {
|
||||
if strings.TrimSpace(username) == "" || password == "" {
|
||||
return s.SetCredentials(ctx, actor, id, username, password, true, "", "")
|
||||
}
|
||||
func (s *Service) SetCredentials(ctx context.Context, actor identity.Principal, id, onvifUsername, onvifPassword string, rtspSame bool, rtspUsername, rtspPassword string) (Device, error) {
|
||||
if strings.TrimSpace(onvifUsername) == "" || onvifPassword == "" {
|
||||
return Device{}, fmt.Errorf("用户名和密码不能为空")
|
||||
}
|
||||
if !rtspSame && (strings.TrimSpace(rtspUsername) == "" || rtspPassword == "") {
|
||||
return Device{}, fmt.Errorf("RTSP 用户名和密码不能为空")
|
||||
}
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(username, password)
|
||||
ciphertext, err := s.vault.Encrypt(onvifUsername, onvifPassword)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
expected := item.Version
|
||||
item.CredentialCiphertext = ciphertext
|
||||
item.RTSPCredentialSameAsONVIF = rtspSame
|
||||
if rtspSame {
|
||||
item.RTSPCredentialCiphertext = append([]byte(nil), ciphertext...)
|
||||
} else {
|
||||
item.RTSPCredentialCiphertext, err = s.vault.Encrypt(rtspUsername, rtspPassword)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
}
|
||||
item.CredentialConfigured = true
|
||||
item.RTSPCredentialConfigured = true
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Update(ctx, item, expected); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true})
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true, "rtsp_same_as_onvif": rtspSame})
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) {
|
||||
@@ -114,7 +136,9 @@ func (s *Service) Disable(ctx context.Context, actor identity.Principal, id stri
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.disable", id, "success", nil)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func newID() string {
|
||||
@@ -124,3 +148,4 @@ func newID() string {
|
||||
}
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,22 +89,23 @@ type PostgresStore struct{ database *sql.DB }
|
||||
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
|
||||
|
||||
func (s *PostgresStore) Create(ctx context.Context, item Device) error {
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.CreatedAt, item.UpdatedAt)
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,rtsp_credential_ciphertext,rtsp_credential_same_as_onvif,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.RTSPCredentialCiphertext, item.RTSPCredentialSameAsONVIF, item.Version, item.CreatedAt, item.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, id string) (Device, error) {
|
||||
var item Device
|
||||
var capabilities string
|
||||
err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.Version, &item.CreatedAt, &item.UpdatedAt)
|
||||
err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,rtsp_credential_ciphertext,rtsp_credential_same_as_onvif,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.RTSPCredentialCiphertext, &item.RTSPCredentialSameAsONVIF, &item.Version, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
item.Capabilities = splitCapabilities(capabilities)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
return item, err
|
||||
}
|
||||
func (s *PostgresStore) Update(ctx context.Context, item Device, expected int64) error {
|
||||
result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,version=$9,updated_at=$10 WHERE id=$1 AND version=$11`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.UpdatedAt, expected)
|
||||
result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,rtsp_credential_ciphertext=$9,rtsp_credential_same_as_onvif=$10,version=$11,updated_at=$12 WHERE id=$1 AND version=$13`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.RTSPCredentialCiphertext, item.RTSPCredentialSameAsONVIF, item.Version, item.UpdatedAt, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +122,7 @@ func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, erro
|
||||
if err := s.database.QueryRowContext(ctx, `SELECT count(*) FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1`, keyword).Scan(&total); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size)
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),(rtsp_credential_ciphertext IS NOT NULL),rtsp_credential_same_as_onvif,version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
@@ -130,7 +131,7 @@ func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, erro
|
||||
for rows.Next() {
|
||||
var item Device
|
||||
var caps string
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.RTSPCredentialConfigured, &item.RTSPCredentialSameAsONVIF, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
item.Capabilities = splitCapabilities(caps)
|
||||
@@ -158,3 +159,4 @@ func splitCapabilities(value string) []string {
|
||||
}
|
||||
|
||||
var _ = time.Time{}
|
||||
|
||||
|
||||
@@ -13,18 +13,21 @@ const (
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
AdapterStatus string `json:"adapter_status"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
CredentialCiphertext []byte `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
AdapterStatus string `json:"adapter_status"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
CredentialCiphertext []byte `json:"-"`
|
||||
RTSPCredentialConfigured bool `json:"rtsp_credential_configured"`
|
||||
RTSPCredentialSameAsONVIF bool `json:"rtsp_credential_same_as_onvif"`
|
||||
RTSPCredentialCiphertext []byte `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListFilter struct {
|
||||
@@ -39,3 +42,4 @@ type Page struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,27 +2,13 @@ package identity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func ValidatePassword(username, password string) error {
|
||||
if len([]rune(password)) < 12 {
|
||||
return fmt.Errorf("密码至少需要 12 个字符")
|
||||
}
|
||||
var upper, lower, digit bool
|
||||
for _, value := range password {
|
||||
upper = upper || unicode.IsUpper(value)
|
||||
lower = lower || unicode.IsLower(value)
|
||||
digit = digit || unicode.IsDigit(value)
|
||||
}
|
||||
if !upper || !lower || !digit {
|
||||
return fmt.Errorf("密码必须同时包含大写字母、小写字母和数字")
|
||||
}
|
||||
if username != "" && strings.Contains(strings.ToLower(password), strings.ToLower(username)) {
|
||||
return fmt.Errorf("密码不能包含用户名")
|
||||
func ValidatePassword(_ string, password string) error {
|
||||
if len([]rune(password)) < 6 {
|
||||
return fmt.Errorf("密码至少需要 6 个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePasswordLengthAndComplexity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
want string
|
||||
}{
|
||||
{name: "six lowercase characters", username: "operator", password: "abcdef"},
|
||||
{name: "contains username", username: "admin", password: "admin123"},
|
||||
{name: "six unicode characters", username: "operator", password: "密码密码密码"},
|
||||
{name: "five characters", username: "operator", password: "Ab123", want: "至少需要 6 个字符"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidatePassword(test.username, test.password)
|
||||
if test.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePassword() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ValidatePassword() error = %v, want substring %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,16 @@ import (
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP"))
|
||||
var store admission.Store
|
||||
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
|
||||
store = admission.NewMemoryStore()
|
||||
} else {
|
||||
store = admission.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081302, Name: "sense_admission_profiles", SQL: admission.MigrationSQL})
|
||||
service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP"), store)
|
||||
admission.NewModule(service).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ func init() {
|
||||
store = device.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL})
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081301, Name: "sense_device_split_credentials", SQL: device.SplitCredentialMigrationSQL})
|
||||
device.NewModule(device.NewService(store, vault)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "eslint \"src/**/*.{js,vue}\""
|
||||
"lint": "eslint \"src/**/*.{js,vue}\"",
|
||||
"test:navigation": "node --test tests/navigation.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
@@ -50,4 +51,3 @@
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -58,22 +58,14 @@ import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
|
||||
import { buildNavigation } from './navigation'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
const collapsed = ref(false)
|
||||
const navigation = computed(() => {
|
||||
const root = router.getRoutes().find((route) => route.path === '/')
|
||||
const permissions = store.getters['sense-identity/permissions'] || []
|
||||
return (root?.children || [])
|
||||
.filter((route) => route.meta?.title && !route.meta?.hidden)
|
||||
.filter((route) => !route.meta?.permission || permissions.includes(route.meta.permission))
|
||||
.map((route) => ({
|
||||
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
|
||||
title: route.meta.title,
|
||||
icon: route.meta.icon,
|
||||
order: route.meta.order || 0
|
||||
}))
|
||||
.sort((left, right) => left.order - right.order)
|
||||
return buildNavigation(router.getRoutes(), permissions)
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
function buildNavigation(routes, permissions = []) {
|
||||
const layout = routes
|
||||
.filter((route) => route.path === '/' && Array.isArray(route.children) && route.children.length > 0)
|
||||
.sort((left, right) => right.children.length - left.children.length)[0]
|
||||
|
||||
return (layout?.children || [])
|
||||
.filter((route) => route.meta?.title && !route.meta?.hidden)
|
||||
.filter((route) => !route.meta?.permission || permissions.includes(route.meta.permission))
|
||||
.map((route) => ({
|
||||
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
|
||||
title: route.meta.title,
|
||||
icon: route.meta.icon,
|
||||
order: route.meta.order || 0
|
||||
}))
|
||||
.sort((left, right) => left.order - right.order)
|
||||
}
|
||||
|
||||
module.exports = { buildNavigation }
|
||||
@@ -7,7 +7,7 @@
|
||||
<template #header><strong>接入检查</strong></template>
|
||||
<el-alert title="发现功能只在实施人员配置获准网卡后启用,不会扫描其他网络。" type="info" show-icon :closable="false" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="admission-form">
|
||||
<el-form-item label="设备" prop="device_id"><el-select v-model="form.device_id" filterable placeholder="选择已登记的视频设备" style="width: 100%"><el-option v-for="item in devices" :key="item.id" :label="`${item.name} · ${item.location || '未填写位置'}`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="设备" prop="device_id"><el-select v-model="form.device_id" filterable placeholder="选择已登记的视频设备" style="width: 100%" @change="loadSavedResult"><el-option v-for="item in devices" :key="item.id" :label="`${item.name} · ${item.location || '未填写位置'}`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="ONVIF 服务地址" prop="address"><el-input v-model="form.address" placeholder="例如:http://设备地址/onvif/device_service" /><div class="field-hint">地址中不能包含用户名或密码;凭据来自设备管理中的安全配置。</div></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="probing" @click="probe">检查设备与视频</el-button><el-button :loading="discovering" @click="discover">发现设备</el-button></el-form-item>
|
||||
</el-form>
|
||||
@@ -38,15 +38,17 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listDevices } from '../../../api/sense/device'
|
||||
import { discoverDevices, probeDevice } from '../../../api/sense/admission'
|
||||
import { admissionResult, discoverDevices, probeDevice } from '../../../api/sense/admission'
|
||||
const devices=ref([]),probing=ref(false),discovering=ref(false),discoveryDialog=ref(false),discovered=ref([]),formRef=ref()
|
||||
const form=reactive({device_id:'',address:''}),result=reactive({})
|
||||
const rules={device_id:[{required:true,message:'请选择设备',trigger:'change'}],address:[{required:true,message:'请输入 ONVIF 服务地址',trigger:'blur'},{validator:(_r,v,done)=>v.includes('@')?done(new Error('地址中不能包含凭据')):done(),trigger:'blur'}]}
|
||||
function statusLabel(value){return({ready:'接入正常',profile_failed:'部分码流失败',authentication_failed:'认证失败',timeout:'响应超时',clock_skew:'需要校时',unreachable:'设备不可达'})[value]||value}
|
||||
async function loadDevices(){devices.value=(await listDevices({page:1,page_size:100})).items.filter(item=>item.modality==='video'&&item.status!=='disabled')}
|
||||
async function loadSavedResult(){Object.keys(result).forEach(key=>delete result[key]);if(!form.device_id)return;try{Object.assign(result,await admissionResult(form.device_id))}catch{/* 尚未接入时保持空状态 */}}
|
||||
async function probe(){const valid=await formRef.value?.validate().catch(()=>false);if(!valid)return;probing.value=true;try{Object.assign(result,await probeDevice(form))}catch(error){ElMessage.error(error.message||'检查失败')}finally{probing.value=false}}
|
||||
async function discover(){discovering.value=true;try{discovered.value=(await discoverDevices()).items||[];discoveryDialog.value=true}catch(error){ElMessage.warning(error.message||'未配置获准发现网卡')}finally{discovering.value=false}}
|
||||
function useAddress(value){form.address=value;discoveryDialog.value=false}
|
||||
onMounted(loadDevices)
|
||||
</script>
|
||||
<style scoped>.admission-form{margin-top:18px}.field-hint{color:#86909c;font-size:12px;line-height:1.5}.result-header{display:flex;align-items:center;justify-content:space-between}</style>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-table-column prop="location" label="安装位置" min-width="150" />
|
||||
<el-table-column label="类型" width="110"><template #default="scope">{{ scope.row.modality === 'video' ? '视频设备' : scope.row.modality }}</template></el-table-column>
|
||||
<el-table-column label="接入能力" width="140"><template #default="scope"><el-tag :type="scope.row.adapter_status === 'ready' ? 'success' : 'warning'">{{ scope.row.adapter_status === 'ready' ? '可接入' : '适配器未就绪' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="凭据" width="110"><template #default="scope"><el-tag :type="scope.row.credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured ? '已配置' : '未配置' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="设备凭据" width="150"><template #default="scope"><el-tag :type="scope.row.credential_configured && scope.row.rtsp_credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured && scope.row.rtsp_credential_configured ? '已配置' : '未完整配置' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.status === 'disabled' ? 'info' : 'primary'">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="canWrite" label="操作" width="210" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button link type="primary" @click="openCredential(scope.row)">更新凭据</el-button><el-button v-if="scope.row.status !== 'disabled'" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
@@ -33,8 +33,13 @@
|
||||
<el-dialog v-model="credentialDialog" title="更新设备凭据" width="520px" destroy-on-close>
|
||||
<el-alert title="凭据保存后不能查看,只能再次更新。请勿把密码写入设备地址。" type="warning" show-icon :closable="false" />
|
||||
<el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="82px" class="credential-form">
|
||||
<el-form-item label="用户名" prop="username"><el-input v-model="credentialForm.username" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="密码" prop="password"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label="ONVIF 用户名" prop="onvif_username"><el-input v-model="credentialForm.onvif_username" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="ONVIF 密码" prop="onvif_password"><el-input v-model="credentialForm.onvif_password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label-width="0"><el-checkbox v-model="credentialForm.rtsp_same_as_onvif">RTSP 与 ONVIF 使用相同账号</el-checkbox></el-form-item>
|
||||
<template v-if="!credentialForm.rtsp_same_as_onvif">
|
||||
<el-form-item label="RTSP 用户名" prop="rtsp_username"><el-input v-model="credentialForm.rtsp_username" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="RTSP 密码" prop="rtsp_password"><el-input v-model="credentialForm.rtsp_password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">安全保存</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -54,18 +59,18 @@ const editing = ref(null), credentialTarget = ref(null), deviceFormRef = ref(),
|
||||
const query = reactive({ keyword: '', page: 1, page_size: 20 })
|
||||
const page = reactive({ items: [], total: 0 })
|
||||
const deviceForm = reactive({ name: '', location: '', modality: 'video', capabilities: ['video'] })
|
||||
const credentialForm = reactive({ username: '', password: '' })
|
||||
const credentialForm = reactive({ onvif_username: '', onvif_password: '', rtsp_same_as_onvif: true, rtsp_username: '', rtsp_password: '' })
|
||||
const deviceRules = { name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], modality: [{ required: true, message: '请选择类型', trigger: 'change' }] }
|
||||
const credentialRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] }
|
||||
const credentialRules = { onvif_username: [{ required: true, message: '请输入 ONVIF 用户名', trigger: 'blur' }], onvif_password: [{ required: true, message: '请输入 ONVIF 密码', trigger: 'blur' }], rtsp_username: [{ validator: (_r, value, done) => !credentialForm.rtsp_same_as_onvif && !value ? done(new Error('请输入 RTSP 用户名')) : done(), trigger: 'blur' }], rtsp_password: [{ validator: (_r, value, done) => !credentialForm.rtsp_same_as_onvif && !value ? done(new Error('请输入 RTSP 密码')) : done(), trigger: 'blur' }] }
|
||||
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('device.write'))
|
||||
function statusLabel(value) { return ({ pending: '待接入', active: '正常', offline: '离线', disabled: '已停用' })[value] || value }
|
||||
async function load() { loading.value = true; try { Object.assign(page, await listDevices(query)) } finally { loading.value = false } }
|
||||
function reset() { Object.assign(query, { keyword: '', page: 1, page_size: 20 }); load() }
|
||||
function openCreate() { editing.value = null; Object.assign(deviceForm, { name: '', location: '', modality: 'video', capabilities: ['video'] }); deviceDialog.value = true }
|
||||
function openEdit(row) { editing.value = row; Object.assign(deviceForm, { name: row.name, location: row.location, modality: row.modality, capabilities: row.capabilities }); deviceDialog.value = true }
|
||||
function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { username: '', password: '' }); credentialDialog.value = true }
|
||||
function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { onvif_username: '', onvif_password: '', rtsp_same_as_onvif: row.rtsp_credential_same_as_onvif !== false, rtsp_username: '', rtsp_password: '' }); credentialDialog.value = true }
|
||||
async function saveDevice() { const valid = await deviceFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { if (editing.value) await updateDevice(editing.value.id, { ...deviceForm, version: editing.value.version }); else await createDevice(deviceForm); ElMessage.success('设备已保存'); deviceDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false } }
|
||||
async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { username: '', password: '' }); ElMessage.success('凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } }
|
||||
async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { onvif_username: '', onvif_password: '', rtsp_same_as_onvif: true, rtsp_username: '', rtsp_password: '' }); ElMessage.success('ONVIF 与 RTSP 凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } }
|
||||
async function disable(row) { await ElMessageBox.confirm(`停用“${row.name}”后将停止后续接入,设备记录和审计仍保留。`, '确认停用', { type: 'warning' }); await disableDevice(row.id, row.version); ElMessage.success('设备已停用'); await load() }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -73,3 +78,4 @@ onMounted(load)
|
||||
<style scoped>
|
||||
.credential-form { margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<el-form-item label="用户名" prop="username"><el-input v-model="form.username" :disabled="Boolean(editingID)" /></el-form-item>
|
||||
<el-form-item label="姓名" prop="display_name"><el-input v-model="form.display_name" /></el-form-item>
|
||||
<el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width: 100%"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item>
|
||||
<el-form-item v-if="!editingID" label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password /><div class="field-hint">至少 12 位,包含大小写字母和数字,且不含用户名。</div></el-form-item>
|
||||
<el-form-item v-if="!editingID" label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password /><div class="field-hint">至少 6 位字符。</div></el-form-item>
|
||||
<el-form-item v-else label="状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
@@ -64,7 +64,7 @@ const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
display_name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
password: [{ required: true, min: 12, message: '请输入至少 12 位初始密码', trigger: 'blur' }]
|
||||
password: [{ required: true, min: 6, message: '请输入至少 6 位初始密码', trigger: 'blur' }]
|
||||
}
|
||||
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('identity.users.write'))
|
||||
const filteredUsers = computed(() => {
|
||||
@@ -99,4 +99,3 @@ onMounted(load)
|
||||
<style scoped>
|
||||
.field-hint { color: #86909c; font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { buildNavigation } = require('../src/layout/navigation.js')
|
||||
|
||||
test('selects the layout record when normalized routes contain duplicate root paths', () => {
|
||||
const routes = [
|
||||
{ path: '/', name: 'sense-dashboard', children: [], meta: { title: '工作台' } },
|
||||
{ path: '/devices', name: 'sense-devices', children: [], meta: { title: '设备管理' } },
|
||||
{
|
||||
path: '/',
|
||||
children: [
|
||||
{ path: 'devices', meta: { title: '设备管理', permission: 'device.read', order: 10 } },
|
||||
{ path: '', meta: { title: '工作台', order: 0 } },
|
||||
{ path: 'users', meta: { title: '用户与权限', permission: 'identity.users.read', order: 90 } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
assert.deepEqual(buildNavigation(routes, ['device.read']), [
|
||||
{ path: '/', title: '工作台', icon: undefined, order: 0 },
|
||||
{ path: '/devices', title: '设备管理', icon: undefined, order: 10 }
|
||||
])
|
||||
})
|
||||
|
||||
test('returns no unauthorized or hidden entries', () => {
|
||||
const routes = [{
|
||||
path: '/',
|
||||
children: [
|
||||
{ path: '', meta: { title: '工作台' } },
|
||||
{ path: 'users', meta: { title: '用户与权限', permission: 'identity.users.read' } },
|
||||
{ path: 'internal', meta: { title: '内部页', hidden: true } }
|
||||
]
|
||||
}]
|
||||
|
||||
assert.deepEqual(buildNavigation(routes), [
|
||||
{ path: '/', title: '工作台', icon: undefined, order: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
test('administrator receives every Sense MVP navigation entry', () => {
|
||||
const entries = [
|
||||
['', '工作台', null, 0],
|
||||
['devices', '设备管理', 'device.read', 10],
|
||||
['admission', '视频接入', 'device.write', 20],
|
||||
['media', '视频服务', 'media.read', 30],
|
||||
['liveview', '实时监看', 'media.read', 40],
|
||||
['areas', '区域与警戒线', 'area.read', 50],
|
||||
['users', '用户与权限', 'identity.users.read', 90],
|
||||
['audit', '操作记录', 'identity.audit.read', 91]
|
||||
]
|
||||
const routes = [{ path: '/', children: entries.map(([path, title, permission, order]) => ({
|
||||
path,
|
||||
meta: { title, permission, order }
|
||||
})) }]
|
||||
const permissions = [
|
||||
'device.read', 'device.write', 'media.read', 'area.read',
|
||||
'identity.users.read', 'identity.audit.read'
|
||||
]
|
||||
|
||||
assert.deepEqual(buildNavigation(routes, permissions).map((item) => item.title), [
|
||||
'工作台', '设备管理', '视频接入', '视频服务', '实时监看',
|
||||
'区域与警戒线', '用户与权限', '操作记录'
|
||||
])
|
||||
})
|
||||
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
|
||||
wiki_revision: 8d21b036c7a4077560b44790bb55602c84caaab1
|
||||
synchronized_at: 2026-08-12T10:17:39Z
|
||||
synchronized_at: 2026-08-12T10:21:47Z
|
||||
<!-- gitea-wiki-mirror: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: 1712a8053f365781aef9cbf33c576c97491f31d4
|
||||
synchronized_at: 2026-08-12T10:17:44Z
|
||||
wiki_revision: e6fc9d8663d8e9fb5ebfacb3c33a029b2659ae85
|
||||
synchronized_at: 2026-08-13T04:16:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -100,8 +100,8 @@ Sense/Brain 生成事件
|
||||
Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server/cmd/sense/modules_<feature>.go` 独立注册:
|
||||
|
||||
- `identity/`:Sense 独立账户、bcrypt 密码、会话、四角色 RBAC 与统一审计;签发者和受众只属于 Sense。
|
||||
- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;读取模型只返回 `credential_configured`。
|
||||
- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Profile/StreamUri 读取和 RTSP 验证。
|
||||
- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;ONVIF 与 RTSP 凭据可分离或显式复用,读取模型只返回两组凭据是否已配置。
|
||||
- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Media 服务发现、Basic/Digest 认证、Profile/StreamUri 读取和 RTSP 验证。跨主机 Media 地址固定回用户已授权的 Device Service origin;跨主机 RTSP URI 只替换为授权主机并保留报告端口与路径。接入结果和脱敏 Profile 持久化到 PostgreSQL,重启后可恢复。
|
||||
- `adapters/mediamtx/`、`media/`:外部 MediaMTX 进程所有权、localhost Control API、媒体期望态与实际态对账。
|
||||
- `liveview/`:绑定当前用户、最长两分钟的单路播放会话;只投影媒体路径,不暴露源 URI 或摄像机秘密。
|
||||
- `area/`:归一化多边形/方向警戒线、不可变版本、并发版本校验和分辨率变化后的重新校准。
|
||||
@@ -124,3 +124,4 @@ Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server
|
||||
|
||||
Event 写入、幂等 Receipt、规则评估和 Alert 创建/关联处于同一数据库事务;规则或关联失败时不保留半成品 Event。一个 Event 可匹配多个规则,一个未关闭 Alert 可聚合相同规则与地点的多个 Event。
|
||||
<!-- bell-mvp: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: 2b222ffc8c2d943fa2a27d9e28ffaf4896143950
|
||||
synchronized_at: 2026-08-12T10:17:48Z
|
||||
wiki_revision: 351a92bd514dd5d3315201284205a5596ccd0084
|
||||
synchronized_at: 2026-08-13T04:16:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -71,11 +71,11 @@ synchronized_at: 2026-08-12T10:17:48Z
|
||||
## Sense MVP 业务与安全规则
|
||||
|
||||
- **Sense 账户**:只登录 Sense;不得接受 Bell JWT、Cookie 或用户数据。角色为系统管理员、实施/运维、站点管理员和只读用户,后端权限是最终边界。
|
||||
- **安全初始化**:系统不提供默认账户、默认密码或默认签名密钥;首次管理员由仓库外一次性令牌创建。
|
||||
- **安全初始化**:系统不提供默认账户、默认密码或默认签名密钥;首次管理员由仓库外一次性令牌创建。所有模式的密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名。负责人已明确接受该生产密码策略的字典猜测与凭据填充风险。
|
||||
- **Device**:设备不可变逻辑 ID 是后续 Profile、媒体和区域的内部引用。非视频适配器未实现时必须显示 `adapter_not_ready`。
|
||||
- **摄像机凭据**:只写不读,使用仓库外 32 字节密钥加密;不得进入 URL、日志、审计、工单或响应。
|
||||
- **摄像机凭据**:ONVIF 与 RTSP 可使用不同账号,也可显式复用;两组均只写不读,使用仓库外 32 字节密钥分别加密。密码不得进入 URL、日志、审计、工单或响应。
|
||||
- **受控发现**:ONVIF Discovery 默认关闭,只有显式设置获准本机 IP 才能发送发现;不得扫描未授权网段。
|
||||
- **Profile**:主辅码流按分辨率分类并分别验证;认证失败、不可达、超时与校时问题使用可定位状态。
|
||||
- **Profile**:主辅码流按分辨率分类并使用 RTSP 凭据分别验证;脱敏 Stream URI、验证状态和时间持久化,重启后保留。至少一个 Profile 验证成功后 Device 进入 `active`;认证失败、不可达、超时与校时问题使用可定位状态。
|
||||
- **MediaMTX**:保持外部进程。Sense 只停止自己启动并持有句柄的进程,最多自动重启三次;摄像机凭据只在 localhost 控制请求中瞬时组装,不持久化、不返回。
|
||||
- **播放会话**:由当前 Sense 用户创建,最长两分钟;设备分页和媒体路径不以 16 路作为硬上限,页面一次只打开一路流。
|
||||
- **区域版本**:坐标为 0..1 归一化值,并绑定 Device、Profile、宽高。每次发布或停用形成新版本;范围、点数、自交、退化、方向和期望版本由后端校验。Profile 分辨率变化后旧版本必须标记为需要重新校准。
|
||||
@@ -96,3 +96,4 @@ synchronized_at: 2026-08-12T10:17:48Z
|
||||
- **close**:只有处置人或管理员可以完成;必须选择“确认有危险、误报、现场正常、无法确认”之一,可附备注。相同重复请求幂等,其他改变结果的请求被拒绝。
|
||||
- **审计事实**:成功生命周期事实只追加;失败、重复和拒绝尝试写入安全审计,但不记录令牌、密码或连接密钥。
|
||||
<!-- bell-mvp: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: a9b66768b039da1d25a5e1688c020167798e9ea0
|
||||
synchronized_at: 2026-08-12T10:17:51Z
|
||||
wiki_revision: 426001a8c28bde9c336ecc8b7166924f14aa55ae
|
||||
synchronized_at: 2026-08-13T03:39:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -73,8 +73,8 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
|
||||
|
||||
## 测试数据与日志
|
||||
|
||||
- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。
|
||||
- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据。
|
||||
- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。ONVIF 自动化测试需覆盖 Basic、Digest challenge、Media 服务发现、跨主机地址归一化、拒绝地址凭据和禁止重定向。
|
||||
- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据;真实设备验证只记录状态与 Profile 数量,不记录设备地址、Authorization 或 Stream URI。
|
||||
- 日志必须可按 request/event/alert ID 追踪,但不得记录 Authorization、Cookie 或连接密钥。
|
||||
|
||||
## 完成修改前
|
||||
@@ -156,3 +156,19 @@ corepack pnpm@9.15.1 build:prod
|
||||
|
||||
在 Sense、Brain 停止时,development/test 可使用“合成事件测试”完成 Event 入站、规则匹配、Alert 关联、ack 和结果必填 close。production 必须验证 `/api/v1/synthetic/*` 返回 404。PostgreSQL 集成测试使用空的 Bell 专用测试数据库,禁止复用 Sense 或生产数据库。
|
||||
<!-- bell-runtime:end -->
|
||||
|
||||
<!-- sense-windows-package:start -->
|
||||
## Sense Windows 打包与产物验证
|
||||
|
||||
将冻结的 Go 1.26.5 放在 `PATH` 首位后,从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
cmd /c .\Sense\scripts\package-windows.bat
|
||||
Get-FileHash .\Sense\dist\sense-windows-amd64.zip -Algorithm SHA256
|
||||
```
|
||||
|
||||
脚本精确校验 Node 22.22.1 和 pnpm 9.15.1,冻结安装并构建前端,以 Windows amd64/CGO 关闭方式编译后端,然后生成被 Git 忽略的 `Sense\dist\sense-windows-amd64\` 和 ZIP。同一运行目录重新打包时会保留已有 `config\sense.env`,但该文件不会进入 ZIP。
|
||||
|
||||
解压后运行 `start-sense.bat demo` 可做内存模式临时预览。生产配置可写入运行目录的 `config\sense.env`,也可通过 Windows 进程环境注入;非空进程环境变量优先,启动器只读取 `SENSE_*` 键且不打印配置值。运行 `start-sense.bat check` 可在不连接数据库、不启动服务的情况下检查必填配置,然后用 `start-sense.bat` 启动生产模式。包内不含 PostgreSQL、MediaMTX、系统服务、客户数据或秘密,真实 `sense.env` 不得提交或重新打入交付 ZIP。
|
||||
<!-- sense-windows-package:end -->
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: 3ee073ae07bb2ab04f02b1178b6d289f6f5a2063
|
||||
synchronized_at: 2026-08-12T09:32:15Z
|
||||
wiki_revision: 0110fbc618f77660bf447c04def347fe9098510f
|
||||
synchronized_at: 2026-08-13T04:16:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -47,9 +47,10 @@ synchronized_at: 2026-08-12T09:32:15Z
|
||||
|---|---|
|
||||
| 启动提示缺少 `SENSE_DATABASE_URL` | 正式模式必须提供独立 PostgreSQL URL;只有测试/smoke 可显式使用 `memory`。 |
|
||||
| 登录统一提示用户名或密码错误 | 为避免枚举账户,失败响应不区分用户不存在、密码错误或账户停用;由管理员查看审计。 |
|
||||
| 管理员登录后侧栏没有模块 | 先确认 /api/v1/identity/me 返回角色与权限;若权限正常,检查前端是否从具有 children 的应用布局路由派生菜单,不得依赖重复 / 路由记录顺序。 |
|
||||
| `adapter_not_ready` | 当前设备类型尚无适配器,不代表网络故障;首期完整支持 video。 |
|
||||
| `discovery_unavailable` | 未设置获准的 `SENSE_ONVIF_DISCOVERY_IP`,或该 IP 不属于本机网卡。可改用手工 ONVIF 地址。 |
|
||||
| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。 |
|
||||
| `authentication_failed` | 在设备管理中分别检查 ONVIF 与 RTSP 凭据,只有设备确实共用账号时才勾选“RTSP 与 ONVIF 使用相同账号”;不要把凭据写进地址。Sense 支持 ONVIF Basic 与 Digest,并会安全归一化摄像机广播的跨主机 Media/RTSP 地址。 |
|
||||
| `clock_skew` | 校准摄像机时间后重新探测。 |
|
||||
| `process_failed` | 检查仓库外 `SENSE_MEDIAMTX_BINARY`、基础配置和进程退出原因;达到三次重启上限后需人工处理。 |
|
||||
| `apply_failed` / `unconverged` | 检查 localhost Control API 是否启用并为 v3;确认媒体路径和外部进程状态。 |
|
||||
@@ -58,3 +59,17 @@ synchronized_at: 2026-08-12T09:32:15Z
|
||||
|
||||
自动测试不访问真实摄像头或未授权网络。PostgreSQL、MediaMTX、目标浏览器与实验室摄像机的联合验证必须在获准部署环境完成。
|
||||
<!-- sense-mvp:end -->
|
||||
|
||||
<!-- sense-windows-package:start -->
|
||||
## Sense Windows 打包与启动排错
|
||||
|
||||
| 现象 | 检查与处理 |
|
||||
|---|---|
|
||||
| 打包提示 Go/Node/pnpm 版本不符 | 对照根 `goadmin-baseline.json` 安装精确版本,并把 Go 1.26.5 放到 `PATH` 首位;不要修改脚本绕过版本检查。 |
|
||||
| 生产启动提示缺少环境变量 | 确认运行目录中存在 `config\sense.env`(不是只保留 `sense.env.example`),并填写 `SENSE_DATABASE_URL`、`SENSE_IDENTITY_SIGNING_KEY`、`SENSE_BOOTSTRAP_TOKEN`、`SENSE_CREDENTIAL_KEY`;也可在进程环境中设置,非空进程环境变量优先。运行 `start-sense.bat check` 定位缺失的变量名,脚本不会打印变量值。 |
|
||||
| 本机健康检查被代理返回空响应 | localhost 可能被 `HTTP_PROXY` 接管;测试工具应对 `127.0.0.1` 使用 no-proxy,再检查 `SENSE_HTTP_ADDRESS` 端口占用。 |
|
||||
| 页面可打开但视频不可用 | Windows 运行包不包含 MediaMTX;按包内说明配置独立 MediaMTX 二进制、配置和 Control API。 |
|
||||
|
||||
`demo` 只用于临时查看。生产数据持久性、真实设备和媒体链路不能用 demo 验证替代。
|
||||
<!-- sense-windows-package:end -->
|
||||
|
||||
|
||||
+14
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Documentation-Guide
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
|
||||
wiki_revision: 9847e4c065e513107f8f79617c6529a8300874cb
|
||||
synchronized_at: 2026-08-12T09:32:40Z
|
||||
wiki_revision: d4f2bfcef46493361ba019839b36957ed9947003
|
||||
synchronized_at: 2026-08-13T02:35:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 交付文档指南
|
||||
@@ -84,10 +84,21 @@ synchronized_at: 2026-08-12T09:32:40Z
|
||||
|
||||
Sense 面向网管、实施人员和非技术现场人员,菜单按日常任务组织:工作台 → 设备管理 → 视频接入 → 视频服务 → 实时监看 → 区域与警戒线。普通操作优先展示中文状态与下一步,不要求用户理解 ONVIF、RTSP 或 MediaMTX 内部模型。
|
||||
|
||||
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。
|
||||
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。首次管理员和新用户密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名;仍应由管理员选择难猜且不复用的密码。
|
||||
- 实施/运维:添加设备、只写更新凭据、在获准网卡发现或手工接入、检查主辅码流、处理媒体进程与对账。
|
||||
- 站点管理员:查看/维护站点设备,实时监看,绘制和发布区域版本;无权创建系统管理员。
|
||||
- 只读用户:查看设备、媒体状态、实时画面和区域,不能修改。
|
||||
|
||||
交付验收必须使用仓库外测试账户和获准实验室设备;文档、截图、工单和日志不得包含摄像机密码、Cookie、令牌、含凭据 URI 或客户真实画面。当前自动化已覆盖内部状态和安全边界;真实 PostgreSQL、MediaMTX、摄像机兼容与目标浏览器画面仍需实施人员验证。
|
||||
<!-- sense-mvp:end -->
|
||||
|
||||
<!-- sense-windows-package:start -->
|
||||
## Sense Windows 运行包交付
|
||||
|
||||
交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。
|
||||
|
||||
- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。
|
||||
- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。
|
||||
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
|
||||
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
|
||||
<!-- sense-windows-package:end -->
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-12-Bell独立登录RBAC与认证审计
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-12-Bell%E7%8B%AC%E7%AB%8B%E7%99%BB%E5%BD%95RBAC%E4%B8%8E%E8%AE%A4%E8%AF%81%E5%AE%A1%E8%AE%A1.-
|
||||
wiki_revision: 133ad095749c9258fa2becc65d8946233fdfd56d
|
||||
synchronized_at: 2026-08-12T10:19:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 Bell独立登录RBAC与认证审计
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/12
|
||||
- Wiki 页面:Task-12-Bell独立登录RBAC与认证审计
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
为 Bell 建立不信任 Sense token 的独立账户、会话、角色权限与认证审计边界。
|
||||
|
||||
## 最终方案
|
||||
|
||||
使用 bcrypt 密码和只存 HMAC 摘要的服务端会话,Cookie 固定为 `bell_session`。系统无默认账户/密码/密钥,首次管理员由一次性环境变量创建。管理员、操作员、只读角色按最小权限执行,认证审计由数据库触发器保证只追加。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Bell/server/app/auth/`、`rbac/`、`audit/`:身份、权限与审计。
|
||||
- `Bell/server/migrations/2026081201_auth.sql`:账户、角色、会话、审计及不可变约束。
|
||||
- `Bell/web/src/views/login/`、`system/`:登录、用户角色和审计页面。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 缺少 session secret 拒绝启动 | 通过 |
|
||||
| Bell 登录/登出和过期会话闭环 | 通过 |
|
||||
| Sense Cookie 不被接受 | 通过 |
|
||||
| 后端 RBAC 隔离管理员接口 | 通过 |
|
||||
| 审计事实不可更新/删除 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:Go 单元/集成测试;登录 200→登出 204→会话 401;伪 Sense Cookie 401;管理员/操作员/只读权限矩阵;数据库不可变触发器。
|
||||
- 结果:全部通过。
|
||||
- **未验证部分**:生产域名下 Secure Cookie、外部身份提供商和真实用户人工验收。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `a5fc8f5` 独立认证与 RBAC(PR #35)。
|
||||
@@ -1,52 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-17-Bell不可变Event与永久幂等收据
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-17-Bell%E4%B8%8D%E5%8F%AF%E5%8F%98Event%E4%B8%8E%E6%B0%B8%E4%B9%85%E5%B9%82%E7%AD%89%E6%94%B6%E6%8D%AE.-
|
||||
wiki_revision: dab364664ed4d1aa7605de1a0c1a02299df75941
|
||||
synchronized_at: 2026-08-12T10:19:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 17 Bell不可变Event与永久幂等收据
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/17
|
||||
- Wiki 页面:Task-17-Bell不可变Event与永久幂等收据
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
可靠接收外部事件,在重试、并发和重启后保持永久幂等,同时禁止改写原始 Event。
|
||||
|
||||
## 最终方案
|
||||
|
||||
以 `(producer_id, source_event_id)` 唯一键和 payload hash 建立稳定 Receipt;相同正文返回原结果,不同正文返回冲突。Event/Receipt 持久化在同一事务,数据库触发器拒绝 Event 更新或删除,并提供游标分页与详情查询。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Bell/server/app/event/`、`receipt/`:入站服务、HTTP、查询和类型。
|
||||
- `Bell/server/migrations/2026081202_event_receipt.sql`:不可变表、索引、Receipt 和触发器。
|
||||
- `Bell/web/src/views/event/Events.vue`:事件查询与技术详情。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 并发重复返回同一 Event/Receipt | 通过 |
|
||||
| 同键不同 payload 返回冲突 | 通过 |
|
||||
| 进程/连接重建后仍幂等 | 通过 |
|
||||
| Event 数据库层不可变 | 通过 |
|
||||
| 下游失败时不留下半成品 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:PostgreSQL 集成测试,16 个并发相同入站、冲突、连接池重建、不可变触发器、事务 hook 回滚。
|
||||
- 结果:全部通过。
|
||||
- **未验证部分**:正式跨项目契约、生产规模压测与生产备份恢复。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `64a1d82` 不可变 Event 与永久幂等 Receipt(PR #35)。
|
||||
@@ -1,52 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-18-Bell开发测试合成事件入口
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-18-Bell%E5%BC%80%E5%8F%91%E6%B5%8B%E8%AF%95%E5%90%88%E6%88%90%E4%BA%8B%E4%BB%B6%E5%85%A5%E5%8F%A3.-
|
||||
wiki_revision: 2ce181d6bd9001661af20940cbcbf74beda4d7ba
|
||||
synchronized_at: 2026-08-12T10:19:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 18 Bell开发测试合成事件入口
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/18
|
||||
- Wiki 页面:Task-18-Bell开发测试合成事件入口
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 Sense、Brain 离线时提供安全、可重复的 Bell 纵切验收入口,同时保证生产环境不可访问。
|
||||
|
||||
## 最终方案
|
||||
|
||||
把虚构匿名 fixture 嵌入 `server/testdata/events/`;仅在 development/test 注册 synthetic 路由并要求管理员权限,所有成功注入都调用正式 Event/Receipt 服务。production 不注册路由,直接返回 404。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Bell/server/app/synthetic/`、`server/testdata/events/`:fixture、校验和 HTTP 入口。
|
||||
- `Bell/server/migrations/2026081203_synthetic_permission.sql`:测试权限。
|
||||
- `Bell/web/src/views/synthetic-event/`:仅环境与权限允许时显示的测试页。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 无 Sense/Brain 可注入匿名事件 | 通过 |
|
||||
| 重放相同 fixture 验证幂等 | 通过 |
|
||||
| 同键不同正文验证冲突 | 通过 |
|
||||
| 非法 fixture 返回 400 | 通过 |
|
||||
| production 路由返回 404 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:Go 单元/HTTP 测试与 dev/test/prod 手工 API 验收。
|
||||
- 结果:首次 201、重复 200、冲突 409、非法 400、production 404。
|
||||
- **未验证部分**:正式生产者契约与客户环境验收。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `55d54d0` 仅开发测试可用的合成事件(PR #35)。
|
||||
@@ -1,52 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-19-Bell规则匹配与Event-Alert链路
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-19-Bell%E8%A7%84%E5%88%99%E5%8C%B9%E9%85%8D%E4%B8%8EEvent-Alert%E9%93%BE%E8%B7%AF.-
|
||||
wiki_revision: ca2150b47195eb44b14c4c73a2eab8fd3be14617
|
||||
synchronized_at: 2026-08-12T10:19:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 19 Bell规则匹配与Event-Alert链路
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/19
|
||||
- Wiki 页面:Task-19-Bell规则匹配与Event-Alert链路
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
把已接收 Event 通过可审计规则转换为可处置 Alert,并保留 Event/Alert 双向追溯。
|
||||
|
||||
## 最终方案
|
||||
|
||||
Event 接收事务内执行规则评估,记录规则版本、解释和实际快照。一个 Event 可匹配多个规则;相同规则与地点的多个 Event 聚合到同一未关闭 Alert。未匹配 Event 仍可查询且不冒充通知/处置成功。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Bell/server/app/rule/`、`alert/model/`、`alert/query/`:规则、评估、Alert 和查询。
|
||||
- `Bell/server/migrations/2026081204_rule_alert_base.sql`:规则版本、评估、Alert 与多对多关联。
|
||||
- `Bell/web/src/views/rule/`、`alert/list/`:规则与预警列表/详情。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 一个 Event 可形成多个 Alert | 通过 |
|
||||
| 多 Event 可聚合同一开放 Alert | 通过 |
|
||||
| 未匹配评估可审计 | 通过 |
|
||||
| 失败时 Event/Receipt/Alert 同事务回滚 | 通过 |
|
||||
| 规则写权限仅管理员 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:PostgreSQL 集成测试与 API 验收;覆盖 1 Event→2 Alert、2 Event→同 2 Alert、未匹配、事务回滚和 RBAC。
|
||||
- 结果:全部通过;Alert 详情返回 2 个关联 Event 与 2 条匹配解释。
|
||||
- **未验证部分**:大规模规则性能、正式生产者契约和通知投递。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `d550269` 规则匹配与 Event/Alert 可导航链路(PR #35)。
|
||||
@@ -1,54 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-20-Bell-Alert并发处置与审计时间线
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-20-Bell-Alert%E5%B9%B6%E5%8F%91%E5%A4%84%E7%BD%AE%E4%B8%8E%E5%AE%A1%E8%AE%A1%E6%97%B6%E9%97%B4%E7%BA%BF.-
|
||||
wiki_revision: b7ed3e488bf2484b4b4690902f8e446e0e6a1b54
|
||||
synchronized_at: 2026-08-12T10:19:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 20 Bell-Alert并发处置与审计时间线
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/20
|
||||
- Wiki 页面:Task-20-Bell-Alert并发处置与审计时间线
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
实现并发安全、责任清晰、可审计的 Alert ack/close 处置状态机。
|
||||
|
||||
## 最终方案
|
||||
|
||||
PostgreSQL 条件更新选择唯一 ack 获胜者,后来者看到真实处置人。只有处置人或管理员可 close,且必须记录四种现场结果之一;同一重复 close 幂等。成功事实进入不可变生命周期时间线,失败/重复/拒绝尝试进入安全审计。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Bell/server/app/alert/lifecycle/`:ack/close 服务、HTTP 与并发测试。
|
||||
- `Bell/server/migrations/2026081205_alert_lifecycle.sql`:生命周期事实、审计和不可变约束。
|
||||
- `Bell/web/src/views/alert/list/Alerts.vue`:两步处置、结果表单与时间线。
|
||||
- `Bell/web/src/layout/AppLayout.vue`:对齐已确认的导航顺序。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 并发 ack 仅一个处置人 | 通过 |
|
||||
| 后来者得到真实处置人且不能覆盖 | 通过 |
|
||||
| close 要求结果且受所有者/管理员约束 | 通过 |
|
||||
| 相同重复 close 幂等 | 通过 |
|
||||
| 重启后状态与只追加时间线保留 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:20 并发 ack PostgreSQL 集成测试;API ack/冲突/结果必填/close/重复 close;重启持久性;Vue lint/build。
|
||||
- 结果:全部通过,时间线含 ack 与 close 两条成功事实。
|
||||
- **未验证部分**:多人真实浏览器并发、生产规模压测和通知/升级流程。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `10225f4` 并发安全的 Alert 生命周期(PR #35)。
|
||||
- `9ee06e3` 对齐正式页面导航顺序(PR #35)。
|
||||
@@ -1,50 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-31-Bell原型对齐GoAdmin与Element-Plus
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-31-Bell%E5%8E%9F%E5%9E%8B%E5%AF%B9%E9%BD%90GoAdmin%E4%B8%8EElement-Plus.-
|
||||
wiki_revision: e3ceec7443b6ba2f01db0caa13eae5fc9b0545c2
|
||||
synchronized_at: 2026-08-12T10:19:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 31 Bell原型对齐GoAdmin与Element-Plus
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/31
|
||||
- Wiki 页面:Task-31-Bell原型对齐GoAdmin与Element-Plus
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
将 Bell HTML 原型对齐冻结 go-admin-ui 外壳与 Element Plus 交互语言,为正式 Vue 页面提供已验证设计基线。
|
||||
|
||||
## 最终方案
|
||||
|
||||
复用共享 Sidebar、Navbar、TagsView、AppMain、表单、表格、分页、Dialog、Tag、Tabs 与权限按钮,只新增 AlertWorkbench 和 AlertAuditTimeline 业务组件;未修改只读上游仓库。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `prototypes/bell/**`:Bell 原型页面、样式与交互。
|
||||
- `prototypes/assets/go-admin-ui.css`、`go-admin-shell.js`:在协调范围内复用的原型外壳适配器。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| go-admin-ui/Element Plus 外壳一致 | 通过 |
|
||||
| ack/close、时间线与 Dialog 键盘交互 | 通过 |
|
||||
| 1440/768/375/手机横屏响应式 | 通过 |
|
||||
| 无重复组件、外部资源或控制台错误 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:Harness、25 项仓库测试、JavaScript 语法、重复 ID、响应式、键盘、焦点、Escape 与交互走查。
|
||||
- 结果:自动化和浏览器检查通过。
|
||||
- **未验证部分**:产品/客户人工验收。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `0bdd88b` 原型对齐冻结 GoAdmin UI 基线(PR #29)。
|
||||
@@ -1,49 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-33-Bell普通用户预警处理原型
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-33-Bell%E6%99%AE%E9%80%9A%E7%94%A8%E6%88%B7%E9%A2%84%E8%AD%A6%E5%A4%84%E7%90%86%E5%8E%9F%E5%9E%8B.-
|
||||
wiki_revision: c829e54319af164c80be8a07048ef7e74181600b
|
||||
synchronized_at: 2026-08-12T10:19:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 33 Bell普通用户预警处理原型
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/33
|
||||
- Wiki 页面:Task-33-Bell普通用户预警处理原型
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
把领域模型导向的 Bell 原型调整为普通客户熟悉的预警后台,使用户无需理解 Event/Alert 即可完成核心处置。
|
||||
|
||||
## 最终方案
|
||||
|
||||
一级模块固定为工作台、预警管理、事件查询、规则配置。默认展示事项、地点、时间、风险和建议;“开始处理”与“记录现场结果”分成两步,关闭前必须选择确认有危险、误报、现场正常或无法确认。内部 ID、Receipt、规则快照和审计链路进入技术详情。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `prototypes/bell/index.html`、`bell.css`、`bell.js`:普通用户信息架构、处置动作与响应式交互。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 四个常见一级模块与中文业务字段 | 通过 |
|
||||
| ack 与结果必填 close 语义清晰 | 通过 |
|
||||
| 并发处理人反馈和技术详情可追溯 | 通过 |
|
||||
| 375/768/1440/812×375 无页面溢出 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:导航、筛选、分页、ack、结果必填、误报 close、响应式、键盘/焦点、Harness 与仓库测试。
|
||||
- 结果:自动化与浏览器检查通过,无控制台错误或警告。
|
||||
- **未验证部分**:普通用户代表无提示人工走查与产品确认。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `8d1875c` 按确认反馈调整为传统预警后台(PR #29)。
|
||||
@@ -1,52 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-36-Bell-MVP文档与待验收归档
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-36-Bell-MVP%E6%96%87%E6%A1%A3%E4%B8%8E%E5%BE%85%E9%AA%8C%E6%94%B6%E5%BD%92%E6%A1%A3.-
|
||||
wiki_revision: e9880ec350e7d9fea1119b98da48ce47c871a9c6
|
||||
synchronized_at: 2026-08-12T10:24:25Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 36 Bell-MVP文档与待验收归档
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/36
|
||||
- Wiki 页面:Task-36-Bell-MVP文档与待验收归档
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
统一把 Bell #9、#12、#17、#18、#19、#20、#31、#33 的长期结论与待验收证据写入 Wiki,并生成只读镜像。
|
||||
|
||||
## 最终方案
|
||||
|
||||
由单一协调 agent 在 PR #34 的精确文档基线上串行更新共享页面,Bell 代码仍由 PR #35 独立审查。先更新并读取 Wiki,再生成镜像;不删除/重命名页面,不关闭待验收工单。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `wiki-docs.json`:登记九个任务归档显式映射。
|
||||
- `docs/00-project-profile.md`、`docs/02-architecture-and-code-map.md`、`docs/03-business-rules-and-glossary.md`、`docs/04-local-development-and-verification.md`:Wiki 镜像。
|
||||
- `docs/task/{9,12,17,18,19,20,31,33,36}-*.md`:待验收归档镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 四个稳定 Wiki 页面保留其他项目并包含 Bell 当前事实 | 通过 |
|
||||
| 八个 Bell 工单归档包含真实提交、测试和未验证项 | 通过 |
|
||||
| Wiki 写后读取确认 | 通过 |
|
||||
| 镜像、Harness、仓库测试与 diff 检查 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:`sync_wiki_docs.py --check`、`check_harness.py --strict`、仓库 unittest、`git diff --check`。
|
||||
- 结果:Wiki 36 个映射全部一致;DevHarness 通过;31 项 unittest 全部通过;`git diff --check` 通过。
|
||||
- **未验证部分**:用户对代码、原型与文档的人工验收。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `e2a4345` Bell 稳定文档与待验收归档镜像。
|
||||
- 最终归档镜像提交与串联 PR 见工单 #36。
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-37-Sense-Windows前后端一键打包
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-37-Sense-Windows%E5%89%8D%E5%90%8E%E7%AB%AF%E4%B8%80%E9%94%AE%E6%89%93%E5%8C%85.-
|
||||
wiki_revision: 50a1b74dd33ccd52b8642599b3b579cbf4ac870e
|
||||
synchronized_at: 2026-08-12T10:33:06Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 37 Sense Windows前后端一键打包
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/37
|
||||
- Wiki 页面:Task-37-Sense-Windows前后端一键打包
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
客户需要在 Windows 上快速查看和交付 Sense。目标是用一个简单 BAT 从冻结工具链构建 Vue 前端和 Go 后端,组装不含秘密的 Windows amd64 运行目录及 ZIP,并提供临时预览和生产配置边界。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- `Sense/scripts/package-windows.bat` 精确校验 Go 1.26.5、Node 22.22.1、pnpm 9.15.1,执行冻结安装、前端构建、CGO 关闭的 Windows amd64 后端编译、白名单文件组装和 ZIP 压缩。
|
||||
- 产物固定为被 Git 忽略的 `Sense/dist/sense-windows-amd64/` 与 `Sense/dist/sense-windows-amd64.zip`;路径保护确保重建只清理这两个目标。
|
||||
- 包内包含后端 EXE、前端静态资源、空值示例配置、上游许可证、`start-sense.bat` 和 `README-WINDOWS.md`。
|
||||
- `start-sense.bat demo` 是明确的内存临时预览;无参数生产启动强制要求外部 PostgreSQL URL、身份签名密钥、引导令牌和凭据加密密钥。
|
||||
- Wiki 增加本地打包、交付边界与排错说明;全量镜像同步同时带入其他已完成工单的最新 Wiki revision。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/scripts/package-windows.bat`:一键构建、组装与压缩。
|
||||
- `Sense/scripts/runtime/start-sense.bat`:demo/生产双模式启动入口。
|
||||
- `Sense/scripts/runtime/README-WINDOWS.md`:包内运行说明。
|
||||
- `Sense/config/sense.env.example`:补齐身份、凭据、ONVIF 与 MediaMTX 空值示例。
|
||||
- `Sense/README.md`:增加 Windows 打包入口。
|
||||
- `docs/04-local-development-and-verification.md`、`docs/06-troubleshooting.md`、`docs/delivery/README.md`:Wiki-first 镜像。
|
||||
- `wiki-docs.json`、`docs/task/37-Sense-Windows前后端一键打包.md`:任务归档登记和镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 冻结工具链一键生成前后端 ZIP | 通过;连续执行两次均成功 |
|
||||
| ZIP 含 EXE、UI、空值配置、许可证、启动脚本与说明 | 通过;共 14 个条目 |
|
||||
| 包内不含秘密或生产数据 | 通过;敏感示例值为空且文本扫描未发现令牌、私钥或带密码 PostgreSQL URL |
|
||||
| 包内后端加载 UI,健康检查与首页可访问 | 通过;`/healthz` 200,`/` 200 且包含 Vue 挂载点 |
|
||||
| 固定路径安全重复覆盖 | 通过 |
|
||||
| Brain、Bell 离线可构建和验证 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `cmd /c Sense\scripts\package-windows.bat`:连续两次通过;前端仅有既有 vendor bundle 体积建议警告。
|
||||
- ZIP:7,281,398 bytes;SHA-256 `1BAEB17F1976DD925D8C01A245C951081FAE149E5151DA5523190F24E0B6AA6D`。
|
||||
- 包内后端:内存模式绑定 `127.0.0.1:18082`,绕过本机代理后 `/healthz` 与 `/` 均为 200。
|
||||
- `python -m unittest discover -s tests -v`:31 项通过。
|
||||
- `python dev_scripts/check_harness.py --strict`:通过。
|
||||
- `python dev_scripts/sync_wiki_docs.py --check`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- **未验证部分**:未连接真实 PostgreSQL、MediaMTX 或摄像机;未用真实生产秘密运行生产模式;未由目标实施/运维人员完成解压和人工验收。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 前端 vendor bundle 超过 Vue CLI 建议体积阈值,本工单不改变现有分包策略,不阻塞 Windows 打包。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `e80180e` build: add Sense Windows package script (#37)
|
||||
- `efb48b4` docs: document Sense Windows package workflow (#37)
|
||||
- `6d35fa7` docs: create task 37 acceptance archive (#37)
|
||||
@@ -0,0 +1,70 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-40-Sense全模式密码最短6位
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-40-Sense%E5%85%A8%E6%A8%A1%E5%BC%8F%E5%AF%86%E7%A0%81%E6%9C%80%E7%9F%AD6%E4%BD%8D.-
|
||||
wiki_revision: 89cf46b02ed85e26165cd835ccebb78a6621aa8b
|
||||
synchronized_at: 2026-08-13T01:00:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 40 Sense全模式密码最短6位
|
||||
|
||||
- 类型:需求变更
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/40
|
||||
- Wiki 页面:Task-40-Sense全模式密码最短6位
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
Sense 原规则要求密码至少 12 个字符。负责人在收到生产安全风险提示后,明确确认 demo 与生产全部调整为最少 6 个字符;大小写字母、数字和不得包含用户名的复杂度规则保持不变。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 后端统一密码校验最短长度由 12 改为 6,bootstrap 和管理员创建用户继续复用同一校验入口。
|
||||
- 新增 6 字符通过、5 字符拒绝及大小写/数字/用户名约束的表驱动测试。
|
||||
- 前端新用户表单最小长度和中文提示同步为 6。
|
||||
- Wiki 记录全模式稳定规则和管理员交付说明。
|
||||
- 重新生成 Windows amd64 前后端 ZIP,并用包内程序执行 6 位密码初始化和登录。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/identity/password.go`:最短长度改为 6。
|
||||
- `Sense/server/app/sense/identity/password_test.go`:长度和复杂度边界测试。
|
||||
- `Sense/ui/src/views/sense/identity/Users.vue`:前端最小长度及提示。
|
||||
- `docs/03-business-rules-and-glossary.md`、`docs/delivery/README.md`:Wiki-first 规则镜像。
|
||||
- `wiki-docs.json`、`docs/task/40-Sense全模式密码最短6位.md`:任务归档。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 6 字符合规密码通过统一校验 | 通过 |
|
||||
| 5 字符密码被拒绝 | 通过 |
|
||||
| 大写、小写、数字和用户名限制保留 | 通过 |
|
||||
| 前后端提示一致 | 通过 |
|
||||
| Windows ZIP 重新生成并启动 | 通过 |
|
||||
| 不生成默认账号、密码或秘密 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./app/sense/identity/...`:通过。
|
||||
- `go test ./...`:Sense 全包通过。
|
||||
- `corepack pnpm@9.15.1 lint`:0 error;存在既有格式 warning。
|
||||
- `cmd /c Sense\scripts\package-windows.bat`:通过;前端仅有既有 vendor bundle 体积警告。
|
||||
- 包内接口验证:使用 `Abc123` 初始化返回 201,登录返回 200。
|
||||
- ZIP:7,281,361 bytes;SHA-256 `9A623C7B1E0F9F925DB7BBE3B5FFBE81D3A6B5BA5819F741D0AEC5681583D63C`。
|
||||
- Harness:31 项测试和 strict 检查通过;Wiki 镜像检查通过。
|
||||
- **未验证部分**:未在真实 PostgreSQL 环境执行首次管理员初始化,未由目标管理员人工验收;生产采用 6 字符密码的安全风险为用户明确接受的产品决策。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 6 字符最短长度显著弱于原 12 字符规则;本工单未增加 MFA、登录限速或外部身份提供者。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `f841ef1` feat: lower Sense password minimum to six characters (#40)
|
||||
- `2d501ea` docs: record Sense six-character password policy (#40)
|
||||
- `4c4b114` docs: create task 40 acceptance archive (#40)
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-42-Sense密码策略仅限制最短6位
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-42-Sense%E5%AF%86%E7%A0%81%E7%AD%96%E7%95%A5%E4%BB%85%E9%99%90%E5%88%B6%E6%9C%80%E7%9F%AD6%E4%BD%8D.-
|
||||
wiki_revision: 53d52a17ae17072e1335b8bdff30fa19ad50f20f
|
||||
synchronized_at: 2026-08-13T01:20:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 42 Sense密码策略仅限制最短6位
|
||||
|
||||
- 类型:需求变更
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/42
|
||||
- Wiki 页面:Task-42-Sense密码策略仅限制最短6位
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
负责人确认 Sense demo 与生产密码仅要求至少 6 个字符,取消字符种类和用户名包含限制,以支持用户指定的简单管理员凭据。实际账号和密码属于运行时秘密,不进入本归档。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 后端 `ValidatePassword` 只按 Unicode 字符数检查至少 6 个字符;bootstrap 与创建用户继续共享该入口。
|
||||
- 测试覆盖 6 位全小写、包含用户名和 6 个 Unicode 字符通过,以及 5 字符拒绝。
|
||||
- 前端新用户表单只提示“至少 6 位字符”。
|
||||
- Wiki 记录全模式规则和明确接受的风险,交付说明仍建议选择难猜且不复用的密码。
|
||||
- 重新生成 Windows 包,使用运行时注入的脱敏凭据完成 demo 管理员初始化和登录,服务保持运行。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/identity/password.go`
|
||||
- `Sense/server/app/sense/identity/password_test.go`
|
||||
- `Sense/ui/src/views/sense/identity/Users.vue`
|
||||
- `docs/03-business-rules-and-glossary.md`
|
||||
- `docs/delivery/README.md`
|
||||
- `wiki-docs.json`
|
||||
- `docs/task/42-Sense密码策略仅限制最短6位.md`
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 6 位全小写密码通过,5 位拒绝 | 通过 |
|
||||
| 包含用户名密码通过 | 通过 |
|
||||
| bootstrap 与创建用户共享规则 | 通过 |
|
||||
| 前端只提示至少 6 位 | 通过 |
|
||||
| Windows ZIP 重建并完成管理员初始化/登录 | 通过;bootstrap 201,login 200 |
|
||||
| 凭据不进入仓库、Wiki、工单或日志 | 通过;工单需求已脱敏 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./app/sense/identity/...`、`go test ./...`:通过。
|
||||
- `corepack pnpm@9.15.1 lint`:0 error,存在既有格式 warning。
|
||||
- `cmd /c Sense\scripts\package-windows.bat`:通过;前端仅有既有 vendor bundle 体积警告。
|
||||
- ZIP:7,280,519 bytes;SHA-256 `7A1DC3378ECAD90DC7521F1E49C7A279E4C31B38D6EBC8CC30E123528BECDEE4`。
|
||||
- 包内 demo:管理员初始化 201、登录 200;服务监听 `127.0.0.1:18080`。
|
||||
- Harness:31 项测试、strict 和 Wiki 镜像检查通过。
|
||||
- **未验证部分**:未在真实 PostgreSQL 环境验证;未由目标管理员人工页面登录验收。生产弱密码风险为负责人明确接受的产品决策。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 本策略显著增加字典猜测和凭据填充风险;当前未增加 MFA、登录限速或外部身份提供者。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `8091fbb` feat: simplify Sense password policy (#42)
|
||||
- `88dcebc` docs: record length-only Sense password policy (#42)
|
||||
- `245d38d` docs: create task 42 acceptance archive (#42)
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-44-Sense管理员侧栏模块为空修复
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-44-Sense%E7%AE%A1%E7%90%86%E5%91%98%E4%BE%A7%E6%A0%8F%E6%A8%A1%E5%9D%97%E4%B8%BA%E7%A9%BA%E4%BF%AE%E5%A4%8D.-
|
||||
wiki_revision: 9647fffe7d8b0ede13c53746e8966fffb3555820
|
||||
synchronized_at: 2026-08-13T01:31:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 44 Sense管理员侧栏模块为空修复
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/44
|
||||
- Wiki 页面:Task-44-Sense管理员侧栏模块为空修复
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
最新 Windows 包中管理员可登录,但侧栏不显示任何模块。目标是在不放宽 RBAC 的前提下恢复 Sense MVP 导航,并防止 Vue Router 规范化记录顺序再次导致空菜单。
|
||||
|
||||
## 根因与最终方案
|
||||
|
||||
后端管理员会话正确返回 9 项权限。Vue Router `getRoutes()` 会同时返回父布局 `/` 和路径也为 `/` 的工作台子记录;原代码用首个 `path === '/'` 记录派生菜单,误选无 children 的工作台记录。
|
||||
|
||||
修复后只选择路径为 `/` 且具有 children 的布局记录,并在多个候选中优先 children 最完整者。权限过滤、隐藏入口过滤和排序逻辑保持不变,并提取为独立可测试函数。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/ui/src/layout/AppLayout.vue`:调用稳定的菜单派生函数。
|
||||
- `Sense/ui/src/layout/navigation.js`:定位布局、按权限过滤并排序。
|
||||
- `Sense/ui/tests/navigation.test.cjs`:重复根路径、权限/隐藏过滤和管理员八项导航测试。
|
||||
- `Sense/ui/package.json`:增加 `test:navigation`。
|
||||
- `docs/06-troubleshooting.md`:Wiki-first 排错说明。
|
||||
- `wiki-docs.json`、`docs/task/44-Sense管理员侧栏模块为空修复.md`:任务归档。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 管理员显示八项 Sense MVP 导航 | 自动回归通过 |
|
||||
| 不依赖重复 `/` 记录顺序 | 通过 |
|
||||
| 权限不足和隐藏入口仍被过滤 | 通过 |
|
||||
| 前端 test/lint/build | 通过;lint 0 error,存在既有 warning |
|
||||
| Windows ZIP 重建并以管理员登录 | 通过;角色 administrator,9 项权限,新前端资源已加载 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `corepack pnpm@9.15.1 test:navigation`:3 项通过。
|
||||
- Vue Router 实际规范化记录探针:正确派生工作台和设备管理。
|
||||
- `corepack pnpm@9.15.1 lint`:0 error,既有 warning。
|
||||
- Windows 打包:通过;前端仅有既有 vendor bundle 体积警告。
|
||||
- 包内运行:登录角色 administrator、权限数 9,首页引用新脚本 `js/index.25411ed5.js`。
|
||||
- Harness:31 项、strict 与 Wiki 镜像检查通过。
|
||||
- ZIP:7,280,825 bytes;SHA-256 `0703B397D20EAAE54BCB6B5A83170EFF6816246A8C163276495CD93EEEB72B4F`。
|
||||
- **未验证部分**:未通过自动化真实浏览器截图断言侧栏 DOM;已要求用户 Ctrl+F5 后人工查看。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 浏览器可能缓存旧前端资源,更新包后需要强制刷新。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `048a6ec` fix: restore Sense permission navigation (#44)
|
||||
- `62ed5cc` docs: add Sense empty navigation troubleshooting (#44)
|
||||
- `c4a799c` test: cover Sense administrator navigation (#44)
|
||||
- `21624f6` docs: create task 44 acceptance archive (#44)
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-46-Sense-Windows包内配置加载
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-46-Sense-Windows%E5%8C%85%E5%86%85%E9%85%8D%E7%BD%AE%E5%8A%A0%E8%BD%BD.-
|
||||
wiki_revision: eab38da7cae8089d9cf7ed38e48d3e52962b68b4
|
||||
synchronized_at: 2026-08-13T02:42:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 46 Sense Windows包内配置加载
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/46
|
||||
- Wiki 页面:Task-46-Sense-Windows包内配置加载
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
Windows 打包目录已有 `config\sense.env`,但旧版 `start-sense.bat` 只读取进程环境,导致生产启动误报缺少 `SENSE_DATABASE_URL`。本工单让启动入口安全读取包内配置,同时确保真实配置不进入 Git 或交付 ZIP。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- `start-sense.bat` 保持现场入口,委托同目录 `start-sense.ps1` 加载配置并启动服务。
|
||||
- 加载器只接受 `SENSE_*` 键,忽略空行和 `#` 注释;不执行配置内容、不输出配置值,外部非空进程环境变量优先。
|
||||
- 默认生产模式强制 PostgreSQL,验证数据库连接、身份签名、初始化令牌和凭据加密四个必填配置;`check` 模式只验证配置,不连接数据库、不启动服务。
|
||||
- `demo` 仍强制内存模式;UI 路径固定为包内 `ui`。
|
||||
- 重打包会安全暂存并恢复已有部署目录的 `config\sense.env`;交付 ZIP 只包含 `sense.env.example`,不包含真实配置。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/scripts/runtime/start-sense.bat`:委托 PowerShell 启动器并传递退出码。
|
||||
- `Sense/scripts/runtime/start-sense.ps1`:加载、校验配置并处理 production、demo、check 模式。
|
||||
- `Sense/scripts/runtime/README-WINDOWS.md`:补充包内配置、优先级和检查命令。
|
||||
- `Sense/scripts/package-windows.bat`:复制 PowerShell 启动器,重打包时保留本机配置且从 ZIP 排除。
|
||||
- `docs/04-local-development-and-verification.md`、`docs/06-troubleshooting.md`、`docs/delivery/README.md`:由 Gitea Wiki 同步生成的长期说明。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 包内已有 `config\sense.env` 时无需手工 `set` 即可通过生产配置检查 | 通过 |
|
||||
| `check` 不连接数据库、不启动服务且不输出配置值 | 通过 |
|
||||
| 外部非空进程环境优先,加载器只接受 `SENSE_*` 且不执行配置内容 | 通过(代码检查) |
|
||||
| demo 强制内存模式,production 强制 PostgreSQL 和包内 UI | 通过(代码检查) |
|
||||
| 重打包后原配置不变 | 通过;重建前后 SHA-256 一致 |
|
||||
| ZIP 含 BAT、PowerShell 启动器、示例与说明,不含真实 `sense.env` | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行命令:`cmd /c Sense\scripts\package-windows.bat`
|
||||
- 结果:前后端打包成功,生成 Windows amd64 目录和 ZIP。
|
||||
- 执行命令:`cmd /c Sense\dist\sense-windows-amd64\start-sense.bat check`
|
||||
- 结果:输出 `Sense production configuration check passed.`,退出码 0。
|
||||
- 执行命令:检查 ZIP 条目和 SHA-256。
|
||||
- 结果:`start-sense.ps1` 与 `sense.env.example` 存在,真实 `config/sense.env` 不存在;ZIP SHA-256 为 `6311B29F1899691AF793A4758CD59096C5388D3C85214209B9EAFBCAD232BA8A`。
|
||||
- 执行命令:`python dev_scripts/sync_wiki_docs.py --check`
|
||||
- 结果:Wiki 镜像检查通过。
|
||||
- 执行命令:`python dev_scripts/check_harness.py --strict`
|
||||
- 结果:未通过;被基线中工单 #44 归档缺少“最终方案”章节阻塞,与本工单修改无关。
|
||||
- **未验证部分**:未连接用户 PostgreSQL 启动生产服务;未使用真实摄像机或 MediaMTX;因本工单不修改业务服务逻辑,这些留待部署验收。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 基线工单 #44 的任务归档需在其自身验收闭环中补齐“最终方案”章节,之后再运行严格 Harness。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `5a17898` 修复 Windows 包内配置加载。
|
||||
- `6bbaa07` 更新 Wiki 镜像中的启动、排错和交付说明。
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-48-支持ONVIF-Digest认证与安全Media地址归一化
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-48-%E6%94%AF%E6%8C%81ONVIF-Digest%E8%AE%A4%E8%AF%81%E4%B8%8E%E5%AE%89%E5%85%A8Media%E5%9C%B0%E5%9D%80%E5%BD%92%E4%B8%80%E5%8C%96.-
|
||||
wiki_revision: 16667fcc50dac28098a3c4b5b9018bac1ff249be
|
||||
synchronized_at: 2026-08-13T03:46:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 48 支持ONVIF Digest认证与安全Media地址归一化
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/48
|
||||
- Wiki 页面:Task-48-支持ONVIF-Digest认证与安全Media地址归一化
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
真实摄像机的 Device Service 可以访问,但 Media Service 要求 Digest Authentication,并广播了当前主机无法访问的 Media 地址。旧版 Sense 只预发送 Basic,并把 GetProfiles 发往 Device Service,无法读取 Profile。本任务在不泄露设备信息的前提下兼容该设备。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 先向用户填写的 Device Service 请求 GetCapabilities,解析 Media XAddr,再向 Media Service请求 GetProfiles 与 GetStreamUri。
|
||||
- 收到 Digest challenge 时仅重试一次,支持 MD5、SHA-256 和 qop=auth;拒绝缺失必填参数、不支持的算法和 qop。
|
||||
- Media XAddr 与 Device Service 同主机时保留服务公布的端口;跨主机时固定回用户已授权的 Device Service scheme/host/port,仅保留 Media path/query。
|
||||
- HTTP 客户端不跟随重定向,继续拒绝地址或 Stream URI 中携带凭据,不记录 Authorization。
|
||||
- 保留 Basic 兼容路径。真实设备使用专用 ONVIF 凭据成功读取 2 个 Profile。
|
||||
- 当前真实设备 ONVIF 与 RTSP 使用不同账号,而 Sense 每台设备只保存一组凭据,因此 RTSP 验证仍为 profile_failed;不同凭据模型不在本工单范围。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/adapters/onvif/client.go`:Media 服务发现、Digest challenge-response、地址归一化和重定向禁止。
|
||||
- `Sense/server/app/sense/adapters/onvif/client_test.go`:Digest、Basic 流程、安全地址和重定向测试。
|
||||
- `Sense/server/app/sense/adapters/onvif/parser.go`:解析 GetCapabilities 中的 Media XAddr。
|
||||
- `Sense/server/app/sense/adapters/onvif/parser_test.go`:Media 地址解析测试。
|
||||
- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/06-troubleshooting.md`:由 Wiki 同步的长期说明。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| Digest Media Service 可完成 GetProfiles 和 GetStreamUri | 通过 |
|
||||
| 用户只填写 Device Service,Sense 自动发现 Media Service | 通过 |
|
||||
| 跨主机 Media XAddr 不被直接访问 | 通过 |
|
||||
| 同源合法 Media XAddr 与 Basic 路径不回归 | 通过 |
|
||||
| 危险地址、错误 challenge、算法/qop、重定向被拒绝 | 通过 |
|
||||
| 真实设备能够读取 Profile | 通过,读取 2 个 Profile |
|
||||
| 不泄露设备地址、凭据、Authorization 或 Stream URI | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行命令:`go test ./app/sense/adapters/onvif ./app/sense/admission`
|
||||
- 结果:通过。
|
||||
- 执行命令:`go test ./...`
|
||||
- 结果:Sense 全部 Go 测试通过。
|
||||
- 执行命令:`Sense/scripts/package-windows.bat`(Go 1.26.5、Node 22.22.1、pnpm 9.15.1)
|
||||
- 结果:前后端 Windows 包构建通过;只有既有 webpack 体积警告。
|
||||
- 真实设备验证:通过 Sense API 使用只读 `ip_camera.env`,不输出敏感值。
|
||||
- 结果:`profile_count=2`、`admission_status=profile_failed`;Profile 已读取,RTSP 因 ONVIF/RTSP 不同账号未通过。
|
||||
- Windows ZIP SHA-256:`628CC85B009628C062ADB6F5125A2F89CF7501727042152D29D13A7AE6D3F888`。
|
||||
- **未验证部分**:未完成真实 RTSP 播放与 MediaMTX 接入;需要每设备分离 ONVIF/RTSP 凭据后再验收。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 真实摄像机的 ONVIF 与 RTSP 使用不同账号;Sense 当前单凭据模型无法同时验证两者,需要独立工单扩展凭据边界。
|
||||
- 严格 Harness 当前仍被既有工单 #44 归档缺少“最终方案”章节阻塞,不在本工单中混入修复。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `688080c` 支持 ONVIF Digest Media 服务与安全地址归一化。
|
||||
- `2a0b63c` 更新架构、验证与排错 Wiki 镜像。
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-50-ONVIF与RTSP分离凭据并持久化Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-50-ONVIF%E4%B8%8ERTSP%E5%88%86%E7%A6%BB%E5%87%AD%E6%8D%AE%E5%B9%B6%E6%8C%81%E4%B9%85%E5%8C%96Profile.-
|
||||
wiki_revision: 27ac0fe8ef97f9ec835fa62713f3a8c23c077940
|
||||
synchronized_at: 2026-08-13T04:18:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 50 ONVIF与RTSP分离凭据并持久化Profile
|
||||
|
||||
- 类型:功能
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/50
|
||||
- Wiki 页面:Task-50-ONVIF与RTSP分离凭据并持久化Profile
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
真实摄像机的 ONVIF 与 RTSP 使用不同账号,旧版 Sense 每台设备只有一组凭据;接入结果还只保存在内存中。目标是分别安全保存两组凭据、持久化脱敏 Profile,并让接入成功的设备进入 active。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- Device 新增独立 RTSP 密文与“复用 ONVIF”标志,旧凭据通过版本化迁移安全回填;API 只返回配置状态。
|
||||
- 设备页面同时维护 ONVIF/RTSP 凭据,默认允许显式复用;审计只记录是否复用,不记录值。
|
||||
- Admission 使用 ONVIF 凭据读取 Profile、RTSP 凭据验证视频;结果与脱敏 Stream URI、主/子码流、验证状态写入 PostgreSQL。
|
||||
- 摄像机广播跨主机 RTSP URI 时,只把主机归一化到用户授权的 Device Service 主机,保留报告端口与路径。
|
||||
- 至少一个 Profile 验证成功后 Device 进入 active;接入页切换设备时读取持久化结果。
|
||||
- 真实设备使用分离凭据后 2 个 Profile 均 ready,重启 Sense 后仍可读取。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/device/**`:分离凭据模型、迁移、加密读写与内部端口。
|
||||
- `Sense/server/app/sense/admission/**`:Profile Store、迁移、持久化和设备状态更新。
|
||||
- `Sense/server/app/sense/adapters/onvif/**`:安全归一化跨主机 RTSP URI。
|
||||
- `Sense/server/cmd/sense/modules_device.go`、`modules_admission.go`:注册版本化迁移与 PostgreSQL Store。
|
||||
- `Sense/ui/src/views/sense/device/Devices.vue`、`admission/Admission.vue`:分离凭据表单与持久结果恢复。
|
||||
- Wiki 架构、业务规则和排错页面:记录新安全边界。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 两组凭据分别加密且只写不可读 | 通过 |
|
||||
| 同凭据复用与分离凭据均受支持 | 通过 |
|
||||
| Profile 重启后存在 | 通过,2 个 Profile |
|
||||
| 至少一个 Profile ready 后设备 active | 通过 |
|
||||
| 真实摄像机 ONVIF 与 RTSP 验证 | 通过,2/2 ready |
|
||||
| 不泄露地址、密码、Authorization 或 Stream URI | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `pnpm lint`:0 error,存在基线格式 warning。
|
||||
- `pnpm build`:通过,存在既有 webpack 体积 warning。
|
||||
- Go 1.26.5 Windows 打包:通过。
|
||||
- 真实设备脱敏验证:`admission_status=ready`、`profile_count=2`、`ready_profile_count=2`、`device_status=active`。
|
||||
- 重启验证:`persisted_status=ready`、`persisted_profile_count=2`。
|
||||
- **未验证部分**:MediaMTX 自动路由与实时监看属于后续工单 #51。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 无本工单范围内遗留;自动媒体路由在 #51 实施。
|
||||
- 严格 Harness 仍受既有 #44 归档缺少“最终方案”章节影响。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `1a63264` 分离凭据、持久化 Profile 和真实地址归一化。
|
||||
- `032f8c5` 更新长期 Wiki 镜像。
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-9-Bell独立GoAdmin产品骨架
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-9-Bell%E7%8B%AC%E7%AB%8BGoAdmin%E4%BA%A7%E5%93%81%E9%AA%A8%E6%9E%B6.-
|
||||
wiki_revision: affa26c4134ad936c277a960aa45a838db28e73c
|
||||
synchronized_at: 2026-08-12T10:19:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 9 Bell独立GoAdmin产品骨架
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-12
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/9
|
||||
- Wiki 页面:Task-9-Bell独立GoAdmin产品骨架
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 Sense、Brain 不启动时建立 Bell 可独立构建、运行和交付的 Go + Vue 3 + Element Plus + PostgreSQL 产品骨架,冻结工具链并移除无关 go-admin 演示入口。
|
||||
|
||||
## 最终方案
|
||||
|
||||
实现独立后端命令、事务迁移、健康/就绪检查、静态资源宿主和 Vue 管理外壳;保留上游 MIT 许可证。正式运行要求 Bell 专用 PostgreSQL,未提供内置账户、密码或生产配置。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Bell/server/`:Go 入口、配置、PostgreSQL、迁移与健康检查。
|
||||
- `Bell/web/`:Vue 3、Element Plus、路由、状态与应用外壳。
|
||||
- `Bell/README.md`、`Bell/.env.example`、`Bell/LICENSES/`:运行说明、无秘密模板与上游来源。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| Sense/Brain 停止时独立启动 | 通过 |
|
||||
| 空 PostgreSQL 迁移,health/ready 正常 | 通过 |
|
||||
| 无关演示路由不可访问 | 通过 |
|
||||
| 精确工具链与许可证可追溯 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行:`go test ./...`、`pnpm lint`、`pnpm build:prod`、空库迁移、health/ready smoke、Harness、仓库 unittest。
|
||||
- 结果:全部通过;前端构建仅有 Element Plus vendor 体积警告。
|
||||
- **未验证部分**:生产部署、备份恢复与人工 UI 验收。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `a5d846a` 初始化独立 Bell 产品骨架(PR #35)。
|
||||
+15
-22
@@ -113,40 +113,33 @@
|
||||
"path": "docs/task/27-Sense区域与方向警戒线版本配置.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-9-Bell独立GoAdmin产品骨架",
|
||||
"path": "docs/task/9-Bell独立GoAdmin产品骨架.md"
|
||||
"page": "Task-37-Sense-Windows前后端一键打包",
|
||||
"path": "docs/task/37-Sense-Windows前后端一键打包.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-12-Bell独立登录RBAC与认证审计",
|
||||
"path": "docs/task/12-Bell独立登录RBAC与认证审计.md"
|
||||
"page": "Task-40-Sense全模式密码最短6位",
|
||||
"path": "docs/task/40-Sense全模式密码最短6位.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-17-Bell不可变Event与永久幂等收据",
|
||||
"path": "docs/task/17-Bell不可变Event与永久幂等收据.md"
|
||||
"page": "Task-42-Sense密码策略仅限制最短6位",
|
||||
"path": "docs/task/42-Sense密码策略仅限制最短6位.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-18-Bell开发测试合成事件入口",
|
||||
"path": "docs/task/18-Bell开发测试合成事件入口.md"
|
||||
"page": "Task-44-Sense管理员侧栏模块为空修复",
|
||||
"path": "docs/task/44-Sense管理员侧栏模块为空修复.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-19-Bell规则匹配与Event-Alert链路",
|
||||
"path": "docs/task/19-Bell规则匹配与Event-Alert链路.md"
|
||||
"page": "Task-46-Sense-Windows包内配置加载",
|
||||
"path": "docs/task/46-Sense-Windows包内配置加载.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-20-Bell-Alert并发处置与审计时间线",
|
||||
"path": "docs/task/20-Bell-Alert并发处置与审计时间线.md"
|
||||
"page": "Task-48-支持ONVIF-Digest认证与安全Media地址归一化",
|
||||
"path": "docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-31-Bell原型对齐GoAdmin与Element-Plus",
|
||||
"path": "docs/task/31-Bell原型对齐GoAdmin与Element-Plus.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-33-Bell普通用户预警处理原型",
|
||||
"path": "docs/task/33-Bell普通用户预警处理原型.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-36-Bell-MVP文档与待验收归档",
|
||||
"path": "docs/task/36-Bell-MVP文档与待验收归档.md"
|
||||
"page": "Task-50-ONVIF与RTSP分离凭据并持久化Profile",
|
||||
"path": "docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user