Compare commits

...
Author SHA1 Message Date
QiuSW cb3015a5b1 docs: complete task 42 acceptance archive (#42) 2026-08-13 09:21:17 +08:00
QiuSW 245d38d471 docs: create task 42 acceptance archive (#42) 2026-08-13 09:19:03 +08:00
QiuSW 88dcebc3fb docs: record length-only Sense password policy (#42) 2026-08-13 09:16:42 +08:00
QiuSW 8091fbb89c feat: simplify Sense password policy (#42) 2026-08-13 09:14:14 +08:00
QiuSW 5e6b5fa0d9 docs: complete task 40 acceptance archive (#40) 2026-08-13 09:01:23 +08:00
QiuSW 4c4b11470d docs: create task 40 acceptance archive (#40) 2026-08-13 08:58:38 +08:00
QiuSW 2d501eabae docs: record Sense six-character password policy (#40) 2026-08-13 08:57:00 +08:00
QiuSW f841ef1913 feat: lower Sense password minimum to six characters (#40) 2026-08-13 08:51:35 +08:00
QiuSW 8b3d814628 docs: complete task 37 acceptance archive (#37) 2026-08-12 18:34:36 +08:00
QiuSW 6d35fa7514 docs: create task 37 acceptance archive (#37) 2026-08-12 18:30:37 +08:00
QiuSW efb48b4030 docs: document Sense Windows package workflow (#37) 2026-08-12 18:28:22 +08:00
QiuSW e80180e2b6 build: add Sense Windows package script (#37) 2026-08-12 18:19:08 +08:00
QiuSW 6d90d54592 docs: finalize Sense task archives (#22-#27) 2026-08-12 17:47:11 +08:00
QiuSW 997900dd08 docs: register task #27 archive 2026-08-12 17:44:23 +08:00
QiuSW 9c8a322607 docs: register task #26 archive 2026-08-12 17:42:10 +08:00
QiuSW 118423a128 docs: register task #25 archive 2026-08-12 17:40:30 +08:00
QiuSW c7c380458f docs: register task #24 archive 2026-08-12 17:38:45 +08:00
QiuSW a82718ad13 docs: register task #23 archive 2026-08-12 17:36:56 +08:00
QiuSW c524a7f2c2 docs: register task #22 archive 2026-08-12 17:35:10 +08:00
QiuSW 5177861c7c docs: document Sense MVP operations (#22-#27) 2026-08-12 17:33:09 +08:00
QiuSW e2e18b0380 feat: add versioned Sense areas and directional lines (#27) 2026-08-12 17:29:09 +08:00
QiuSW 8509699fff feat: add protected single-stream Sense live view (#26) 2026-08-12 17:24:47 +08:00
QiuSW 398534aabe feat: add MediaMTX process and reconciliation control (#25) 2026-08-12 17:21:14 +08:00
QiuSW 4f17cca91f feat: add controlled ONVIF and RTSP admission (#24) 2026-08-12 17:14:34 +08:00
QiuSW 5b8f4a4c8c feat: add Sense device inventory and credential boundary (#23) 2026-08-12 17:10:13 +08:00
QiuSW b9416ebc08 feat: add Sense identity RBAC and audit (#22) 2026-08-12 17:06:26 +08:00
QiuSW 2c0e37eff7 docs: archive task #21 for acceptance 2026-08-12 16:55:45 +08:00
QiuSW 2e22252feb docs: document Sense product skeleton (#21) 2026-08-12 16:51:44 +08:00
QiuSW d2169990d3 feat: initialize independent Sense product skeleton (#21) 2026-08-12 16:49:17 +08:00
149 changed files with 14776 additions and 18 deletions
+15
View File
@@ -0,0 +1,15 @@
# Upstream sources
Sense is an independent product derived from the frozen GoAdmin baseline in
the repository root `goadmin-baseline.json`.
- go-admin: `https://github.com/go-admin-team/go-admin`, commit
`f06540883b41d03782bb6b2c4150f298f328c6b6`, MIT.
- go-admin-ui: `https://github.com/go-admin-team/go-admin-ui`, commit
`67d393d713877572fab0b897296a4c1d525fc81d`, MIT.
- go-admin-doc: documentation reference only, commit
`424855aacf6905f3fde860c3331385cb25529a0d`, MIT.
The local copies under `D:\github\goadmin` are read-only references and are
not a distributable or reproducible source identifier.
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 go-admin-team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2020 wenjianzhang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
# Sense
Sense is the independently deployable intelligent NVR and video-sensing
management product in YoVision. It uses Go 1.26.5, PostgreSQL, Vue 3 and
Element Plus. Brain and Bell are not required for startup or smoke tests.
## Toolchain
Use the exact versions from the repository root `goadmin-baseline.json`:
- Go 1.26.5
- Node.js 22.22.1
- pnpm 9.15.1
The repositories under `D:\github\goadmin` are read-only references. Do not
develop Sense inside them.
```powershell
$env:PATH = 'C:\Users\ila20\AppData\Local\Temp\yovision-tools\go1.26.5\go\bin;' + $env:PATH
powershell -ExecutionPolicy Bypass -File .\Sense\scripts\bootstrap\check-toolchain.ps1
```
## Backend
Production/default startup requires an independent PostgreSQL URL. No default
password or signing secret is provided.
```powershell
cd Sense\server
$env:SENSE_DATABASE_MODE = 'postgres'
$env:SENSE_DATABASE_URL = '<provided outside the repository>'
go run .
```
For an isolated smoke test only:
```powershell
powershell -ExecutionPolicy Bypass -File .\Sense\scripts\bootstrap\smoke.ps1
```
Health endpoints are `GET /healthz` and `GET /readyz`. Upstream demo and code
generation routes are deliberately not registered.
## Frontend
```powershell
cd Sense\ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
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.
+15
View File
@@ -0,0 +1,15 @@
# Copy values into a repository-external environment store. Do not commit secrets.
SENSE_HTTP_ADDRESS=127.0.0.1:18080
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,20 @@
$ErrorActionPreference = 'Stop'
$expectedGo = 'go version go1.26.5 windows/amd64'
$actualGo = (& go version).Trim()
if ($actualGo -ne $expectedGo) {
throw "Go toolchain mismatch. Expected '$expectedGo', got '$actualGo'."
}
$actualNode = (& node --version).Trim()
if ($actualNode -ne 'v22.22.1') {
throw "Node.js mismatch. Expected 'v22.22.1', got '$actualNode'."
}
$actualPnpm = (& corepack pnpm@9.15.1 --version).Trim()
if ($actualPnpm -ne '9.15.1') {
throw "pnpm mismatch. Expected '9.15.1', got '$actualPnpm'."
}
Write-Output 'Sense toolchain matches goadmin-baseline.json.'
+38
View File
@@ -0,0 +1,38 @@
$ErrorActionPreference = 'Stop'
$senseRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$serverRoot = Join-Path $senseRoot 'server'
$env:SENSE_DATABASE_MODE = 'memory'
$env:SENSE_HTTP_ADDRESS = '127.0.0.1:18080'
$process = Start-Process -FilePath 'go' -ArgumentList @('run', '.') -WorkingDirectory $serverRoot -PassThru -WindowStyle Hidden
try {
$ready = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
try {
$health = Invoke-RestMethod -Uri 'http://127.0.0.1:18080/healthz' -TimeoutSec 1
$readiness = Invoke-RestMethod -Uri 'http://127.0.0.1:18080/readyz' -TimeoutSec 1
if ($health.status -eq 'ok' -and $readiness.status -eq 'ready') {
$ready = $true
break
}
} catch {
Start-Sleep -Milliseconds 250
}
}
if (-not $ready) {
throw 'Sense smoke server did not become ready.'
}
try {
Invoke-WebRequest -Uri 'http://127.0.0.1:18080/api/v1/demo' -TimeoutSec 2 | Out-Null
throw 'Disabled demo route unexpectedly returned success.'
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
}
Write-Output 'Sense independent smoke test passed.'
} finally {
if (-not $process.HasExited) { Stop-Process -Id $process.Id }
$process.WaitForExit()
}
+93
View File
@@ -0,0 +1,93 @@
@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 ("%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
)
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 "%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\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
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
+32
View File
@@ -0,0 +1,32 @@
# Sense Windows 运行包
此目录同时包含 Sense 后端和已经构建好的前端静态页面。Brain、Bell 无需启动。
## 快速查看
在命令提示符中运行:
```bat
start-sense.bat demo
```
然后访问 <http://127.0.0.1:18080>。`demo` 使用内存数据,进程退出后数据会丢失,仅用于本机查看,不能用于生产。
## 生产启动
生产环境必须先安装并准备独立 PostgreSQL,然后在仓库和运行包之外安全设置以下环境变量:
- `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
```
完整变量名可参考 `config\sense.env.example`。示例文件只有空值和非秘密默认值,启动脚本不会自动读取它;请使用 Windows 环境变量或外部秘密管理工具注入真实值。
健康检查为 `GET /healthz` 和 `GET /readyz`。MediaMTX 仍是独立程序,本运行包不会安装 PostgreSQL、MediaMTX 或 Windows 服务。
+45
View File
@@ -0,0 +1,45 @@
@echo off
setlocal
cd /d "%~dp0"
set "SENSE_UI_STATIC_DIR=%~dp0ui"
if /I "%~1"=="demo" goto :demo
set "SENSE_DATABASE_MODE=postgres"
if "%SENSE_DATABASE_URL%"=="" goto :missing_database_url
if "%SENSE_IDENTITY_SIGNING_KEY%"=="" goto :missing_identity_key
if "%SENSE_BOOTSTRAP_TOKEN%"=="" goto :missing_bootstrap_token
if "%SENSE_CREDENTIAL_KEY%"=="" goto :missing_credential_key
echo Starting Sense in production mode at %SENSE_HTTP_ADDRESS%...
"%~dp0sense-server.exe"
exit /b %ERRORLEVEL%
:demo
set "SENSE_DATABASE_MODE=memory"
if "%SENSE_HTTP_ADDRESS%"=="" set "SENSE_HTTP_ADDRESS=127.0.0.1:18080"
echo Starting Sense in temporary demo mode at http://%SENSE_HTTP_ADDRESS% ...
echo Demo data is discarded when the process stops. Do not use this mode in production.
"%~dp0sense-server.exe"
exit /b %ERRORLEVEL%
:missing_database_url
echo [ERROR] SENSE_DATABASE_URL is required in production mode.
goto :configuration_help
:missing_identity_key
echo [ERROR] SENSE_IDENTITY_SIGNING_KEY is required in production mode.
goto :configuration_help
:missing_bootstrap_token
echo [ERROR] SENSE_BOOTSTRAP_TOKEN is required in production mode.
goto :configuration_help
:missing_credential_key
echo [ERROR] SENSE_CREDENTIAL_KEY is required in production mode.
:configuration_help
echo Set the required environment variables outside this directory.
echo See README-WINDOWS.md and config\sense.env.example.
exit /b 1
@@ -0,0 +1,92 @@
package mediamtx
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Source struct {
Path string
URI string
Username string
Password string
}
type PathStatus struct {
Name string `json:"name"`
Ready bool `json:"ready"`
Readers int `json:"readers"`
BytesReceived int64 `json:"bytes_received"`
}
type Controller interface {
Apply(context.Context, Source) error
Status(context.Context, string) (PathStatus, error)
}
type HTTPController struct {
base string
client *http.Client
}
func NewHTTPController(base string) *HTTPController {
return &HTTPController{base: strings.TrimRight(base, "/"), client: &http.Client{Timeout: 5 * time.Second}}
}
func (c *HTTPController) Apply(ctx context.Context, source Source) error {
parsed, err := url.Parse(source.URI)
if err != nil || parsed.User != nil {
return fmt.Errorf("invalid credential-free media source")
}
if source.Username != "" {
parsed.User = url.UserPassword(source.Username, source.Password)
}
payload := map[string]any{"source": parsed.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
data, _ := json.Marshal(payload)
endpoint := c.base + "/v3/config/paths/replace/" + url.PathEscape(source.Path)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("mediamtx control unavailable: %w", err)
}
defer res.Body.Close()
io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("mediamtx apply returned %d", res.StatusCode)
}
return nil
}
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v3/paths/get/"+url.PathEscape(path), nil)
if err != nil {
return PathStatus{}, err
}
res, err := c.client.Do(req)
if err != nil {
return PathStatus{}, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return PathStatus{Name: path}, nil
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return PathStatus{}, fmt.Errorf("mediamtx status returned %d", res.StatusCode)
}
var raw struct {
Name string `json:"name"`
Ready bool `json:"ready"`
BytesReceived int64 `json:"bytesReceived"`
Readers []any `json:"readers"`
}
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&raw); err != nil {
return PathStatus{}, err
}
return PathStatus{Name: raw.Name, Ready: raw.Ready, Readers: len(raw.Readers), BytesReceived: raw.BytesReceived}, nil
}
@@ -0,0 +1,37 @@
package mediamtx
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestApplyBuildsCredentialSourceOnlyInTransientBody(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.String(), "password") {
t.Fatal("credential leaked in control URL")
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewHTTPController(server.URL)
if err := client.Apply(context.Background(), Source{Path: "sense_test", URI: "rtsp://camera.invalid/main", Username: "fixture-user", Password: "fixture-password"}); err != nil {
t.Fatal(err)
}
if body["source"] != "rtsp://fixture-user:fixture-password@camera.invalid/main" {
t.Fatalf("body=%#v", body)
}
}
func TestApplyRejectsCredentialURI(t *testing.T) {
client := NewHTTPController("http://127.0.0.1")
if err := client.Apply(context.Background(), Source{Path: "x", URI: "rtsp://user:pass@camera.invalid/main"}); err == nil {
t.Fatal("credential URI accepted")
}
}
@@ -0,0 +1,113 @@
package mediamtx
import (
"context"
"fmt"
"os"
"os/exec"
"sync"
"time"
)
type ProcessState struct {
Running bool `json:"running"`
PID int `json:"pid,omitempty"`
Restarts int `json:"restarts"`
LastExit string `json:"last_exit,omitempty"`
}
type Process interface {
Start(context.Context) error
Stop(context.Context) error
State() ProcessState
}
type Supervisor struct {
binary string
config string
maxRestarts int
mu sync.Mutex
command *exec.Cmd
state ProcessState
}
func NewSupervisor(binary, config string, maxRestarts int) *Supervisor {
if maxRestarts < 0 {
maxRestarts = 0
}
return &Supervisor{binary: binary, config: config, maxRestarts: maxRestarts}
}
func (s *Supervisor) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Running {
return nil
}
if s.binary == "" {
return fmt.Errorf("SENSE_MEDIAMTX_BINARY is not configured")
}
if s.state.LastExit != "" {
if s.state.Restarts >= s.maxRestarts {
return fmt.Errorf("MediaMTX restart limit reached")
}
s.state.Restarts++
}
arguments := []string{}
if s.config != "" {
arguments = append(arguments, s.config)
}
command := exec.CommandContext(context.Background(), s.binary, arguments...)
if err := command.Start(); err != nil {
return fmt.Errorf("start MediaMTX: %w", err)
}
s.command = command
s.state.Running = true
s.state.PID = command.Process.Pid
s.state.LastExit = ""
go s.wait(command)
return nil
}
func (s *Supervisor) wait(command *exec.Cmd) {
err := command.Wait()
s.mu.Lock()
defer s.mu.Unlock()
if s.command != command {
return
}
s.state.Running = false
s.state.PID = 0
if err != nil {
s.state.LastExit = err.Error()
} else {
s.state.LastExit = "exited"
}
}
func (s *Supervisor) Stop(ctx context.Context) error {
s.mu.Lock()
command := s.command
if command == nil || !s.state.Running {
s.mu.Unlock()
return nil
}
process := command.Process
s.mu.Unlock()
if err := process.Signal(os.Interrupt); err != nil {
if killErr := process.Kill(); killErr != nil {
return fmt.Errorf("stop owned MediaMTX process: %w", killErr)
}
}
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
s.mu.Lock()
running := s.state.Running
s.mu.Unlock()
if !running {
return nil
}
}
}
}
func (s *Supervisor) State() ProcessState { s.mu.Lock(); defer s.mu.Unlock(); return s.state }
@@ -0,0 +1,109 @@
package onvif
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Credential struct {
Username string
Password string
}
type Profile struct {
Token string `json:"token"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
StreamURI string `json:"stream_uri"`
}
type Client interface {
Profiles(context.Context, string, Credential) ([]Profile, error)
}
type HTTPClient struct{ client *http.Client }
func NewHTTPClient(timeout time.Duration) *HTTPClient {
if timeout <= 0 {
timeout = 8 * time.Second
}
return &HTTPClient{client: &http.Client{Timeout: timeout}}
}
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
endpoint, err := validateEndpoint(address)
if err != nil {
return nil, err
}
body := `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`
data, err := c.soap(ctx, endpoint, credential, body)
if err != nil {
return nil, err
}
profiles, err := ParseProfiles(data)
if err != nil {
return nil, err
}
for i := range profiles {
uriBody := fmt.Sprintf(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, xmlEscape(profiles[i].Token))
streamData, err := c.soap(ctx, endpoint, credential, uriBody)
if err != nil {
return nil, err
}
profiles[i].StreamURI, err = ParseStreamURI(streamData)
if err != nil {
return nil, err
}
}
return profiles, nil
}
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body 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 != "" {
req.SetBasicAuth(credential.Username, credential.Password)
}
res, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("onvif request failed: %w", err)
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20))
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("authentication_failed")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("onvif_http_%d", res.StatusCode)
}
return data, nil
}
func validateEndpoint(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid ONVIF address")
}
if parsed.User != nil {
return "", fmt.Errorf("credentials must not appear in ONVIF address")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("unsupported ONVIF scheme")
}
return parsed.String(), nil
}
func xmlEscape(value string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(value))
return b.String()
}
@@ -0,0 +1,80 @@
package onvif
import (
"context"
"fmt"
"net"
"strings"
"time"
)
const discoveryProbe = `<?xml version="1.0"?><e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl"><e:Header><w:MessageID>uuid:sense-controlled-discovery</w:MessageID><w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To><w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action></e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body></e:Envelope>`
func Discover(ctx context.Context, localIP string, timeout time.Duration) ([]string, error) {
ip := net.ParseIP(localIP)
if ip == nil {
return nil, fmt.Errorf("approved discovery interface is not configured")
}
approved := false
interfaces, _ := net.Interfaces()
for _, iface := range interfaces {
addresses, _ := iface.Addrs()
for _, address := range addresses {
if strings.HasPrefix(address.String(), ip.String()+"/") {
approved = true
}
}
}
if !approved {
return nil, fmt.Errorf("approved discovery IP is not a local interface")
}
connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
if err != nil {
return nil, err
}
defer connection.Close()
deadline := time.Now().Add(timeout)
_ = connection.SetDeadline(deadline)
target := &net.UDPAddr{IP: net.ParseIP("239.255.255.250"), Port: 3702}
if _, err := connection.WriteToUDP([]byte(discoveryProbe), target); err != nil {
return nil, err
}
found := map[string]bool{}
buffer := make([]byte, 65535)
for {
select {
case <-ctx.Done():
return keys(found), nil
default:
}
n, _, err := connection.ReadFromUDP(buffer)
if err != nil {
if networkErr, ok := err.(net.Error); ok && networkErr.Timeout() {
return keys(found), nil
}
return nil, err
}
for _, value := range extractXAddrs(string(buffer[:n])) {
found[value] = true
}
}
}
func extractXAddrs(value string) []string {
startTag, endTag := "<d:XAddrs>", "</d:XAddrs>"
start := strings.Index(value, startTag)
if start < 0 {
return nil
}
end := strings.Index(value[start:], endTag)
if end < 0 {
return nil
}
return strings.Fields(value[start+len(startTag) : start+end])
}
func keys(values map[string]bool) []string {
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
return result
}
@@ -0,0 +1,56 @@
package onvif
import (
"encoding/xml"
"fmt"
"strings"
)
type profileEnvelope struct {
Profiles []struct {
Token string `xml:"token,attr"`
Name string `xml:"Name"`
Encoder struct {
Encoding string `xml:"Encoding"`
Resolution struct {
Width int `xml:"Width"`
Height int `xml:"Height"`
} `xml:"Resolution"`
} `xml:"VideoEncoderConfiguration"`
} `xml:"Body>GetProfilesResponse>Profiles"`
}
func ParseProfiles(data []byte) ([]Profile, error) {
var envelope profileEnvelope
if err := xml.Unmarshal(data, &envelope); err != nil {
return nil, fmt.Errorf("parse ONVIF profiles: %w", err)
}
if len(envelope.Profiles) == 0 {
return nil, fmt.Errorf("no_profiles")
}
result := make([]Profile, 0, len(envelope.Profiles))
for _, value := range envelope.Profiles {
result = append(result, Profile{Token: value.Token, Name: value.Name, Width: value.Encoder.Resolution.Width, Height: value.Encoder.Resolution.Height, Encoding: value.Encoder.Encoding})
}
return result, nil
}
func ParseStreamURI(data []byte) (string, error) {
decoder := xml.NewDecoder(strings.NewReader(string(data)))
for {
token, err := decoder.Token()
if err != nil {
return "", fmt.Errorf("stream_uri_not_found")
}
start, ok := token.(xml.StartElement)
if ok && start.Name.Local == "Uri" {
var value string
if err := decoder.DecodeElement(&value, &start); err != nil {
return "", err
}
if strings.Contains(value, "@") {
return "", fmt.Errorf("stream_uri_contains_credentials")
}
return value, nil
}
}
}
@@ -0,0 +1,31 @@
package onvif
import (
"os"
"path/filepath"
"testing"
)
func TestParseFixtureProfilesAndStreamURI(t *testing.T) {
profilesData, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "testdata", "onvif", "profiles.xml"))
if err != nil {
t.Fatal(err)
}
profiles, err := ParseProfiles(profilesData)
if err != nil || len(profiles) != 2 || profiles[0].Width != 1920 {
t.Fatalf("profiles=%#v err=%v", profiles, err)
}
uriData, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "testdata", "onvif", "stream-uri.xml"))
if err != nil {
t.Fatal(err)
}
uri, err := ParseStreamURI(uriData)
if err != nil || uri != "rtsp://camera.invalid/live/main" {
t.Fatalf("uri=%q err=%v", uri, err)
}
}
func TestRejectCredentialInStreamURI(t *testing.T) {
if _, err := ParseStreamURI([]byte(`<Uri>rtsp://user:pass@camera.invalid/live</Uri>`)); err == nil {
t.Fatal("credential URI accepted")
}
}
@@ -0,0 +1,74 @@
package rtsp
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net"
"net/url"
"strings"
"time"
)
type Credential struct {
Username string
Password string
}
type Result struct {
Status string `json:"status"`
LatencyMS int64 `json:"latency_ms"`
Detail string `json:"detail,omitempty"`
}
type Verifier interface {
Verify(context.Context, string, Credential) (Result, error)
}
type NetVerifier struct{ Timeout time.Duration }
func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "rtsp" || parsed.Host == "" {
return Result{}, fmt.Errorf("invalid_rtsp_uri")
}
if parsed.User != nil {
return Result{}, fmt.Errorf("rtsp_uri_contains_credentials")
}
address := parsed.Host
if !strings.Contains(address, ":") {
address += ":554"
}
timeout := v.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
dialer := net.Dialer{Timeout: timeout}
started := time.Now()
connection, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(timeout))
authorization := ""
if credential.Username != "" {
authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n"
}
request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization)
if _, err := connection.Write([]byte(request)); err != nil {
return Result{}, err
}
line, err := bufio.NewReader(connection).ReadString('\n')
if err != nil {
return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil
}
status := "ready"
detail := "码流可访问"
if strings.Contains(line, " 401 ") {
status = "authentication_failed"
detail = "设备拒绝了当前凭据"
} else if !strings.Contains(line, " 200 ") {
status = "failed"
detail = "设备返回非成功状态"
}
return Result{Status: status, LatencyMS: time.Since(started).Milliseconds(), Detail: detail}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package admission
import (
"net/http"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type Module struct{ service *Service }
func NewModule(service *Service) *Module { return &Module{service: service} }
func (m *Module) Register(app *platform.App) {
setCurrentService(m.service)
app.Handle("GET /api/v1/admission/discover", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.discover)))
app.Handle("POST /api/v1/admission/probe", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.probe)))
app.Handle("GET /api/v1/admission/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get)))
}
func (m *Module) discover(w http.ResponseWriter, r *http.Request) {
items, err := m.service.Discover(r.Context())
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "discovery_unavailable", Message: "未配置获准的发现网卡"})
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (m *Module) probe(w http.ResponseWriter, r *http.Request) {
var request struct {
DeviceID string `json:"device_id"`
Address string `json:"address"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
result, err := m.service.Probe(r.Context(), actor, request.DeviceID, request.Address)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "admission_failed", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusOK, result)
}
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
result, ok := m.service.Get(r.PathValue("id"))
if !ok {
platform.WriteError(w, &platform.APIError{Status: http.StatusNotFound, Code: "admission_not_found", Message: "尚未执行接入验证"})
return
}
platform.WriteJSON(w, http.StatusOK, result)
}
@@ -0,0 +1,24 @@
package admission
import "fmt"
var currentService *Service
func setCurrentService(service *Service) { currentService = service }
// VerifiedProfile is a Sense-internal projection, not a cross-product contract.
func VerifiedProfile(deviceID, token string) (Profile, error) {
if currentService == nil {
return Profile{}, fmt.Errorf("admission service is not ready")
}
result, ok := currentService.Get(deviceID)
if !ok {
return Profile{}, fmt.Errorf("device admission has not been verified")
}
for _, profile := range result.Profiles {
if profile.Token == token && profile.Verification.Status == "ready" {
return profile, nil
}
}
return Profile{}, fmt.Errorf("verified profile not found")
}
+119
View File
@@ -0,0 +1,119 @@
package admission
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"yovision.local/sense/app/sense/adapters/onvif"
"yovision.local/sense/app/sense/adapters/rtsp"
"yovision.local/sense/app/sense/device"
"yovision.local/sense/app/sense/identity"
)
type Profile struct {
Token string `json:"token"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
StreamURI string `json:"stream_uri"`
Kind string `json:"kind"`
Verification rtsp.Result `json:"verification"`
}
type Result struct {
DeviceID string `json:"device_id"`
Address string `json:"address"`
Status string `json:"status"`
Detail string `json:"detail"`
Profiles []Profile `json:"profiles"`
CheckedAt time.Time `json:"checked_at"`
}
type Service struct {
onvif onvif.Client
rtsp rtsp.Verifier
discoveryIP string
discoveryTimeout time.Duration
mu sync.RWMutex
results map[string]Result
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 (s *Service) Discover(ctx context.Context) ([]string, error) {
if strings.TrimSpace(s.discoveryIP) == "" {
return nil, fmt.Errorf("discovery_not_configured")
}
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)
if err != nil {
return Result{}, fmt.Errorf("credential_required")
}
profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: credential.Username, Password: credential.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)
identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "failure", map[string]any{"status": status})
return result, nil
}
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})
if verifyErr != nil {
verification = rtsp.Result{Status: "failed", Detail: "视频地址格式不正确"}
}
kind := "other"
items = append(items, Profile{Token: profile.Token, Name: profile.Name, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding, StreamURI: profile.StreamURI, Kind: kind, Verification: verification})
}
sort.Slice(items, func(i, j int) bool { return items[i].Width*items[i].Height > items[j].Width*items[j].Height })
if len(items) > 0 {
items[0].Kind = "main"
}
if len(items) > 1 {
items[len(items)-1].Kind = "sub"
}
status := "ready"
detail := "设备与视频 Profile 已验证"
for _, item := range items {
if item.Verification.Status != "ready" {
status = "profile_failed"
detail = "部分视频 Profile 验证失败"
}
}
result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, Profiles: items, CheckedAt: s.now().UTC()}
s.save(result)
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
}
func classify(err error) (string, string) {
value := strings.ToLower(err.Error())
switch {
case strings.Contains(value, "authentication"):
return "authentication_failed", "设备拒绝了当前凭据,请更新后重试"
case strings.Contains(value, "deadline") || strings.Contains(value, "timeout"):
return "timeout", "设备响应超时"
case strings.Contains(value, "clock") || strings.Contains(value, "time"):
return "clock_skew", "设备时间可能不准确,请校时后重试"
default:
return "unreachable", "无法读取设备信息,请检查地址和网络"
}
}
@@ -0,0 +1,46 @@
package admission
import (
"context"
"encoding/base64"
"testing"
"yovision.local/sense/app/sense/adapters/onvif"
"yovision.local/sense/app/sense/adapters/rtsp"
"yovision.local/sense/app/sense/device"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type fakeONVIF struct{}
func (fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://camera.invalid/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://camera.invalid/sub"}}, nil
}
type fakeRTSP struct{}
func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
return rtsp.Result{Status: "ready"}, nil
}
func TestProbeProfilesWithoutCredentialURI(t *testing.T) {
vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
if err != nil {
t.Fatal(err)
}
store := device.NewMemoryStore()
deviceService := device.NewService(store, vault)
module := device.NewModule(deviceService)
module.Register(platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, nil))
item, err := deviceService.Create(context.Background(), identity.Principal{}, "camera", "", device.ModalityVideo, nil)
if err != nil {
t.Fatal(err)
}
if _, err = deviceService.SetCredential(context.Background(), identity.Principal{}, item.ID, "fixture-user", "fixture-password"); err != nil {
t.Fatal(err)
}
service := NewService(fakeONVIF{}, fakeRTSP{}, "")
result, err := service.Probe(context.Background(), identity.Principal{}, item.ID, "http://camera.invalid/onvif/device_service")
if err != nil || result.Status != "ready" || result.Profiles[0].Kind != "main" || result.Profiles[1].Kind != "sub" {
t.Fatalf("result=%#v err=%v", result, err)
}
}
@@ -0,0 +1,19 @@
package area
import (
"context"
"fmt"
"sync/atomic"
)
var activeService atomic.Pointer[Service]
// ExportCurrent is a project-internal adapter port for a future versioned
// contract mapper. The internal Area type is not a shared Brain contract.
func ExportCurrent(ctx context.Context, deviceID string) ([]Area, error) {
service := activeService.Load()
if service == nil {
return nil, fmt.Errorf("area service is not ready")
}
return service.List(ctx, deviceID)
}
+77
View File
@@ -0,0 +1,77 @@
package area
import (
"fmt"
"math"
)
const epsilon = 1e-9
func ValidateGeometry(kind, direction string, points []Point) error {
for _, point := range points {
if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
return fmt.Errorf("坐标必须位于画面范围内")
}
}
switch kind {
case KindPolygon:
if len(points) < 3 {
return fmt.Errorf("多边形至少需要 3 个点")
}
if math.Abs(polygonArea(points)) < epsilon {
return fmt.Errorf("多边形不能退化为直线")
}
if selfIntersects(points, true) {
return fmt.Errorf("多边形不能自交")
}
case KindLine:
if len(points) != 2 {
return fmt.Errorf("警戒线必须恰好包含 2 个点")
}
if distance(points[0], points[1]) < epsilon {
return fmt.Errorf("警戒线两点不能重合")
}
if direction != DirectionAToB && direction != DirectionBToA && direction != DirectionBoth {
return fmt.Errorf("警戒线方向不正确")
}
default:
return fmt.Errorf("区域类型不正确")
}
return nil
}
func polygonArea(points []Point) float64 {
sum := 0.0
for i := range points {
next := (i + 1) % len(points)
sum += points[i].X*points[next].Y - points[next].X*points[i].Y
}
return sum / 2
}
func distance(a, b Point) float64 { dx, dy := a.X-b.X, a.Y-b.Y; return dx*dx + dy*dy }
func selfIntersects(points []Point, closed bool) bool {
segments := len(points) - 1
if closed {
segments = len(points)
}
for i := 0; i < segments; i++ {
a1, a2 := points[i], points[(i+1)%len(points)]
if distance(a1, a2) < epsilon {
return true
}
for j := i + 1; j < segments; j++ {
if j == i+1 || closed && i == 0 && j == segments-1 {
continue
}
b1, b2 := points[j], points[(j+1)%len(points)]
if segmentsIntersect(a1, a2, b1, b2) {
return true
}
}
}
return false
}
func segmentsIntersect(a, b, c, d Point) bool {
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
return o1*o2 < 0 && o3*o4 < 0
}
func orientation(a, b, c Point) float64 { return (b.X-a.X)*(c.Y-a.Y) - (b.Y-a.Y)*(c.X-a.X) }
@@ -0,0 +1,19 @@
package area
import "testing"
func TestValidateGeometry(t *testing.T) {
tests := []struct {
name, kind, direction string
points []Point
valid bool
}{{"polygon", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}}, true}, {"self intersection", KindPolygon, "", []Point{{.1, .1}, {.9, .9}, {.9, .1}, {.1, .9}}, false}, {"outside", KindPolygon, "", []Point{{-.1, .1}, {.9, .1}, {.5, .8}}, false}, {"line", KindLine, DirectionAToB, []Point{{.1, .1}, {.9, .9}}, true}, {"line direction", KindLine, "", []Point{{.1, .1}, {.9, .9}}, false}, {"line degenerate", KindLine, DirectionBoth, []Point{{.1, .1}, {.1, .1}}, false}}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateGeometry(test.kind, test.direction, test.points)
if (err == nil) != test.valid {
t.Fatalf("err=%v valid=%v", err, test.valid)
}
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package area
import (
"net/http"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type Module struct{ service *Service }
func NewModule(service *Service) *Module { return &Module{service: service} }
func (m *Module) Register(app *platform.App) {
activeService.Store(m.service)
app.Handle("GET /api/v1/areas", identity.Require(identity.PermissionAreaRead, http.HandlerFunc(m.list)))
app.Handle("POST /api/v1/areas", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.create)))
app.Handle("PATCH /api/v1/areas/{id}", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.update)))
app.Handle("POST /api/v1/areas/{id}/recalibrate", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.recalibrate)))
app.Handle("POST /api/v1/areas/{id}/disable", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.disable)))
}
type writeRequest struct {
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
Name string `json:"name"`
Kind string `json:"kind"`
Direction string `json:"direction"`
Points []Point `json:"points"`
Version int64 `json:"version"`
}
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
items, err := m.service.List(r.Context(), r.URL.Query().Get("device_id"))
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
var req writeRequest
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Create(r.Context(), actor, req.DeviceID, req.ProfileToken, req.Name, req.Kind, req.Direction, req.Points)
writeResult(w, item, err, http.StatusCreated)
}
func (m *Module) update(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, false) }
func (m *Module) recalibrate(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, true) }
func (m *Module) writeExisting(w http.ResponseWriter, r *http.Request, recalibrate bool) {
var req writeRequest
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Direction, req.Points, req.Version, recalibrate)
writeResult(w, item, err, http.StatusOK)
}
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
var req struct {
Version int64 `json:"version"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
writeResult(w, item, err, http.StatusOK)
}
func writeResult(w http.ResponseWriter, item Area, err error, status int) {
if err != nil {
code := "area_invalid"
httpStatus := http.StatusBadRequest
if err == ErrConflict {
code = "version_conflict"
httpStatus = http.StatusConflict
}
if err == ErrNotFound {
code = "area_not_found"
httpStatus = http.StatusNotFound
}
platform.WriteError(w, &platform.APIError{Status: httpStatus, Code: code, Message: err.Error()})
return
}
platform.WriteJSON(w, status, item)
}
+3
View File
@@ -0,0 +1,3 @@
package area
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);`
+117
View File
@@ -0,0 +1,117 @@
package area
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"yovision.local/sense/app/sense/admission"
"yovision.local/sense/app/sense/identity"
)
type Service struct {
store Store
profile func(string, string) (admission.Profile, error)
now func() time.Time
}
func NewService(store Store) *Service {
return &Service{store: store, profile: admission.VerifiedProfile, now: time.Now}
}
func (s *Service) Create(ctx context.Context, actor identity.Principal, deviceID, profileToken, name, kind, direction string, points []Point) (Area, error) {
profile, err := s.profile(deviceID, profileToken)
if err != nil {
return Area{}, err
}
if err := ValidateGeometry(kind, direction, points); err != nil {
return Area{}, err
}
name = strings.TrimSpace(name)
if name == "" {
return Area{}, fmt.Errorf("名称不能为空")
}
now := s.now().UTC()
item := Area{ID: newID(), DeviceID: deviceID, ProfileToken: profileToken, Width: profile.Width, Height: profile.Height, Name: name, Kind: kind, Direction: direction, Points: points, Enabled: true, Version: 1, CreatedAt: now, UpdatedAt: now}
if err := s.store.Save(ctx, item, 0); err != nil {
return Area{}, err
}
identity.RecordAudit(ctx, actor.UserID, "area.create", item.ID, "success", map[string]any{"version": 1, "kind": kind})
return item, nil
}
func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, direction string, points []Point, expected int64, recalibrate bool) (Area, error) {
current, err := s.store.Get(ctx, id)
if err != nil {
return Area{}, err
}
if current.Version != expected {
return Area{}, ErrConflict
}
profile, err := s.profile(current.DeviceID, current.ProfileToken)
if err != nil {
return Area{}, err
}
resolutionChanged := profile.Width != current.Width || profile.Height != current.Height
if resolutionChanged && !recalibrate {
current.RecalibrationRequired = true
current.Version++
current.UpdatedAt = s.now().UTC()
if err := s.store.Save(ctx, current, expected); err != nil {
return Area{}, err
}
return current, nil
}
if err := ValidateGeometry(current.Kind, direction, points); err != nil {
return Area{}, err
}
current.Name = strings.TrimSpace(name)
current.Direction = direction
current.Points = points
current.Width = profile.Width
current.Height = profile.Height
current.RecalibrationRequired = false
current.Version++
current.UpdatedAt = s.now().UTC()
if err := s.store.Save(ctx, current, expected); err != nil {
return Area{}, err
}
identity.RecordAudit(ctx, actor.UserID, "area.publish", id, "success", map[string]any{"version": current.Version, "recalibrated": recalibrate})
return current, nil
}
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Area, error) {
item, err := s.store.Get(ctx, id)
if err != nil {
return Area{}, err
}
if item.Version != expected {
return Area{}, ErrConflict
}
item.Enabled = false
item.Version++
item.UpdatedAt = s.now().UTC()
if err := s.store.Save(ctx, item, expected); err != nil {
return Area{}, err
}
identity.RecordAudit(ctx, actor.UserID, "area.disable", id, "success", map[string]any{"version": item.Version})
return item, nil
}
func (s *Service) List(ctx context.Context, deviceID string) ([]Area, error) {
items, err := s.store.List(ctx, deviceID)
if err != nil {
return nil, err
}
for i := range items {
if profile, profileErr := s.profile(items[i].DeviceID, items[i].ProfileToken); profileErr == nil && (profile.Width != items[i].Width || profile.Height != items[i].Height) {
items[i].RecalibrationRequired = true
}
}
return items, nil
}
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return "area_" + hex.EncodeToString(b)
}
@@ -0,0 +1,36 @@
package area
import (
"context"
"testing"
"yovision.local/sense/app/sense/admission"
"yovision.local/sense/app/sense/identity"
)
func TestVersionsConflictAndRecalibration(t *testing.T) {
service := NewService(NewMemoryStore())
width := 1920
service.profile = func(string, string) (admission.Profile, error) {
return admission.Profile{Width: width, Height: 1080}, nil
}
item, err := service.Create(context.Background(), identity.Principal{}, "device", "main", "区域", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}})
if err != nil {
t.Fatal(err)
}
updated, err := service.Update(context.Background(), identity.Principal{}, item.ID, "区域2", "", item.Points, item.Version, false)
if err != nil || updated.Version != 2 {
t.Fatalf("updated=%#v err=%v", updated, err)
}
if _, err := service.Update(context.Background(), identity.Principal{}, item.ID, "冲突", "", item.Points, item.Version, false); err != ErrConflict {
t.Fatalf("conflict err=%v", err)
}
width = 1280
flagged, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, updated.Version, false)
if err != nil || !flagged.RecalibrationRequired {
t.Fatalf("flagged=%#v err=%v", flagged, err)
}
recalibrated, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, flagged.Version, true)
if err != nil || recalibrated.RecalibrationRequired || recalibrated.Width != 1280 {
t.Fatalf("recalibrated=%#v err=%v", recalibrated, err)
}
}
+120
View File
@@ -0,0 +1,120 @@
package area
import (
"context"
"database/sql"
"encoding/json"
"errors"
"sync"
)
var (
ErrNotFound = errors.New("area not found")
ErrConflict = errors.New("area version conflict")
)
type Store interface {
Save(context.Context, Area, int64) error
Get(context.Context, string) (Area, error)
List(context.Context, string) ([]Area, error)
}
type MemoryStore struct {
mu sync.RWMutex
versions map[string][]Area
}
func NewMemoryStore() *MemoryStore { return &MemoryStore{versions: map[string][]Area{}} }
func (s *MemoryStore) Save(_ context.Context, item Area, expected int64) error {
s.mu.Lock()
defer s.mu.Unlock()
values := s.versions[item.ID]
if len(values) == 0 {
if expected != 0 {
return ErrConflict
}
} else if values[len(values)-1].Version != expected {
return ErrConflict
}
s.versions[item.ID] = append(values, item)
return nil
}
func (s *MemoryStore) Get(_ context.Context, id string) (Area, error) {
s.mu.RLock()
defer s.mu.RUnlock()
values := s.versions[id]
if len(values) == 0 {
return Area{}, ErrNotFound
}
return values[len(values)-1], nil
}
func (s *MemoryStore) List(_ context.Context, deviceID string) ([]Area, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := []Area{}
for _, values := range s.versions {
item := values[len(values)-1]
if deviceID == "" || item.DeviceID == deviceID {
items = append(items, item)
}
}
return items, nil
}
type PostgresStore struct{ database *sql.DB }
func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} }
func (s *PostgresStore) Save(ctx context.Context, item Area, expected int64) error {
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer tx.Rollback()
var current int64
err = tx.QueryRowContext(ctx, `SELECT COALESCE(max(version),0) FROM sense_area_versions WHERE id=$1`, item.ID).Scan(&current)
if err != nil {
return err
}
if current != expected {
return ErrConflict
}
points, _ := json.Marshal(item.Points)
_, err = tx.ExecContext(ctx, `INSERT INTO sense_area_versions(id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, item.ID, item.Version, item.DeviceID, item.ProfileToken, item.Width, item.Height, item.Name, item.Kind, item.Direction, points, item.Enabled, item.RecalibrationRequired, item.CreatedAt, item.UpdatedAt)
if err != nil {
return err
}
return tx.Commit()
}
func (s *PostgresStore) Get(ctx context.Context, id string) (Area, error) {
return scan(s.database.QueryRowContext(ctx, `SELECT id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE id=$1 ORDER BY version DESC LIMIT 1`, id))
}
type scanner interface{ Scan(...any) error }
func scan(row scanner) (Area, error) {
var item Area
var points []byte
err := row.Scan(&item.ID, &item.Version, &item.DeviceID, &item.ProfileToken, &item.Width, &item.Height, &item.Name, &item.Kind, &item.Direction, &points, &item.Enabled, &item.RecalibrationRequired, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Area{}, ErrNotFound
}
if err == nil {
err = json.Unmarshal(points, &item.Points)
}
return item, err
}
func (s *PostgresStore) List(ctx context.Context, deviceID string) ([]Area, error) {
rows, err := s.database.QueryContext(ctx, `SELECT DISTINCT ON(id) id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE ($1='' OR device_id=$1) ORDER BY id,version DESC`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Area{}
for rows.Next() {
item, err := scan(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
+32
View File
@@ -0,0 +1,32 @@
package area
import "time"
const (
KindPolygon = "polygon"
KindLine = "line"
DirectionAToB = "a_to_b"
DirectionBToA = "b_to_a"
DirectionBoth = "both"
)
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type Area struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
Width int `json:"width"`
Height int `json:"height"`
Name string `json:"name"`
Kind string `json:"kind"`
Direction string `json:"direction,omitempty"`
Points []Point `json:"points"`
Enabled bool `json:"enabled"`
Version int64 `json:"version"`
RecalibrationRequired bool `json:"recalibration_required"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,32 @@
package device
import (
"context"
"fmt"
"sync/atomic"
)
type Credential struct {
Username string
Password string
}
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) {
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.CredentialCiphertext) == 0 {
return Credential{}, fmt.Errorf("device credential is not configured")
}
username, password, err := service.vault.Decrypt(item.CredentialCiphertext)
return Credential{Username: username, Password: password}, err
}
+80
View File
@@ -0,0 +1,80 @@
package device
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
)
type CredentialVault struct{ key []byte }
func NewCredentialVault(encodedKey string, allowRandom bool) (*CredentialVault, error) {
var key []byte
if encodedKey != "" {
decoded, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must be base64 encoded")
}
key = decoded
} else if allowRandom {
key = make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
}
if len(key) != 32 {
return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must decode to 32 bytes")
}
return &CredentialVault{key: key}, nil
}
func (v *CredentialVault) Encrypt(username, password string) ([]byte, error) {
plaintext, err := json.Marshal(map[string]string{"username": username, "password": password})
if err != nil {
return nil, err
}
block, err := aes.NewCipher(v.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, []byte("sense-device-credential-v1")), nil
}
func (v *CredentialVault) Decrypt(ciphertext []byte) (string, string, error) {
block, err := aes.NewCipher(v.key)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
if len(ciphertext) < gcm.NonceSize() {
return "", "", fmt.Errorf("invalid credential ciphertext")
}
nonce, encrypted := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, encrypted, []byte("sense-device-credential-v1"))
if err != nil {
return "", "", fmt.Errorf("decrypt device credential: %w", err)
}
var value struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.Unmarshal(plaintext, &value); err != nil {
return "", "", err
}
return value.Username, value.Password, nil
}
+124
View File
@@ -0,0 +1,124 @@
package device
import (
"net/http"
"strconv"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type Module struct{ service *Service }
func NewModule(service *Service) *Module { return &Module{service: service} }
func (m *Module) Register(app *platform.App) {
activeService.Store(m.service)
app.Handle("GET /api/v1/devices", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.list)))
app.Handle("POST /api/v1/devices", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.create)))
app.Handle("GET /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get)))
app.Handle("PATCH /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.update)))
app.Handle("POST /api/v1/devices/{id}/credentials", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.credential)))
app.Handle("POST /api/v1/devices/{id}/disable", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.disable)))
}
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
size, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
result, err := m.service.List(r.Context(), ListFilter{Keyword: r.URL.Query().Get("keyword"), Page: page, PageSize: size})
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, result)
}
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
item, err := m.service.Get(r.Context(), r.PathValue("id"))
if err != nil {
writeDeviceError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, item)
}
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Location string `json:"location"`
Modality string `json:"modality"`
Capabilities []string `json:"capabilities"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Create(r.Context(), actor, req.Name, req.Location, req.Modality, req.Capabilities)
if err != nil {
writeDeviceError(w, err)
return
}
platform.WriteJSON(w, http.StatusCreated, item)
}
func (m *Module) update(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Location string `json:"location"`
Capabilities []string `json:"capabilities"`
Version int64 `json:"version"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Location, req.Capabilities, req.Version)
if err != nil {
writeDeviceError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, item)
}
func (m *Module) credential(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"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 err != nil {
writeDeviceError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, item)
}
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
var req struct {
Version int64 `json:"version"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
if err != nil {
writeDeviceError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, item)
}
func writeDeviceError(w http.ResponseWriter, err error) {
status := http.StatusBadRequest
code := "device_invalid"
if err == ErrNotFound {
status = http.StatusNotFound
code = "device_not_found"
}
if err == ErrConflict {
status = http.StatusConflict
code = "version_conflict"
}
platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()})
}
@@ -0,0 +1,3 @@
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);`
+126
View File
@@ -0,0 +1,126 @@
package device
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"yovision.local/sense/app/sense/identity"
)
type Service struct {
store Store
vault *CredentialVault
now func() time.Time
}
func NewService(store Store, vault *CredentialVault) *Service {
return &Service{store: store, vault: vault, now: time.Now}
}
func (s *Service) Create(ctx context.Context, actor identity.Principal, name, location, modality string, capabilities []string) (Device, error) {
name = strings.TrimSpace(name)
if name == "" {
return Device{}, fmt.Errorf("设备名称不能为空")
}
if modality == "" {
modality = ModalityVideo
}
adapter := AdapterNotReady
if modality == ModalityVideo {
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}
if err := s.store.Create(ctx, item); err != nil {
return Device{}, err
}
identity.RecordAudit(ctx, actor.UserID, "device.create", item.ID, "success", map[string]any{"modality": modality})
return item, nil
}
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.CredentialCiphertext = nil
}
return item, err
}
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
}
return page, err
}
func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, location string, capabilities []string, expected int64) (Device, error) {
item, err := s.store.Get(ctx, id)
if err != nil {
return Device{}, err
}
item.Name = strings.TrimSpace(name)
item.Location = strings.TrimSpace(location)
item.Capabilities = capabilities
item.Version++
item.UpdatedAt = s.now().UTC()
if item.Name == "" {
return Device{}, fmt.Errorf("设备名称不能为空")
}
if err := s.store.Update(ctx, item, expected); err != nil {
return Device{}, err
}
identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version})
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
item.CredentialCiphertext = 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 Device{}, fmt.Errorf("用户名和密码不能为空")
}
item, err := s.store.Get(ctx, id)
if err != nil {
return Device{}, err
}
ciphertext, err := s.vault.Encrypt(username, password)
if err != nil {
return Device{}, err
}
expected := item.Version
item.CredentialCiphertext = ciphertext
item.CredentialConfigured = 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})
item.CredentialCiphertext = nil
return item, nil
}
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) {
item, err := s.store.Get(ctx, id)
if err != nil {
return Device{}, err
}
item.Status = StatusDisabled
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.disable", id, "success", nil)
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
item.CredentialCiphertext = nil
return item, nil
}
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return "dev_" + hex.EncodeToString(b)
}
@@ -0,0 +1,58 @@
package device
import (
"context"
"encoding/base64"
"strings"
"testing"
"yovision.local/sense/app/sense/identity"
)
func testService(t *testing.T) *Service {
t.Helper()
vault, err := NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
if err != nil {
t.Fatal(err)
}
return NewService(NewMemoryStore(), vault)
}
func TestDeviceLifecycleAndCredentialNeverReturned(t *testing.T) {
svc := testService(t)
ctx := context.Background()
actor := identity.Principal{UserID: "test"}
item, err := svc.Create(ctx, actor, "东门摄像机", "东门", ModalityVideo, []string{"video"})
if err != nil {
t.Fatal(err)
}
if item.AdapterStatus != AdapterReady {
t.Fatalf("adapter=%s", item.AdapterStatus)
}
item, err = svc.SetCredential(ctx, actor, item.ID, "camera-user", "camera-password")
if err != nil {
t.Fatal(err)
}
if !item.CredentialConfigured || len(item.CredentialCiphertext) != 0 {
t.Fatal("credential leaked or not configured")
}
page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20})
if err != nil || page.Total != 1 || len(page.Items[0].CredentialCiphertext) != 0 {
t.Fatalf("page=%#v err=%v", page, err)
}
}
func TestNonVideoAdapterNotReadyAndPaginationNotCappedAt16(t *testing.T) {
svc := testService(t)
ctx := context.Background()
for i := 0; i < 20; i++ {
item, err := svc.Create(ctx, identity.Principal{}, strings.Repeat("x", i+1), "", "radar", nil)
if err != nil {
t.Fatal(err)
}
if item.AdapterStatus != AdapterNotReady {
t.Fatal("unsupported adapter shown ready")
}
}
page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20})
if err != nil || len(page.Items) != 20 {
t.Fatalf("items=%d err=%v", len(page.Items), err)
}
}
+160
View File
@@ -0,0 +1,160 @@
package device
import (
"context"
"database/sql"
"errors"
"sort"
"strings"
"sync"
"time"
)
var (
ErrNotFound = errors.New("device not found")
ErrConflict = errors.New("device version conflict")
)
type Store interface {
Create(context.Context, Device) error
Get(context.Context, string) (Device, error)
Update(context.Context, Device, int64) error
List(context.Context, ListFilter) (Page, error)
}
type MemoryStore struct {
mu sync.RWMutex
devices map[string]Device
}
func NewMemoryStore() *MemoryStore { return &MemoryStore{devices: map[string]Device{}} }
func (s *MemoryStore) Create(_ context.Context, item Device) error {
s.mu.Lock()
defer s.mu.Unlock()
s.devices[item.ID] = item
return nil
}
func (s *MemoryStore) Get(_ context.Context, id string) (Device, error) {
s.mu.RLock()
defer s.mu.RUnlock()
item, ok := s.devices[id]
if !ok {
return Device{}, ErrNotFound
}
return item, nil
}
func (s *MemoryStore) Update(_ context.Context, item Device, expected int64) error {
s.mu.Lock()
defer s.mu.Unlock()
current, ok := s.devices[item.ID]
if !ok {
return ErrNotFound
}
if current.Version != expected {
return ErrConflict
}
s.devices[item.ID] = item
return nil
}
func (s *MemoryStore) List(_ context.Context, filter ListFilter) (Page, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]Device, 0, len(s.devices))
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
for _, item := range s.devices {
if keyword == "" || strings.Contains(strings.ToLower(item.Name+" "+item.Location), keyword) {
items = append(items, item)
}
}
sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) })
page, size := normalizePage(filter.Page, filter.PageSize)
total := len(items)
start := (page - 1) * size
if start > total {
start = total
}
end := start + size
if end > total {
end = total
}
return Page{Items: append([]Device(nil), items[start:end]...), Total: total, Page: page, PageSize: size}, nil
}
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)
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)
if errors.Is(err, sql.ErrNoRows) {
return Device{}, ErrNotFound
}
item.Capabilities = splitCapabilities(capabilities)
item.CredentialConfigured = len(item.CredentialCiphertext) > 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)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrConflict
}
return nil
}
func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, error) {
page, size := normalizePage(filter.Page, filter.PageSize)
keyword := "%" + strings.TrimSpace(filter.Keyword) + "%"
var total int
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)
if err != nil {
return Page{}, err
}
defer rows.Close()
items := []Device{}
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 {
return Page{}, err
}
item.Capabilities = splitCapabilities(caps)
items = append(items, item)
}
return Page{Items: items, Total: total, Page: page, PageSize: size}, rows.Err()
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
func splitCapabilities(value string) []string {
if value == "" {
return []string{}
}
return strings.Split(value, ",")
}
var _ = time.Time{}
+41
View File
@@ -0,0 +1,41 @@
package device
import "time"
const (
ModalityVideo = "video"
StatusPending = "pending"
StatusActive = "active"
StatusOffline = "offline"
StatusDisabled = "disabled"
AdapterReady = "ready"
AdapterNotReady = "adapter_not_ready"
)
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"`
}
type ListFilter struct {
Keyword string
Page int
PageSize int
}
type Page struct {
Items []Device `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
@@ -0,0 +1,13 @@
package identity
import "context"
// RecordAudit is the narrow project-internal audit port used by Sense business
// modules. It never accepts secrets and does not expose identity persistence.
func RecordAudit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
module := activeModule.Load()
if module == nil {
return
}
module.service.audit(ctx, actor, action, target, outcome, metadata)
}
+222
View File
@@ -0,0 +1,222 @@
package identity
import (
"context"
"crypto/rand"
"errors"
"net/http"
"strings"
"sync/atomic"
"time"
"yovision.local/sense/internal/platform"
)
const sessionCookieName = "sense_session"
type principalContextKey struct{}
type Module struct {
service *Service
cfg Config
}
var activeModule atomic.Pointer[Module]
func NewModule(service *Service, cfg Config) *Module { return &Module{service: service, cfg: cfg} }
func (m *Module) Register(app *platform.App) {
activeModule.Store(m)
app.HandleFunc("POST /api/v1/identity/bootstrap", m.bootstrap)
app.HandleFunc("POST /api/v1/identity/login", m.login)
app.Handle("POST /api/v1/identity/logout", m.Authenticated(http.HandlerFunc(m.logout)))
app.Handle("GET /api/v1/identity/me", m.Authenticated(http.HandlerFunc(m.me)))
app.Handle("GET /api/v1/identity/users", m.Require(PermissionUsersRead, http.HandlerFunc(m.users)))
app.Handle("POST /api/v1/identity/users", m.Require(PermissionUsersWrite, http.HandlerFunc(m.users)))
app.Handle("PATCH /api/v1/identity/users/{id}", m.Require(PermissionUsersWrite, http.HandlerFunc(m.updateUser)))
app.Handle("GET /api/v1/identity/audit", m.Require(PermissionAuditRead, http.HandlerFunc(m.audit)))
}
func Require(permission string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
module := activeModule.Load()
if module == nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "identity_not_ready", Message: "身份服务尚未就绪"})
return
}
module.Require(permission, next).ServeHTTP(w, r)
})
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(Principal)
return principal, ok
}
func (m *Module) Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Product") != "sense" {
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
return
}
principal, err := m.service.Authenticate(r.Context(), cookie.Value)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal)))
})
}
func (m *Module) Require(permission string, next http.Handler) http.Handler {
return m.Authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := PrincipalFromContext(r.Context())
if !HasPermission(principal.Role, permission) {
platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "forbidden", Message: "没有执行此操作的权限"})
return
}
next.ServeHTTP(w, r)
}))
}
func (m *Module) bootstrap(w http.ResponseWriter, r *http.Request) {
var request struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
user, err := m.service.Bootstrap(r.Context(), r.Header.Get("X-Sense-Bootstrap-Token"), request.Username, request.DisplayName, request.Password)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "bootstrap_denied", Message: "安全初始化不可用或输入不符合要求"})
return
}
platform.WriteJSON(w, http.StatusCreated, user)
}
func (m *Module) login(w http.ResponseWriter, r *http.Request) {
var request struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
principal, token, err := m.service.Login(r.Context(), request.Username, request.Password)
if err != nil {
// A small constant delay and one generic message reduce account discovery.
time.Sleep(80*time.Millisecond + randomJitter())
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "用户名或密码不正确"})
return
}
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: token, Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: int(m.cfg.SessionTTL.Seconds())})
platform.WriteJSON(w, http.StatusOK, principal)
}
func randomJitter() time.Duration {
var value [1]byte
_, _ = rand.Read(value[:])
return time.Duration(value[0]%40) * time.Millisecond
}
func (m *Module) logout(w http.ResponseWriter, r *http.Request) {
principal, _ := PrincipalFromContext(r.Context())
if err := m.service.Logout(r.Context(), principal); err != nil {
platform.WriteError(w, err)
return
}
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
w.WriteHeader(http.StatusNoContent)
}
func (m *Module) me(w http.ResponseWriter, r *http.Request) {
principal, _ := PrincipalFromContext(r.Context())
platform.WriteJSON(w, http.StatusOK, principal)
}
func (m *Module) users(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
users, err := m.service.ListUsers(r.Context())
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": users, "total": len(users)})
return
}
var request struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Password string `json:"password"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
principal, _ := PrincipalFromContext(r.Context())
user, err := m.service.CreateUser(r.Context(), principal, request.Username, request.DisplayName, request.Role, request.Password)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_created", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusCreated, user)
}
func (m *Module) updateUser(w http.ResponseWriter, r *http.Request) {
var request struct {
DisplayName string `json:"display_name"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
}
if err := platform.DecodeJSON(r, &request); err != nil {
platform.WriteError(w, err)
return
}
principal, _ := PrincipalFromContext(r.Context())
user, err := m.service.UpdateUser(r.Context(), principal, r.PathValue("id"), request.DisplayName, request.Role, request.Enabled)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_updated", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusOK, user)
}
func (m *Module) audit(w http.ResponseWriter, r *http.Request) {
entries, err := m.service.ListAudit(r.Context())
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
}
func SigningKeyOrRandom(raw string, allowRandom bool) ([]byte, error) {
if len(raw) >= 32 {
return []byte(raw), nil
}
if !allowRandom {
return nil, errors.New("SENSE_IDENTITY_SIGNING_KEY must contain at least 32 bytes")
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
return key, nil
}
func SecureCookieFromEnvironment(value string, memoryMode bool) bool {
if memoryMode {
return strings.EqualFold(value, "true")
}
return !strings.EqualFold(value, "false")
}
@@ -0,0 +1,59 @@
package identity
import (
"bytes"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"yovision.local/sense/internal/platform"
)
func TestHTTPLoginCookieAndProductBoundary(t *testing.T) {
store := NewMemoryStore()
cfg := Config{SigningKey: []byte("0123456789abcdef0123456789abcdef"), BootstrapToken: "bootstrap-test", SessionTTL: time.Hour}
service, err := NewService(store, cfg)
if err != nil {
t.Fatal(err)
}
app := platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
NewModule(service, cfg).Register(app)
bootstrap := httptest.NewRequest(http.MethodPost, "/api/v1/identity/bootstrap", bytes.NewBufferString(`{"username":"admin","display_name":"管理员","password":"StrongPass2026"}`))
bootstrap.Header.Set("X-Sense-Bootstrap-Token", "bootstrap-test")
bootstrapResult := httptest.NewRecorder()
app.Handler().ServeHTTP(bootstrapResult, bootstrap)
if bootstrapResult.Code != http.StatusCreated {
t.Fatalf("bootstrap status = %d, body = %s", bootstrapResult.Code, bootstrapResult.Body.String())
}
login := httptest.NewRequest(http.MethodPost, "/api/v1/identity/login", bytes.NewBufferString(`{"username":"admin","password":"StrongPass2026"}`))
loginResult := httptest.NewRecorder()
app.Handler().ServeHTTP(loginResult, login)
if loginResult.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", loginResult.Code, loginResult.Body.String())
}
cookies := loginResult.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("unexpected session cookies: %#v", cookies)
}
me := httptest.NewRequest(http.MethodGet, "/api/v1/identity/me", nil)
me.AddCookie(cookies[0])
me.Header.Set("X-Product", "bell")
foreignResult := httptest.NewRecorder()
app.Handler().ServeHTTP(foreignResult, me)
if foreignResult.Code != http.StatusUnauthorized {
t.Fatalf("Bell product header status = %d", foreignResult.Code)
}
me.Header.Set("X-Product", "sense")
senseResult := httptest.NewRecorder()
app.Handler().ServeHTTP(senseResult, me)
if senseResult.Code != http.StatusOK {
t.Fatalf("Sense product header status = %d", senseResult.Code)
}
}
@@ -0,0 +1,30 @@
package identity
const MigrationSQL = `
CREATE TABLE IF NOT EXISTS sense_identity_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES sense_identity_users(id),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_audit (
id TEXT PRIMARY KEY,
actor_id TEXT NULL REFERENCES sense_identity_users(id),
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sense_identity_audit_created_idx ON sense_identity_audit(created_at DESC);
`
@@ -0,0 +1,23 @@
package identity
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func ValidatePassword(_ string, password string) error {
if len([]rune(password)) < 6 {
return fmt.Errorf("密码至少需要 6 个字符")
}
return nil
}
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == 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)
}
})
}
}
@@ -0,0 +1,42 @@
package identity
var rolePermissions = map[string][]string{
RoleAdministrator: {
PermissionUsersRead, PermissionUsersWrite, PermissionAuditRead,
PermissionDeviceRead, PermissionDeviceWrite,
PermissionMediaRead, PermissionMediaWrite,
PermissionAreaRead, PermissionAreaWrite,
},
RoleOperator: {
PermissionDeviceRead, PermissionDeviceWrite,
PermissionMediaRead, PermissionMediaWrite,
PermissionAreaRead, PermissionAreaWrite,
},
RoleSiteAdmin: {
PermissionUsersRead, PermissionAuditRead,
PermissionDeviceRead, PermissionDeviceWrite,
PermissionMediaRead, PermissionAreaRead, PermissionAreaWrite,
},
RoleViewer: {
PermissionDeviceRead, PermissionMediaRead, PermissionAreaRead,
},
}
func ValidRole(role string) bool {
_, ok := rolePermissions[role]
return ok
}
func PermissionsForRole(role string) []string {
permissions := rolePermissions[role]
return append([]string(nil), permissions...)
}
func HasPermission(role, permission string) bool {
for _, candidate := range rolePermissions[role] {
if candidate == permission {
return true
}
}
return false
}
+132
View File
@@ -0,0 +1,132 @@
package identity
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type PostgresStore struct{ database *sql.DB }
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
func (s *PostgresStore) BootstrapUser(ctx context.Context, user User) error {
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM sense_identity_users`).Scan(&count); err != nil {
return err
}
if count != 0 {
return ErrBootstrapDone
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_identity_users(id, username, display_name, role, enabled, password_hash, created_at, updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, user.ID, user.Username, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
return tx.Commit()
}
func (s *PostgresStore) CreateUser(ctx context.Context, user User) error {
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_identity_users(id, username, display_name, role, enabled, password_hash, created_at, updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, user.ID, user.Username, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.CreatedAt, user.UpdatedAt)
return err
}
func (s *PostgresStore) UpdateUser(ctx context.Context, user User) error {
result, err := s.database.ExecContext(ctx, `UPDATE sense_identity_users SET display_name=$2, role=$3, enabled=$4, password_hash=$5, updated_at=$6 WHERE id=$1`, user.ID, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.UpdatedAt)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) FindUserByUsername(ctx context.Context, username string) (User, error) {
return scanUser(s.database.QueryRowContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users WHERE lower(username)=lower($1)`, username))
}
func (s *PostgresStore) FindUserByID(ctx context.Context, id string) (User, error) {
return scanUser(s.database.QueryRowContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users WHERE id=$1`, id))
}
type rowScanner interface{ Scan(...any) error }
func scanUser(row rowScanner) (User, error) {
var user User
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &user.Enabled, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
return user, err
}
func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.database.QueryContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s *PostgresStore) CreateSession(ctx context.Context, session Session) error {
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_identity_sessions(id,user_id,expires_at) VALUES($1,$2,$3)`, session.ID, session.UserID, session.ExpiresAt)
return err
}
func (s *PostgresStore) SessionActive(ctx context.Context, id string, now time.Time) (bool, error) {
var active bool
err := s.database.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_identity_sessions WHERE id=$1 AND revoked_at IS NULL AND expires_at>$2)`, id, now).Scan(&active)
return active, err
}
func (s *PostgresStore) RevokeSession(ctx context.Context, id string, now time.Time) error {
_, err := s.database.ExecContext(ctx, `UPDATE sense_identity_sessions SET revoked_at=$2 WHERE id=$1`, id, now)
return err
}
func (s *PostgresStore) AppendAudit(ctx context.Context, entry AuditEntry) error {
metadata, err := json.Marshal(entry.Metadata)
if err != nil {
return err
}
_, err = s.database.ExecContext(ctx, `INSERT INTO sense_identity_audit(id,actor_id,action,target,outcome,metadata,created_at) VALUES($1,NULLIF($2,''),$3,$4,$5,$6,$7)`, entry.ID, entry.ActorID, entry.Action, entry.Target, entry.Outcome, metadata, entry.CreatedAt)
return err
}
func (s *PostgresStore) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
rows, err := s.database.QueryContext(ctx, `SELECT id,COALESCE(actor_id,''),action,target,outcome,metadata,created_at FROM sense_identity_audit ORDER BY created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var entry AuditEntry
var metadata []byte
if err := rows.Scan(&entry.ID, &entry.ActorID, &entry.Action, &entry.Target, &entry.Outcome, &metadata, &entry.CreatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal(metadata, &entry.Metadata)
entries = append(entries, entry)
}
return entries, rows.Err()
}
+159
View File
@@ -0,0 +1,159 @@
package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
)
type Config struct {
SigningKey []byte
BootstrapToken string
SecureCookie bool
SessionTTL time.Duration
}
type Service struct {
store Store
cfg Config
now func() time.Time
}
func NewService(store Store, cfg Config) (*Service, error) {
if len(cfg.SigningKey) < 32 {
return nil, fmt.Errorf("Sense identity signing key must contain at least 32 bytes")
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 8 * time.Hour
}
return &Service{store: store, cfg: cfg, now: time.Now}, nil
}
func (s *Service) Bootstrap(ctx context.Context, token, username, displayName, password string) (User, error) {
if s.cfg.BootstrapToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.BootstrapToken)) != 1 {
return User{}, errors.New("bootstrap denied")
}
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return User{}, fmt.Errorf("用户名格式不正确")
}
if err := ValidatePassword(username, password); err != nil {
return User{}, err
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
now := s.now().UTC()
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdministrator, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
if err := s.store.BootstrapUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, user.ID, "identity.bootstrap", user.ID, "success", nil)
return user, nil
}
func (s *Service) Login(ctx context.Context, username, password string) (Principal, string, error) {
user, err := s.store.FindUserByUsername(ctx, strings.TrimSpace(username))
if err != nil || !user.Enabled || !VerifyPassword(user.PasswordHash, password) {
s.audit(ctx, "", "identity.login", "", "failure", map[string]any{"reason": "invalid_credentials"})
return Principal{}, "", errors.New("invalid credentials")
}
now := s.now().UTC()
session := Session{ID: newID("ses"), UserID: user.ID, ExpiresAt: now.Add(s.cfg.SessionTTL)}
if err := s.store.CreateSession(ctx, session); err != nil {
return Principal{}, "", err
}
raw, err := issueToken(s.cfg.SigningKey, user, session, now)
if err != nil {
return Principal{}, "", err
}
s.audit(ctx, user.ID, "identity.login", user.ID, "success", nil)
return principalFor(user, session.ID), raw, nil
}
func (s *Service) Authenticate(ctx context.Context, raw string) (Principal, error) {
claims, err := parseToken(s.cfg.SigningKey, raw, s.now())
if err != nil {
return Principal{}, err
}
active, err := s.store.SessionActive(ctx, claims.ID, s.now())
if err != nil || !active {
return Principal{}, errors.New("inactive Sense session")
}
user, err := s.store.FindUserByID(ctx, claims.Subject)
if err != nil || !user.Enabled || user.Role != claims.Role {
return Principal{}, errors.New("invalid Sense principal")
}
return principalFor(user, claims.ID), nil
}
func (s *Service) Logout(ctx context.Context, principal Principal) error {
if err := s.store.RevokeSession(ctx, principal.SessionID, s.now().UTC()); err != nil && !errors.Is(err, ErrNotFound) {
return err
}
s.audit(ctx, principal.UserID, "identity.logout", principal.UserID, "success", nil)
return nil
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.store.ListUsers(ctx) }
func (s *Service) ListAudit(ctx context.Context) ([]AuditEntry, error) {
return s.store.ListAudit(ctx, 100)
}
func (s *Service) CreateUser(ctx context.Context, actor Principal, username, displayName, role, password string) (User, error) {
if !ValidRole(role) {
return User{}, fmt.Errorf("角色不正确")
}
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return User{}, fmt.Errorf("用户名格式不正确")
}
if err := ValidatePassword(username, password); err != nil {
return User{}, err
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
now := s.now().UTC()
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: role, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
if err := s.store.CreateUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, actor.UserID, "identity.user.create", user.ID, "success", map[string]any{"role": role})
return user, nil
}
func (s *Service) UpdateUser(ctx context.Context, actor Principal, id, displayName, role string, enabled bool) (User, error) {
if !ValidRole(role) {
return User{}, fmt.Errorf("角色不正确")
}
user, err := s.store.FindUserByID(ctx, id)
if err != nil {
return User{}, err
}
if actor.UserID == id && !enabled {
return User{}, fmt.Errorf("不能停用当前登录账户")
}
user.DisplayName = strings.TrimSpace(displayName)
user.Role = role
user.Enabled = enabled
user.UpdatedAt = s.now().UTC()
if err := s.store.UpdateUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, actor.UserID, "identity.user.update", user.ID, "success", map[string]any{"role": role, "enabled": enabled})
return user, nil
}
func (s *Service) audit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
_ = s.store.AppendAudit(ctx, AuditEntry{ID: newID("aud"), ActorID: actor, Action: action, Target: target, Outcome: outcome, Metadata: metadata, CreatedAt: s.now().UTC()})
}
func principalFor(user User, sessionID string) Principal {
return Principal{UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Role: user.Role, Permissions: PermissionsForRole(user.Role), SessionID: sessionID}
}
@@ -0,0 +1,77 @@
package identity
import (
"context"
"testing"
"time"
)
func newTestService(t *testing.T) (*Service, *MemoryStore) {
t.Helper()
store := NewMemoryStore()
service, err := NewService(store, Config{SigningKey: []byte("0123456789abcdef0123456789abcdef"), BootstrapToken: "one-time-token", SessionTTL: time.Hour})
if err != nil {
t.Fatal(err)
}
return service, store
}
func TestBootstrapLoginRolesAndRevocation(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
admin, err := service.Bootstrap(ctx, "one-time-token", "admin", "管理员", "StrongPass2026")
if err != nil {
t.Fatalf("Bootstrap() error = %v", err)
}
if _, err := service.Bootstrap(ctx, "one-time-token", "another", "", "SecondStrong2026"); err != ErrBootstrapDone {
t.Fatalf("second Bootstrap() error = %v", err)
}
principal, raw, err := service.Login(ctx, "admin", "StrongPass2026")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if principal.Role != RoleAdministrator || !HasPermission(principal.Role, PermissionUsersWrite) {
t.Fatalf("unexpected principal: %#v", principal)
}
authenticated, err := service.Authenticate(ctx, raw)
if err != nil || authenticated.UserID != admin.ID {
t.Fatalf("Authenticate() = %#v, %v", authenticated, err)
}
if err := service.Logout(ctx, authenticated); err != nil {
t.Fatal(err)
}
if _, err := service.Authenticate(ctx, raw); err == nil {
t.Fatal("revoked token remained valid")
}
}
func TestInvalidLoginIsUniformAndAudited(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
if _, _, err := service.Login(ctx, "missing", "WrongPass2026"); err == nil {
t.Fatal("missing user login succeeded")
}
entries, err := service.ListAudit(ctx)
if err != nil || len(entries) != 1 || entries[0].Outcome != "failure" {
t.Fatalf("audit entries = %#v, %v", entries, err)
}
}
func TestBellTokenIsRejected(t *testing.T) {
service, _ := newTestService(t)
if _, err := service.Authenticate(context.Background(), "not-a-sense-token"); err == nil {
t.Fatal("foreign token accepted")
}
}
func TestRoleMatrix(t *testing.T) {
if HasPermission(RoleViewer, PermissionDeviceWrite) {
t.Fatal("viewer received write permission")
}
if !HasPermission(RoleOperator, PermissionMediaWrite) {
t.Fatal("operator missing media write permission")
}
if HasPermission(RoleSiteAdmin, PermissionUsersWrite) {
t.Fatal("site admin received user write permission")
}
}
+171
View File
@@ -0,0 +1,171 @@
package identity
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"sort"
"strings"
"sync"
"time"
)
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrBootstrapDone = errors.New("bootstrap already completed")
)
type Store interface {
BootstrapUser(context.Context, User) error
CreateUser(context.Context, User) error
UpdateUser(context.Context, User) error
FindUserByUsername(context.Context, string) (User, error)
FindUserByID(context.Context, string) (User, error)
ListUsers(context.Context) ([]User, error)
CreateSession(context.Context, Session) error
SessionActive(context.Context, string, time.Time) (bool, error)
RevokeSession(context.Context, string, time.Time) error
AppendAudit(context.Context, AuditEntry) error
ListAudit(context.Context, int) ([]AuditEntry, error)
}
type MemoryStore struct {
mu sync.RWMutex
users map[string]User
byName map[string]string
sessions map[string]Session
audit []AuditEntry
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{users: map[string]User{}, byName: map[string]string{}, sessions: map[string]Session{}}
}
func (s *MemoryStore) BootstrapUser(_ context.Context, user User) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.users) != 0 {
return ErrBootstrapDone
}
return s.createUserLocked(user)
}
func (s *MemoryStore) CreateUser(_ context.Context, user User) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.createUserLocked(user)
}
func (s *MemoryStore) createUserLocked(user User) error {
key := strings.ToLower(user.Username)
if _, exists := s.byName[key]; exists {
return ErrAlreadyExists
}
s.users[user.ID] = user
s.byName[key] = user.ID
return nil
}
func (s *MemoryStore) UpdateUser(_ context.Context, user User) error {
s.mu.Lock()
defer s.mu.Unlock()
previous, exists := s.users[user.ID]
if !exists {
return ErrNotFound
}
delete(s.byName, strings.ToLower(previous.Username))
s.users[user.ID] = user
s.byName[strings.ToLower(user.Username)] = user.ID
return nil
}
func (s *MemoryStore) FindUserByUsername(_ context.Context, username string) (User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
id, exists := s.byName[strings.ToLower(username)]
if !exists {
return User{}, ErrNotFound
}
return s.users[id], nil
}
func (s *MemoryStore) FindUserByID(_ context.Context, id string) (User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, exists := s.users[id]
if !exists {
return User{}, ErrNotFound
}
return user, nil
}
func (s *MemoryStore) ListUsers(_ context.Context) ([]User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]User, 0, len(s.users))
for _, user := range s.users {
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool { return users[i].Username < users[j].Username })
return users, nil
}
func (s *MemoryStore) CreateSession(_ context.Context, session Session) error {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[session.ID] = session
return nil
}
func (s *MemoryStore) SessionActive(_ context.Context, id string, now time.Time) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[id]
return exists && session.RevokedAt == nil && now.Before(session.ExpiresAt), nil
}
func (s *MemoryStore) RevokeSession(_ context.Context, id string, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
session, exists := s.sessions[id]
if !exists {
return ErrNotFound
}
session.RevokedAt = &now
s.sessions[id] = session
return nil
}
func (s *MemoryStore) AppendAudit(_ context.Context, entry AuditEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.audit = append(s.audit, entry)
return nil
}
func (s *MemoryStore) ListAudit(_ context.Context, limit int) ([]AuditEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 200 {
limit = 100
}
start := len(s.audit) - limit
if start < 0 {
start = 0
}
entries := append([]AuditEntry(nil), s.audit[start:]...)
for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 {
entries[left], entries[right] = entries[right], entries[left]
}
return entries, nil
}
func newID(prefix string) string {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
panic("cryptographic random source unavailable")
}
return prefix + "_" + hex.EncodeToString(buffer)
}
+50
View File
@@ -0,0 +1,50 @@
package identity
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
tokenIssuer = "yovision-sense"
tokenAudience = "sense-ui"
)
type sessionClaims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func issueToken(key []byte, user User, session Session, now time.Time) (string, error) {
claims := sessionClaims{
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: tokenIssuer,
Subject: user.ID,
Audience: jwt.ClaimStrings{tokenAudience},
ExpiresAt: jwt.NewNumericDate(session.ExpiresAt),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)),
ID: session.ID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
}
func parseToken(key []byte, raw string, now time.Time) (*sessionClaims, error) {
claims := &sessionClaims{}
token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return key, nil
}, jwt.WithIssuer(tokenIssuer), jwt.WithAudience(tokenAudience), jwt.WithTimeFunc(func() time.Time { return now }))
if err != nil || !token.Valid {
return nil, fmt.Errorf("invalid Sense session")
}
return claims, nil
}
+59
View File
@@ -0,0 +1,59 @@
package identity
import "time"
const (
RoleAdministrator = "administrator"
RoleOperator = "operator"
RoleSiteAdmin = "site_admin"
RoleViewer = "viewer"
)
const (
PermissionUsersRead = "identity.users.read"
PermissionUsersWrite = "identity.users.write"
PermissionAuditRead = "identity.audit.read"
PermissionDeviceRead = "device.read"
PermissionDeviceWrite = "device.write"
PermissionMediaRead = "media.read"
PermissionMediaWrite = "media.write"
PermissionAreaRead = "area.read"
PermissionAreaWrite = "area.write"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct {
ID string
UserID string
ExpiresAt time.Time
RevokedAt *time.Time
}
type AuditEntry struct {
ID string `json:"id"`
ActorID string `json:"actor_id,omitempty"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Outcome string `json:"outcome"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Principal struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
SessionID string `json:"-"`
}
+66
View File
@@ -0,0 +1,66 @@
package liveview
import (
"html/template"
"net/http"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type Module struct{ service *Service }
func NewModule(service *Service) *Module { return &Module{service: service} }
func (m *Module) Register(app *platform.App) {
app.Handle("GET /api/v1/liveview/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.routes)))
app.Handle("POST /api/v1/liveview/sessions", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.create)))
app.Handle("GET /api/v1/liveview/sessions/{id}", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.get)))
app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.player)))
}
func (m *Module) routes(w http.ResponseWriter, r *http.Request) {
items, err := m.service.Routes(r.Context())
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
var req struct {
RouteID string `json:"route_id"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
principal, _ := identity.PrincipalFromContext(r.Context())
session, err := m.service.Create(r.Context(), principal.UserID, req.RouteID)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "liveview_unavailable", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusCreated, session)
}
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
principal, _ := identity.PrincipalFromContext(r.Context())
session, err := m.service.Get(r.Context(), principal.UserID, r.PathValue("id"))
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusGone, Code: "session_expired", Message: "播放会话已过期,请重新打开"})
return
}
platform.WriteJSON(w, http.StatusOK, session)
}
var playerTemplate = template.Must(template.New("player").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#111}</style></head><body><iframe src="{{.}}" allow="autoplay; fullscreen" title="Sense live video"></iframe></body></html>`))
func (m *Module) player(w http.ResponseWriter, r *http.Request) {
principal, _ := identity.PrincipalFromContext(r.Context())
target, err := m.service.EmbedURL(r.Context(), principal.UserID, r.PathValue("id"))
if err != nil {
http.Error(w, "播放会话已过期", http.StatusGone)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-src http: https:; style-src 'unsafe-inline'")
_ = playerTemplate.Execute(w, target)
}
+112
View File
@@ -0,0 +1,112 @@
package liveview
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"strings"
"sync"
"time"
"yovision.local/sense/app/sense/media"
)
type Session struct {
ID string `json:"id"`
OwnerID string `json:"-"`
RouteID string `json:"route_id"`
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
PlayerURL string `json:"player_url"`
Status string `json:"status"`
Detail string `json:"detail"`
ExpiresAt time.Time `json:"expires_at"`
}
type Service struct {
base *url.URL
ttl time.Duration
route func(context.Context, string) (media.Route, error)
routes func(context.Context) ([]media.Route, error)
mu sync.RWMutex
sessions map[string]Session
now func() time.Time
}
func NewService(rawBase string, ttl time.Duration) (*Service, error) {
parsed, err := url.Parse(strings.TrimRight(rawBase, "/"))
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
return nil, fmt.Errorf("invalid MediaMTX WebRTC base URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("unsupported MediaMTX WebRTC scheme")
}
if ttl <= 0 || ttl > 10*time.Minute {
ttl = 2 * time.Minute
}
return &Service{base: parsed, ttl: ttl, route: media.PlaybackRoute, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil
}
func (s *Service) Routes(ctx context.Context) ([]media.Route, error) {
items, err := s.routes(ctx)
if err != nil {
return nil, err
}
result := items[:0]
for _, item := range items {
if item.Desired == "running" {
result = append(result, item)
}
}
return result, nil
}
func (s *Service) Create(ctx context.Context, owner, routeID string) (Session, error) {
route, err := s.route(ctx, routeID)
if err != nil {
return Session{}, err
}
if route.Desired != "running" {
return Session{}, fmt.Errorf("media route is stopped")
}
id := newID()
session := Session{ID: id, OwnerID: owner, RouteID: route.ID, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)}
s.mu.Lock()
s.sessions[id] = session
s.mu.Unlock()
return session, nil
}
func (s *Service) Get(ctx context.Context, owner, id string) (Session, error) {
s.mu.RLock()
session, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || session.OwnerID != owner || !s.now().Before(session.ExpiresAt) {
return Session{}, fmt.Errorf("playback session expired")
}
route, err := s.route(ctx, session.RouteID)
if err != nil {
return Session{}, err
}
session.Status = route.Actual
session.Detail = route.Detail
return session, nil
}
func (s *Service) EmbedURL(ctx context.Context, owner, id string) (string, error) {
session, err := s.Get(ctx, owner, id)
if err != nil {
return "", err
}
route, err := s.route(ctx, session.RouteID)
if err != nil {
return "", err
}
target := *s.base
target.Path = strings.TrimRight(target.Path, "/") + "/" + route.Path
return target.String(), nil
}
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return "view_" + hex.EncodeToString(b)
}
@@ -0,0 +1,46 @@
package liveview
import (
"context"
"testing"
"time"
"yovision.local/sense/app/sense/media"
)
func TestRejectCredentialBaseURL(t *testing.T) {
if _, err := NewService("http://user:pass@127.0.0.1:8889", time.Minute); err == nil {
t.Fatal("credential base URL accepted")
}
}
func TestSessionIsOwnerBoundAndExpires(t *testing.T) {
service, err := NewService("http://127.0.0.1:8889", time.Minute)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
route := media.Route{ID: "device:main", DeviceID: "device", ProfileToken: "main", Path: "sense_device_main", Desired: "running", Actual: "ready"}
service.route = func(context.Context, string) (media.Route, error) { return route, nil }
session, err := service.Create(context.Background(), "owner-a", route.ID)
if err != nil {
t.Fatal(err)
}
if _, err := service.Get(context.Background(), "owner-b", session.ID); err == nil {
t.Fatal("other owner accessed session")
}
now = now.Add(2 * time.Minute)
if _, err := service.Get(context.Background(), "owner-a", session.ID); err == nil {
t.Fatal("expired session remained active")
}
}
func TestTTLIsBounded(t *testing.T) {
service, err := NewService("http://127.0.0.1:8889", time.Hour)
if err != nil {
t.Fatal(err)
}
if service.ttl != 2*time.Minute {
t.Fatalf("ttl=%v", service.ttl)
}
}
+65
View File
@@ -0,0 +1,65 @@
package media
import (
"net/http"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type Module struct{ service *Service }
func NewModule(service *Service) *Module { return &Module{service: service} }
func (m *Module) Register(app *platform.App) {
setActiveService(m.service)
app.Handle("GET /api/v1/media/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.list)))
app.Handle("POST /api/v1/media/routes", identity.Require(identity.PermissionMediaWrite, http.HandlerFunc(m.configure)))
app.Handle("POST /api/v1/media/routes/{id}/reconcile", identity.Require(identity.PermissionMediaWrite, http.HandlerFunc(m.reconcile)))
app.Handle("POST /api/v1/media/routes/{id}/stop", identity.Require(identity.PermissionMediaWrite, http.HandlerFunc(m.stop)))
app.Handle("GET /api/v1/media/process", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.process)))
}
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
items, err := m.service.List(r.Context())
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
func (m *Module) configure(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Configure(r.Context(), actor, req.DeviceID, req.ProfileToken)
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "media_invalid", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusCreated, item)
}
func (m *Module) reconcile(w http.ResponseWriter, r *http.Request) {
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Reconcile(r.Context(), actor, r.PathValue("id"))
if err != nil {
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "media_reconcile_failed", Message: err.Error()})
return
}
platform.WriteJSON(w, http.StatusOK, item)
}
func (m *Module) stop(w http.ResponseWriter, r *http.Request) {
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Stop(r.Context(), actor, r.PathValue("id"))
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, item)
}
func (m *Module) process(w http.ResponseWriter, _ *http.Request) {
platform.WriteJSON(w, http.StatusOK, m.service.ProcessState())
}
@@ -0,0 +1,3 @@
package media
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_media_routes(id TEXT PRIMARY KEY,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,path TEXT NOT NULL UNIQUE,desired TEXT NOT NULL,actual TEXT NOT NULL,readers INTEGER NOT NULL DEFAULT 0,detail TEXT NOT NULL DEFAULT '',version BIGINT NOT NULL,updated_at TIMESTAMPTZ NOT NULL);`
@@ -0,0 +1,25 @@
package media
import (
"context"
"fmt"
"sync/atomic"
)
var activeService atomic.Pointer[Service]
func setActiveService(service *Service) { activeService.Store(service) }
func PlaybackRoute(ctx context.Context, id string) (Route, error) {
service := activeService.Load()
if service == nil {
return Route{}, fmt.Errorf("media service is not ready")
}
return service.store.Get(ctx, id)
}
func PlaybackRoutes(ctx context.Context) ([]Route, error) {
service := activeService.Load()
if service == nil {
return nil, fmt.Errorf("media service is not ready")
}
return service.store.List(ctx)
}
+140
View File
@@ -0,0 +1,140 @@
package media
import (
"context"
"strings"
"time"
"yovision.local/sense/app/sense/adapters/mediamtx"
"yovision.local/sense/app/sense/admission"
"yovision.local/sense/app/sense/device"
"yovision.local/sense/app/sense/identity"
)
type Service struct {
store Store
process mediamtx.Process
controller mediamtx.Controller
profile func(string, string) (admission.Profile, error)
credential func(context.Context, string) (device.Credential, error)
now func() time.Time
}
func NewService(store Store, process mediamtx.Process, controller mediamtx.Controller) *Service {
return &Service{store: store, process: process, controller: controller, profile: admission.VerifiedProfile, credential: device.ReadCredential, now: time.Now}
}
func (s *Service) Configure(ctx context.Context, actor identity.Principal, deviceID, profileToken string) (Route, error) {
if _, err := s.profile(deviceID, profileToken); err != nil {
return Route{}, err
}
id := deviceID + ":" + profileToken
path := "sense_" + safe(deviceID) + "_" + safe(profileToken)
now := s.now().UTC()
route := Route{ID: id, DeviceID: deviceID, ProfileToken: profileToken, Path: path, Desired: "running", Actual: "pending", Detail: "等待状态对账", Version: 1, UpdatedAt: now}
if previous, err := s.store.Get(ctx, id); err == nil {
route.Version = previous.Version + 1
}
if err := s.store.Save(ctx, route); err != nil {
return Route{}, err
}
identity.RecordAudit(ctx, actor.UserID, "media.configure", id, "success", map[string]any{"path": path})
return route, nil
}
func (s *Service) Reconcile(ctx context.Context, actor identity.Principal, id string) (Route, error) {
route, err := s.store.Get(ctx, id)
if err != nil {
return Route{}, err
}
if route.Desired == "stopped" {
route.Actual = "stopped"
route.Detail = "已停止"
route.Readers = 0
route.Version++
route.UpdatedAt = s.now().UTC()
return route, s.store.Save(ctx, route)
}
if err := s.process.Start(ctx); err != nil {
route.Actual = "process_failed"
route.Detail = err.Error()
route.Version++
route.UpdatedAt = s.now().UTC()
_ = s.store.Save(ctx, route)
return route, nil
}
profile, err := s.profile(route.DeviceID, route.ProfileToken)
if err != nil {
return Route{}, err
}
credential, err := s.credential(ctx, route.DeviceID)
if err != nil {
return Route{}, err
}
source := mediamtx.Source{Path: route.Path, URI: profile.StreamURI, Username: credential.Username, Password: credential.Password}
if err := s.controller.Apply(ctx, source); err != nil {
route.Actual = "apply_failed"
route.Detail = err.Error()
route.Version++
route.UpdatedAt = s.now().UTC()
_ = s.store.Save(ctx, route)
return route, nil
}
status, err := s.controller.Status(ctx, route.Path)
if err != nil {
route.Actual = "unconverged"
route.Detail = "尚未取得媒体状态"
} else if status.Ready {
route.Actual = "ready"
route.Detail = "上游拉流正常"
route.Readers = status.Readers
} else {
route.Actual = "waiting"
route.Detail = "等待上游拉流"
route.Readers = status.Readers
}
route.Version++
route.UpdatedAt = s.now().UTC()
if err := s.store.Save(ctx, route); err != nil {
return Route{}, err
}
identity.RecordAudit(ctx, actor.UserID, "media.reconcile", id, "success", map[string]any{"actual": route.Actual})
return route, nil
}
func (s *Service) Stop(ctx context.Context, actor identity.Principal, id string) (Route, error) {
route, err := s.store.Get(ctx, id)
if err != nil {
return Route{}, err
}
route.Desired = "stopped"
route.Actual = "stopped"
route.Readers = 0
route.Detail = "已停止"
route.Version++
route.UpdatedAt = s.now().UTC()
if err := s.store.Save(ctx, route); err != nil {
return Route{}, err
}
items, _ := s.store.List(ctx)
allStopped := true
for _, item := range items {
if item.ID != id && item.Desired == "running" {
allStopped = false
}
}
if allStopped {
stopCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = s.process.Stop(stopCtx)
}
identity.RecordAudit(ctx, actor.UserID, "media.stop", id, "success", nil)
return route, nil
}
func (s *Service) List(ctx context.Context) ([]Route, error) { return s.store.List(ctx) }
func (s *Service) ProcessState() mediamtx.ProcessState { return s.process.State() }
func safe(value string) string {
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return '_'
}, value)
}
@@ -0,0 +1,110 @@
package media
import (
"context"
"errors"
"testing"
"yovision.local/sense/app/sense/adapters/mediamtx"
"yovision.local/sense/app/sense/admission"
"yovision.local/sense/app/sense/device"
"yovision.local/sense/app/sense/identity"
)
type fakeProcess struct {
state mediamtx.ProcessState
startErr error
stopped bool
}
func (p *fakeProcess) Start(context.Context) error {
if p.startErr != nil {
return p.startErr
}
p.state.Running = true
p.state.PID = 123
return nil
}
func (p *fakeProcess) Stop(context.Context) error {
p.stopped = true
p.state.Running = false
p.state.PID = 0
return nil
}
func (p *fakeProcess) State() mediamtx.ProcessState { return p.state }
type fakeController struct {
applyErr error
status mediamtx.PathStatus
}
func (f fakeController) Apply(context.Context, mediamtx.Source) error { return f.applyErr }
func (f fakeController) Status(context.Context, string) (mediamtx.PathStatus, error) {
if f.status.Name == "error" {
return mediamtx.PathStatus{}, errors.New("timeout")
}
if !f.status.Ready {
return mediamtx.PathStatus{Ready: true, Readers: 2}, nil
}
return f.status, nil
}
func TestStoreAndProcessBoundary(t *testing.T) {
store := NewMemoryStore()
process := &fakeProcess{}
service := NewService(store, process, fakeController{})
route := Route{ID: "dev:main", DeviceID: "dev", ProfileToken: "main", Path: "sense_dev_main", Desired: "running", Actual: "pending", Version: 1}
if err := store.Save(context.Background(), route); err != nil {
t.Fatal(err)
}
items, err := service.List(context.Background())
if err != nil || len(items) != 1 {
t.Fatalf("items=%#v err=%v", items, err)
}
if process.State().Running {
t.Fatal("process started without explicit reconcile")
}
}
func preparedService(t *testing.T, process *fakeProcess, controller fakeController) (*Service, Route) {
t.Helper()
service := NewService(NewMemoryStore(), process, controller)
service.profile = func(string, string) (admission.Profile, error) {
return admission.Profile{StreamURI: "rtsp://camera.invalid/main"}, nil
}
service.credential = func(context.Context, string) (device.Credential, error) {
return device.Credential{Username: "fixture", Password: "fixture"}, nil
}
route, err := service.Configure(context.Background(), identity.Principal{}, "device", "main")
if err != nil {
t.Fatal(err)
}
return service, route
}
func TestReconcileReadyAndStopOwnedProcess(t *testing.T) {
process := &fakeProcess{}
service, route := preparedService(t, process, fakeController{status: mediamtx.PathStatus{Ready: true, Readers: 2}})
result, err := service.Reconcile(context.Background(), identity.Principal{}, route.ID)
if err != nil || result.Actual != "ready" || result.Readers != 2 {
t.Fatalf("result=%#v err=%v", result, err)
}
if _, err := service.Stop(context.Background(), identity.Principal{}, route.ID); err != nil || !process.stopped {
t.Fatalf("stop err=%v stopped=%v", err, process.stopped)
}
}
func TestReconcileRecordsFailureStates(t *testing.T) {
process := &fakeProcess{startErr: errors.New("restart limit reached")}
service, route := preparedService(t, process, fakeController{})
result, err := service.Reconcile(context.Background(), identity.Principal{}, route.ID)
if err != nil || result.Actual != "process_failed" {
t.Fatalf("result=%#v err=%v", result, err)
}
process.startErr = nil
service.controller = fakeController{applyErr: errors.New("control timeout")}
result, err = service.Reconcile(context.Background(), identity.Principal{}, route.ID)
if err != nil || result.Actual != "apply_failed" {
t.Fatalf("result=%#v err=%v", result, err)
}
}
+78
View File
@@ -0,0 +1,78 @@
package media
import (
"context"
"database/sql"
"errors"
"sync"
)
var ErrNotFound = errors.New("media route not found")
type Store interface {
Save(context.Context, Route) error
Get(context.Context, string) (Route, error)
List(context.Context) ([]Route, error)
}
type MemoryStore struct {
mu sync.RWMutex
items map[string]Route
}
func NewMemoryStore() *MemoryStore { return &MemoryStore{items: map[string]Route{}} }
func (s *MemoryStore) Save(_ context.Context, item Route) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items[item.ID] = item
return nil
}
func (s *MemoryStore) Get(_ context.Context, id string) (Route, error) {
s.mu.RLock()
defer s.mu.RUnlock()
item, ok := s.items[id]
if !ok {
return Route{}, ErrNotFound
}
return item, nil
}
func (s *MemoryStore) List(_ context.Context) ([]Route, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]Route, 0, len(s.items))
for _, item := range s.items {
items = append(items, item)
}
return items, nil
}
type PostgresStore struct{ database *sql.DB }
func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} }
func (s *PostgresStore) Save(ctx context.Context, item Route) error {
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_media_routes(id,device_id,profile_token,path,desired,actual,readers,detail,version,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(id) DO UPDATE SET desired=EXCLUDED.desired,actual=EXCLUDED.actual,readers=EXCLUDED.readers,detail=EXCLUDED.detail,version=EXCLUDED.version,updated_at=EXCLUDED.updated_at`, item.ID, item.DeviceID, item.ProfileToken, item.Path, item.Desired, item.Actual, item.Readers, item.Detail, item.Version, item.UpdatedAt)
return err
}
func (s *PostgresStore) Get(ctx context.Context, id string) (Route, error) {
var item Route
err := s.database.QueryRowContext(ctx, `SELECT id,device_id,profile_token,path,desired,actual,readers,detail,version,updated_at FROM sense_media_routes WHERE id=$1`, id).Scan(&item.ID, &item.DeviceID, &item.ProfileToken, &item.Path, &item.Desired, &item.Actual, &item.Readers, &item.Detail, &item.Version, &item.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Route{}, ErrNotFound
}
return item, err
}
func (s *PostgresStore) List(ctx context.Context) ([]Route, error) {
rows, err := s.database.QueryContext(ctx, `SELECT id,device_id,profile_token,path,desired,actual,readers,detail,version,updated_at FROM sense_media_routes ORDER BY updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Route
for rows.Next() {
var item Route
if err := rows.Scan(&item.ID, &item.DeviceID, &item.ProfileToken, &item.Path, &item.Desired, &item.Actual, &item.Readers, &item.Detail, &item.Version, &item.UpdatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
+16
View File
@@ -0,0 +1,16 @@
package media
import "time"
type Route struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
Path string `json:"path"`
Desired string `json:"desired"`
Actual string `json:"actual"`
Readers int `json:"readers"`
Detail string `json:"detail"`
Version int64 `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,18 @@
package sense
import (
"os"
"time"
"yovision.local/sense/app/sense/adapters/onvif"
"yovision.local/sense/app/sense/adapters/rtsp"
"yovision.local/sense/app/sense/admission"
"yovision.local/sense/internal/platform"
)
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"))
admission.NewModule(service).Register(app)
return nil
})
}
+20
View File
@@ -0,0 +1,20 @@
package sense
import (
"yovision.local/sense/app/sense/area"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
var store area.Store
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
store = area.NewMemoryStore()
} else {
store = area.NewPostgresStore(app.Database())
}
app.RegisterMigration(platform.Migration{Version: 2026081204, Name: "sense_area", SQL: area.MigrationSQL})
area.NewModule(area.NewService(store)).Register(app)
return nil
})
}
+26
View File
@@ -0,0 +1,26 @@
package sense
import (
"os"
"yovision.local/sense/app/sense/device"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
memory := app.Config().DatabaseMode == platform.DatabaseModeMemory
vault, err := device.NewCredentialVault(os.Getenv("SENSE_CREDENTIAL_KEY"), memory)
if err != nil {
return err
}
var store device.Store
if memory {
store = device.NewMemoryStore()
} else {
store = device.NewPostgresStore(app.Database())
}
app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL})
device.NewModule(device.NewService(store, vault)).Register(app)
return nil
})
}
@@ -0,0 +1,42 @@
package sense
import (
"fmt"
"os"
"time"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
memoryMode := app.Config().DatabaseMode == platform.DatabaseModeMemory
key, err := identity.SigningKeyOrRandom(os.Getenv("SENSE_IDENTITY_SIGNING_KEY"), memoryMode)
if err != nil {
return err
}
cfg := identity.Config{
SigningKey: key,
BootstrapToken: os.Getenv("SENSE_BOOTSTRAP_TOKEN"),
SecureCookie: identity.SecureCookieFromEnvironment(os.Getenv("SENSE_COOKIE_SECURE"), memoryMode),
SessionTTL: 8 * time.Hour,
}
var store identity.Store
if memoryMode {
store = identity.NewMemoryStore()
} else {
if cfg.BootstrapToken == "" {
return fmt.Errorf("SENSE_BOOTSTRAP_TOKEN is required until secure initialization is completed")
}
store = identity.NewPostgresStore(app.Database())
}
service, err := identity.NewService(store, cfg)
if err != nil {
return err
}
app.RegisterMigration(platform.Migration{Version: 2026081201, Name: "sense_identity", SQL: identity.MigrationSQL})
identity.NewModule(service, cfg).Register(app)
return nil
})
}
@@ -0,0 +1,18 @@
package sense
import (
"time"
"yovision.local/sense/app/sense/liveview"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
service, err := liveview.NewService(valueOr("SENSE_MEDIAMTX_WEBRTC_BASE", "http://127.0.0.1:8889"), 2*time.Minute)
if err != nil {
return err
}
liveview.NewModule(service).Register(app)
return nil
})
}
+30
View File
@@ -0,0 +1,30 @@
package sense
import (
"os"
"yovision.local/sense/app/sense/adapters/mediamtx"
"yovision.local/sense/app/sense/media"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
var store media.Store
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
store = media.NewMemoryStore()
} else {
store = media.NewPostgresStore(app.Database())
}
process := mediamtx.NewSupervisor(os.Getenv("SENSE_MEDIAMTX_BINARY"), os.Getenv("SENSE_MEDIAMTX_CONFIG"), 3)
controller := mediamtx.NewHTTPController(valueOr("SENSE_MEDIAMTX_API", "http://127.0.0.1:9997"))
app.RegisterMigration(platform.Migration{Version: 2026081203, Name: "sense_media", SQL: media.MigrationSQL})
media.NewModule(media.NewService(store, process, controller)).Register(app)
return nil
})
}
func valueOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+79
View File
@@ -0,0 +1,79 @@
package sense
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"yovision.local/sense/internal/platform"
)
type moduleRegistrar func(*platform.App) error
var moduleRegistrars []moduleRegistrar
// registerModule is intentionally package-local. Feature integration files in
// this directory append one registrar without changing the shared bootstrap.
func registerModule(registrar moduleRegistrar) {
moduleRegistrars = append(moduleRegistrars, registrar)
}
func Run() error {
cfg, err := platform.LoadConfig()
if err != nil {
return err
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel()}))
database, err := platform.OpenDatabase(context.Background(), cfg)
if err != nil {
return err
}
if database != nil {
defer database.Close()
}
app := platform.NewApp(cfg, database, logger)
for _, registrar := range moduleRegistrars {
if err := registrar(app); err != nil {
return fmt.Errorf("register Sense module: %w", err)
}
}
if err := app.ApplyMigrations(context.Background()); err != nil {
return err
}
server := &http.Server{
Addr: cfg.HTTPAddress,
Handler: app.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("Sense server started", "address", cfg.HTTPAddress, "database_mode", cfg.DatabaseMode)
errCh <- server.ListenAndServe()
}()
stopCh := make(chan os.Signal, 1)
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-stopCh:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(ctx)
case serveErr := <-errCh:
if serveErr == http.ErrServerClosed {
return nil
}
return serveErr
}
}
+5
View File
@@ -0,0 +1,5 @@
package config
// Package config exists as the stable public configuration boundary for Sense.
// Runtime parsing is implemented in internal/platform so feature packages do
// not read process environment variables directly.
@@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));
CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);
@@ -0,0 +1,3 @@
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);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS sense_identity_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES sense_identity_users(id),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NULL
);
CREATE TABLE IF NOT EXISTS sense_identity_audit (
id TEXT PRIMARY KEY,
actor_id TEXT NULL REFERENCES sense_identity_users(id),
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sense_identity_audit_created_idx ON sense_identity_audit(created_at DESC);
@@ -0,0 +1,2 @@
CREATE TABLE IF NOT EXISTS sense_media_routes(id TEXT PRIMARY KEY,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,path TEXT NOT NULL UNIQUE,desired TEXT NOT NULL,actual TEXT NOT NULL,readers INTEGER NOT NULL DEFAULT 0,detail TEXT NOT NULL DEFAULT '',version BIGINT NOT NULL,updated_at TIMESTAMPTZ NOT NULL);
+17
View File
@@ -0,0 +1,17 @@
module yovision.local/sense
go 1.26.5
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.8.0
golang.org/x/crypto v0.55.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+103
View File
@@ -0,0 +1,103 @@
package platform
import (
"database/sql"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type App struct {
config Config
database *sql.DB
logger *slog.Logger
mux *http.ServeMux
migrations []Migration
}
func NewApp(cfg Config, database *sql.DB, logger *slog.Logger) *App {
app := &App{config: cfg, database: database, logger: logger, mux: http.NewServeMux()}
app.registerPlatformRoutes()
return app
}
func (a *App) Config() Config { return a.config }
func (a *App) Database() *sql.DB { return a.database }
func (a *App) Logger() *slog.Logger { return a.logger }
func (a *App) Handle(pattern string, handler http.Handler) {
a.mux.Handle(pattern, handler)
}
func (a *App) HandleFunc(pattern string, handler http.HandlerFunc) {
a.mux.HandleFunc(pattern, handler)
}
func (a *App) Handler() http.Handler {
return requestSecurityHeaders(a.mux)
}
func (a *App) registerPlatformRoutes() {
a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "product": "sense"})
})
a.mux.HandleFunc("GET /readyz", a.ready)
a.mux.HandleFunc("GET /api/v1/system/info", func(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"product": "Sense",
"database": a.config.DatabaseMode,
"modules": []string{},
})
})
// Deliberately do not register upstream demo/code-generation routes.
if a.config.StaticDir != "" {
a.registerStaticFiles(a.config.StaticDir)
}
}
func (a *App) ready(w http.ResponseWriter, r *http.Request) {
if a.database == nil {
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "memory"})
return
}
ctx, cancel := timeLimitedContext(r, 2*time.Second)
defer cancel()
if err := a.database.PingContext(ctx); err != nil {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready", "database": "unavailable"})
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "postgres"})
}
func (a *App) registerStaticFiles(directory string) {
absolute, err := filepath.Abs(directory)
if err != nil {
a.logger.Warn("invalid UI static directory", "error", err)
return
}
fileSystem := os.DirFS(absolute)
a.mux.Handle("GET /assets/", http.StripPrefix("/", http.FileServer(http.FS(fileSystem))))
a.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(r.URL.Path)), "/")
if path == "." || path == "" {
path = "index.html"
}
if _, err := fs.Stat(fileSystem, path); err != nil {
path = "index.html"
}
http.ServeFileFS(w, r, fileSystem, path)
})
}
func requestSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,31 @@
package platform
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestPlatformRoutesAndDisabledDemo(t *testing.T) {
app := NewApp(Config{DatabaseMode: DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
for _, test := range []struct {
path string
want int
}{
{path: "/healthz", want: http.StatusOK},
{path: "/readyz", want: http.StatusOK},
{path: "/api/v1/system/info", want: http.StatusOK},
{path: "/api/v1/demo", want: http.StatusNotFound},
{path: "/api/v1/system/tools", want: http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
res := httptest.NewRecorder()
app.Handler().ServeHTTP(res, req)
if res.Code != test.want {
t.Errorf("GET %s status = %d, want %d", test.path, res.Code, test.want)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package platform
import (
"fmt"
"log/slog"
"os"
"strings"
)
const (
DatabaseModePostgres = "postgres"
DatabaseModeMemory = "memory"
)
type Config struct {
HTTPAddress string
DatabaseMode string
DatabaseURL string
StaticDir string
LogLevelName string
}
func LoadConfig() (Config, error) {
cfg := Config{
HTTPAddress: valueOrDefault("SENSE_HTTP_ADDRESS", "127.0.0.1:18080"),
DatabaseMode: strings.ToLower(valueOrDefault("SENSE_DATABASE_MODE", DatabaseModePostgres)),
DatabaseURL: strings.TrimSpace(os.Getenv("SENSE_DATABASE_URL")),
StaticDir: strings.TrimSpace(os.Getenv("SENSE_UI_STATIC_DIR")),
LogLevelName: strings.ToLower(valueOrDefault("SENSE_LOG_LEVEL", "info")),
}
switch cfg.DatabaseMode {
case DatabaseModePostgres:
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("SENSE_DATABASE_URL is required when SENSE_DATABASE_MODE=postgres")
}
case DatabaseModeMemory:
// Explicitly limited to local smoke tests and automated tests.
default:
return Config{}, fmt.Errorf("unsupported SENSE_DATABASE_MODE %q", cfg.DatabaseMode)
}
return cfg, nil
}
func (c Config) LogLevel() slog.Level {
switch c.LogLevelName {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func valueOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
@@ -0,0 +1,23 @@
package platform
import "testing"
func TestLoadConfigRequiresPostgresURL(t *testing.T) {
t.Setenv("SENSE_DATABASE_MODE", "postgres")
t.Setenv("SENSE_DATABASE_URL", "")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected missing PostgreSQL URL to fail")
}
}
func TestLoadConfigAllowsExplicitMemoryMode(t *testing.T) {
t.Setenv("SENSE_DATABASE_MODE", "memory")
t.Setenv("SENSE_DATABASE_URL", "")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if cfg.DatabaseMode != DatabaseModeMemory {
t.Fatalf("DatabaseMode = %q", cfg.DatabaseMode)
}
}
+11
View File
@@ -0,0 +1,11 @@
package platform
import (
"context"
"net/http"
"time"
)
func timeLimitedContext(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), timeout)
}
@@ -0,0 +1,31 @@
package platform
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func OpenDatabase(ctx context.Context, cfg Config) (*sql.DB, error) {
if cfg.DatabaseMode == DatabaseModeMemory {
return nil, nil
}
database, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open Sense PostgreSQL: %w", err)
}
database.SetMaxOpenConns(20)
database.SetMaxIdleConns(5)
database.SetConnMaxLifetime(30 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := database.PingContext(pingCtx); err != nil {
database.Close()
return nil, fmt.Errorf("connect Sense PostgreSQL: %w", err)
}
return database, nil
}
+43
View File
@@ -0,0 +1,43 @@
package platform
import (
"encoding/json"
"errors"
"io"
"net/http"
)
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
func WriteJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func WriteError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
WriteJSON(w, apiErr.Status, map[string]any{"error": map[string]string{"code": apiErr.Code, "message": apiErr.Message}})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "internal_error", "message": "服务暂时不可用"}})
}
func DecodeJSON(r *http.Request, target any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, (1<<20)+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
}
return nil
}
@@ -0,0 +1,63 @@
package platform
import (
"context"
"database/sql"
"fmt"
"sort"
)
type Migration struct {
Version int64
Name string
SQL string
}
func (a *App) RegisterMigration(migration Migration) {
a.migrations = append(a.migrations, migration)
}
func (a *App) ApplyMigrations(ctx context.Context) error {
if a.database == nil {
return nil
}
if _, err := a.database.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS sense_schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create Sense migration table: %w", err)
}
sort.Slice(a.migrations, func(i, j int) bool { return a.migrations[i].Version < a.migrations[j].Version })
for _, migration := range a.migrations {
if err := applyMigration(ctx, a.database, migration); err != nil {
return err
}
}
return nil
}
func applyMigration(ctx context.Context, database *sql.DB, migration Migration) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", migration.Version, err)
}
defer tx.Rollback()
var exists bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_schema_migrations WHERE version = $1)`, migration.Version).Scan(&exists); err != nil {
return fmt.Errorf("check migration %d: %w", migration.Version, err)
}
if exists {
return tx.Commit()
}
if _, err := tx.ExecContext(ctx, migration.SQL); err != nil {
return fmt.Errorf("apply migration %d (%s): %w", migration.Version, migration.Name, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_schema_migrations(version, name) VALUES($1, $2)`, migration.Version, migration.Name); err != nil {
return fmt.Errorf("record migration %d: %w", migration.Version, err)
}
return tx.Commit()
}
+13
View File
@@ -0,0 +1,13 @@
package main
import (
"log"
sensecmd "yovision.local/sense/cmd/sense"
)
func main() {
if err := sensecmd.Run(); err != nil {
log.Fatal(err)
}
}
+1
View File
@@ -0,0 +1 @@
{"kind":"polygon","points":[{"x":0.1,"y":0.1},{"x":0.9,"y":0.1},{"x":0.5,"y":0.8}]}
+1
View File
@@ -0,0 +1 @@
{"route_id":"fixture-device:main","status":"ready","expires_in_seconds":120}
+1
View File
@@ -0,0 +1 @@
{"name":"sense_fixture_main","ready":true,"bytesReceived":4096,"readers":[{"type":"webrtc"}]}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfilesResponse xmlns="http://www.onvif.org/ver10/media/wsdl"><Profiles token="main"><Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles><Profiles token="sub"><Name>子码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>640</Width><Height>360</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></s:Body></s:Envelope>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUriResponse xmlns="http://www.onvif.org/ver10/media/wsdl"><MediaUri><Uri>rtsp://camera.invalid/live/main</Uri></MediaUri></GetStreamUriResponse></s:Body></s:Envelope>
+6
View File
@@ -0,0 +1,6 @@
# Admission acceptance
Fixture tests cover ONVIF profile parsing, credential-free StreamUri handling,
main/sub classification and RTSP result mapping. Controlled WS-Discovery is
disabled until `SENSE_ONVIF_DISCOVERY_IP` names an approved local interface.
No discovery scan or real camera access runs during automated tests.
+7
View File
@@ -0,0 +1,7 @@
# Area acceptance
Tests cover normalized coordinate bounds, minimum points, polygon self-
intersection and degeneration, directional line validation, immutable version
increments, optimistic conflicts and explicit recalibration after a Profile
resolution change. No customer image or production geometry is included.
+6
View File
@@ -0,0 +1,6 @@
# Device acceptance
Package tests verify CRUD state, pagination beyond 16 items, unsupported
adapter status, optimistic versions and that encrypted camera credentials are
write-only. Fixtures use generated values only; no address or real credential
is stored in the repository.
+10
View File
@@ -0,0 +1,10 @@
# Identity acceptance boundary
Automated identity tests live with the Go package at
`Sense/server/app/sense/identity`. They use generated, non-production values
and never contain a real password, token, cookie or signing key.
The acceptance checks cover bootstrap uniqueness, password hashing, login,
role permissions, product-specific token validation, session revocation and
audit records. PostgreSQL migration execution remains a deployment-environment
integration check.
+7
View File
@@ -0,0 +1,7 @@
# Live-view acceptance
Sense creates owner-bound, two-minute playback sessions only for configured
media routes. Camera credentials and long-lived tokens are never placed in the
page URL. Automated tests cover URL validation and bounded TTL; a real browser,
MediaMTX WebRTC page and laboratory stream remain target-environment checks.
+6
View File
@@ -0,0 +1,6 @@
# Media acceptance
Tests keep MediaMTX behind process and control interfaces. They verify that
the credential-bearing MediaMTX control request is localhost-only and transient,
never returned or persisted by Sense, and no process starts before an explicit reconcile. Real MediaMTX binary,
camera pull, reader counts and crash recovery require the deployment lab.
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.eslintcache
+53
View File
@@ -0,0 +1,53 @@
{
"name": "yovision-sense-ui",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.15.1",
"engines": {
"node": "22.22.1",
"pnpm": "9.15.1"
},
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "eslint \"src/**/*.{js,vue}\""
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"axios": "1.13.6",
"element-plus": "2.14.4",
"vue": "3.5.41",
"vue-router": "4.6.4",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/eslint-parser": "7.28.6",
"@vue/cli-plugin-babel": "5.0.9",
"@vue/cli-service": "5.0.9",
"eslint": "8.57.1",
"eslint-plugin-vue": "9.33.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser",
"requireConfigFile": false
},
"rules": {
"vue/multi-word-component-names": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
+8091
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
import request from '../../../bootstrap/request'
export const discoverDevices = () => request.get('/admission/discover')
export const probeDevice = (data) => request.post('/admission/probe', data)
export const admissionResult = (deviceID) => request.get(`/admission/devices/${deviceID}`)

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