Compare commits

..
174 changed files with 1612 additions and 15343 deletions
-20
View File
@@ -180,26 +180,6 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- 16 路是默认交付配额,不得成为数据库、数组、循环、分页、批处理或单机容量的硬上限。
- 开始子项目工作前还必须读取对应目录的 `AGENTS.md`;共享契约工作读取 `contracts/AGENTS.md`。
### go-admin / go-admin-ui 精简与复用
- Sense、Bell 使用 go-admin 和 go-admin-ui 时遵循“最小可见、最小启用”:只呈现当前产品需要的菜单、路由、权限和接口,不把框架演示或无关管理能力暴露给用户。
- 快速交付阶段优先使用配置、菜单权限或路由开关隐藏不用模块。隐藏只移除用户入口,不等于禁用;权限或安全相关能力必须同时关闭前端入口与后端访问能力。
- 永久删除默认模块必须单独建立清理工单,先核对代码依赖、数据库对象、权限记录、构建影响、框架升级影响和回退方式,不在业务页面工单中顺手删除。
- 新页面优先复用现有布局、表格、表单、弹窗、上传、权限控制和请求封装;现有组件无法满足明确需求时才新增组件,并在工单中说明复用缺口。
- 新组件必须沿用项目现有的颜色、字号、间距、状态反馈、交互方式、命名和目录结构;不得引入与整体风格冲突的独立视觉体系。
- 不为单个页面直接改变底层公共组件的全局行为。确需全局修改或同时影响 Sense、Bell 时,必须单独建单并明确受影响页面、兼容方式和回归验证;跨产品影响按协同工单处理。
- UI 验收至少检查:没有多余入口、没有可见但不可用的失效入口、没有重复实现已有组件,并且视觉与交互保持一致。
### Sense/Bell GoAdmin 技术基线
- `goadmin-baseline.json` 是 Sense、Bell 共用的 GoAdmin 上游与工具链版本事实来源;上游 URL 加完整 commit 是可复现标识,tag、`package.json` version、本机目录内容或本机已安装版本都不能替代 commit。
- `D:\github\goadmin\go-admin`、`go-admin-ui`、`go-admin-doc` 是当前机器的只读参考副本。Agent 可以读取和验证,不得在这些目录开发、修改或提交 yovision 产品代码。
- Sense、Bell 必须从同一冻结基线分别初始化,保留独立代码、配置、构建、测试、版本和发布;不得通过共同修改参考仓库形成隐藏的共享实现。
- 复制或派生上游代码时必须保留对应 MIT 许可证和版权声明,并记录实际复制来源的 commit。
- `go-admin-doc` 只用于理解上游框架用法;yovision 的 Gitea Wiki、`AGENTS.md` 和 `goadmin-baseline.json` 仍是本项目事实来源。
- 变更任一上游 commit、Go、Node 或 pnpm 版本必须建立 Sense/Bell 协同升级工单,验证两个产品后再更新基线;不得在单端业务工单中静默升级。
- 当前冻结后端要求 Go 1.26.5;本机 Go 1.23.0 不满足要求,安装隔离的正确工具链并完成构建验证前,不得声称后端基线可构建。
### 三项目并行建单顺序
客户交付需要 Sense、Brain、Bell 并行推进时,固定使用以下顺序:
-15
View File
@@ -14,18 +14,3 @@
- 每个工单必须写明任务类型、主项目 `Bell`、主 agent、精确 `write_paths`、独立运行方式、测试命令和在 Sense/Brain 未启动时使用合成或第三方事件的验收标准。
- 首轮并行建单只创建 Bell 独立工单;发现需要外部事件/证据契约、producer identity、Sense connector、根级部署或端到端测试时,停止扩写本工单,向主 agent 提交协同需求摘要。
- 协同需求摘要至少包含生产者、消费者、接口目的、候选事实源、阻塞的 Bell 工单和建议验证;Bell agent 不直接创建契约实现或取得 `contracts/`、`Sense/`、`Brain/` 的写入权。
## go-admin / go-admin-ui 约束
- Bell 管理面只启用事件预警、告警处置和通知运维所需的默认能力;框架演示页和无关管理模块优先通过配置、菜单权限或路由开关隐藏。
- 涉及权限或安全的不用模块不能只隐藏菜单,必须同时验证前端入口和后端接口均不可访问;永久删除默认模块必须单独建立清理工单。
- 新页面优先复用 Bell/go-admin-ui 已有的布局、表格、表单、弹窗、上传、权限控制和请求封装;新增组件前在工单中说明现有组件为何不能满足需求。
- 新增组件沿用 Bell 现有颜色、字号、间距、状态反馈、交互方式、命名和目录结构,并验证没有多余入口、失效入口或重复组件。
- 修改底层公共组件的全局行为时,必须单独建单并列出所有受影响页面;同时影响 Sense 或共享基线时升级为协同工单。
## GoAdmin 技术基线
- 初始化或升级 Bell 前必须读取根 `goadmin-baseline.json`,按其中的 go-admin、go-admin-ui commit 和 Go/Node/pnpm 精确版本实施。
- `D:\github\goadmin` 下的仓库仅可只读参考;Bell 产品代码只能写入 `Bell/` 或工单授权路径,不得在参考仓库原地开发。
- 派生上游代码必须保留 MIT 许可证和版权声明;上游 `go-admin-doc` 只作框架用法参考,不覆盖 yovision Wiki 和本目录规则。
- 变更冻结基线必须升级为 Sense/Bell 协同工单;Bell 可以独立发布,但不得单独漂移上游或工具链版本。
-15
View File
@@ -14,18 +14,3 @@
- 每个工单必须写明任务类型、主项目 `Sense`、主 agent、精确 `write_paths`、独立运行方式、测试命令和在 Bell/Brain 未启动时的验收标准。
- 首轮并行建单只创建 Sense 独立工单;发现需要 Sense→Brain 源/配置契约、Sense/Brain→Bell 事件/证据契约、根级部署或端到端测试时,停止扩写本工单,向主 agent 提交协同需求摘要。
- 协同需求摘要至少包含生产者、消费者、接口目的、候选事实源、阻塞的 Sense 工单和建议验证;Sense agent 不直接创建契约实现或取得 `contracts/`、`Brain/`、`Bell/` 的写入权。
## go-admin / go-admin-ui 约束
- Sense 管理面只启用智能 NVR、视频感知和本地运维所需的默认能力;框架演示页和无关管理模块优先通过配置、菜单权限或路由开关隐藏。
- 涉及权限或安全的不用模块不能只隐藏菜单,必须同时验证前端入口和后端接口均不可访问;永久删除默认模块必须单独建立清理工单。
- 新页面优先复用 Sense/go-admin-ui 已有的布局、表格、表单、弹窗、上传、权限控制和请求封装;新增组件前在工单中说明现有组件为何不能满足需求。
- 新增组件沿用 Sense 现有颜色、字号、间距、状态反馈、交互方式、命名和目录结构,并验证没有多余入口、失效入口或重复组件。
- 修改底层公共组件的全局行为时,必须单独建单并列出所有受影响页面;同时影响 Bell 或共享基线时升级为协同工单。
## GoAdmin 技术基线
- 初始化或升级 Sense 前必须读取根 `goadmin-baseline.json`,按其中的 go-admin、go-admin-ui commit 和 Go/Node/pnpm 精确版本实施。
- `D:\github\goadmin` 下的仓库仅可只读参考;Sense 产品代码只能写入 `Sense/` 或工单授权路径,不得在参考仓库原地开发。
- 派生上游代码必须保留 MIT 许可证和版权声明;上游 `go-admin-doc` 只作框架用法参考,不覆盖 yovision Wiki 和本目录规则。
- 变更冻结基线必须升级为 Sense/Bell 协同工单;Sense 可以独立发布,但不得单独漂移上游或工具链版本。
-15
View File
@@ -1,15 +0,0 @@
# 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
@@ -1,22 +0,0 @@
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
@@ -1,22 +0,0 @@
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.
-55
View File
@@ -1,55 +0,0 @@
# 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.
-7
View File
@@ -1,7 +0,0 @@
# 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=
@@ -1,20 +0,0 @@
$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
@@ -1,38 +0,0 @@
$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()
}
@@ -1,92 +0,0 @@
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
}
@@ -1,37 +0,0 @@
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")
}
}
@@ -1,113 +0,0 @@
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 }
@@ -1,109 +0,0 @@
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()
}
@@ -1,80 +0,0 @@
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
}
@@ -1,56 +0,0 @@
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
}
}
}
@@ -1,31 +0,0 @@
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")
}
}
@@ -1,74 +0,0 @@
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
@@ -1,50 +0,0 @@
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)
}
@@ -1,24 +0,0 @@
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
@@ -1,119 +0,0 @@
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", "无法读取设备信息,请检查地址和网络"
}
}
@@ -1,46 +0,0 @@
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)
}
}
@@ -1,19 +0,0 @@
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
@@ -1,77 +0,0 @@
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) }
@@ -1,19 +0,0 @@
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
@@ -1,89 +0,0 @@
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
@@ -1,3 +0,0 @@
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
@@ -1,117 +0,0 @@
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)
}
@@ -1,36 +0,0 @@
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
@@ -1,120 +0,0 @@
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
@@ -1,32 +0,0 @@
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"`
}
@@ -1,32 +0,0 @@
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
@@ -1,80 +0,0 @@
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
@@ -1,124 +0,0 @@
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()})
}
@@ -1,3 +0,0 @@
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
@@ -1,126 +0,0 @@
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)
}
@@ -1,58 +0,0 @@
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
@@ -1,160 +0,0 @@
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
@@ -1,41 +0,0 @@
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"`
}
@@ -1,13 +0,0 @@
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
@@ -1,222 +0,0 @@
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")
}
@@ -1,59 +0,0 @@
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)
}
}
@@ -1,30 +0,0 @@
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);
`
@@ -1,37 +0,0 @@
package identity
import (
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
func ValidatePassword(username, password string) error {
if len([]rune(password)) < 12 {
return fmt.Errorf("密码至少需要 12 个字符")
}
var upper, lower, digit bool
for _, value := range password {
upper = upper || unicode.IsUpper(value)
lower = lower || unicode.IsLower(value)
digit = digit || unicode.IsDigit(value)
}
if !upper || !lower || !digit {
return fmt.Errorf("密码必须同时包含大写字母、小写字母和数字")
}
if username != "" && strings.Contains(strings.ToLower(password), strings.ToLower(username)) {
return fmt.Errorf("密码不能包含用户名")
}
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
}
@@ -1,42 +0,0 @@
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
@@ -1,132 +0,0 @@
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
@@ -1,159 +0,0 @@
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}
}
@@ -1,77 +0,0 @@
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
@@ -1,171 +0,0 @@
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
@@ -1,50 +0,0 @@
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
@@ -1,59 +0,0 @@
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
@@ -1,66 +0,0 @@
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
@@ -1,112 +0,0 @@
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)
}
@@ -1,46 +0,0 @@
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
@@ -1,65 +0,0 @@
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())
}
@@ -1,3 +0,0 @@
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);`
@@ -1,25 +0,0 @@
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
@@ -1,140 +0,0 @@
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)
}
@@ -1,110 +0,0 @@
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
@@ -1,78 +0,0 @@
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
@@ -1,16 +0,0 @@
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"`
}
@@ -1,18 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,26 +0,0 @@
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
})
}
@@ -1,42 +0,0 @@
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
})
}
@@ -1,18 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,79 +0,0 @@
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
@@ -1,5 +0,0 @@
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.
@@ -1,3 +0,0 @@
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);
@@ -1,3 +0,0 @@
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);
@@ -1,27 +0,0 @@
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);
@@ -1,2 +0,0 @@
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
@@ -1,17 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,103 +0,0 @@
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)
})
}
@@ -1,31 +0,0 @@
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
@@ -1,63 +0,0 @@
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
}
@@ -1,23 +0,0 @@
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
@@ -1,11 +0,0 @@
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)
}
@@ -1,31 +0,0 @@
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
@@ -1,43 +0,0 @@
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
}
@@ -1,63 +0,0 @@
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
@@ -1,13 +0,0 @@
package main
import (
"log"
sensecmd "yovision.local/sense/cmd/sense"
)
func main() {
if err := sensecmd.Run(); err != nil {
log.Fatal(err)
}
}
-1
View File
@@ -1 +0,0 @@
{"kind":"polygon","points":[{"x":0.1,"y":0.1},{"x":0.9,"y":0.1},{"x":0.5,"y":0.8}]}
-1
View File
@@ -1 +0,0 @@
{"route_id":"fixture-device:main","status":"ready","expires_in_seconds":120}
-1
View File
@@ -1 +0,0 @@
{"name":"sense_fixture_main","ready":true,"bytesReceived":4096,"readers":[{"type":"webrtc"}]}
-2
View File
@@ -1,2 +0,0 @@
<?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
@@ -1,2 +0,0 @@
<?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
@@ -1,6 +0,0 @@
# 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
@@ -1,7 +0,0 @@
# 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
@@ -1,6 +0,0 @@
# 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
@@ -1,10 +0,0 @@
# 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
@@ -1,7 +0,0 @@
# 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
@@ -1,6 +0,0 @@
# 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
@@ -1,4 +0,0 @@
node_modules/
dist/
.eslintcache
-53
View File
@@ -1,53 +0,0 @@
{
"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
@@ -1,6 +0,0 @@
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}`)
-7
View File
@@ -1,7 +0,0 @@
import request from '../../../bootstrap/request'
export const listAreas = (deviceID) => request.get('/areas', { params: { device_id: deviceID || undefined } })
export const createArea = (data) => request.post('/areas', data)
export const updateArea = (id, data) => request.patch(`/areas/${id}`, data)
export const recalibrateArea = (id, data) => request.post(`/areas/${id}/recalibrate`, data)
export const disableArea = (id, version) => request.post(`/areas/${id}/disable`, { version })

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