Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4514076e | ||
|
|
d517d517d9 | ||
|
|
38c12bb5b7 | ||
|
|
d332797fcb | ||
|
|
14d29a47d1 | ||
|
|
b70df147ea | ||
|
|
1c63143106 | ||
|
|
cc47e14638 | ||
|
|
461b823570 | ||
|
|
885dd0143e | ||
|
|
11e675d45c | ||
|
|
5db37c7142 | ||
|
|
9567838045 | ||
|
|
08692e33e9 | ||
|
|
94ae2f0048 | ||
|
|
c9251daadf | ||
|
|
56c07427c8 | ||
|
|
fd16f77b95 | ||
|
|
155c927a62 | ||
|
|
3db1cf94e3 | ||
|
|
aa62147702 | ||
|
|
5208891e02 | ||
|
|
bafd8d983e | ||
|
|
e28070dd03 | ||
|
|
ef0e76b201 | ||
|
|
43c5c18eec | ||
|
|
f85dbecf93 | ||
|
|
4be15f2452 | ||
|
|
06de7d0399 | ||
|
|
968de2783c | ||
|
|
e2e75694cd | ||
|
|
a800068b8a | ||
|
|
bb1010c09f | ||
|
|
58a0056b1b | ||
|
|
fca32413fa | ||
|
|
6b18857305 | ||
|
|
d5cb2349cf | ||
|
|
c7d0ce3ed9 | ||
|
|
ec9ae76a0d | ||
|
|
697e652e9b | ||
|
|
5ed7397ff0 | ||
|
|
ac92d04d71 | ||
|
|
edde69ac8d | ||
|
|
de2f1027ec | ||
|
|
547232b7b8 | ||
|
|
8115f584dd | ||
|
|
e96cc2fbb5 | ||
|
|
e0b0bfafeb | ||
|
|
e18a1ea83b | ||
|
|
d36b785f67 | ||
|
|
122cde4938 | ||
|
|
9a82b17b10 | ||
|
|
cce38003cb | ||
|
|
618174cbf2 | ||
|
|
5251199205 | ||
|
|
2715e557a9 | ||
|
|
f43a4efa80 | ||
|
|
b643e42535 | ||
|
|
32b9859a40 |
@@ -6,6 +6,9 @@ agent_sessions.txt
|
||||
gitea.env
|
||||
gitea.env.*
|
||||
!gitea.env.example
|
||||
ip_camera.env
|
||||
ip_camera.env.*
|
||||
!ip_camera.env.example
|
||||
|
||||
# 常见构建与测试缓存
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/data/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Sense M1 骨架
|
||||
|
||||
本目录是 YoVision Sense 的 M1 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;默认关闭真实 ONVIF,显式设置 `SENSE_ONVIF_MODE=standard` 后才启用标准 SOAP/WS-Security 适配器。T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
cd Sense
|
||||
go mod download
|
||||
go generate ./internal/mtx
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -o bin/sense-api.exe ./cmd/sense-api
|
||||
go run ./cmd/sense-api
|
||||
```
|
||||
|
||||
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。当前只有 `/healthz` 与 `/readyz`,设备管理公共 API 尚未冻结。
|
||||
|
||||
常用环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `SENSE_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址 |
|
||||
| `SENSE_ALLOW_NON_LOOPBACK` | `false` | 显式允许监听非回环地址;只应在可信网络及外部认证/防火墙就绪后开启 |
|
||||
| `SENSE_DB_DSN` | `file:data/sense.db` | SQLite DSN;凭据不得放入该值 |
|
||||
| `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo |
|
||||
| `SENSE_RECONCILE_INTERVAL` | `5s` | 对账周期 |
|
||||
| `SENSE_PROBE_INTERVAL` | `10s` | path 探活周期 |
|
||||
| `SENSE_ONVIF_MODE` | `disabled` | `disabled` 或 `standard`;默认不访问真实摄像头 |
|
||||
| `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 |
|
||||
| `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 |
|
||||
| `SENSE_ONVIF_RTSP_STRIP_QUERY` | `false` | 仅在已验证设备返回不可用查询串时显式移除;默认保留标准 URI 语义 |
|
||||
|
||||
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
|
||||
|
||||
```text
|
||||
SENSE_CREDENTIAL_<KEY>_ONVIF_USERNAME
|
||||
SENSE_CREDENTIAL_<KEY>_ONVIF_PASSWORD
|
||||
SENSE_CREDENTIAL_<KEY>_RTSP_USERNAME
|
||||
SENSE_CREDENTIAL_<KEY>_RTSP_PASSWORD
|
||||
```
|
||||
|
||||
`cmd/sense-lab` 是回环实验室播种与脱敏收敛查询工具,不是已冻结的公共设备管理 API。`cmd/rtsp-fault-proxy` 只用于 T-006 控制真实上游网络路径故障。
|
||||
|
||||
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
|
||||
|
||||
Windows 本地准备 MediaMTX(从仓库根目录执行):
|
||||
|
||||
```powershell
|
||||
$asset = "mediamtx_v1.19.3_windows_amd64.zip"
|
||||
Invoke-WebRequest "https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/$asset" -OutFile "$env:TEMP\$asset"
|
||||
if ((Get-FileHash "$env:TEMP\$asset" -Algorithm SHA256).Hash.ToLowerInvariant() -ne "5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe") { throw "MediaMTX checksum mismatch" }
|
||||
Expand-Archive "$env:TEMP\$asset" -DestinationPath "$env:TEMP\yovision-mediamtx-v1.19.3" -Force
|
||||
& "$env:TEMP\yovision-mediamtx-v1.19.3\mediamtx.exe" "Sense\deploy\mediamtx.yml"
|
||||
```
|
||||
|
||||
Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`。不要把下载的二进制或摄像头凭据提交到仓库。
|
||||
|
||||
## T-006 Windows 集成验证
|
||||
|
||||
脚本会启动两套独立 MediaMTX、4 个独立 FFmpeg publisher、真实摄像头网络故障代理和 Sense,在临时目录播种 5 条期望态,执行四类恢复后再连续观察 30 分钟。脚本只输出脱敏计数与时间,不保存视频:
|
||||
|
||||
```powershell
|
||||
./Sense/scripts/t006-integration.ps1 -CameraEnv D:\path\to\ip_camera.env
|
||||
```
|
||||
|
||||
`ip_camera.env` 必须保持在 Git 忽略范围内。调试时可把 `-ObservationMinutes` 降为 1;正式 T-006 证据必须使用默认 30 分钟,且最终 `maximum_unconverged`、`final_unconverged` 都为 0。
|
||||
+3766
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listenAddress := flag.String("listen", "127.0.0.1:10554", "local listen address")
|
||||
upstreamAddress := flag.String("upstream", "", "upstream host:port")
|
||||
flag.Parse()
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if *upstreamAddress == "" {
|
||||
logger.Error("upstream is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(*upstreamAddress); err != nil {
|
||||
logger.Error("upstream must be host:port")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := serve(ctx, *listenAddress, *upstreamAddress); err != nil {
|
||||
logger.Error("RTSP fault proxy stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, listenAddress, upstreamAddress string) error {
|
||||
listener, err := net.Listen("tcp", listenAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
var connections sync.WaitGroup
|
||||
defer connections.Wait()
|
||||
for {
|
||||
client, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("accept: %w", acceptErr)
|
||||
}
|
||||
connections.Add(1)
|
||||
go func() {
|
||||
defer connections.Done()
|
||||
proxy(client, upstreamAddress)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func proxy(client net.Conn, upstreamAddress string) {
|
||||
defer client.Close()
|
||||
upstream, err := net.DialTimeout("tcp", upstreamAddress, 5*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
done := make(chan struct{}, 2)
|
||||
copyOneWay := func(destination, source net.Conn) {
|
||||
_, _ = io.Copy(destination, source)
|
||||
done <- struct{}{}
|
||||
}
|
||||
go copyOneWay(upstream, client)
|
||||
go copyOneWay(client, upstream)
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/config"
|
||||
"yovision/sense/internal/mtx"
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/probe"
|
||||
"yovision/sense/internal/reconcile"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("Sense stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
repository, err := store.OpenSQLite(ctx, cfg.DatabaseDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer repository.Close()
|
||||
mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
credentials := onvif.EnvCredentials{}
|
||||
var cameraAdapter onvif.Adapter = onvif.UnavailableAdapter{}
|
||||
if cfg.ONVIFMode == "standard" {
|
||||
cameraAdapter = onvif.NewHTTPAdapter(credentials, nil, onvif.HTTPOptions{
|
||||
RTSPRewriteHost: cfg.RTSPRewriteHost,
|
||||
RTSPRewritePort: cfg.RTSPRewritePort,
|
||||
StripRTSPQuery: cfg.RTSPStripQuery,
|
||||
})
|
||||
}
|
||||
discovery := onvif.NewRouter(cameraAdapter, credentials)
|
||||
reconciler := reconcile.New(repository, discovery, mediaClient)
|
||||
checker := probe.New(repository, mediaClient)
|
||||
report := func(err error) {
|
||||
// Domain and MediaMTX errors intentionally omit stream URIs and credentials.
|
||||
logger.Warn("background convergence error", "error", err)
|
||||
}
|
||||
var background sync.WaitGroup
|
||||
background.Add(2)
|
||||
go func() {
|
||||
defer background.Done()
|
||||
reconciler.Run(ctx, cfg.ReconcileInterval, report)
|
||||
}()
|
||||
go func() {
|
||||
defer background.Done()
|
||||
checker.Run(ctx, cfg.ProbeInterval, report)
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte(`{"status":"ready"}`))
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddress, Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version)
|
||||
serverErrors <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case serverErr := <-serverErrors:
|
||||
if !errors.Is(serverErr, http.ErrServerClosed) {
|
||||
stop()
|
||||
background.Wait()
|
||||
return fmt.Errorf("serve HTTP: %w", serverErr)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
shutdownErr := server.Shutdown(shutdownContext)
|
||||
background.Wait()
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("shutdown HTTP server: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Site manifestSite `json:"site"`
|
||||
Devices []manifestDevice `json:"devices"`
|
||||
}
|
||||
|
||||
type manifestSite struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MaxVideoChannels int `json:"max_video_channels"`
|
||||
}
|
||||
|
||||
type manifestDevice struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Name string `json:"name"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
EndpointRef string `json:"endpoint_ref"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
PathName string `json:"path_name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, output io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: sense-lab <seed|status>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "seed":
|
||||
return seed(args[1:], output)
|
||||
case "status":
|
||||
return status(args[1:], output)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func seed(args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
dsn := flags.String("db", "", "SQLite DSN")
|
||||
manifestPath := flags.String("manifest", "", "manifest JSON path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dsn == "" || *manifestPath == "" {
|
||||
return errors.New("seed requires -db and -manifest")
|
||||
}
|
||||
file, err := os.Open(*manifestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open manifest: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var value manifest
|
||||
decoder := json.NewDecoder(io.LimitReader(file, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
repository, err := store.OpenSQLite(context.Background(), *dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer repository.Close()
|
||||
ctx := context.Background()
|
||||
if err := repository.EnsureSite(ctx, device.Site{
|
||||
TenantID: value.Site.TenantID, ID: value.Site.ID, Name: value.Site.Name,
|
||||
MaxVideoChannels: value.Site.MaxVideoChannels,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, input := range value.Devices {
|
||||
capabilities := make([]device.Capability, 0, len(input.Capabilities))
|
||||
for _, capability := range input.Capabilities {
|
||||
capabilities = append(capabilities, device.Capability(capability))
|
||||
}
|
||||
if err := repository.CreateDevice(ctx, device.Device{
|
||||
ID: input.ID, TenantID: input.TenantID, SiteID: input.SiteID,
|
||||
SerialNumber: input.SerialNumber, Name: input.Name, Modality: device.ModalityVideo,
|
||||
Capabilities: capabilities, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
EndpointRef: input.EndpointRef, CredentialRef: input.CredentialRef, PathName: input.PathName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("create device %s: %w", input.ID, err)
|
||||
}
|
||||
}
|
||||
return json.NewEncoder(output).Encode(map[string]int{"seeded": len(value.Devices)})
|
||||
}
|
||||
|
||||
func status(args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
dsn := flags.String("db", "", "SQLite DSN")
|
||||
expect := flags.Int("expect", -1, "expected device count")
|
||||
requireConverged := flags.Bool("require-converged", false, "fail when unconverged is non-zero")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dsn == "" {
|
||||
return errors.New("status requires -db")
|
||||
}
|
||||
repository, err := store.OpenSQLite(context.Background(), *dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer repository.Close()
|
||||
snapshot, err := repository.ConvergenceSnapshot(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.NewEncoder(output).Encode(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *expect >= 0 && snapshot.Total != *expect {
|
||||
return fmt.Errorf("expected %d devices, got %d", *expect, snapshot.Total)
|
||||
}
|
||||
if *requireConverged && snapshot.Unconverged != 0 {
|
||||
return fmt.Errorf("unconverged devices: %d", snapshot.Unconverged)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# T-006-only publisher fixture. This is a separate MediaMTX instance; Sense
|
||||
# never edits these source paths. Each path has one independent FFmpeg process.
|
||||
logLevel: warn
|
||||
rtspAddress: 127.0.0.1:8555
|
||||
rtspTransports: [tcp]
|
||||
api: false
|
||||
metrics: false
|
||||
rtmp: false
|
||||
hls: false
|
||||
webrtc: false
|
||||
srt: false
|
||||
moq: false
|
||||
paths:
|
||||
synthetic-1:
|
||||
source: publisher
|
||||
synthetic-2:
|
||||
source: publisher
|
||||
synthetic-3:
|
||||
source: publisher
|
||||
synthetic-4:
|
||||
source: publisher
|
||||
@@ -0,0 +1,9 @@
|
||||
# MediaMTX v1.19.3 minimal control-plane configuration for local T-003 work.
|
||||
# Keep the API on loopback. Production authentication/network policy is a
|
||||
# later deployment concern and must be in place before any non-loopback bind.
|
||||
logLevel: info
|
||||
api: true
|
||||
apiAddress: 127.0.0.1:9997
|
||||
metrics: true
|
||||
metricsAddress: 127.0.0.1:9998
|
||||
paths: {}
|
||||
@@ -0,0 +1,42 @@
|
||||
module yovision/sense
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.5
|
||||
|
||||
require (
|
||||
github.com/oapi-codegen/runtime v1.6.0
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/getkin/kin-openapi v0.142.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
|
||||
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.14 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
|
||||
github.com/speakeasy-api/openapi v1.24.0 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
|
||||
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
|
||||
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
|
||||
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
|
||||
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
|
||||
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
|
||||
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package config loads and validates the Sense process configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabaseDSN = "file:data/sense.db"
|
||||
defaultMediaMTXURL = "http://127.0.0.1:9997"
|
||||
defaultReconcilePeriod = 5 * time.Second
|
||||
defaultProbePeriod = 10 * time.Second
|
||||
defaultONVIFMode = "disabled"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
AllowNonLoopback bool
|
||||
DatabaseDSN string
|
||||
MediaMTXURL string
|
||||
ReconcileInterval time.Duration
|
||||
ProbeInterval time.Duration
|
||||
ONVIFMode string
|
||||
RTSPRewriteHost string
|
||||
RTSPRewritePort int
|
||||
RTSPStripQuery bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
rewritePort, err := intEnv("SENSE_ONVIF_RTSP_REWRITE_PORT", 0)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
stripQuery, err := boolEnv("SENSE_ONVIF_RTSP_STRIP_QUERY", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
|
||||
AllowNonLoopback: allow,
|
||||
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
|
||||
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
|
||||
ReconcileInterval: reconcilePeriod,
|
||||
ProbeInterval: probePeriod,
|
||||
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
|
||||
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
|
||||
RTSPRewritePort: rewritePort,
|
||||
RTSPStripQuery: stripQuery,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
host, _, err := net.SplitHostPort(c.HTTPAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||
if !isLoopback && !c.AllowNonLoopback {
|
||||
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
|
||||
}
|
||||
if c.DatabaseDSN == "" {
|
||||
return fmt.Errorf("SENSE_DB_DSN must not be empty")
|
||||
}
|
||||
mediaURL, err := url.Parse(c.MediaMTXURL)
|
||||
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
|
||||
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
|
||||
}
|
||||
if mediaURL.User != nil {
|
||||
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
|
||||
}
|
||||
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
|
||||
return fmt.Errorf("loop intervals must be positive")
|
||||
}
|
||||
if c.ONVIFMode != "" && c.ONVIFMode != "disabled" && c.ONVIFMode != "standard" {
|
||||
return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard")
|
||||
}
|
||||
if c.RTSPRewritePort < 0 || c.RTSPRewritePort > 65535 {
|
||||
return fmt.Errorf("SENSE_ONVIF_RTSP_REWRITE_PORT must be between 0 and 65535")
|
||||
}
|
||||
if c.RTSPRewriteHost != "" {
|
||||
if strings.TrimSpace(c.RTSPRewriteHost) != c.RTSPRewriteHost ||
|
||||
strings.ContainsAny(c.RTSPRewriteHost, "/@") {
|
||||
return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringEnv(name, fallback string) string {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolEnv(name string, fallback bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid %s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func intEnv(name string, fallback int) (int, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{
|
||||
HTTPAddress: "0.0.0.0:8080",
|
||||
DatabaseDSN: "file:test.db",
|
||||
MediaMTXURL: "http://127.0.0.1:9997",
|
||||
ReconcileInterval: 1,
|
||||
ProbeInterval: 1,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected non-loopback bind to be rejected")
|
||||
}
|
||||
cfg.AllowNonLoopback = true
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("explicit non-loopback opt-in failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{
|
||||
HTTPAddress: "127.0.0.1:8080",
|
||||
DatabaseDSN: "file:test.db",
|
||||
MediaMTXURL: "http://" + "user" + ":" + "redacted" + "@127.0.0.1:9997",
|
||||
ReconcileInterval: 1,
|
||||
ProbeInterval: 1,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected credentials in MediaMTX URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateONVIFModeAndRewritePort(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{
|
||||
HTTPAddress: "127.0.0.1:8080",
|
||||
DatabaseDSN: "file:test.db",
|
||||
MediaMTXURL: "http://127.0.0.1:9997",
|
||||
ReconcileInterval: 1,
|
||||
ProbeInterval: 1,
|
||||
ONVIFMode: "standard",
|
||||
RTSPRewriteHost: "127.0.0.1",
|
||||
RTSPRewritePort: 10554,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("valid ONVIF configuration failed: %v", err)
|
||||
}
|
||||
cfg.ONVIFMode = "vendor"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("unknown ONVIF mode must be rejected")
|
||||
}
|
||||
cfg.ONVIFMode = "standard"
|
||||
cfg.RTSPRewritePort = 65536
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("invalid RTSP rewrite port must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRTSPStripQueryOptIn(t *testing.T) {
|
||||
t.Setenv("SENSE_ONVIF_RTSP_STRIP_QUERY", "true")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.RTSPStripQuery {
|
||||
t.Fatal("explicit RTSP query stripping was not loaded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package device contains the Sense device-ledger domain model.
|
||||
package device
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultVideoChannels = 16
|
||||
MaximumVideoChannels = 128
|
||||
)
|
||||
|
||||
type Modality string
|
||||
|
||||
const (
|
||||
ModalityVideo Modality = "video"
|
||||
ModalityRadar Modality = "radar"
|
||||
ModalityContact Modality = "contact"
|
||||
ModalityButton Modality = "button"
|
||||
ModalityWearable Modality = "wearable"
|
||||
ModalityOther Modality = "other"
|
||||
)
|
||||
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapabilityVideoCapture Capability = "video_capture"
|
||||
CapabilityAudioCapture Capability = "audio_capture"
|
||||
CapabilitySpatialRule Capability = "spatial_rule"
|
||||
CapabilityTelemetry Capability = "telemetry"
|
||||
)
|
||||
|
||||
type DesiredState string
|
||||
|
||||
const (
|
||||
DesiredDisabled DesiredState = "disabled"
|
||||
DesiredEnabled DesiredState = "enabled"
|
||||
)
|
||||
|
||||
type ActualState string
|
||||
|
||||
const (
|
||||
ActualPending ActualState = "pending"
|
||||
ActualOnline ActualState = "online"
|
||||
ActualOffline ActualState = "offline"
|
||||
ActualFailed ActualState = "failed"
|
||||
)
|
||||
|
||||
type Site struct {
|
||||
TenantID string
|
||||
ID string
|
||||
Name string
|
||||
MaxVideoChannels int
|
||||
}
|
||||
|
||||
func (s *Site) ApplyDefaults() {
|
||||
if s.MaxVideoChannels == 0 {
|
||||
s.MaxVideoChannels = DefaultVideoChannels
|
||||
}
|
||||
}
|
||||
|
||||
func (s Site) Validate() error {
|
||||
if strings.TrimSpace(s.TenantID) == "" || strings.TrimSpace(s.ID) == "" {
|
||||
return errors.New("tenant ID and site ID are required")
|
||||
}
|
||||
if s.MaxVideoChannels < 1 || s.MaxVideoChannels > MaximumVideoChannels {
|
||||
return fmt.Errorf("max video channels must be between 1 and %d", MaximumVideoChannels)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
TenantID string
|
||||
SiteID string
|
||||
SerialNumber string
|
||||
Name string
|
||||
Modality Modality
|
||||
Capabilities []Capability
|
||||
DesiredState DesiredState
|
||||
ActualState ActualState
|
||||
EndpointRef string
|
||||
CredentialRef string
|
||||
PathName string
|
||||
Generation int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (d Device) Validate() error {
|
||||
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.TenantID) == "" || strings.TrimSpace(d.SiteID) == "" {
|
||||
return errors.New("device ID, tenant ID and site ID are required")
|
||||
}
|
||||
if strings.TrimSpace(d.SerialNumber) == "" || strings.TrimSpace(d.Name) == "" {
|
||||
return errors.New("serial number and device name are required")
|
||||
}
|
||||
if !validModality(d.Modality) {
|
||||
return fmt.Errorf("unsupported modality %q", d.Modality)
|
||||
}
|
||||
if d.DesiredState != DesiredEnabled && d.DesiredState != DesiredDisabled {
|
||||
return fmt.Errorf("unsupported desired state %q", d.DesiredState)
|
||||
}
|
||||
if d.ActualState != ActualPending && d.ActualState != ActualOnline && d.ActualState != ActualOffline && d.ActualState != ActualFailed {
|
||||
return fmt.Errorf("unsupported actual state %q", d.ActualState)
|
||||
}
|
||||
if d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture) {
|
||||
if strings.TrimSpace(d.EndpointRef) == "" || strings.TrimSpace(d.PathName) == "" {
|
||||
return errors.New("enabled video devices require endpoint ref and path name")
|
||||
}
|
||||
}
|
||||
if d.EndpointRef != "" {
|
||||
endpoint, err := url.Parse(d.EndpointRef)
|
||||
if err != nil || endpoint.Scheme == "" {
|
||||
return errors.New("endpoint ref must be an absolute URI")
|
||||
}
|
||||
if endpoint.User != nil {
|
||||
return errors.New("endpoint ref must not contain credentials")
|
||||
}
|
||||
}
|
||||
if d.PathName != "" && !validPathName(d.PathName) {
|
||||
return errors.New("path name must contain safe ASCII segments")
|
||||
}
|
||||
seen := make(map[Capability]struct{}, len(d.Capabilities))
|
||||
for _, capability := range d.Capabilities {
|
||||
if !validCapability(capability) {
|
||||
return fmt.Errorf("unsupported capability %q", capability)
|
||||
}
|
||||
if _, ok := seen[capability]; ok {
|
||||
return fmt.Errorf("duplicate capability %q", capability)
|
||||
}
|
||||
seen[capability] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Device) HasCapability(capability Capability) bool {
|
||||
return slices.Contains(d.Capabilities, capability)
|
||||
}
|
||||
|
||||
func (d Device) ConsumesVideoChannel() bool {
|
||||
return d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture)
|
||||
}
|
||||
|
||||
func validModality(value Modality) bool {
|
||||
return slices.Contains([]Modality{ModalityVideo, ModalityRadar, ModalityContact, ModalityButton, ModalityWearable, ModalityOther}, value)
|
||||
}
|
||||
|
||||
func validCapability(value Capability) bool {
|
||||
return slices.Contains([]Capability{CapabilityVideoCapture, CapabilityAudioCapture, CapabilitySpatialRule, CapabilityTelemetry}, value)
|
||||
}
|
||||
|
||||
func validPathName(value string) bool {
|
||||
if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') || strings.ContainsRune("-_/.", character) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
type QuotaExceededError struct {
|
||||
TenantID string
|
||||
SiteID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (e *QuotaExceededError) Error() string {
|
||||
return fmt.Sprintf("video channel quota exceeded for site %s/%s (limit %d)", e.TenantID, e.SiteID, e.Limit)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package device
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeviceRejectsCredentialsInEndpointReference(t *testing.T) {
|
||||
t.Parallel()
|
||||
value := validVideoDevice()
|
||||
value.EndpointRef = "http://" + "user" + ":" + "redacted" + "@camera.invalid/onvif"
|
||||
if err := value.Validate(); err == nil {
|
||||
t.Fatal("expected endpoint credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceRejectsUnsafeMediaPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
value := validVideoDevice()
|
||||
value.PathName = "tenant/../another-camera"
|
||||
if err := value.Validate(); err == nil {
|
||||
t.Fatal("expected unsafe path name to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func validVideoDevice() Device {
|
||||
return Device{
|
||||
ID: "camera", TenantID: "tenant", SiteID: "site", SerialNumber: "serial", Name: "Camera",
|
||||
Modality: ModalityVideo, Capabilities: []Capability{CapabilityVideoCapture},
|
||||
DesiredState: DesiredEnabled, ActualState: ActualPending,
|
||||
EndpointRef: "onvif://camera", PathName: "sense/tenant/site/camera",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package mtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
mediamtxapi "yovision/sense/internal/mtx/generated"
|
||||
)
|
||||
|
||||
var ErrPathNotFound = errors.New("MediaMTX path not found")
|
||||
|
||||
type APIError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("MediaMTX %s failed with HTTP status %d", e.Operation, e.StatusCode)
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
Name string
|
||||
Source string
|
||||
}
|
||||
|
||||
type pathAPI interface {
|
||||
ConfigPathsAddWithResponse(context.Context, string, mediamtxapi.ConfigPathsAddJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsAddResponse, error)
|
||||
ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error)
|
||||
ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error)
|
||||
ConfigPathsDeleteWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsDeleteResponse, error)
|
||||
PathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.PathsGetResponse, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
api pathAPI
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
|
||||
options := make([]mediamtxapi.ClientOption, 0, 1)
|
||||
if httpClient != nil {
|
||||
options = append(options, mediamtxapi.WithHTTPClient(httpClient))
|
||||
}
|
||||
generated, err := mediamtxapi.NewClientWithResponses(strings.TrimRight(baseURL, "/"), options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create MediaMTX client: %w", err)
|
||||
}
|
||||
return &Client{api: generated}, nil
|
||||
}
|
||||
|
||||
func newClientWithAPI(api pathAPI) *Client {
|
||||
return &Client{api: api}
|
||||
}
|
||||
|
||||
func (c *Client) CreatePath(ctx context.Context, name, source string) error {
|
||||
response, err := c.api.ConfigPathsAddWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
|
||||
if err != nil {
|
||||
return fmt.Errorf("MediaMTX create path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK {
|
||||
return &APIError{Operation: "create path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPath(ctx context.Context, name string) (PathConfig, error) {
|
||||
response, err := c.api.ConfigPathsGetWithResponse(ctx, name)
|
||||
if err != nil {
|
||||
return PathConfig{}, fmt.Errorf("MediaMTX read path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return PathConfig{}, ErrPathNotFound
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
|
||||
return PathConfig{}, &APIError{Operation: "read path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
result := PathConfig{Name: name}
|
||||
if response.JSON200.Name != nil {
|
||||
result.Name = *response.JSON200.Name
|
||||
}
|
||||
if response.JSON200.Source != nil {
|
||||
result.Source = *response.JSON200.Source
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePath(ctx context.Context, name string) error {
|
||||
response, err := c.api.ConfigPathsDeleteWithResponse(ctx, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MediaMTX delete path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return ErrPathNotFound
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK {
|
||||
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsurePath converges one desired path. It never enumerates or deletes orphans.
|
||||
func (c *Client) EnsurePath(ctx context.Context, name, source string) (bool, error) {
|
||||
current, err := c.GetPath(ctx, name)
|
||||
if errors.Is(err, ErrPathNotFound) {
|
||||
if err := c.CreatePath(ctx, name, source); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if current.Source == source {
|
||||
return false, nil
|
||||
}
|
||||
response, err := c.api.ConfigPathsPatchWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MediaMTX patch path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK {
|
||||
return false, &APIError{Operation: "patch path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Client) PathReady(ctx context.Context, name string) (bool, error) {
|
||||
response, err := c.api.PathsGetWithResponse(ctx, name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MediaMTX probe path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return false, ErrPathNotFound
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
|
||||
return false, &APIError{Operation: "probe path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
if response.JSON200.Online == nil || response.JSON200.Available == nil {
|
||||
return false, &APIError{Operation: "probe path response", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return *response.JSON200.Online && *response.JSON200.Available, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package mtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeMediaMTX struct {
|
||||
mu sync.Mutex
|
||||
paths map[string]string
|
||||
mutations int
|
||||
}
|
||||
|
||||
func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
prefixes := map[string]string{
|
||||
"/v3/config/paths/get/": "get",
|
||||
"/v3/config/paths/add/": "add",
|
||||
"/v3/config/paths/patch/": "patch",
|
||||
"/v3/config/paths/delete/": "delete",
|
||||
"/v3/paths/get/": "runtime",
|
||||
}
|
||||
for prefix, operation := range prefixes {
|
||||
if !strings.HasPrefix(request.URL.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(request.URL.Path, prefix)
|
||||
source, exists := f.paths[name]
|
||||
switch operation {
|
||||
case "get":
|
||||
if !exists {
|
||||
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"name": name, "source": source})
|
||||
case "add", "patch":
|
||||
var body struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
http.Error(writer, `{}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.paths[name] = body.Source
|
||||
f.mutations++
|
||||
_, _ = writer.Write([]byte(`{}`))
|
||||
case "delete":
|
||||
if !exists {
|
||||
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(f.paths, name)
|
||||
f.mutations++
|
||||
_, _ = writer.Write([]byte(`{}`))
|
||||
case "runtime":
|
||||
if !exists {
|
||||
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{"online":true,"available":true}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
|
||||
func TestGeneratedClientCreateReadDeleteMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeMediaMTX{paths: make(map[string]string)}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := client.CreatePath(ctx, "camera-1", "rtsp://media.invalid/camera-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, err := client.GetPath(ctx, "camera-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path.Source != "rtsp://media.invalid/camera-1" {
|
||||
t.Fatalf("unexpected source mapping: %+v", path)
|
||||
}
|
||||
if err := client.DeletePath(ctx, "camera-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.GetPath(ctx, "camera-1"); err != ErrPathNotFound {
|
||||
t.Fatalf("expected not found after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePathIsIdempotentAndCanPatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeMediaMTX{paths: make(map[string]string)}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
changed, err := client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first ensure: changed=%v err=%v", changed, err)
|
||||
}
|
||||
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
|
||||
if err != nil || changed {
|
||||
t.Fatalf("second ensure must be idempotent: changed=%v err=%v", changed, err)
|
||||
}
|
||||
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/second")
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed source must patch: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if fake.mutations != 2 {
|
||||
t.Fatalf("expected create + patch, got %d mutations", fake.mutations)
|
||||
}
|
||||
ready, err := client.PathReady(ctx, "camera-2")
|
||||
if err != nil || !ready {
|
||||
t.Fatalf("runtime probe: ready=%v err=%v", ready, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIErrorDoesNotLeakSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(writer, `{"error":"upstream included a secret"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secretSource := "rtsp://" + "user" + ":" + "redacted" + "@camera.invalid/live"
|
||||
err = client.CreatePath(context.Background(), "camera", secretSource)
|
||||
if err == nil || strings.Contains(err.Error(), secretSource) || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("error must be redacted, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package mtx wraps the generated MediaMTX control API client.
|
||||
package mtx
|
||||
|
||||
// The input is the official API document vendored from the frozen MediaMTX tag.
|
||||
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../api/vendor/mediamtx-v1.19.3.openapi.yaml
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
package: mediamtxapi
|
||||
output: generated/client.gen.go
|
||||
generate:
|
||||
models: true
|
||||
client: true
|
||||
output-options:
|
||||
skip-prune: false
|
||||
@@ -0,0 +1,62 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Credentials struct {
|
||||
ONVIFUsername string
|
||||
ONVIFPassword string
|
||||
RTSPUsername string
|
||||
RTSPPassword string
|
||||
}
|
||||
|
||||
type CredentialProvider interface {
|
||||
Resolve(reference string) (Credentials, error)
|
||||
}
|
||||
|
||||
type EnvCredentials struct {
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
var credentialKey = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
func (p EnvCredentials) Resolve(reference string) (Credentials, error) {
|
||||
parsed, err := url.Parse(reference)
|
||||
if err != nil || parsed.Scheme != "env" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return Credentials{}, fmt.Errorf("credential reference must use env://<key>")
|
||||
}
|
||||
key := strings.Trim(strings.TrimSpace(parsed.Host+parsed.Path), "/")
|
||||
if !credentialKey.MatchString(key) {
|
||||
return Credentials{}, fmt.Errorf("credential reference contains an invalid key")
|
||||
}
|
||||
lookup := p.LookupEnv
|
||||
if lookup == nil {
|
||||
lookup = os.LookupEnv
|
||||
}
|
||||
prefix := "SENSE_CREDENTIAL_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
|
||||
read := func(suffix string) string {
|
||||
value, _ := lookup(prefix + suffix)
|
||||
return value
|
||||
}
|
||||
result := Credentials{
|
||||
ONVIFUsername: read("_ONVIF_USERNAME"),
|
||||
ONVIFPassword: read("_ONVIF_PASSWORD"),
|
||||
RTSPUsername: read("_RTSP_USERNAME"),
|
||||
RTSPPassword: read("_RTSP_PASSWORD"),
|
||||
}
|
||||
if result.ONVIFUsername == "" || result.ONVIFPassword == "" {
|
||||
return Credentials{}, fmt.Errorf("ONVIF credentials are not configured for reference")
|
||||
}
|
||||
if result.RTSPUsername == "" {
|
||||
result.RTSPUsername = result.ONVIFUsername
|
||||
}
|
||||
if result.RTSPPassword == "" {
|
||||
result.RTSPPassword = result.ONVIFPassword
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FakeScenario struct {
|
||||
Result ProbeResult `json:"result"`
|
||||
ProbeError ErrorCode `json:"probe_error,omitempty"`
|
||||
ClockError ErrorCode `json:"clock_error,omitempty"`
|
||||
DelayMillis int `json:"delay_millis,omitempty"`
|
||||
}
|
||||
|
||||
type fakeFixture struct {
|
||||
Scenarios map[string]FakeScenario `json:"scenarios"`
|
||||
}
|
||||
|
||||
// Fake is deterministic and intended only for tests and offline development.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
scenarios map[string]FakeScenario
|
||||
probeCalls map[string]int
|
||||
clockSyncCalls map[string]int
|
||||
}
|
||||
|
||||
func NewFake(scenarios map[string]FakeScenario) *Fake {
|
||||
copyOfScenarios := make(map[string]FakeScenario, len(scenarios))
|
||||
for key, value := range scenarios {
|
||||
copyOfScenarios[key] = value
|
||||
}
|
||||
return &Fake{
|
||||
scenarios: copyOfScenarios, probeCalls: make(map[string]int), clockSyncCalls: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFakeFixture(path string) (*Fake, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ONVIF fixture: %w", err)
|
||||
}
|
||||
var fixture fakeFixture
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
return nil, fmt.Errorf("decode ONVIF fixture: %w", err)
|
||||
}
|
||||
return NewFake(fixture.Scenarios), nil
|
||||
}
|
||||
|
||||
func (f *Fake) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||
scenario, ok := f.scenario(target.EndpointRef)
|
||||
if !ok {
|
||||
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
|
||||
}
|
||||
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
|
||||
return ProbeResult{}, &Error{Code: ErrorTimeout, Err: err}
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.probeCalls[target.EndpointRef]++
|
||||
f.mu.Unlock()
|
||||
if scenario.ProbeError != "" {
|
||||
return ProbeResult{}, &Error{Code: scenario.ProbeError, Err: fmt.Errorf("fixture probe failure")}
|
||||
}
|
||||
if scenario.Result.StreamURI == "" || len(scenario.Result.Profiles) == 0 {
|
||||
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("fixture lacks profile or stream URI")}
|
||||
}
|
||||
return scenario.Result, nil
|
||||
}
|
||||
|
||||
func (f *Fake) SetSystemDateAndTime(ctx context.Context, target Target, _ time.Time) error {
|
||||
scenario, ok := f.scenario(target.EndpointRef)
|
||||
if !ok {
|
||||
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
|
||||
}
|
||||
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
|
||||
return &Error{Code: ErrorTimeout, Err: err}
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.clockSyncCalls[target.EndpointRef]++
|
||||
f.mu.Unlock()
|
||||
if scenario.ClockError != "" {
|
||||
return &Error{Code: scenario.ClockError, Err: fmt.Errorf("fixture clock failure")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fake) ProbeCalls(endpointRef string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.probeCalls[endpointRef]
|
||||
}
|
||||
|
||||
func (f *Fake) ClockSyncCalls(endpointRef string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.clockSyncCalls[endpointRef]
|
||||
}
|
||||
|
||||
func (f *Fake) scenario(endpointRef string) (FakeScenario, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
scenario, ok := f.scenarios[endpointRef]
|
||||
return scenario, ok
|
||||
}
|
||||
|
||||
func waitForFakeDelay(ctx context.Context, milliseconds int) error {
|
||||
if milliseconds <= 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(milliseconds) * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeMapsProfilesStreamAndClockSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := Target{EndpointRef: "onvif://camera-ok", CredentialRef: "secret://camera-ok"}
|
||||
result, err := fake.Probe(context.Background(), target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Profiles) != 2 || result.StreamURI != "rtsp://media.invalid/camera-ok" {
|
||||
t.Fatalf("unexpected fixture mapping: %+v", result)
|
||||
}
|
||||
if err := fake.SetSystemDateAndTime(context.Background(), target, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.ProbeCalls(target.EndpointRef) != 1 || fake.ClockSyncCalls(target.EndpointRef) != 1 {
|
||||
t.Fatal("expected one probe and one clock-sync call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeMapsAuthenticationFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = fake.Probe(context.Background(), Target{EndpointRef: "onvif://camera-auth"})
|
||||
if CodeOf(err) != ErrorAuthentication {
|
||||
t.Fatalf("expected authentication error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeHonorsCancellationAsTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = fake.Probe(ctx, Target{EndpointRef: "onvif://camera-slow"})
|
||||
if CodeOf(err) != ErrorTimeout {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
deviceNamespace = "http://www.onvif.org/ver10/device/wsdl"
|
||||
mediaNamespace = "http://www.onvif.org/ver10/media/wsdl"
|
||||
)
|
||||
|
||||
type HTTPOptions struct {
|
||||
RTSPRewriteHost string
|
||||
RTSPRewritePort int
|
||||
StripRTSPQuery bool
|
||||
}
|
||||
|
||||
type HTTPAdapter struct {
|
||||
credentials CredentialProvider
|
||||
client *http.Client
|
||||
options HTTPOptions
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
}
|
||||
|
||||
func NewHTTPAdapter(credentials CredentialProvider, client *http.Client, options HTTPOptions) *HTTPAdapter {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
return &HTTPAdapter{
|
||||
credentials: credentials,
|
||||
client: client,
|
||||
options: options,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *HTTPAdapter) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||
endpoint, credentials, err := a.target(target)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
infoBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetDeviceInformation",
|
||||
`<tds:GetDeviceInformation xmlns:tds="`+deviceNamespace+`"/>`, credentials)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
var info deviceInformationEnvelope
|
||||
if err := xml.Unmarshal(infoBody, &info); err != nil {
|
||||
return ProbeResult{}, invalidResponse("decode device information")
|
||||
}
|
||||
|
||||
servicesBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetServices",
|
||||
`<tds:GetServices xmlns:tds="`+deviceNamespace+`"><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>`, credentials)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
var services servicesEnvelope
|
||||
if err := xml.Unmarshal(servicesBody, &services); err != nil {
|
||||
return ProbeResult{}, invalidResponse("decode services")
|
||||
}
|
||||
mediaEndpoint, err := externalMediaEndpoint(endpoint, services.Body.Response.Services)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
|
||||
profilesBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetProfiles",
|
||||
`<trt:GetProfiles xmlns:trt="`+mediaNamespace+`"/>`, credentials)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
var profilesResponse profilesEnvelope
|
||||
if err := xml.Unmarshal(profilesBody, &profilesResponse); err != nil {
|
||||
return ProbeResult{}, invalidResponse("decode profiles")
|
||||
}
|
||||
profiles := make([]Profile, 0, len(profilesResponse.Body.Response.Profiles))
|
||||
selectedToken := ""
|
||||
for _, value := range profilesResponse.Body.Response.Profiles {
|
||||
video := value.VideoEncoder != nil
|
||||
profiles = append(profiles, Profile{Token: value.Token, Name: value.Name, VideoEncoder: video})
|
||||
if selectedToken == "" && video && value.Token != "" {
|
||||
selectedToken = value.Token
|
||||
}
|
||||
}
|
||||
if selectedToken == "" {
|
||||
return ProbeResult{}, invalidResponse("no video profile")
|
||||
}
|
||||
|
||||
streamRequest := `<trt:GetStreamUri xmlns:trt="` + mediaNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
|
||||
`<trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>` +
|
||||
`<trt:ProfileToken>` + escapeXML(selectedToken) + `</trt:ProfileToken></trt:GetStreamUri>`
|
||||
streamBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetStreamUri", streamRequest, credentials)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
var streamResponse streamURIEnvelope
|
||||
if err := xml.Unmarshal(streamBody, &streamResponse); err != nil {
|
||||
return ProbeResult{}, invalidResponse("decode stream URI")
|
||||
}
|
||||
streamURI, err := a.rewriteStreamURI(endpoint, streamResponse.Body.Response.MediaURI.URI, credentials)
|
||||
if err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
return ProbeResult{
|
||||
Manufacturer: info.Body.Response.Manufacturer,
|
||||
Model: info.Body.Response.Model,
|
||||
FirmwareVersion: info.Body.Response.FirmwareVersion,
|
||||
SerialNumber: info.Body.Response.SerialNumber,
|
||||
Profiles: profiles,
|
||||
StreamURI: streamURI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *HTTPAdapter) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
|
||||
endpoint, credentials, err := a.target(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
utc := value.UTC()
|
||||
body := `<tds:SetSystemDateAndTime xmlns:tds="` + deviceNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
|
||||
`<tds:DateTimeType>Manual</tds:DateTimeType><tds:DaylightSavings>false</tds:DaylightSavings>` +
|
||||
`<tds:UTCDateTime><tt:Time><tt:Hour>` + strconv.Itoa(utc.Hour()) + `</tt:Hour><tt:Minute>` + strconv.Itoa(utc.Minute()) +
|
||||
`</tt:Minute><tt:Second>` + strconv.Itoa(utc.Second()) + `</tt:Second></tt:Time><tt:Date><tt:Year>` + strconv.Itoa(utc.Year()) +
|
||||
`</tt:Year><tt:Month>` + strconv.Itoa(int(utc.Month())) + `</tt:Month><tt:Day>` + strconv.Itoa(utc.Day()) +
|
||||
`</tt:Day></tt:Date></tds:UTCDateTime></tds:SetSystemDateAndTime>`
|
||||
_, err = a.call(ctx, endpoint, deviceNamespace+"/SetSystemDateAndTime", body, credentials)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *HTTPAdapter) target(target Target) (*url.URL, Credentials, error) {
|
||||
endpoint, err := url.Parse(target.EndpointRef)
|
||||
if err != nil || endpoint.Host == "" || endpoint.User != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||
return nil, Credentials{}, invalidResponse("invalid ONVIF endpoint")
|
||||
}
|
||||
credentials, err := a.credentials.Resolve(target.CredentialRef)
|
||||
if err != nil {
|
||||
return nil, Credentials{}, &Error{Code: ErrorAuthentication, Err: err}
|
||||
}
|
||||
return endpoint, credentials, nil
|
||||
}
|
||||
|
||||
func (a *HTTPAdapter) call(ctx context.Context, endpoint *url.URL, action, body string, credentials Credentials) ([]byte, error) {
|
||||
nonce := make([]byte, 20)
|
||||
if _, err := io.ReadFull(a.random, nonce); err != nil {
|
||||
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create authentication nonce")}
|
||||
}
|
||||
created := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
digestInput := append(append(append([]byte{}, nonce...), []byte(created)...), []byte(credentials.ONVIFPassword)...)
|
||||
digest := sha1.Sum(digestInput)
|
||||
envelope := `<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` +
|
||||
`<s:Header><wsse:Security s:mustUnderstand="1"><wsse:UsernameToken><wsse:Username>` + escapeXML(credentials.ONVIFUsername) +
|
||||
`</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` +
|
||||
base64.StdEncoding.EncodeToString(digest[:]) + `</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` +
|
||||
base64.StdEncoding.EncodeToString(nonce) + `</wsse:Nonce><wsu:Created>` + created +
|
||||
`</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header><s:Body>` + body + `</s:Body></s:Envelope>`
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(envelope))
|
||||
if err != nil {
|
||||
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create ONVIF request")}
|
||||
}
|
||||
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
|
||||
response, err := a.client.Do(request)
|
||||
if err != nil {
|
||||
code := ErrorUnavailable
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = ErrorTimeout
|
||||
}
|
||||
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF transport failed")}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||
if err != nil {
|
||||
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("read ONVIF response")}
|
||||
}
|
||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||
return nil, &Error{Code: ErrorAuthentication, Err: fmt.Errorf("ONVIF authorization failed")}
|
||||
}
|
||||
if fault := soapFault(responseBody); fault != "" {
|
||||
code := ErrorInvalidReply
|
||||
lower := strings.ToLower(fault)
|
||||
if strings.Contains(lower, "authoriz") || strings.Contains(lower, "notauthorized") {
|
||||
code = ErrorAuthentication
|
||||
}
|
||||
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF SOAP fault")}
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("ONVIF returned HTTP status %d", response.StatusCode)}
|
||||
}
|
||||
return responseBody, nil
|
||||
}
|
||||
|
||||
func externalMediaEndpoint(deviceEndpoint *url.URL, services []service) (*url.URL, error) {
|
||||
for _, value := range services {
|
||||
if value.Namespace != mediaNamespace || value.XAddr == "" {
|
||||
continue
|
||||
}
|
||||
mediaEndpoint, err := url.Parse(value.XAddr)
|
||||
if err != nil || mediaEndpoint.Host == "" {
|
||||
return nil, invalidResponse("invalid media service address")
|
||||
}
|
||||
mediaEndpoint.Scheme = deviceEndpoint.Scheme
|
||||
mediaEndpoint.Host = deviceEndpoint.Host
|
||||
mediaEndpoint.User = nil
|
||||
return mediaEndpoint, nil
|
||||
}
|
||||
return nil, invalidResponse("media service is unavailable")
|
||||
}
|
||||
|
||||
func (a *HTTPAdapter) rewriteStreamURI(deviceEndpoint *url.URL, raw string, credentials Credentials) (string, error) {
|
||||
stream, err := url.Parse(raw)
|
||||
if err != nil || stream.Host == "" || (stream.Scheme != "rtsp" && stream.Scheme != "rtsps") {
|
||||
return "", invalidResponse("invalid stream URI")
|
||||
}
|
||||
host := a.options.RTSPRewriteHost
|
||||
if host == "" {
|
||||
host = deviceEndpoint.Hostname()
|
||||
}
|
||||
port := a.options.RTSPRewritePort
|
||||
if port == 0 {
|
||||
if parsedPort := stream.Port(); parsedPort != "" {
|
||||
value, parseErr := strconv.Atoi(parsedPort)
|
||||
if parseErr != nil {
|
||||
return "", invalidResponse("invalid stream port")
|
||||
}
|
||||
port = value
|
||||
}
|
||||
}
|
||||
if port > 0 {
|
||||
stream.Host = net.JoinHostPort(host, strconv.Itoa(port))
|
||||
} else {
|
||||
stream.Host = host
|
||||
}
|
||||
stream.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
|
||||
if a.options.StripRTSPQuery {
|
||||
stream.RawQuery = ""
|
||||
stream.ForceQuery = false
|
||||
}
|
||||
return stream.String(), nil
|
||||
}
|
||||
|
||||
func invalidResponse(message string) error {
|
||||
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("%s", message)}
|
||||
}
|
||||
|
||||
func escapeXML(value string) string {
|
||||
var buffer bytes.Buffer
|
||||
_ = xml.EscapeText(&buffer, []byte(value))
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
func soapFault(body []byte) string {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(body))
|
||||
inFault := false
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if value.Name.Local == "Fault" {
|
||||
inFault = true
|
||||
}
|
||||
if inFault && (value.Name.Local == "Text" || value.Name.Local == "faultstring") {
|
||||
var message string
|
||||
if decoder.DecodeElement(&message, &value) == nil {
|
||||
return message
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if value.Name.Local == "Fault" {
|
||||
return "SOAP fault"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type deviceInformationEnvelope struct {
|
||||
Body struct {
|
||||
Response struct {
|
||||
Manufacturer string `xml:"Manufacturer"`
|
||||
Model string `xml:"Model"`
|
||||
FirmwareVersion string `xml:"FirmwareVersion"`
|
||||
SerialNumber string `xml:"SerialNumber"`
|
||||
} `xml:"GetDeviceInformationResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
type service struct {
|
||||
Namespace string `xml:"Namespace"`
|
||||
XAddr string `xml:"XAddr"`
|
||||
}
|
||||
|
||||
type servicesEnvelope struct {
|
||||
Body struct {
|
||||
Response struct {
|
||||
Services []service `xml:"Service"`
|
||||
} `xml:"GetServicesResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
type profileResponse struct {
|
||||
Token string `xml:"token,attr"`
|
||||
Name string `xml:"Name"`
|
||||
VideoEncoder *struct{} `xml:"VideoEncoderConfiguration"`
|
||||
}
|
||||
|
||||
type profilesEnvelope struct {
|
||||
Body struct {
|
||||
Response struct {
|
||||
Profiles []profileResponse `xml:"Profiles"`
|
||||
} `xml:"GetProfilesResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
type streamURIEnvelope struct {
|
||||
Body struct {
|
||||
Response struct {
|
||||
MediaURI struct {
|
||||
URI string `xml:"Uri"`
|
||||
} `xml:"MediaUri"`
|
||||
} `xml:"GetStreamUriResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type staticCredentials struct {
|
||||
value Credentials
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticCredentials) Resolve(string) (Credentials, error) {
|
||||
return s.value, s.err
|
||||
}
|
||||
|
||||
func TestHTTPAdapterDiscoversMediaAndRewritesNATStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if !strings.Contains(request.Header.Get("Content-Type"), "action=") {
|
||||
t.Fatal("SOAP action is required")
|
||||
}
|
||||
body := ""
|
||||
switch {
|
||||
case strings.Contains(request.Header.Get("Content-Type"), "GetDeviceInformation"):
|
||||
body = `<tds:GetDeviceInformationResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Manufacturer>HIKVISION</tds:Manufacturer><tds:Model>camera</tds:Model><tds:FirmwareVersion>v1</tds:FirmwareVersion><tds:SerialNumber>serial</tds:SerialNumber></tds:GetDeviceInformationResponse>`
|
||||
case strings.Contains(request.Header.Get("Content-Type"), "GetServices"):
|
||||
body = `<tds:GetServicesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Service><tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace><tds:XAddr>http://192.0.2.10/onvif/Media</tds:XAddr></tds:Service></tds:GetServicesResponse>`
|
||||
case strings.Contains(request.Header.Get("Content-Type"), "GetProfiles"):
|
||||
body = `<trt:GetProfilesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:Profiles token="main"><trt:Name>Main</trt:Name><trt:VideoEncoderConfiguration/></trt:Profiles></trt:GetProfilesResponse>`
|
||||
case strings.Contains(request.Header.Get("Content-Type"), "GetStreamUri"):
|
||||
body = `<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:MediaUri><trt:Uri>rtsp://192.0.2.10:554/Streaming/Channels/101?transportmode=unicast&profile=Profile_1</trt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`
|
||||
default:
|
||||
http.Error(writer, "unexpected action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/soap+xml")
|
||||
_, _ = fmt.Fprintf(writer, `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body>%s</s:Body></s:Envelope>`, body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
credentials := Credentials{
|
||||
ONVIFUsername: "onvif-user", ONVIFPassword: "onvif-password",
|
||||
RTSPUsername: "rtsp-user", RTSPPassword: "rtsp-password",
|
||||
}
|
||||
adapter := NewHTTPAdapter(staticCredentials{value: credentials}, server.Client(), HTTPOptions{
|
||||
RTSPRewriteHost: "127.0.0.1", RTSPRewritePort: 10554, StripRTSPQuery: true,
|
||||
})
|
||||
result, err := adapter.Probe(context.Background(), Target{
|
||||
EndpointRef: server.URL + "/onvif/device_service", CredentialRef: "env://camera",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Manufacturer != "HIKVISION" || result.Model != "camera" || len(result.Profiles) != 1 {
|
||||
t.Fatalf("unexpected probe result: %+v", result)
|
||||
}
|
||||
stream, err := url.Parse(result.StreamURI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream.Host != "127.0.0.1:10554" || stream.Path != "/Streaming/Channels/101" || stream.RawQuery != "" {
|
||||
t.Fatalf("unexpected rewritten stream address: host=%s path=%s", stream.Host, stream.Path)
|
||||
}
|
||||
password, _ := stream.User.Password()
|
||||
if stream.User.Username() != credentials.RTSPUsername || password != credentials.RTSPPassword {
|
||||
t.Fatal("RTSP credentials were not injected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapterPreservesRTSPQueryByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := NewHTTPAdapter(staticCredentials{}, nil, HTTPOptions{})
|
||||
streamURI, err := adapter.rewriteStreamURI(
|
||||
&url.URL{Scheme: "http", Host: "camera.example:8008"},
|
||||
"rtsp://192.0.2.10:554/live?profile=main",
|
||||
Credentials{RTSPUsername: "user", RTSPPassword: "secret"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := url.Parse(streamURI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream.RawQuery != "profile=main" {
|
||||
t.Fatalf("RTSP query was unexpectedly changed: %q", stream.RawQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapterMapsAuthorizationFaultWithoutLeakingSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = writer.Write([]byte(`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><s:Fault><s:Reason><s:Text>The action requires authorization</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
secret := "not-for-errors"
|
||||
adapter := NewHTTPAdapter(staticCredentials{value: Credentials{
|
||||
ONVIFUsername: "user", ONVIFPassword: secret, RTSPUsername: "user", RTSPPassword: secret,
|
||||
}}, server.Client(), HTTPOptions{})
|
||||
_, err := adapter.Probe(context.Background(), Target{EndpointRef: server.URL, CredentialRef: "env://camera"})
|
||||
if CodeOf(err) != ErrorAuthentication || strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("expected redacted authentication error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvCredentialsAndDirectRTSPRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
values := map[string]string{
|
||||
"SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME": "onvif",
|
||||
"SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD": "onvif-secret",
|
||||
"SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME": "rtsp",
|
||||
"SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD": "rtsp-secret",
|
||||
}
|
||||
provider := EnvCredentials{LookupEnv: func(name string) (string, bool) {
|
||||
value, ok := values[name]
|
||||
return value, ok
|
||||
}}
|
||||
resolved, err := provider.Resolve("env://camera")
|
||||
if err != nil || resolved.RTSPUsername != "rtsp" {
|
||||
t.Fatalf("resolve credentials: %+v err=%v", resolved, err)
|
||||
}
|
||||
router := NewRouter(UnavailableAdapter{}, provider)
|
||||
result, err := router.Probe(context.Background(), Target{
|
||||
EndpointRef: "rtsp://127.0.0.1:8555/synthetic-1",
|
||||
})
|
||||
if err != nil || result.StreamURI != "rtsp://127.0.0.1:8555/synthetic-1" {
|
||||
t.Fatalf("route direct RTSP: %+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package onvif defines the ONVIF boundary used by Sense.
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
// EndpointRef identifies a device endpoint but must not contain credentials.
|
||||
EndpointRef string
|
||||
// CredentialRef is an opaque secret-store reference, never a password.
|
||||
CredentialRef string
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
VideoEncoder bool `json:"video_encoder"`
|
||||
}
|
||||
|
||||
type ProbeResult struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
StreamURI string `json:"stream_uri"`
|
||||
}
|
||||
|
||||
type Adapter interface {
|
||||
Probe(ctx context.Context, target Target) (ProbeResult, error)
|
||||
SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error
|
||||
}
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorAuthentication ErrorCode = "authentication_failed"
|
||||
ErrorTimeout ErrorCode = "timeout"
|
||||
ErrorUnavailable ErrorCode = "unavailable"
|
||||
ErrorInvalidReply ErrorCode = "invalid_response"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Code ErrorCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Err == nil {
|
||||
return string(e.Code)
|
||||
}
|
||||
return fmt.Sprintf("%s: %v", e.Code, e.Err)
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
|
||||
func CodeOf(err error) ErrorCode {
|
||||
var onvifError *Error
|
||||
if errors.As(err, &onvifError) {
|
||||
return onvifError.Code
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return ErrorTimeout
|
||||
}
|
||||
return ErrorUnavailable
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
camera Adapter
|
||||
credentials CredentialProvider
|
||||
}
|
||||
|
||||
func NewRouter(camera Adapter, credentials CredentialProvider) *Router {
|
||||
return &Router{camera: camera, credentials: credentials}
|
||||
}
|
||||
|
||||
func (r *Router) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||
endpoint, err := url.Parse(target.EndpointRef)
|
||||
if err != nil || endpoint.Host == "" || endpoint.User != nil {
|
||||
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("endpoint reference is invalid")}
|
||||
}
|
||||
switch endpoint.Scheme {
|
||||
case "http", "https":
|
||||
return r.camera.Probe(ctx, target)
|
||||
case "rtsp", "rtsps":
|
||||
if target.CredentialRef != "" {
|
||||
credentials, resolveErr := r.credentials.Resolve(target.CredentialRef)
|
||||
if resolveErr != nil {
|
||||
return ProbeResult{}, &Error{Code: ErrorAuthentication, Err: resolveErr}
|
||||
}
|
||||
endpoint.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
|
||||
}
|
||||
return ProbeResult{
|
||||
Profiles: []Profile{{Token: "direct", Name: "direct", VideoEncoder: true}},
|
||||
StreamURI: endpoint.String(),
|
||||
}, nil
|
||||
default:
|
||||
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("unsupported endpoint scheme")}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
|
||||
endpoint, err := url.Parse(target.EndpointRef)
|
||||
if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("clock sync requires an ONVIF endpoint")}
|
||||
}
|
||||
return r.camera.SetSystemDateAndTime(ctx, target, value)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"scenarios": {
|
||||
"onvif://camera-ok": {
|
||||
"result": {
|
||||
"manufacturer": "YoVision Fixture",
|
||||
"model": "Offline Camera",
|
||||
"firmware_version": "0.0-fixture",
|
||||
"serial_number": "REDACTED-001",
|
||||
"profiles": [
|
||||
{"token": "main", "name": "Main stream", "video_encoder": true},
|
||||
{"token": "sub", "name": "Sub stream", "video_encoder": true}
|
||||
],
|
||||
"stream_uri": "rtsp://media.invalid/camera-ok"
|
||||
}
|
||||
},
|
||||
"onvif://camera-auth": {
|
||||
"probe_error": "authentication_failed",
|
||||
"result": {}
|
||||
},
|
||||
"onvif://camera-slow": {
|
||||
"delay_millis": 100,
|
||||
"result": {
|
||||
"profiles": [{"token": "main", "name": "Main stream", "video_encoder": true}],
|
||||
"stream_uri": "rtsp://media.invalid/camera-slow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UnavailableAdapter keeps the process boundary explicit until T-006 supplies
|
||||
// a real, whitelist-validated ONVIF adapter. It must not be mistaken for a
|
||||
// compatibility implementation.
|
||||
type UnavailableAdapter struct{}
|
||||
|
||||
func (UnavailableAdapter) Probe(context.Context, Target) (ProbeResult, error) {
|
||||
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
|
||||
}
|
||||
|
||||
func (UnavailableAdapter) SetSystemDateAndTime(context.Context, Target, time.Time) error {
|
||||
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package probe maps MediaMTX runtime path health into the Sense actual state.
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
const defaultBatchSize = 128
|
||||
|
||||
type Repository interface {
|
||||
ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error)
|
||||
UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error
|
||||
RequestReconcile(ctx context.Context, id string, now time.Time) error
|
||||
}
|
||||
|
||||
type RuntimePaths interface {
|
||||
PathReady(ctx context.Context, name string) (bool, error)
|
||||
}
|
||||
|
||||
type Checker struct {
|
||||
repository Repository
|
||||
media RuntimePaths
|
||||
now func() time.Time
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func New(repository Repository, media RuntimePaths) *Checker {
|
||||
return &Checker{repository: repository, media: media, now: time.Now, batchSize: defaultBatchSize}
|
||||
}
|
||||
|
||||
func (c *Checker) RunOnce(ctx context.Context) error {
|
||||
devices, err := c.repository.ListEnabledVideoDevices(ctx, c.batchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list probe candidates: %w", err)
|
||||
}
|
||||
var runErrors []error
|
||||
for _, value := range devices {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ready, probeErr := c.media.PathReady(ctx, value.PathName)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
state := device.ActualOffline
|
||||
if probeErr == nil && ready {
|
||||
state = device.ActualOnline
|
||||
}
|
||||
now := c.now().UTC()
|
||||
if probeErr != nil {
|
||||
if requestErr := c.repository.RequestReconcile(ctx, value.ID, now); requestErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("request device %s reconciliation: %w", value.ID, requestErr))
|
||||
}
|
||||
}
|
||||
if updateErr := c.repository.UpdateActualState(ctx, value.ID, state, now); updateErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("update device %s health: %w", value.ID, updateErr))
|
||||
}
|
||||
if probeErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("probe device %s: %w", value.ID, probeErr))
|
||||
}
|
||||
}
|
||||
return errors.Join(runErrors...)
|
||||
}
|
||||
|
||||
func (c *Checker) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
devices []device.Device
|
||||
states map[string]device.ActualState
|
||||
requested map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeRepository) RequestReconcile(_ context.Context, id string, _ time.Time) error {
|
||||
if f.requested == nil {
|
||||
f.requested = make(map[string]int)
|
||||
}
|
||||
f.requested[id]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ListEnabledVideoDevices(context.Context, int) ([]device.Device, error) {
|
||||
return f.devices, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) UpdateActualState(_ context.Context, id string, state device.ActualState, _ time.Time) error {
|
||||
if f.states == nil {
|
||||
f.states = make(map[string]device.ActualState)
|
||||
}
|
||||
f.states[id] = state
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRuntime struct {
|
||||
ready map[string]bool
|
||||
errors map[string]error
|
||||
}
|
||||
|
||||
func (f fakeRuntime) PathReady(_ context.Context, name string) (bool, error) {
|
||||
return f.ready[name], f.errors[name]
|
||||
}
|
||||
|
||||
func TestCheckerMapsReadyAndUnavailablePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := &fakeRepository{devices: []device.Device{
|
||||
{ID: "online", PathName: "online"}, {ID: "offline", PathName: "offline"},
|
||||
}}
|
||||
checker := New(repository, fakeRuntime{
|
||||
ready: map[string]bool{"online": true}, errors: map[string]error{"offline": errors.New("unavailable")},
|
||||
})
|
||||
err := checker.RunOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("probe transport error must remain observable")
|
||||
}
|
||||
if repository.states["online"] != device.ActualOnline || repository.states["offline"] != device.ActualOffline {
|
||||
t.Fatalf("unexpected actual states: %+v", repository.states)
|
||||
}
|
||||
if repository.requested["offline"] != 1 || repository.requested["online"] != 0 {
|
||||
t.Fatalf("unexpected reconcile requests: %+v", repository.requested)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package reconcile converges MediaMTX paths from the database desired state.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
const defaultBatchSize = 128
|
||||
|
||||
type Repository interface {
|
||||
ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]store.ReconcileCandidate, error)
|
||||
MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error
|
||||
MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error
|
||||
}
|
||||
|
||||
type MediaPaths interface {
|
||||
EnsurePath(ctx context.Context, name, source string) (bool, error)
|
||||
}
|
||||
|
||||
type Reconciler struct {
|
||||
repository Repository
|
||||
discovery onvif.Adapter
|
||||
media MediaPaths
|
||||
now func() time.Time
|
||||
baseBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func New(repository Repository, discovery onvif.Adapter, media MediaPaths) *Reconciler {
|
||||
return &Reconciler{
|
||||
repository: repository, discovery: discovery, media: media,
|
||||
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) RunOnce(ctx context.Context) error {
|
||||
now := r.now().UTC()
|
||||
candidates, err := r.repository.ListDueReconcile(ctx, now, r.batchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list reconciliation candidates: %w", err)
|
||||
}
|
||||
var runErrors []error
|
||||
for _, candidate := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.reconcileOne(ctx, candidate, now); err != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("reconcile device %s: %w", candidate.Device.ID, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(runErrors...)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error {
|
||||
result, err := r.discovery.Probe(ctx, onvif.Target{
|
||||
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
|
||||
})
|
||||
if err == nil {
|
||||
err = validateStreamURI(result.StreamURI)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
|
||||
}
|
||||
if err == nil {
|
||||
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
failureCount := candidate.FailureCount + 1
|
||||
nextAttempt := now.Add(r.backoff(failureCount))
|
||||
errorCode := string(onvif.CodeOf(err))
|
||||
var onvifError *onvif.Error
|
||||
if !errors.As(err, &onvifError) {
|
||||
errorCode = "media_error"
|
||||
}
|
||||
if markErr := r.repository.MarkReconcileFailure(
|
||||
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, now,
|
||||
); markErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func validateStreamURI(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
|
||||
return &onvif.Error{Code: onvif.ErrorInvalidReply, Err: fmt.Errorf("stream URI has invalid scheme or host")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) backoff(failureCount int) time.Duration {
|
||||
if failureCount <= 1 {
|
||||
return r.baseBackoff
|
||||
}
|
||||
value := r.baseBackoff
|
||||
for step := 1; step < failureCount; step++ {
|
||||
if value >= r.maxBackoff/2 {
|
||||
return r.maxBackoff
|
||||
}
|
||||
value *= 2
|
||||
}
|
||||
if value > r.maxBackoff {
|
||||
return r.maxBackoff
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *Reconciler) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
type recordingMedia struct {
|
||||
calls int
|
||||
changed int
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (bool, error) {
|
||||
m.calls++
|
||||
if m.paths == nil {
|
||||
m.paths = make(map[string]string)
|
||||
}
|
||||
if m.paths[name] == source {
|
||||
return false, nil
|
||||
}
|
||||
m.paths[name] = source
|
||||
m.changed++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {Result: onvif.ProbeResult{
|
||||
Profiles: []onvif.Profile{{Token: "main", Name: "Main", VideoEncoder: true}},
|
||||
StreamURI: "rtsp://media.invalid/camera-1",
|
||||
}},
|
||||
})
|
||||
media := &recordingMedia{}
|
||||
reconciler := New(repository, discovery, media)
|
||||
reconciler.now = func() time.Time { return time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) }
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 1 || media.changed != 1 {
|
||||
t.Fatalf("converged generation should not repeat: calls=%d changed=%d", media.calls, media.changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffSurvivesStoreRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
databasePath := filepath.Join(t.TempDir(), "sense.db")
|
||||
repository := openRepository(t, databasePath)
|
||||
createReconcileDevice(t, repository)
|
||||
failingDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {ProbeError: onvif.ErrorAuthentication},
|
||||
})
|
||||
media := &recordingMedia{}
|
||||
initialTime := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
first := New(repository, failingDiscovery, media)
|
||||
first.now = func() time.Time { return initialTime }
|
||||
err := first.RunOnce(context.Background())
|
||||
if err == nil || onvif.CodeOf(err) != onvif.ErrorAuthentication {
|
||||
t.Fatalf("expected authentication failure, got %v", err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(databasePath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
successDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {Result: onvif.ProbeResult{
|
||||
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
|
||||
StreamURI: "rtsp://media.invalid/camera-1",
|
||||
}},
|
||||
})
|
||||
afterRestart := New(reopened, successDiscovery, media)
|
||||
afterRestart.now = func() time.Time { return initialTime.Add(500 * time.Millisecond) }
|
||||
if err := afterRestart.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 0 {
|
||||
t.Fatal("backoff window must survive restart")
|
||||
}
|
||||
afterRestart.now = func() time.Time { return initialTime.Add(time.Second) }
|
||||
if err := afterRestart.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 1 {
|
||||
t.Fatal("device must retry when persisted backoff expires")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationDoesNotPersistFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {
|
||||
DelayMillis: 100,
|
||||
Result: onvif.ProbeResult{
|
||||
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
|
||||
StreamURI: "rtsp://media.invalid/camera-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reconciler := New(repository, discovery, &recordingMedia{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
err := reconciler.RunOnce(ctx)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
candidates, err := repository.ListDueReconcile(context.Background(), time.Now().Add(time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 || candidates[0].FailureCount != 0 {
|
||||
t.Fatalf("cancellation must not consume retry budget: %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func openRepository(t *testing.T, path string) *store.SQLite {
|
||||
t.Helper()
|
||||
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Close() })
|
||||
return repository
|
||||
}
|
||||
|
||||
func createReconcileDevice(t *testing.T, repository *store.SQLite) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := repository.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateDevice(ctx, device.Device{
|
||||
ID: "camera-1", TenantID: "tenant", SiteID: "site", SerialNumber: "camera-1", Name: "Camera 1",
|
||||
Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
EndpointRef: "onvif://camera-1", CredentialRef: "secret://camera-1", PathName: "camera-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
// Package store persists the Sense desired state and reconciliation progress.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("store record not found")
|
||||
|
||||
type ReconcileCandidate struct {
|
||||
Device device.Device
|
||||
FailureCount int
|
||||
NextAttempt *time.Time
|
||||
}
|
||||
|
||||
type DeviceConvergence struct {
|
||||
ID string `json:"id"`
|
||||
PathName string `json:"path_name"`
|
||||
DesiredState device.DesiredState `json:"desired_state"`
|
||||
ActualState device.ActualState `json:"actual_state"`
|
||||
Generation int64 `json:"generation"`
|
||||
ObservedGeneration int64 `json:"observed_generation"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
||||
LastErrorCode string `json:"last_error_code,omitempty"`
|
||||
Converged bool `json:"converged"`
|
||||
}
|
||||
|
||||
type ConvergenceSnapshot struct {
|
||||
Total int `json:"total"`
|
||||
Unconverged int `json:"unconverged"`
|
||||
Devices []DeviceConvergence `json:"devices"`
|
||||
}
|
||||
|
||||
type SQLite struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenSQLite(ctx context.Context, dsn string) (*SQLite, error) {
|
||||
if err := ensureSQLiteDirectory(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
// M1 runs one writer per edge instance. A single connection also gives
|
||||
// deterministic quota transactions and avoids :memory: connection splits.
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
store := &SQLite{db: db}
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func ensureSQLiteDirectory(dsn string) error {
|
||||
if !strings.HasPrefix(dsn, "file:") {
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimPrefix(dsn, "file:")
|
||||
path = strings.SplitN(path, "?", 2)[0]
|
||||
if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") {
|
||||
return nil
|
||||
}
|
||||
directory := filepath.Dir(filepath.FromSlash(path))
|
||||
if directory == "." {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||
return fmt.Errorf("create sqlite directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) Migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("enable sqlite foreign keys: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA busy_timeout = 5000`); err != nil {
|
||||
return fmt.Errorf("configure sqlite busy timeout: %w", err)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, statement := range migrationStatements {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("apply sqlite migration: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO sense_schema_migrations(version, applied_at)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT(version) DO NOTHING`, formatTime(time.Now())); err != nil {
|
||||
return fmt.Errorf("record sqlite migration: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit sqlite migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var migrationStatements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS sense_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_sites (
|
||||
tenant_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
max_video_channels INTEGER NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128),
|
||||
PRIMARY KEY (tenant_id, id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
site_id TEXT NOT NULL,
|
||||
serial_number TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
modality TEXT NOT NULL,
|
||||
desired_state TEXT NOT NULL CHECK (desired_state IN ('disabled', 'enabled')),
|
||||
actual_state TEXT NOT NULL CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
|
||||
endpoint_ref TEXT NOT NULL DEFAULT '',
|
||||
credential_ref TEXT NOT NULL DEFAULT '',
|
||||
path_name TEXT NOT NULL DEFAULT '',
|
||||
generation INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, site_id, serial_number),
|
||||
FOREIGN KEY (tenant_id, site_id) REFERENCES sense_sites(tenant_id, id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_device_capabilities (
|
||||
device_id TEXT NOT NULL,
|
||||
capability TEXT NOT NULL,
|
||||
PRIMARY KEY (device_id, capability),
|
||||
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_reconcile_state (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT,
|
||||
last_error_code TEXT,
|
||||
observed_generation INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
|
||||
ON sense_devices(tenant_id, site_id, desired_state)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
|
||||
ON sense_devices(path_name) WHERE path_name <> ''`,
|
||||
`CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
|
||||
ON sense_reconcile_state(next_attempt_at)`,
|
||||
}
|
||||
|
||||
func (s *SQLite) EnsureSite(ctx context.Context, site device.Site) error {
|
||||
site.ApplyDefaults()
|
||||
if err := site.Validate(); err != nil {
|
||||
return fmt.Errorf("validate site: %w", err)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sense_sites(tenant_id, id, name, max_video_channels)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(tenant_id, id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
max_video_channels = excluded.max_video_channels`,
|
||||
site.TenantID, site.ID, site.Name, site.MaxVideoChannels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure site: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) CreateDevice(ctx context.Context, value device.Device) error {
|
||||
if value.Generation == 0 {
|
||||
value.Generation = 1
|
||||
}
|
||||
if value.ActualState == "" {
|
||||
value.ActualState = device.ActualPending
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return fmt.Errorf("validate device: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
value.UpdatedAt = now
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin create device: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if value.ConsumesVideoChannel() {
|
||||
if err := checkVideoQuota(ctx, tx, value.TenantID, value.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO sense_devices(
|
||||
id, tenant_id, site_id, serial_number, name, modality,
|
||||
desired_state, actual_state, endpoint_ref, credential_ref,
|
||||
path_name, generation, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name,
|
||||
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||
value.CredentialRef, value.PathName, value.Generation,
|
||||
formatTime(value.CreatedAt), formatTime(value.UpdatedAt))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert device: %w", err)
|
||||
}
|
||||
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO sense_device_capabilities(device_id, capability) VALUES (?, ?)`,
|
||||
value.ID, capability); err != nil {
|
||||
return fmt.Errorf("insert device capability: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO sense_reconcile_state(device_id, updated_at)
|
||||
VALUES (?, ?)`, value.ID, formatTime(now)); err != nil {
|
||||
return fmt.Errorf("insert reconcile state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit create device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkVideoQuota(ctx context.Context, tx *sql.Tx, tenantID, siteID string) error {
|
||||
var limit int
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT max_video_channels FROM sense_sites WHERE tenant_id = ? AND id = ?`,
|
||||
tenantID, siteID).Scan(&limit)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("site %s/%s: %w", tenantID, siteID, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read site quota: %w", err)
|
||||
}
|
||||
var current int
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM sense_devices d
|
||||
JOIN sense_device_capabilities c ON c.device_id = d.id
|
||||
WHERE d.tenant_id = ? AND d.site_id = ?
|
||||
AND d.desired_state = 'enabled'
|
||||
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count site video channels: %w", err)
|
||||
}
|
||||
if current >= limit {
|
||||
return &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
|
||||
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
|
||||
return fmt.Errorf("invalid desired state %q", desired)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin desired-state update: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var tenantID, siteID, endpointRef, pathName string
|
||||
var current device.DesiredState
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name FROM sense_devices WHERE id = ?`, id).
|
||||
Scan(&tenantID, &siteID, ¤t, &endpointRef, &pathName)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read device desired state: %w", err)
|
||||
}
|
||||
if current == desired {
|
||||
return tx.Commit()
|
||||
}
|
||||
if desired == device.DesiredEnabled {
|
||||
var videoCapability int
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sense_device_capabilities
|
||||
WHERE device_id = ? AND capability = 'video_capture'`, id).Scan(&videoCapability); err != nil {
|
||||
return fmt.Errorf("read video capability: %w", err)
|
||||
}
|
||||
if videoCapability > 0 {
|
||||
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||
return fmt.Errorf("enabled video devices require endpoint ref and path name")
|
||||
}
|
||||
if err := checkVideoQuota(ctx, tx, tenantID, siteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_devices
|
||||
SET desired_state = ?, actual_state = 'pending', generation = generation + 1, updated_at = ?
|
||||
WHERE id = ?`, desired, formatTime(time.Now()), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update desired state: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = ?
|
||||
WHERE device_id = ?`, formatTime(time.Now()), id); err != nil {
|
||||
return fmt.Errorf("reset reconcile state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit desired-state update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) GetDevice(ctx context.Context, id string) (device.Device, error) {
|
||||
row := s.db.QueryRowContext(ctx, deviceSelect+` WHERE d.id = ?`, id)
|
||||
value, err := scanDevice(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return device.Device{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return device.Device{}, fmt.Errorf("get device: %w", err)
|
||||
}
|
||||
capabilities, err := s.capabilities(ctx, value.ID)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
value.Capabilities = capabilities
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at
|
||||
FROM sense_devices d
|
||||
JOIN sense_reconcile_state r ON r.device_id = d.id
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense_device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
AND (r.observed_generation < d.generation OR r.failure_count > 0)
|
||||
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
|
||||
ORDER BY d.updated_at, d.id
|
||||
LIMIT ?`, formatTime(now), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due reconcile devices: %w", err)
|
||||
}
|
||||
candidates := make([]ReconcileCandidate, 0)
|
||||
for rows.Next() {
|
||||
var candidate ReconcileCandidate
|
||||
var createdAt, updatedAt string
|
||||
var nextAttempt sql.NullString
|
||||
if err := rows.Scan(
|
||||
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||
&candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||
&candidate.Device.PathName, &candidate.Device.Generation,
|
||||
&createdAt, &updatedAt, &candidate.FailureCount, &nextAttempt,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan due reconcile device: %w", err)
|
||||
}
|
||||
candidate.Device.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
candidate.Device.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if nextAttempt.Valid {
|
||||
value, parseErr := parseTime(nextAttempt.String)
|
||||
if parseErr != nil {
|
||||
rows.Close()
|
||||
return nil, parseErr
|
||||
}
|
||||
candidate.NextAttempt = &value
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close due reconcile rows: %w", err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate due reconcile devices: %w", err)
|
||||
}
|
||||
for index := range candidates {
|
||||
capabilities, err := s.capabilities(ctx, candidates[index].Device.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates[index].Device.Capabilities = capabilities
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, deviceSelect+`
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense_device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
ORDER BY d.id LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled video devices: %w", err)
|
||||
}
|
||||
values := make([]device.Device, 0)
|
||||
for rows.Next() {
|
||||
value, err := scanDevice(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan enabled video device: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close enabled video rows: %w", err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate enabled video devices: %w", err)
|
||||
}
|
||||
for index := range values {
|
||||
capabilities, err := s.capabilities(ctx, values[index].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[index].Capabilities = capabilities
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin reconciled update: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
observed_generation = ?, updated_at = ?
|
||||
WHERE device_id = ?`, generation, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark device reconciled: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_devices SET actual_state = 'pending', updated_at = ? WHERE id = ?`,
|
||||
formatTime(now), id); err != nil {
|
||||
return fmt.Errorf("mark reconciled device pending: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit reconciled update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin reconcile failure update: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET failure_count = ?, next_attempt_at = ?, last_error_code = ?, updated_at = ?
|
||||
WHERE device_id = ?`, failureCount, formatTime(nextAttempt), errorCode, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark reconcile failure: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_devices SET actual_state = 'failed', updated_at = ? WHERE id = ?`,
|
||||
formatTime(now), id); err != nil {
|
||||
return fmt.Errorf("mark failed device state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit reconcile failure update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
|
||||
if state != device.ActualPending && state != device.ActualOnline && state != device.ActualOffline && state != device.ActualFailed {
|
||||
return fmt.Errorf("invalid actual state %q", state)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE sense_devices SET actual_state = ?, updated_at = ? WHERE id = ?`,
|
||||
state, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update actual state: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestReconcile invalidates the observed generation without changing the
|
||||
// desired state or retry backoff. Runtime probes use it when MediaMTX loses a
|
||||
// configured path, including after a MediaMTX process restart.
|
||||
func (s *SQLite) RequestReconcile(ctx context.Context, id string, now time.Time) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET observed_generation = 0, updated_at = ?
|
||||
WHERE device_id = ?`, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request device reconciliation: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvergenceSnapshot returns only identifiers, state and counters. Endpoint
|
||||
// and credential references are deliberately excluded from diagnostics.
|
||||
func (s *SQLite) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
|
||||
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
|
||||
FROM sense_devices d
|
||||
JOIN sense_reconcile_state r ON r.device_id = d.id
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense_device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
ORDER BY d.id`)
|
||||
if err != nil {
|
||||
return ConvergenceSnapshot{}, fmt.Errorf("query convergence snapshot: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
|
||||
for rows.Next() {
|
||||
var value DeviceConvergence
|
||||
var nextAttempt, lastError sql.NullString
|
||||
if err := rows.Scan(
|
||||
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
|
||||
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
|
||||
&nextAttempt, &lastError,
|
||||
); err != nil {
|
||||
return ConvergenceSnapshot{}, fmt.Errorf("scan convergence snapshot: %w", err)
|
||||
}
|
||||
if nextAttempt.Valid {
|
||||
parsed, parseErr := parseTime(nextAttempt.String)
|
||||
if parseErr != nil {
|
||||
return ConvergenceSnapshot{}, parseErr
|
||||
}
|
||||
value.NextAttemptAt = &parsed
|
||||
}
|
||||
if lastError.Valid {
|
||||
value.LastErrorCode = lastError.String
|
||||
}
|
||||
value.Converged = value.ObservedGeneration == value.Generation &&
|
||||
value.FailureCount == 0 && value.ActualState == device.ActualOnline
|
||||
if !value.Converged {
|
||||
snapshot.Unconverged++
|
||||
}
|
||||
snapshot.Devices = append(snapshot.Devices, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ConvergenceSnapshot{}, fmt.Errorf("iterate convergence snapshot: %w", err)
|
||||
}
|
||||
snapshot.Total = len(snapshot.Devices)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
const deviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
|
||||
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
|
||||
d.path_name, d.generation, d.created_at, d.updated_at`
|
||||
|
||||
const deviceSelect = `SELECT ` + deviceColumns + ` FROM sense_devices d`
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanDevice(row scanner) (device.Device, error) {
|
||||
var value device.Device
|
||||
var createdAt, updatedAt string
|
||||
err := row.Scan(
|
||||
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber,
|
||||
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
||||
&value.EndpointRef, &value.CredentialRef, &value.PathName,
|
||||
&value.Generation, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
value.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
value.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT capability FROM sense_device_capabilities WHERE device_id = ? ORDER BY capability`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device capabilities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]device.Capability, 0)
|
||||
for rows.Next() {
|
||||
var value device.Capability
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
return nil, fmt.Errorf("scan device capability: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate device capabilities: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func sortedCapabilities(values []device.Capability) []device.Capability {
|
||||
result := append([]device.Capability(nil), values...)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, error) {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
func TestDefaultVideoQuotaRejectsSeventeenthChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-a", ID: "site-a", Name: "Site A"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= device.DefaultVideoChannels; index++ {
|
||||
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-a", "site-a")); err != nil {
|
||||
t.Fatalf("create channel %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
err := store.CreateDevice(ctx, videoDevice(17, "tenant-a", "site-a"))
|
||||
var quotaError *device.QuotaExceededError
|
||||
if !errors.As(err, "aError) || quotaError.Limit != 16 {
|
||||
t.Fatalf("expected 16-channel quota error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredMaximumAccepts128AndRejects129(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant-b", ID: "site-b", Name: "Site B", MaxVideoChannels: 128,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 128; index++ {
|
||||
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-b", "site-b")); err != nil {
|
||||
t.Fatalf("create channel %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
err := store.CreateDevice(ctx, videoDevice(129, "tenant-b", "site-b"))
|
||||
var quotaError *device.QuotaExceededError
|
||||
if !errors.As(err, "aError) || quotaError.Limit != 128 {
|
||||
t.Fatalf("expected 128-channel quota error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRejectsCapacityAbove128(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
err := store.EnsureSite(context.Background(), device.Site{
|
||||
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 129,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected capacity 129 to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonVideoDeviceDoesNotConsumeVideoQuota(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
radar := device.Device{
|
||||
ID: "radar-1", TenantID: "tenant", SiteID: "site", SerialNumber: "radar-1",
|
||||
Name: "Radar", Modality: device.ModalityRadar,
|
||||
Capabilities: []device.Capability{device.CapabilityTelemetry},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
}
|
||||
if err := store.CreateDevice(ctx, radar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
|
||||
t.Fatalf("video channel should remain available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnablingSeventeenthVideoDeviceIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-c", ID: "site-c", Name: "Site C"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 17; index++ {
|
||||
value := videoDevice(index, "tenant-c", "site-c")
|
||||
if index == 17 {
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
}
|
||||
if err := store.CreateDevice(ctx, value); err != nil {
|
||||
t.Fatalf("create device %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
err := store.SetDesiredState(ctx, "camera-017", device.DesiredEnabled)
|
||||
var quotaError *device.QuotaExceededError
|
||||
if !errors.As(err, "aError) || quotaError.Limit != 16 {
|
||||
t.Fatalf("expected enable to enforce quota, got %v", err)
|
||||
}
|
||||
value, err := store.GetDevice(ctx, "camera-017")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.DesiredState != device.DesiredDisabled {
|
||||
t.Fatal("failed enable must leave the existing desired state unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerQuotaDoesNotDisableExistingStreams(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 2,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 2; index++ {
|
||||
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-d", "site-d")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 2; index++ {
|
||||
value, err := store.GetDevice(ctx, fmt.Sprintf("camera-%03d", index))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.DesiredState != device.DesiredEnabled {
|
||||
t.Fatalf("existing channel %d was disabled", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvergenceSnapshotAndRuntimeReconcileRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
if err := store.MarkReconciled(ctx, "camera-001", 1, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateActualState(ctx, "camera-001", device.ActualOnline, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := store.ConvergenceSnapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Total != 1 || snapshot.Unconverged != 0 {
|
||||
t.Fatalf("expected converged snapshot, got %+v", snapshot)
|
||||
}
|
||||
if err := store.RequestReconcile(ctx, "camera-001", now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err = store.ConvergenceSnapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Unconverged != 1 || snapshot.Devices[0].ObservedGeneration != 0 {
|
||||
t.Fatalf("runtime loss must invalidate convergence: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *SQLite {
|
||||
t.Helper()
|
||||
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sense.db"))
|
||||
store, err := OpenSQLite(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
func videoDevice(index int, tenantID, siteID string) device.Device {
|
||||
id := fmt.Sprintf("camera-%03d", index)
|
||||
return device.Device{
|
||||
ID: id, TenantID: tenantID, SiteID: siteID, SerialNumber: id, Name: id,
|
||||
Modality: device.ModalityVideo,
|
||||
Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
EndpointRef: "onvif://" + id, CredentialRef: "secret://" + id,
|
||||
PathName: "sense/" + tenantID + "/" + siteID + "/" + id,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CameraEnv,
|
||||
[string]$RuntimeRoot = (Join-Path ([IO.Path]::GetTempPath()) 'yovision-t006'),
|
||||
[ValidateRange(1, 1440)]
|
||||
[int]$ObservationMinutes = 30,
|
||||
[switch]$KeepSession
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Read-EnvFile([string]$Path) {
|
||||
$result = @{}
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) {
|
||||
return
|
||||
}
|
||||
$parts = $line -split '=', 2
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.Length -ge 2 -and (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'")))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$result[$parts[0].Trim().ToLowerInvariant()] = $value
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Require-Keys([hashtable]$Config, [string[]]$Keys) {
|
||||
foreach ($key in $Keys) {
|
||||
if (-not $Config.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($Config[$key])) {
|
||||
throw "camera environment is missing required key: $key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PortFree([int]$Port) {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$task = $client.ConnectAsync('127.0.0.1', $Port)
|
||||
if ($task.Wait(250) -and $client.Connected) {
|
||||
throw "required local port is already in use: $Port"
|
||||
}
|
||||
}
|
||||
catch [AggregateException] {
|
||||
return
|
||||
}
|
||||
catch [Net.Sockets.SocketException] {
|
||||
return
|
||||
}
|
||||
finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-Port([int]$Port, [int]$TimeoutSeconds = 30) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$task = $client.ConnectAsync('127.0.0.1', $Port)
|
||||
if ($task.Wait(500) -and $client.Connected) {
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "local port did not become ready: $Port"
|
||||
}
|
||||
|
||||
function Start-ManagedProcess(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[hashtable]$Environment = @{}
|
||||
) {
|
||||
$start = [Diagnostics.ProcessStartInfo]::new()
|
||||
$start.FileName = $FilePath
|
||||
$start.WorkingDirectory = $session
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
$start.RedirectStandardOutput = $true
|
||||
$start.RedirectStandardError = $true
|
||||
foreach ($argument in $Arguments) {
|
||||
$start.ArgumentList.Add($argument)
|
||||
}
|
||||
foreach ($entry in $Environment.GetEnumerator()) {
|
||||
$start.Environment[$entry.Key] = [string]$entry.Value
|
||||
}
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $start
|
||||
if (-not $process.Start()) {
|
||||
throw "failed to start process: $Name"
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
Name = $Name
|
||||
Process = $process
|
||||
Stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
Stderr = $process.StandardError.ReadToEndAsync()
|
||||
StartedAt = [DateTimeOffset]::UtcNow
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-ManagedProcess($Managed) {
|
||||
if ($null -eq $Managed -or $null -eq $Managed.Process) {
|
||||
return
|
||||
}
|
||||
if (-not $Managed.Process.HasExited) {
|
||||
# Every fixture is started as a leaf process. Killing only that exact
|
||||
# process avoids Windows process-tree edge cases during fault tests.
|
||||
$Managed.Process.Kill()
|
||||
$Managed.Process.WaitForExit(10000) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Alive($Managed) {
|
||||
if ($null -eq $Managed -or $Managed.Process.HasExited) {
|
||||
$code = if ($null -eq $Managed) { 'not-started' } else { $Managed.Process.ExitCode }
|
||||
throw "required process exited: $($Managed.Name), code=$code"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-LabStatus {
|
||||
$lastDiagnostic = ''
|
||||
foreach ($attempt in 1..5) {
|
||||
$raw = @(& $labBinary status -db $databaseDSN 2>&1)
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
try {
|
||||
return (($raw -join "`n") | ConvertFrom-Json)
|
||||
}
|
||||
catch {
|
||||
$lastDiagnostic = 'invalid JSON response'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$lastDiagnostic = (($raw -join "`n") -split "`r?`n" | Select-Object -Last 2) -join ' | '
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "sense-lab status failed after retries: $lastDiagnostic"
|
||||
}
|
||||
|
||||
function Wait-Converged([int]$TimeoutSeconds = 180) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||
try {
|
||||
$snapshot = Invoke-LabStatus
|
||||
if ([int]$snapshot.total -eq 5 -and [int]$snapshot.unconverged -eq 0) {
|
||||
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
throw 'five devices did not converge before timeout'
|
||||
}
|
||||
|
||||
function Wait-DeviceState([string]$ID, [string]$State, [int]$TimeoutSeconds = 90) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||
try {
|
||||
$snapshot = Invoke-LabStatus
|
||||
$match = @($snapshot.devices | Where-Object { $_.id -eq $ID })
|
||||
if ($match.Count -eq 1 -and $match[0].actual_state -eq $State) {
|
||||
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
throw "device did not reach expected state: $ID/$State"
|
||||
}
|
||||
|
||||
function Wait-SenseHealth($Managed, [int]$TimeoutSeconds = 30) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||
if ($Managed.Process.HasExited) {
|
||||
$output = @(
|
||||
$Managed.Stdout.GetAwaiter().GetResult()
|
||||
$Managed.Stderr.GetAwaiter().GetResult()
|
||||
) -join "`n"
|
||||
$summary = (($output -split "`r?`n") | Where-Object { $_ } | Select-Object -Last 3) -join ' | '
|
||||
throw "Sense exited before health check, code=$($Managed.Process.ExitCode), output=$summary"
|
||||
}
|
||||
try {
|
||||
$response = Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:18080/healthz' -TimeoutSec 2 -NoProxy
|
||||
if ($response.status -eq 'ok') {
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
$listeners = @(Get-NetTCPConnection -State Listen -OwningProcess $Managed.Process.Id -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { "$($_.LocalAddress):$($_.LocalPort)" })
|
||||
throw "Sense health endpoint did not become ready; process listeners=$($listeners -join ',')"
|
||||
}
|
||||
|
||||
function Start-Publisher([int]$Index) {
|
||||
return Start-ManagedProcess "publisher-$Index" $ffmpeg @(
|
||||
'-hide_banner', '-loglevel', 'warning', '-re',
|
||||
'-f', 'lavfi', '-i', "testsrc2=size=640x360:rate=10",
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuv420p', '-g', '10', '-an',
|
||||
'-f', 'rtsp', '-rtsp_transport', 'tcp',
|
||||
"rtsp://127.0.0.1:8555/synthetic-$Index"
|
||||
)
|
||||
}
|
||||
|
||||
function Start-Proxy {
|
||||
return Start-ManagedProcess 'real-camera-network-proxy' $proxyBinary @(
|
||||
'-listen', '127.0.0.1:10554', '-upstream', "$($camera.host):$($camera.rtspport)"
|
||||
)
|
||||
}
|
||||
|
||||
function Start-Sense {
|
||||
$environment = @{
|
||||
SENSE_HTTP_ADDR = '127.0.0.1:18080'
|
||||
SENSE_DB_DSN = $databaseDSN
|
||||
SENSE_MEDIAMTX_URL = 'http://127.0.0.1:9997'
|
||||
SENSE_RECONCILE_INTERVAL = '1s'
|
||||
SENSE_PROBE_INTERVAL = '1s'
|
||||
SENSE_ONVIF_MODE = 'standard'
|
||||
SENSE_ONVIF_RTSP_REWRITE_HOST = '127.0.0.1'
|
||||
SENSE_ONVIF_RTSP_REWRITE_PORT = '10554'
|
||||
SENSE_ONVIF_RTSP_STRIP_QUERY = 'true'
|
||||
SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME = $camera.onvifuser
|
||||
SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD = $camera.onvifpwd
|
||||
SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME = $camera.username
|
||||
SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD = $camera.password
|
||||
}
|
||||
return Start-ManagedProcess -Name 'sense-api' -FilePath $senseBinary -Arguments @() -Environment $environment
|
||||
}
|
||||
|
||||
function Start-ProductionMediaMTX {
|
||||
return Start-ManagedProcess 'mediamtx-production' $mediaMTX @($productionConfig)
|
||||
}
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$cameraPath = (Resolve-Path -LiteralPath $CameraEnv).Path
|
||||
$camera = Read-EnvFile $cameraPath
|
||||
Require-Keys $camera @('host', 'username', 'password', 'rtspport', 'onvif', 'onvifuser', 'onvifpwd')
|
||||
|
||||
foreach ($port in 8554, 8555, 9997, 10554, 18080) {
|
||||
Assert-PortFree $port
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $RuntimeRoot -Force | Out-Null
|
||||
$mediaDirectory = Join-Path $RuntimeRoot 'mediamtx-v1.19.3'
|
||||
$mediaMTX = Join-Path $mediaDirectory 'mediamtx.exe'
|
||||
if (-not (Test-Path -LiteralPath $mediaMTX)) {
|
||||
$zip = Join-Path $RuntimeRoot 'mediamtx_v1.19.3_windows_amd64.zip'
|
||||
Invoke-WebRequest 'https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/mediamtx_v1.19.3_windows_amd64.zip' -OutFile $zip
|
||||
$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe') {
|
||||
throw 'MediaMTX checksum mismatch'
|
||||
}
|
||||
New-Item -ItemType Directory -Path $mediaDirectory -Force | Out-Null
|
||||
Expand-Archive -LiteralPath $zip -DestinationPath $mediaDirectory -Force
|
||||
}
|
||||
|
||||
$ffmpeg = (Get-Command ffmpeg -ErrorAction Stop).Source
|
||||
$session = Join-Path $RuntimeRoot ('session-' + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $session | Out-Null
|
||||
$senseBinary = Join-Path $session 'sense-api.exe'
|
||||
$labBinary = Join-Path $session 'sense-lab.exe'
|
||||
$proxyBinary = Join-Path $session 'rtsp-fault-proxy.exe'
|
||||
$productionConfig = Join-Path $session 'mediamtx-production.yml'
|
||||
$syntheticConfig = Join-Path $session 'mediamtx-synthetic.yml'
|
||||
[IO.File]::Copy((Join-Path $repoRoot 'Sense\deploy\mediamtx.yml'), $productionConfig)
|
||||
[IO.File]::Copy((Join-Path $repoRoot 'Sense\deploy\mediamtx-synthetic.yml'), $syntheticConfig)
|
||||
|
||||
& go -C (Join-Path $repoRoot 'Sense') build -o $senseBinary ./cmd/sense-api
|
||||
if ($LASTEXITCODE -ne 0) { throw 'build sense-api failed' }
|
||||
& go -C (Join-Path $repoRoot 'Sense') build -o $labBinary ./cmd/sense-lab
|
||||
if ($LASTEXITCODE -ne 0) { throw 'build sense-lab failed' }
|
||||
& go -C (Join-Path $repoRoot 'Sense') build -o $proxyBinary ./cmd/rtsp-fault-proxy
|
||||
if ($LASTEXITCODE -ne 0) { throw 'build RTSP fault proxy failed' }
|
||||
|
||||
$databasePath = Join-Path $session 'sense.db'
|
||||
$databaseDSN = 'file:' + $databasePath.Replace('\', '/')
|
||||
$manifestPath = Join-Path $session 'manifest.json'
|
||||
$onvifEndpoint = if ($camera.onvif -match '^https?://') {
|
||||
$camera.onvif
|
||||
} else {
|
||||
"http://$($camera.host):$($camera.onvif)/onvif/device_service"
|
||||
}
|
||||
$devices = @(
|
||||
[ordered]@{ id = 'camera-real'; tenant_id = 'lab'; site_id = 'site'; serial_number = 'e9ed6a555ae0'; name = 'Approved real camera'; capabilities = @('video_capture', 'audio_capture'); endpoint_ref = $onvifEndpoint; credential_ref = 'env://camera'; path_name = 'sense/lab/site/camera-real' }
|
||||
)
|
||||
foreach ($index in 1..4) {
|
||||
$devices += [ordered]@{ id = "synthetic-$index"; tenant_id = 'lab'; site_id = 'site'; serial_number = "synthetic-$index"; name = "Synthetic source $index"; capabilities = @('video_capture'); endpoint_ref = "rtsp://127.0.0.1:8555/synthetic-$index"; credential_ref = ''; path_name = "sense/lab/site/synthetic-$index" }
|
||||
}
|
||||
[ordered]@{
|
||||
site = [ordered]@{ tenant_id = 'lab'; id = 'site'; name = 'T-006 Lab'; max_video_channels = 16 }
|
||||
devices = $devices
|
||||
} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||
|
||||
$managed = [Collections.Generic.List[object]]::new()
|
||||
$publishers = @{}
|
||||
$events = [Collections.Generic.List[object]]::new()
|
||||
$observationSamples = 0
|
||||
$maxUnconverged = 0
|
||||
$success = $false
|
||||
$stage = 'starting fixtures'
|
||||
$failureMessage = $null
|
||||
try {
|
||||
$sourceMedia = Start-ManagedProcess 'mediamtx-synthetic' $mediaMTX @($syntheticConfig)
|
||||
$managed.Add($sourceMedia)
|
||||
Wait-Port 8555
|
||||
$productionMedia = Start-ProductionMediaMTX
|
||||
$managed.Add($productionMedia)
|
||||
Wait-Port 9997
|
||||
|
||||
foreach ($index in 1..4) {
|
||||
$publishers[$index] = Start-Publisher $index
|
||||
$managed.Add($publishers[$index])
|
||||
}
|
||||
$stage = 'checking synthetic publishers'
|
||||
Start-Sleep -Seconds 3
|
||||
foreach ($publisher in $publishers.Values) { Assert-Alive $publisher }
|
||||
|
||||
$proxy = Start-Proxy
|
||||
$managed.Add($proxy)
|
||||
Wait-Port 10554
|
||||
|
||||
& $labBinary seed -db $databaseDSN -manifest $manifestPath | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'seed device ledger failed' }
|
||||
$stage = 'initial convergence'
|
||||
$sense = Start-Sense
|
||||
$managed.Add($sense)
|
||||
Wait-SenseHealth $sense
|
||||
$initialSeconds = Wait-Converged
|
||||
$events.Add([ordered]@{ event = 'initial_convergence'; seconds = $initialSeconds; unconverged = 0 })
|
||||
|
||||
$stage = 'real camera network recovery'
|
||||
Stop-ManagedProcess $proxy
|
||||
$offlineSeconds = Wait-DeviceState 'camera-real' 'offline'
|
||||
$proxy = Start-Proxy
|
||||
$managed.Add($proxy)
|
||||
Wait-Port 10554
|
||||
$recoverySeconds = Wait-Converged
|
||||
$events.Add([ordered]@{ event = 'real_camera_network'; offline_detect_seconds = $offlineSeconds; recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||
|
||||
$stage = 'synthetic publisher recovery'
|
||||
Stop-ManagedProcess $publishers[2]
|
||||
$offlineSeconds = Wait-DeviceState 'synthetic-2' 'offline'
|
||||
$publishers[2] = Start-Publisher 2
|
||||
$managed.Add($publishers[2])
|
||||
$recoverySeconds = Wait-Converged
|
||||
$events.Add([ordered]@{ event = 'synthetic_publisher'; offline_detect_seconds = $offlineSeconds; recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||
|
||||
$stage = 'Sense restart recovery'
|
||||
Stop-ManagedProcess $sense
|
||||
$restartWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$sense = Start-Sense
|
||||
$managed.Add($sense)
|
||||
Wait-SenseHealth $sense
|
||||
$recoverySeconds = Wait-Converged
|
||||
$events.Add([ordered]@{ event = 'sense_restart'; process_ready_seconds = [Math]::Round($restartWatch.Elapsed.TotalSeconds, 1); recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||
|
||||
$stage = 'MediaMTX restart recovery'
|
||||
Stop-ManagedProcess $productionMedia
|
||||
Start-Sleep -Seconds 3
|
||||
$productionMedia = Start-ProductionMediaMTX
|
||||
$managed.Add($productionMedia)
|
||||
Wait-Port 9997
|
||||
$recoverySeconds = Wait-Converged 240
|
||||
$events.Add([ordered]@{ event = 'mediamtx_restart'; recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||
|
||||
$stage = 'checking MediaMTX path count'
|
||||
$configured = Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:9997/v3/config/paths/list' -TimeoutSec 5 -NoProxy
|
||||
if ([int]$configured.itemCount -ne 5) {
|
||||
throw "expected 5 MediaMTX paths, got $($configured.itemCount)"
|
||||
}
|
||||
|
||||
$stage = 'stability observation'
|
||||
$observation = [Diagnostics.Stopwatch]::StartNew()
|
||||
$targetSeconds = $ObservationMinutes * 60
|
||||
while ($observation.Elapsed.TotalSeconds -lt $targetSeconds) {
|
||||
Assert-Alive $sourceMedia
|
||||
Assert-Alive $productionMedia
|
||||
Assert-Alive $sense
|
||||
Assert-Alive $proxy
|
||||
foreach ($publisher in $publishers.Values) { Assert-Alive $publisher }
|
||||
$snapshot = Invoke-LabStatus
|
||||
$observationSamples++
|
||||
$maxUnconverged = [Math]::Max($maxUnconverged, [int]$snapshot.unconverged)
|
||||
if ([int]$snapshot.total -ne 5 -or [int]$snapshot.unconverged -ne 0) {
|
||||
throw "observation detected unconverged devices: $($snapshot.unconverged)"
|
||||
}
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
$observationSeconds = [Math]::Round($observation.Elapsed.TotalSeconds, 1)
|
||||
$final = Invoke-LabStatus
|
||||
$success = $true
|
||||
[ordered]@{
|
||||
success = $true
|
||||
mediamtx_version = 'v1.19.3'
|
||||
mediamtx_sha256 = '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe'
|
||||
source_count = 5
|
||||
synthetic_publishers = 4
|
||||
configured_paths = [int]$configured.itemCount
|
||||
recovery = $events
|
||||
observation_seconds = $observationSeconds
|
||||
observation_samples = $observationSamples
|
||||
maximum_unconverged = $maxUnconverged
|
||||
final_unconverged = [int]$final.unconverged
|
||||
} | ConvertTo-Json -Depth 8
|
||||
}
|
||||
catch {
|
||||
$failureMessage = "T-006 stage '$stage' failed: $($_.Exception.Message)"
|
||||
}
|
||||
finally {
|
||||
foreach ($item in @($managed)) {
|
||||
Stop-ManagedProcess $item
|
||||
}
|
||||
if (-not $KeepSession) {
|
||||
Get-ChildItem -LiteralPath $session -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { $_.Attributes = 'Normal' }
|
||||
if (Test-Path -LiteralPath $session) {
|
||||
(Get-Item -LiteralPath $session -Force).Attributes = 'Directory'
|
||||
Remove-Item -LiteralPath $session -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if (-not $success) {
|
||||
Write-Warning 'T-006 integration did not complete; no success evidence was emitted.'
|
||||
}
|
||||
}
|
||||
if ($failureMessage) {
|
||||
throw $failureMessage
|
||||
}
|
||||
@@ -40,19 +40,19 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前为 **M0:兼容性验证 + 需求定稿**。
|
||||
当前为 **M0 指定型号实机准入已完成,M1 Sense 五路混合源集成待执行**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。
|
||||
|
||||
优先路径:
|
||||
|
||||
1. M0:3–5 款摄像头跑通 ONVIF 核心操作,形成采购白名单;关闭架构影响型开放问题。
|
||||
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,5 路自动建 path、探活、断线重建。
|
||||
1. M0:已用一台真实样机完成首期指定“型号 + 硬件版本 + 固件”ONVIF/RTSP 准入;结论不外推为多品牌兼容,真实断网恢复证据按负责人豁免留痕。
|
||||
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,以 1 路准入实机 + 至少 4 路独立合成源完成五路自动建 path、探活和断线重建。
|
||||
3. M2:对账、多租户、隧道和至少一个站点的 16 路全流程。
|
||||
4. M3:Brain + Bell 起步,默认 16 路端到端事件、预警、ack 与误报反馈。
|
||||
5. M4–M5:64/128 路分片、管理端和第二/第三场景包。
|
||||
|
||||
## 任务领取与状态
|
||||
|
||||
- Gitea Issue 是实时状态权威,状态标签为 `status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
|
||||
- Gitea Issue 是实时状态权威,状态标签为 `status/waiting`、`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`;`waiting` 表示尚未领取且因依赖或外部条件不可领取。
|
||||
- 任务文件保存不可变规格、依赖、写路径、验证门禁和执行证据。
|
||||
- 一个 agent 同时最多一个活跃任务;一个任务同时只有一个写入者。
|
||||
- dispatcher 创建 `claims/T-<编号>` 和 `agent/<agent-id>/T-<编号>` 后,worker 必须读回确认。
|
||||
@@ -85,6 +85,10 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
||||
python scripts/validate_agent_context.py
|
||||
python -m unittest discover -s tests -p "test_*.py"
|
||||
python scripts/validate_harness_governance.py
|
||||
go -C Sense generate ./internal/mtx
|
||||
go -C Sense test ./...
|
||||
go -C Sense vet ./...
|
||||
go -C Sense build ./...
|
||||
```
|
||||
|
||||
当前没有生产代码构建命令。代码出现后,以 [`03-tech-stack.md`](03-tech-stack.md) 的验证矩阵和当前任务门禁为准。
|
||||
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。
|
||||
|
||||
+14
-2
@@ -18,20 +18,25 @@
|
||||
## 2. 当前 M0 验收
|
||||
|
||||
- 在隔离实验室运行 MiBeeNvr 等现成测试台,不改 `_reference/`,不接真实客户摄像头。
|
||||
- 选择 3–5 款候选摄像头,逐款通过 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`。
|
||||
- 记录断线恢复、认证失败、时间漂移、主/子码流和厂商差异,形成采购白名单。
|
||||
- 首期统一采购一个指定摄像头型号;至少使用一台真实样机,对冻结的“厂商 + 型号 + 硬件版本 + 固件版本”组合通过 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`。
|
||||
- 记录认证失败、时间漂移、主/子码流各至少 10 分钟以及至少 3 次断线恢复,形成指定型号准入记录。结论不得外推到其他型号/固件、生产批次或多品牌兼容;基线变化必须重新验证。
|
||||
- 执行 `raw/01-需求收集.md` §8 已批准的决策台账;只将明确记录的下游法务、客户、硬件和供应商门禁留到对应里程碑。
|
||||
- M0 代码与临时配置可丢弃,不作为生产基线;可借鉴范围严格遵循 NVR 白名单。
|
||||
|
||||
本地硬件开发基线为现有一台 Hikvision IP Camera,精确型号/硬件/固件由 T-001 冻结;生产代码仍只依赖标准 ONVIF/RTSP adapter,不得写死海康品牌或私有地址。M1 实验室使用该 1 路实机和至少 4 条可独立启停的合成 RTSP 上游完成五路软件闭环。合成源可用于功能、配额和容量测试,但不能证明多台真实设备故障隔离、批次一致性或生产 SLA;这些结论由客户授权、借用或租赁设备的 T-007 现场门禁提供。
|
||||
|
||||
## 3. P0 功能要求
|
||||
|
||||
### 3.1 接入与设备
|
||||
|
||||
- 支持标准 ONVIF/RTSP,不绑定摄像头品牌。
|
||||
- 台账与管理端以“设备”为根实体,使用 `modality` 表达 video / radar / contact / button / wearable / other,并使用 `capabilities` 决定是否展示画面、媒体、空间配置、遥测等能力;M1~M5 只完整实现 video 适配器,M6 再接入非视频设备,但不得因此把数据模型和一级信息架构写死为摄像头。
|
||||
- 支持 NAT 后的边缘主动推流;设备身份使用稳定序列号而非 IP。
|
||||
- 批量开通不依赖逐路手工操作,支持待激活中间态。
|
||||
- 探活、离线告警、开通校时、断线自动恢复。
|
||||
- 容量写入时校验站点配额;配额服务不可用时拒绝新增/启用,但不影响已有流。
|
||||
- Site、Area 与隐私准入策略由 Bell 统一持有;`capture_policy = video_allowed | non_imaging_only` 通过版本化内部 API 或只读投影提供给 Sense。Sense 在设备新增/启用时依据设备成像能力校验,并显示策略版本/同步状态;策略读取失败时拒绝新的成像设备变更并告警,已有链路不静默停用或伪装为已收敛。
|
||||
- ONVIF 能力探测必须持久化厂商、型号、固件、认证方式、Profiles、校时与事件订阅结果以及探测时间;UI 区分探测值和最终生效值。M6 前未实现的非视频协议适配器使用 `adapter_not_ready`,不得伪造在线状态或遥测。
|
||||
|
||||
### 3.2 分析与规则
|
||||
|
||||
@@ -51,8 +56,13 @@
|
||||
|
||||
- 预警必须有 ack;未 ack 自动升级,进程重启后能续跑。
|
||||
- 升级链、超时、联系人和时段可按租户/站点配置。
|
||||
- 联系人与值班排班必须统一设计并共享人员、值班组和已验证通知通道主数据,但分对象、分版本管理:联系人不承载轮换字段,排班不复制手机号;升级步骤通过类型化目标引用指定人员、值班组或排班计划,不写死号码。
|
||||
- 值班排班至少覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突检查、当前与未来值班人预览、版本发布和审计。每次投递创建时解析当时生效的排班版本,并固化实际收件人、通道与解析版本快照;后续修改不得改写历史投递事实。
|
||||
- 至少两条独立投递路径,其中一条可绕过互联网。
|
||||
- 区分已发出、已送达、已看到;没有回执不能当成功。
|
||||
- Alert 与 Event 保持可导航的多对多关系;被规则抑制且没有创建 Alert 的 Event 不伪装为已投递。
|
||||
- 班次交接覆盖未 ack、处置中与升级中的 Alert;接班确认留痕,交接过程不暂停或重置升级链。
|
||||
- 交接班只显式转移进行中 Alert 的处置责任,不静默修改未来排班;未来班次替换通过排班临时替班并发布新版本完成。
|
||||
- 静默必须限时且自动恢复,单次不超过 4 小时,无永久静默。
|
||||
- 业务预警与运维告警使用不同通道和值班配置。
|
||||
|
||||
@@ -61,12 +71,14 @@
|
||||
- 多租户数据、账号、配置和存储隔离;最小 RBAC 为平台管理员、租户管理员、站点管理员、值班员、只读。
|
||||
- 全链路审计,预警生命周期可追溯。
|
||||
- Prometheus/Grafana 至少覆盖设备在线、流状态、推理延迟、事件量、未收敛项和投递 SLA。
|
||||
- Sense 提供面向接入运维的运维中心,聚合对账差异、重试退避、孤儿安全闸、媒体/推理分片、边缘隧道/补传和运维告警;它不承担 Bell 的业务预警和全局管理审计。
|
||||
- 断网时边缘缓存事件,恢复后补传。
|
||||
- 不在代码、日志、证据文件名和工单中泄露摄像头凭据、客户名或敏感地址。
|
||||
|
||||
### 3.6 管理端与外部集成
|
||||
|
||||
- Bell 自研并持有事件、Alert、ack、升级链、租户和审计真相;客户既有平台不得成为这些状态的唯一真相源。
|
||||
- Bell 管理端持有 Tenant、Site、Area、RBAC、配额和全局审计真相;Sense 只消费当前租户/站点/角色上下文和带版本的配额/Area 策略投影。Area 策略变更与已有成像设备冲突时必须显式迁移或取消,禁止静默停用。
|
||||
- 对外使用版本化 OpenAPI/Webhook;M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统。
|
||||
- 投递层必须使用供应商无关的 provider 接口;试点至少有本地声光/Web 与一条短信或语音,生产前补齐两条独立路径及故障切换。
|
||||
|
||||
|
||||
+27
-3
@@ -8,6 +8,7 @@
|
||||
| --- | --- | --- |
|
||||
| 首期部署 | 客户侧私有化实例 | 保留 `tenant_id`、RBAC、schema 和 API 的 SaaS-ready 隔离边界 |
|
||||
| M1–M3 视频接入 | ONVIF / RTSP | 售前盘点存量 NVR;GB/T 28181-2022 不进当前 MVP,按需另建适配任务 |
|
||||
| 本地摄像头基线 | 1 台 Hikvision IP Camera + 独立合成 RTSP 源 | 海康精确型号/固件由 T-001 冻结;T-006 冻结合成 publisher 及版本。仅为开发策略,不在业务代码写死品牌,也不替代 T-007 真实多路现场验收 |
|
||||
| Sense | Go | 设备、ONVIF、MediaMTX 控制与对账 |
|
||||
| 媒体数据面 | MediaMTX 独立二进制 | MIT;M1 正式生产基线 |
|
||||
| MediaMTX API | 从官方 OpenAPI 用 `oapi-codegen` 生成 + 薄封装 | 不依赖第三方非官方 SDK |
|
||||
@@ -28,6 +29,18 @@
|
||||
| 指标 | Prometheus + Grafana | 三系统统一可观测入口 |
|
||||
| 追踪 | OpenTelemetry + Jaeger | 端到端事件链路 |
|
||||
|
||||
### 1.1 Sense M1 冻结版本(T-003)
|
||||
|
||||
| 组件 | 冻结版本 | 许可证 / 校验 | 升级与退出路线 |
|
||||
| --- | --- | --- | --- |
|
||||
| Go | `1.26.5`(`go 1.26.0` + `toolchain go1.26.5`) | BSD-3-Clause;从 `go.dev/dl` 校验,Windows amd64 ZIP SHA-256 `97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38` | 跟随仍受支持的 Go 小版本,先在 CI/目标平台跑全量测试再升级;标准 Go module,无私有运行时绑定 |
|
||||
| SQLite driver | `modernc.org/sqlite v1.54.0` | BSD-3-Clause;module sum `h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=`,go.mod sum `h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=` | 选择无 CGO driver 以简化 Windows/边缘部署;只经 `database/sql` 与 repository 使用,可替换 driver;生产仍迁移到 PostgreSQL `sense` schema |
|
||||
| MediaMTX | `v1.19.3` | MIT;官方 release `checksums.sha256`:Windows amd64 `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe`、Linux amd64 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`、Linux arm64 `9e5b38a5b5fcab1916341b024031b2fc5dc6a2059baed9ba3f3b0d3768d231a8` | 独立进程,不链接到 Sense;升级时先更新 vendored OpenAPI、重新生成并跑假服务契约测试;可通过 `mtx` port 更换媒体数据面 |
|
||||
| oapi-codegen | `v2.8.0` | Apache-2.0;module sum `h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=`,go.mod sum `h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=` | 仅为构建工具;版本锁在 module tool dependency,生成文件与薄封装分离;升级后必须重新生成并检查 diff |
|
||||
| oapi-codegen runtime | `v1.6.0` | Apache-2.0;module sum `h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=`,go.mod sum `h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=` | 生成客户端的最小运行时;与生成器一起升级并跑假 HTTP 契约测试,退出时随生成客户端一并替换 |
|
||||
|
||||
MediaMTX 官方 `v1.19.3` OpenAPI 固定保存于 `Sense/api/vendor/mediamtx-v1.19.3.openapi.yaml`,SHA-256 为 `a2b58195f1ec76541e124b5de4ee54645e5a3e25f70c4a73acc4a44d6f2b9c52`。下载二进制后必须对照上表或官方同版 `checksums.sha256`,不得使用浮动 `latest` URL。SQLite `v1.56.0` 在本决策日刚发布,T-003 不追新;后续依赖升级单独评审。
|
||||
|
||||
## 2. 外部项目边界
|
||||
|
||||
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
||||
@@ -37,7 +50,7 @@
|
||||
|
||||
## 3. 待冻结项
|
||||
|
||||
- Go、Python、PostgreSQL、MediaMTX、Savant/DeepStream 的精确版本。
|
||||
- Python、PostgreSQL、Savant/DeepStream 的精确版本;Go 与 MediaMTX 已为 Sense M1 冻结,后续阶段可按升级流程调整。
|
||||
- Bell 前端框架和组件库。
|
||||
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。
|
||||
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
||||
@@ -48,7 +61,7 @@
|
||||
|
||||
## 4. 当前标准入口
|
||||
|
||||
仓库当前只有文档和契约,未产生可构建生产代码。根目录脚本执行文档治理验证:
|
||||
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
||||
|
||||
```powershell
|
||||
./init.ps1
|
||||
@@ -60,6 +73,17 @@ WSL/Linux/macOS/Git Bash:
|
||||
./init.sh
|
||||
```
|
||||
|
||||
Sense 单独执行:
|
||||
|
||||
```powershell
|
||||
go -C Sense mod download
|
||||
go -C Sense generate ./internal/mtx
|
||||
go -C Sense test ./...
|
||||
go -C Sense vet ./...
|
||||
go -C Sense build ./...
|
||||
go -C Sense run ./cmd/sense-api
|
||||
```
|
||||
|
||||
直接验证:
|
||||
|
||||
```powershell
|
||||
@@ -74,7 +98,7 @@ python scripts/validate_harness_governance.py
|
||||
| --- | --- | --- | --- |
|
||||
| Harness 文档/任务/Gitea 模板 | 上述三条 Python 命令 | 任一治理协议、清单或任务 schema 变化 | 不适用 |
|
||||
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
|
||||
| Sense Go | `go test ./...`、`go vet ./...`(代码出现后) | ONVIF、存储、MediaMTX、对账或公共 API 变化 | 命中设备任务时使用指定摄像头矩阵 |
|
||||
| Sense Go | `go -C Sense generate ./internal/mtx`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
|
||||
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
|
||||
| Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 |
|
||||
| 容量/分片 | 任务内基准脚本 | 16/64/128 路里程碑 | 目标网络、媒体和 GPU 硬件必需 |
|
||||
|
||||
+21
-11
@@ -16,16 +16,16 @@ YoVision 使用“通用底座 + 场景包”,按变化频率分为:
|
||||
|
||||
| 系统 | 语言/状态 | 职责 | 不负责 |
|
||||
| --- | --- | --- | --- |
|
||||
| Sense | Go,有状态 | 设备台账、ONVIF、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片 | AI 判定、事件业务、预警 |
|
||||
| Sense | Go,有状态 | 设备台账(`modality + capabilities`)、ONVIF 能力探测、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片、配额/Area 策略投影与准入执行、接入运维中心 | AI 判定、事件业务、预警、Tenant/Site/Area/RBAC/全局审计真相 |
|
||||
| Brain | Python/CUDA,业务无状态 | 解码/推理、检测/姿态/跟踪/ReID、时间窗判定、事件 mapper、像素级触发 | 设备真相源、告警升级、租户权限 |
|
||||
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、租户/RBAC、审计、配额真相源和管理端 | 媒体转发、模型执行 |
|
||||
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、Tenant/Site/Area/RBAC、全局审计、配额与 `capture_policy` 真相源和统一管理端 | 媒体转发、模型执行、设备实际态 |
|
||||
|
||||
MediaMTX、PostgreSQL、MinIO、Prometheus 等作为独立基础设施部署。
|
||||
|
||||
## 3. 部署与系统边界
|
||||
|
||||
- 首期每个客户部署一套私有实例,数据和事件证据留在客户环境;数据库实体、RBAC、配置与 API 从第一版携带 `tenant_id` 并保持 SaaS-ready 边界。
|
||||
- Bell 自研且是事件、Alert、ack、升级链、租户和审计的唯一业务真相源;客户平台通过版本化 OpenAPI/Webhook 集成,不反向接管核心状态机。
|
||||
- Bell 自研且是事件、Alert、ack、升级链、Tenant/Site/Area/RBAC、配额和全局审计的唯一业务真相源;客户平台通过版本化 OpenAPI/Webhook 集成,不反向接管核心状态机。Sense 只保存执行所需的版本化只读投影,不形成第二份组织/策略真相。
|
||||
- M1–M3 的视频入口只有 ONVIF/RTSP。现有 NVR 在售前盘点;仅支持 GB/T 28181 的项目必须建立独立适配器任务,不把国标信令混入 Sense 最小骨架。
|
||||
- M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统;是否开发原生 App 在 M4 后另行决定。
|
||||
|
||||
@@ -45,7 +45,7 @@ Sense ── 视频流/触发信号 ──> Brain
|
||||
|
||||
主流程:
|
||||
|
||||
1. Bell 持有站点配额;Sense 在新增/启用设备时通过版本化内部 API 或只读投影校验。
|
||||
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布版本化只读视图 `bell.site_quota_v1`,Sense 在新增/启用设备的同一写路径校验并记录所用 `source_version`。未来分库必须发布新版本契约,不能静默改变 v1 语义。
|
||||
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态。
|
||||
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
|
||||
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。
|
||||
@@ -55,7 +55,7 @@ Sense ── 视频流/触发信号 ──> Brain
|
||||
|
||||
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
|
||||
|
||||
## 5. 九条不可越界的决定
|
||||
## 5. 十二条不可越界的决定
|
||||
|
||||
1. MediaMTX 独立运行,Sense 管配置与生命周期。
|
||||
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
|
||||
@@ -63,9 +63,12 @@ Sense ── 视频流/触发信号 ──> Brain
|
||||
4. 平台事件 ULID 由 Bell 生成;Brain 只填 `source_event_id`。
|
||||
5. 一个 PostgreSQL 实例,`sense`/`bell` schema 分离;Brain 无业务 schema。
|
||||
6. 16/128 都不是单机保证;媒体与推理按独立分片横向扩展。
|
||||
7. Bell 拥有 `site.max_video_channels`,Sense 在设备写路径执行;不跨 schema 直接写。
|
||||
7. Bell 拥有 `site.max_video_channels` 并拥有 `bell.site_quota_v1`;Sense 角色只获得视图 `SELECT`,在设备写路径执行准入,不能写 Bell schema 或读取 Bell 源表。
|
||||
8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。
|
||||
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
|
||||
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
|
||||
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
|
||||
12. Sense 的设备操作审计先写本地持久化 outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。具体字段、签名、重放与留存契约必须由独立 API/契约任务冻结后实现。
|
||||
|
||||
## 6. 容量架构
|
||||
|
||||
@@ -81,15 +84,22 @@ Sense ── 视频流/触发信号 ──> Brain
|
||||
- PostgreSQL 是期望态真相源;MediaMTX、推理 worker 和对象存储是可对账的实际态。
|
||||
- 对账器水平触发、幂等、指数退避、限制并发;部分失败不做跨系统回滚,只持续收敛。
|
||||
- 孤儿删除必须有 10% 安全闸和人工可观察指标。
|
||||
- 配额读取失败只阻止新增/启用,不中断已有流。
|
||||
- `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。
|
||||
- Area 策略投影读取失败只阻止相关设备新增/启用;已有设备保持原状态并产生运维告警。策略变更与已有成像设备冲突时由 Bell 管理端显式处置。
|
||||
- 高风险设备操作在本地事务内同时写期望态与审计 outbox;异步 relay 可重试、幂等投递到 Bell。T-004 只验证交互归属,不定义或实现接口契约。
|
||||
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
|
||||
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
||||
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
||||
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
||||
|
||||
## 8. 数据与契约
|
||||
|
||||
- 核心实体:Tenant → Site → Area/Device → StreamBinding/Zone;Rule → Event → Alert → DeliveryAttempt/Ack。
|
||||
- Event 与 Alert 不合并:一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
|
||||
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及带 `source_version`/`synced_at` 的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 的跨 schema 列签名限定为 `tenant_id/site_id/max_video_channels/source_version/source_updated_at`。
|
||||
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、cursor 分页、幂等键与 ETag;敏感连接引用只写不读。正式签名和兼容规则以 [`contracts/`](contracts/) 为准,当前契约冻结不等于 handler 或 PostgreSQL migration 已实现。
|
||||
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
|
||||
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
|
||||
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
|
||||
- 一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
|
||||
- 事件 v0.1 以 `raw/contracts/event-v0.1.schema.json` 与 `raw/contracts/README.md` 为准;未知顶层字段拒绝,只允许通过 `ext` 扩展。
|
||||
- v0.1 还需代码校验时间自洽、`confidence` 当前为 null、证据文件名隐私、唯一 primary sensor 等跨字段约束。
|
||||
- S2 MVP 不处理人脸。首个人脸试点最早 M5,只允许经法务/客户门禁确认的 S4 成人访客/承包商白名单(≤10,000 人),并提供非人脸替代方式。
|
||||
@@ -108,10 +118,10 @@ Bell/{web,packs,contracts}
|
||||
## 10. 开发顺序
|
||||
|
||||
- M0 不写生产代码。
|
||||
- M1 只动 Sense,5 路接入骨架与 MediaMTX。
|
||||
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
||||
- M2 仍以 Sense 为主,完成 16 路开通/停用、对账、多租户投影与隧道。
|
||||
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
|
||||
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包。
|
||||
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||
|
||||
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
||||
|
||||
|
||||
+10
-6
@@ -4,24 +4,27 @@
|
||||
|
||||
## M0:兼容性验证与需求定稿
|
||||
|
||||
出口:3–5 款摄像头通过 ONVIF 核心操作,采购白名单归档;架构影响型开放问题闭环;M0 临时代码不进入生产基线。
|
||||
出口:至少一台真实样机完成首期指定型号/固件的 ONVIF/RTSP 准入,指定采购基线归档;架构影响型开放问题闭环;M0 临时代码不进入生产基线。该出口不代表多品牌或批次兼容。
|
||||
|
||||
- T-001:建立摄像头兼容性实验矩阵与采购白名单。
|
||||
- T-001:完成指定摄像头型号准入验证。
|
||||
- T-002:关闭 Q1/Q2/Q4–Q12 架构影响型需求问题。
|
||||
|
||||
## M1:Sense 接入骨架 + MediaMTX
|
||||
|
||||
出口:5 路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。
|
||||
出口:1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。该出口是实验室软件闭环,不代表真实五机或生产 SLA。
|
||||
|
||||
- T-003:建立 Sense Go 脚手架、设备台账、ONVIF 与 MediaMTX 薄客户端。
|
||||
- 后续按 T-003 的基线拆分对账、探活、5 路集成与部署任务。
|
||||
- T-003:建立无需真实摄像头即可验证的 Sense Go 脚手架、设备台账、ONVIF port/fake、MediaMTX 生成客户端与薄封装;完成不代表 M0/M1 出口。
|
||||
- T-003 只冻结 `/healthz`、`/readyz` 运维探针,设备管理公共 API 留给后续契约任务;真实 ONVIF adapter 不用 fake 冒充。
|
||||
- T-006:在 T-001 指定准入基线与 T-003 骨架之上,以 1 路海康实机 + 至少 4 路可独立启停的合成 RTSP 源完成五路自动建 path、探活、恢复和 MediaMTX 实验室集成验收。
|
||||
- T-001 与 T-003 已完成;T-006 可领取实施,只需准备至少 4 条可独立启停的合成源,不要求当前购买更多摄像头。
|
||||
|
||||
## M2:对账、多租户投影与 16 路全流程
|
||||
|
||||
出口:10 个站点试点,至少一个站点完成 16 路开通/停用;`unconverged = 0` 稳定。
|
||||
|
||||
- T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。
|
||||
- 按 T-008 契约实现认证 tenant 上下文、设备管理 handler、PostgreSQL repository、Bell 配额源表/migration 与只读投影。
|
||||
- 对账器幂等/退避/并发/10% 安全闸。
|
||||
- Bell 站点配额投影到 Sense 的版本化读取契约。
|
||||
- WireGuard 边缘隧道与断网恢复。
|
||||
- 16 路批量开通、停用和容量基准。
|
||||
|
||||
@@ -34,6 +37,7 @@
|
||||
- 规则引擎、场景包加载、预警状态机与双路径投递。
|
||||
- 最小 Web/App 处置流程、RBAC 与审计。
|
||||
- 现场误报基线和反馈队列。
|
||||
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
|
||||
|
||||
## M4:64 路分片与完整管理系统
|
||||
|
||||
|
||||
+62
-14
@@ -2,28 +2,28 @@
|
||||
|
||||
> 用户故事用于连接需求、页面交互和任务验收。当前 M0/M1 以设备与实施流程为主,最终用户界面在 M3/M4 才实现。
|
||||
|
||||
## US-001 批量开通摄像头
|
||||
## US-001 批量开通视频设备
|
||||
|
||||
- 角色:实施工程师。
|
||||
- 目标:一次导入并验证一个默认 16 路站点,不逐路手工配置。
|
||||
- 目标:一次导入并验证一个默认 16 路视频站点,不逐路手工配置;操作入口位于通用设备台账的视频模态筛选下。
|
||||
- 价值:降低交付时间并为 128 路扩展保留操作效率。
|
||||
- 验收:支持待激活、逐设备结果、失败可重试、超配额明确拒绝;128 路规模下仍使用分页/批量流程。
|
||||
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-003。
|
||||
- 验收:导入后先进入待激活,任务历史可查看逐设备成功/失败原因并仅重试失败项;认证失败时提供不回显旧密码的凭据更新路径;超配额明确拒绝;128 路规模下仍使用分页/批量流程。
|
||||
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-004、IX-015。
|
||||
|
||||
## US-002 查看设备与流健康
|
||||
|
||||
- 角色:平台运维/站点管理员。
|
||||
- 目标:快速知道哪些设备离线、时间漂移、流未收敛或分片异常。
|
||||
- 目标:快速知道哪些设备离线、认证失败、时间漂移、流未收敛、隧道异常或分片异常,并能区分期望态与各下游实际态。
|
||||
- 价值:在业务预警受影响前定位故障。
|
||||
- 验收:设备期望态与实际态分开显示;断线重连可观察;业务预警和运维告警不混用。
|
||||
- 关联:RQ-C-06~RQ-C-08、RQ-C-29、RQ-C-34,IX-004。
|
||||
- 验收:设备期望态与媒体/推理/存储实际态分开显示;对账差异、退避、下次重试和孤儿安全闸可观察;边缘隧道与视频数据面分别显示;业务预警和运维告警不混用。
|
||||
- 关联:RQ-C-06~RQ-C-08、RQ-C-29、RQ-C-34,IX-004、IX-019。
|
||||
|
||||
## US-003 处置业务预警
|
||||
|
||||
- 角色:家属/值班员。
|
||||
- 目标:收到异常后查看关联证据、确认接手并记录处置结果。
|
||||
- 价值:把“发出通知”变成“有人负责”。
|
||||
- 验收:首次投递、送达、看到、ack、升级分别可追溯;无 ack 自动升级;进程重启不丢失升级链。
|
||||
- 验收:通道的已发出、已送达、已看到,与 Alert 的 ack、升级分别可追溯;无回执、通道不支持、可重试失败和最终失败不伪装成功;Alert 可查看聚合的 Event,Event 可查看触发的 Alert;并发 ack 时后到者看到真实处置人且不能覆盖;无 ack 自动升级,进程重启不丢失升级链。
|
||||
- 关联:RQ-C-17~RQ-C-30,IX-005~IX-008。
|
||||
|
||||
## US-004 标记误报
|
||||
@@ -37,9 +37,9 @@
|
||||
## US-005 配置规则与升级链
|
||||
|
||||
- 角色:租户管理员/站点管理员。
|
||||
- 目标:按站点和设备覆盖场景模板,配置区域、时段、持续时间和联系人升级链。
|
||||
- 目标:按站点和设备覆盖场景模板,配置区域、时段、持续时间和升级链;升级步骤引用指定人员、值班组或值班排班,不直接写死手机号。
|
||||
- 价值:同一通用底座适配不同场景。
|
||||
- 验收:继承来源清楚、变更可试运行/回滚、静默不超过 4 小时且自动恢复。
|
||||
- 验收:继承来源清楚、变更可试运行;升级目标可预览当前解析人和通道,投递后保留解析快照;回滚先展示版本差异与影响,并创建新的不可变版本,不改写历史;静默不超过 4 小时且自动恢复。
|
||||
- 关联:RQ-C-11~RQ-C-15、RQ-C-23~RQ-C-28,IX-010~IX-012。
|
||||
|
||||
## US-006 事件最小权限查看
|
||||
@@ -50,14 +50,62 @@
|
||||
- 验收:越权返回统一拒绝;无授权租户看不到人脸能力;常态录像不因事件页面被间接暴露。
|
||||
- 关联:RQ-S1-05~RQ-S1-07、RQ-S2-08、RQ-C-31~RQ-C-33,IX-013。
|
||||
|
||||
## US-007 兼容性实验记录
|
||||
## US-007 指定型号准入记录
|
||||
|
||||
- 角色:M0 测试/实施工程师。
|
||||
- 目标:对候选摄像头执行一致的 ONVIF 与恢复测试并形成采购白名单。
|
||||
- 价值:在写生产接入代码前先识别厂商差异。
|
||||
- 验收:3–5 款设备的型号、固件、认证方式、Profiles/StreamUri/校时、主子码流、掉线恢复都有证据;不记录密码和真实客户信息。
|
||||
- 目标:对首期指定摄像头型号/固件执行一致的 ONVIF 与恢复测试并形成采购准入基线。
|
||||
- 价值:在生产接入前确认统一采购基线可用,同时控制首期多品牌适配成本。
|
||||
- 验收:至少一台真实样机的厂商、型号、硬件版本、固件、认证方式、Profiles/StreamUri/校时、主子码流和 3 次掉线恢复都有证据;明确单样本和非多品牌限制,不记录密码和真实客户信息。
|
||||
- 关联:M0,T-001;无产品 UI,使用版本化测试文档。
|
||||
|
||||
## US-008 管理异构设备与隐私准入
|
||||
|
||||
- 角色:实施工程师/站点管理员。
|
||||
- 目标:在统一设备台账中按模态和能力添加、筛选与查看视频、雷达、门磁、按钮、可穿戴等设备;隐私区域只允许非成像设备。
|
||||
- 价值:M2 固化不会因 M6 异构设备接入而推倒重来的信息架构,同时把隐私要求落实为系统准入约束。
|
||||
- 验收:一级入口使用“设备”;添加时先选模态,详情按探测能力显示页面并标明来源、时间与生效差异;未实现适配器显示 `adapter_not_ready`,不得伪装在线;`non_imaging_only` Area 不允许新增或启用成像设备,策略读取失败时拒绝新变更并给出恢复路径;M2 完成信息架构与视频准入,M6 完成非视频适配器。
|
||||
- 关联:RQ-S1-05、NFR-CMP-05,IX-014、IX-016、IX-019。
|
||||
|
||||
## US-009 以最小权限运维接入系统
|
||||
|
||||
- 角色:平台运维/租户管理员/站点管理员/只读用户。
|
||||
- 目标:进入 Sense 时明确当前租户、站点、账号和角色,并只看到权限范围内的数据与操作。
|
||||
- 价值:让私有化单租户交付也保留 SaaS-ready 隔离边界,避免原型把管理员权限当成默认事实。
|
||||
- 验收:切换站点会刷新上下文;只读角色看不到写操作;深链越权安全拒绝且不泄露设备存在性;设备操作日志可带筛选上下文跳到 Bell 全局审计。
|
||||
- 关联:RQ-C-31~RQ-C-33,IX-013、IX-020。
|
||||
|
||||
## US-010 管理站点与区域准入策略
|
||||
|
||||
- 角色:租户管理员/站点管理员。
|
||||
- 目标:在统一管理端维护 Site、Area 层级与 `capture_policy`,并让 Sense 使用同一版本的策略执行设备准入。
|
||||
- 价值:避免 Area 在 Sense、Bell 各有一份真相,保证权限、报表和隐私准入口径一致。
|
||||
- 验收:Bell 是 Site/Area/配额/策略真相源;策略变更影响已有成像设备时必须显式选择迁移或取消,不自动停用;Sense 展示策略版本与同步状态,投影不可用时仅拒绝相关新写入。
|
||||
- 关联:RQ-S1-05、NFR-CMP-05,IX-016、IX-020。
|
||||
|
||||
## US-011 完成值班交接
|
||||
|
||||
- 角色:交班值班员/接班值班员。
|
||||
- 目标:在班次结束前把所有未 ack、处置中和升级中的 Alert 连同备注交给下一班,并得到明确接手确认。
|
||||
- 价值:保证“有人负责”不在班次边界中断。
|
||||
- 验收:交接清单显示当前状态、处置人与下一次升级时间;接班人确认后写审计;交接过程不暂停、不重置升级链,未确认交接时原责任关系保持不变。
|
||||
- 关联:RQ-C-17~RQ-C-30,IX-021。
|
||||
|
||||
## US-012 导出规则验收效果
|
||||
|
||||
- 角色:项目负责人/租户管理员/验收人员。
|
||||
- 目标:按规则版本和冻结样本窗口查看可复核的召回率与每路每天误报数。
|
||||
- 价值:让站点验收使用场景相关、可追溯的效果口径,避免跨场景“准确率”误导。
|
||||
- 验收:报表展示规则版本、样本时间窗、正样本/漏检样本、通道天数、召回率和每路每天误报数;明确不提供跨场景统一准确率;导出保留筛选与口径说明。
|
||||
- 关联:RQ-C-22、`02-requirements` §8,IX-022。
|
||||
|
||||
## US-013 管理联系人和值班排班
|
||||
|
||||
- 角色:租户管理员/站点管理员。
|
||||
- 目标:使用同一套人员、值班组和通知通道主数据配置可发布的值班排班,并让升级策略引用排班目标。
|
||||
- 价值:避免先把联系人和号码写死进升级链、后续再迁移到时段轮换;同时避免把联系人、班次和轮换混成一个难维护对象。
|
||||
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
|
||||
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
|
||||
|
||||
## 追溯规则
|
||||
|
||||
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
||||
|
||||
@@ -4,19 +4,29 @@
|
||||
|
||||
| ID | 场景 | 必须覆盖的状态与行为 | 关联 US | 阶段 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| IX-001 | 批量导入设备 | 下载模板、上传校验、逐行错误、重复序列号、待激活、确认写入 | US-001 | M2 |
|
||||
| IX-002 | 配额与批量结果 | 显示已用/上限;超 16 不等于非法,按站点配置校验;部分成功可重试 | US-001 | M2 |
|
||||
| IX-001 | 批量导入设备 | 下载模板、上传校验、逐行错误、重复序列号、待激活、确认写入;任务历史保留逐项结果、操作者与时间,支持仅重试失败项 | US-001 | M2 |
|
||||
| IX-002 | 配额与批量结果 | 显示已用/上限;超 16 不等于非法,按站点配置校验;部分成功可重试;配额不可读取时只禁用新增/启用并保留已有列表与流 | US-001 | M2 |
|
||||
| IX-003 | 设备列表 | 分页/筛选/批量选择;128 路不一次加载所有视频和详情 | US-001 | M2/M4 |
|
||||
| IX-004 | 设备健康 | 期望态、实际态、最后在线、时间漂移、分片、重试进度;运维告警独立 | US-002 | M2/M4 |
|
||||
| IX-005 | 预警到达 | 明确严重度、站点、时间、证据可用性;重复投递不产生重复处置 | US-003 | M3 |
|
||||
| IX-006 | ack | 一次点击可确认,显示确认人/时间;并发 ack 有清晰结果 | US-003 | M3 |
|
||||
| IX-007 | 升级 | 显示当前层级、下一次升级时间、每次投递状态;失败不能伪装成功 | US-003 | M3 |
|
||||
| IX-008 | 事件详情 | 结构化事实、抓拍、视频、时间线、处置;证据加载失败可重试且不丢元数据 | US-003 | M3/M4 |
|
||||
| IX-004 | 设备健康 | 期望态、媒体/推理/存储实际态、认证失败、最后在线完整时间与站点时区、时间漂移、分片、重试进度;运维告警独立,凭据更新只允许写入新值且旧值永不回显 | US-002 | M2/M4 |
|
||||
| IX-005 | 预警到达 | 明确严重度、站点、时间、证据可用性与关联事件数;重复投递不产生重复处置 | US-003 | M3 |
|
||||
| IX-006 | ack | 一次点击可确认,显示确认人/时间;服务端首个成功者成为处置人,并发后到者看到当前处置人和冲突结果,不静默覆盖或显示双成功 | US-003 | M3 |
|
||||
| IX-007 | 升级与投递 | 显示当前层级、下一次升级时间;每个通道分别展示已发出、已送达、已看到,Alert ack 单独展示;无回执/不支持/未知、可重试失败及最终失败不得伪装成功 | US-003 | M3 |
|
||||
| IX-008 | 事件详情 | 结构化事实、抓拍、视频、时间线、处置;证据加载失败可重试且不丢元数据;Alert 展示关联 Event 与聚合原因,Event 展示触发的 Alert 与最终状态,两边可深链;被抑制且未创建 Alert 的事件明确标为未触发 | US-003 | M3/M4 |
|
||||
| IX-009 | 误报反馈 | 确认动作、可选原因、提交成功反馈;只改变 outcome,不改原始事件 | US-004 | M3 |
|
||||
| IX-010 | 规则编辑 | 显示租户/站点/设备继承来源,支持区域/警戒线/时段/持续时间 | US-005 | M3/M4 |
|
||||
| IX-011 | 规则试运行 | 明确“未正式生效”,展示命中样本与影响范围,支持取消/回滚 | US-005 | M4 |
|
||||
| IX-012 | 升级链/静默 | 联系人顺序、超时、双通道;静默最长 4h、显示自动恢复时间、无永久选项 | US-005 | M3/M4 |
|
||||
| IX-011 | 规则试运行与回滚 | 明确“未正式生效”,展示命中样本与影响范围,支持取消;回滚前展示目标版本差异与影响,确认后创建并发布等价的新版本,历史版本保持不可变;版本冲突时阻止覆盖并提供刷新/另存路径 | US-005 | M4 |
|
||||
| IX-012 | 升级链/静默 | 升级步骤通过类型化目标引用指定人员、值班组或值班排班,不写死号码;显示目标解析预览、联系人顺序、超时与双通道;每次投递固化实际收件人、通道和排班版本快照;静默最长 4h、显示自动恢复时间、无永久选项 | US-005、US-013 | M3/M4 |
|
||||
| IX-013 | 权限与隐私 | 越权统一处理;无授权租户不展示人脸入口;不泄露流 URL/凭据 | US-006 | M3/M4 |
|
||||
| IX-014 | 设备模态与能力 | 一级入口为“设备”;列表可按 `modality` 筛选,添加时先选模态,详情按 `capabilities` 渐进展示;能力同时显示厂商/型号/固件/认证、探测来源与时间、探测值和生效值差异;视频设备显示画面/媒体/检测区域,未交付的非视频适配器显示 `adapter_not_ready`,不用虚构遥测或禁用的视频页签占位 | US-008 | M2/M6 |
|
||||
| IX-015 | 停用与收敛 | 明确区分暂停推理订阅、停用设备接入和仅踢当前会话;暂停推理必须说明“解除推理侧订阅”,并在结果中显示推理订阅、其他 reader 数与上游按需拉流状态;操作前展示对观看、录制与证据回捞的影响并二次确认;期望态立即改变、实际态经对账收敛,部分失败可逐项重试;重复请求不产生重复副作用,但每次请求均留审计结果 | US-001、US-002 | M2 |
|
||||
| IX-016 | 隐私区域设备准入 | Area 与 `capture_policy` 由 Bell 统一管理,Sense 消费带版本的只读投影;`non_imaging_only` 不允许新增/启用具有成像能力的设备;前端就地禁用并解释,后端返回稳定错误码;策略读取失败时拒绝新变更并告警,已有设备遇到策略变化时由 Bell 进入显式迁移/取消流程,不静默留存或停用 | US-008、US-010 | M2/M6 |
|
||||
| IX-017 | 检测区域几何编辑 | 多边形和方向警戒线支持绘制、显式完成、撤销、清空、顶点编辑;警戒线在草稿/已保存态均显示方向箭头并可反转;鼠标与键盘坐标操作等效;画面参数变化后标记待校准且不自动改坐标;区域版本与规则版本独立 | US-005 | M3 |
|
||||
| IX-018 | 草稿与跨系统上下文 | 几何草稿自动保存并可恢复;只有持久化失败或切换空间类型等会破坏草稿的动作才拦截确认;Sense↔Bell 双向携带并消费 `camera + zone + return_to` 上下文;返回后恢复草稿,版本已被他人更新时提示冲突且不覆盖 | US-005 | M3 |
|
||||
| IX-019 | 运维中心与写入降级 | Sense 用一个一级“运维中心”聚合对账差异/退避/下次重试/孤儿安全闸、媒体与推理分片、边缘节点/隧道/补传、运维告警和系统状态;配额或 Area 策略不可读取时页面继续可读且已有链路不受影响,只禁用相关写操作;不得混入 Bell 业务预警 | US-002、US-008 | M2/M4/M6 |
|
||||
| IX-020 | 管理上下文、RBAC 与审计归属 | 全局头部显示租户、站点、账号和角色;只读角色隐藏写操作,深链越权统一拒绝且不泄露资源是否存在;Tenant/Site/Area/RBAC/配额/全局审计由 Bell 管理,Sense 只呈现消费上下文和设备操作入口,携带 tenant/site/device/return_to 深链到 Bell 审计;Bell 移动端底部导航不超过 5 项,并通过可发现的“管理”入口访问 Site/Area 与审计 | US-006、US-009、US-010 | M2/M4 |
|
||||
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
|
||||
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
|
||||
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
|
||||
|
||||
## 全局状态
|
||||
|
||||
@@ -44,7 +54,10 @@
|
||||
## 无障碍与安全
|
||||
|
||||
- 键盘可完成主要 Web 流程,焦点清晰,表单错误关联到字段。
|
||||
- 文本和关键状态满足可读对比度;严重度同时用文字/图标表达。
|
||||
- 正文、按钮文字和关键状态对比度不低于 4.5:1;大字号(不低于 24px,或不低于 18.66px 且粗体)不低于 3:1;非文本控件、状态边界与焦点指示器不低于 3:1。严重度同时用文字/图标表达。
|
||||
- 当前导航项显式使用 `aria-current="page"`;响应式隐藏可见文字时,图标按钮仍保留稳定的可访问名。
|
||||
- 使用 tab 模式时完整实现 `tablist` / `tab` / `tabpanel`、`aria-controls` / `aria-labelledby`、单一 Tab 停靠点和方向键导航;不能完整实现时不声明 tab 角色。
|
||||
- 移动端主要操作目标不小于 44×44px,输入控件正文不小于 16px;绘制画布必须提供无需精确点击的键盘/表单等效路径。
|
||||
- 删除、停用、踢流、批量覆盖和规则正式发布需要明确影响范围与二次确认。
|
||||
- UI 不展示摄像头密码、完整连接串、token 或可复用的内部流地址。
|
||||
|
||||
|
||||
+48
-7
@@ -1,6 +1,6 @@
|
||||
# API 与契约
|
||||
|
||||
> 事件契约 v0.1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
||||
> Brain → Bell 事件契约 v0.1、Sense Control API v1 与 Bell 站点配额只读投影 v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
||||
|
||||
## 1. 已冻结:Brain → Bell 事件契约
|
||||
|
||||
@@ -17,17 +17,35 @@
|
||||
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
|
||||
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
|
||||
|
||||
## 2. 待冻结的内部接口
|
||||
## 2. 跨系统接口状态
|
||||
|
||||
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| Sense → Bell | 读取站点视频配额 | 版本化;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | 待 M2 设计 |
|
||||
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 已冻结 |
|
||||
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
|
||||
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
|
||||
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
||||
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
||||
|
||||
## 3. 待冻结的 Bell 公共 API
|
||||
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md) 与 [`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)。Bell 拥有源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。
|
||||
|
||||
## 3. 已冻结:Sense Control API v1
|
||||
|
||||
- OpenAPI:[`contracts/sense-control-v1.openapi.json`](contracts/sense-control-v1.openapi.json)
|
||||
- 语义、资源所有权、幂等、并发与兼容规则:[`contracts/README.md`](contracts/README.md)
|
||||
- 范围:设备分页查询、创建、读取、修改、单项启停、最多 128 项的批量启停和批量操作查询。
|
||||
|
||||
关键规则:
|
||||
|
||||
- 所有业务路径使用 `/api/v1` 和 Bearer 认证;tenant 只来自认证上下文,跨租户访问与不存在统一为 `404 not_found`。
|
||||
- Site、Area、RBAC、配额和全局审计仍由 Bell 持有;Sense 只管理 Device 期望态和收敛状态,不提供这些 Bell 资源的 CRUD。
|
||||
- 列表使用稳定顺序和不透明 cursor,默认 50、最大 100;创建与批量写要求 `Idempotency-Key`,资源修改与单项期望态写要求 `If-Match`。
|
||||
- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。
|
||||
- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。
|
||||
|
||||
T-008 只冻结契约和仓库门禁,尚未实现 Sense HTTP handler、认证中间件、Bell 表或 PostgreSQL migration。
|
||||
|
||||
## 4. 待冻结的 Bell 公共 API
|
||||
|
||||
资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足:
|
||||
|
||||
@@ -38,15 +56,38 @@
|
||||
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
|
||||
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
|
||||
|
||||
## 4. MediaMTX 接口边界
|
||||
## 5. MediaMTX 接口边界
|
||||
|
||||
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
||||
|
||||
## 5. 变更流程
|
||||
### 5.1 T-003 已实现的内部适配契约
|
||||
|
||||
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
|
||||
|
||||
| Port | 操作 | 数据所有者 / 失败语义 |
|
||||
| --- | --- | --- |
|
||||
| ONVIF adapter | `Probe(target)`、`SetSystemDateAndTime(target, time)` | 设备是外部来源;`target` 只含 endpoint ref 与不透明 credential ref。错误稳定映射为认证失败、超时、不可用、响应无效,不记录凭据或完整流地址 |
|
||||
| MediaMTX paths | `CreatePath`、`GetPath`、`EnsurePath`、`DeletePath`、`PathReady` | SQLite 设备台账持有期望态,MediaMTX 只持有运行配置;`EnsurePath` 相同 source 不写、不同 source patch、缺失时 add;当前调和器绝不枚举或删除孤儿 |
|
||||
| Device repository | 站点、设备、期望态、实际态、调和进度 | SQLite 是 M1 期望态真相源;调和失败次数与下次时间持久化,进程重启不清空退避;配额读取/写入失败时拒绝新增或启用,不关闭已有流 |
|
||||
|
||||
MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}` 与 `/v3/paths/get/{name}`。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。
|
||||
|
||||
### 5.2 设备台账语义
|
||||
|
||||
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
|
||||
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
|
||||
- SQLite 表使用 `sense_` 前缀对应未来 PostgreSQL `sense` schema:`sense_sites`、`sense_devices`、`sense_device_capabilities`、`sense_reconcile_state`。标识、唯一性、状态与时间字段语义保持一致;本地表名前缀不是跨系统公共契约。
|
||||
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
|
||||
|
||||
### 5.3 Sense 进程 HTTP 面
|
||||
|
||||
T-003 只实现了运维探针:`GET /healthz` 表示进程存活,`GET /readyz` 表示配置、SQLite 打开及 migration 已完成。两者返回 JSON,均不等价于摄像头、MediaMTX path 或 M1 里程碑健康。T-008 已冻结站点作用域的 `/api/v1/sites/{site_id}/devices` 等设备管理契约,但尚未实现对应 handler;当前可运行进程仍只暴露探针,后续实现不得发布 `/api/v1/devices` 等无站点边界的临时接口。
|
||||
|
||||
## 6. 变更流程
|
||||
|
||||
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
||||
2. 更新本文和 schema/OpenAPI。
|
||||
3. 同步生产者、消费者、契约测试和示例。
|
||||
4. 记录迁移、回滚与版本废弃策略。
|
||||
|
||||
事件 v0.1 的破坏性变化必须发布新版本,不能原地修改已被 M3 生产者/消费者使用的契约。
|
||||
事件 v0.1、Sense Control API v1 或站点配额投影 v1 的破坏性变化必须发布新版本,不能原地修改已被生产者/消费者使用的契约。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Sense 控制面与站点配额契约 v1
|
||||
|
||||
> 冻结日期:2026-08-07。契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC 与配额的所有者。本文冻结接口,不表示 HTTP handler、Bell 表或 PostgreSQL migration 已实现。
|
||||
|
||||
## 契约文件
|
||||
|
||||
| 文件 | 生产者 / 所有者 | 消费者 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
|
||||
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 |
|
||||
|
||||
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||
|
||||
## HTTP 资源与操作
|
||||
|
||||
| 操作 | 路径 | 关键约束 |
|
||||
| --- | --- | --- |
|
||||
| 列出 / 创建设备 | `GET/POST /api/v1/sites/{site_id}/devices` | 列表使用不透明 cursor,默认 50、最大 100;创建要求 `Idempotency-Key`,设备 ID 由服务端生成 |
|
||||
| 读取 / 修改设备 | `GET/PATCH /api/v1/sites/{site_id}/devices/{device_id}` | 修改要求 `If-Match`,版本不匹配返回 `412 etag_mismatch` |
|
||||
| 修改期望态 | `PUT /api/v1/sites/{site_id}/devices/{device_id}/desired-state` | 要求 `If-Match`;受理不表示实际态已经收敛 |
|
||||
| 批量修改期望态 | `POST /api/v1/sites/{site_id}/devices:batchDesiredState` | 要求 `Idempotency-Key`,最多 128 项,异步返回逐项结果 |
|
||||
| 查询批量操作 | `GET /api/v1/operations/{operation_id}` | 只返回当前租户和站点可见的操作 |
|
||||
|
||||
列表按 `created_at ASC, id ASC` 稳定排序,cursor 是服务端生成的不透明位置标记。客户端不得解析或拼接 cursor;服务端可以在兼容范围内改变编码。批量不是跨设备全有或全无事务:每项独立接受或拒绝,成功项继续收敛,失败项带稳定错误码。请求内重复 `device_id` 视为对应项 `invalid_request`,不得用“最后一项覆盖”。重试失败项应使用新幂等键;原样重放整个请求必须返回原收据。
|
||||
|
||||
## 身份、租户与敏感信息
|
||||
|
||||
- 所有 `/api/v1` 操作都需要 Bearer 认证;具体 token 格式由认证任务冻结。`tenant_id` 只从认证上下文取得,body、query 和 path 均不能自报 tenant。
|
||||
- 跨租户访问与资源不存在都返回 `404 not_found`,不得用状态码、消息或耗时泄露资源是否存在。授权范围不足但不涉及资源枚举时返回 `403 forbidden`。
|
||||
- `endpoint_ref`、`credential_ref` 和 `profile_token` 是 write-only 输入。不允许 userinfo 形式的完整 RTSP URI;响应、错误、日志与示例不得包含这些引用、密码、token、完整连接串或 MediaMTX 内部配置。
|
||||
- 创建设备显式提交 `modality + capabilities`;`video` 模态必须包含 `video_capture`,任何请求了 `video_capture` 的设备都必须同时提供 endpoint 与 credential 引用。服务端仍须由适配器验证能力,不能把客户端声明当作探测成功。
|
||||
- `ETag` 表示设备资源版本;`If-Match` 缺失返回 `428 precondition_required`,过期版本返回 `412 etag_mismatch`。相同期望态重复提交不增加 generation,但每次受理仍可产生审计记录。
|
||||
|
||||
`Idempotency-Key` 的作用域是“认证主体 + tenant + site + operation + key”,服务端至少保存 24 小时。同作用域、同请求体重放返回首次状态码和响应;同 key 不同请求体返回 `409 idempotency_conflict`。幂等收据不等价于实际态完成。
|
||||
|
||||
## 配额投影与准入
|
||||
|
||||
Bell migration 最终创建 `bell.site_quota_v1`,列顺序和含义固定如下:
|
||||
|
||||
| 列 | 含义 |
|
||||
| --- | --- |
|
||||
| `tenant_id`、`site_id` | 与两个 schema 共享的稳定逻辑 ID |
|
||||
| `max_video_channels` | 默认 16,有效范围 1~128 |
|
||||
| `source_version` | 站点投影每次变更后单调递增的版本 |
|
||||
| `source_updated_at` | Bell 源记录的更新时间 |
|
||||
|
||||
视图由 `bell_app` 拥有;`sense_app` 只有 Bell schema 的 `USAGE` 和该视图的 `SELECT`,没有 `INSERT`、`UPDATE`、`DELETE` 或 Bell 源表权限。Sense 不得通过任何旁路写 Bell schema。首期使用同一 PostgreSQL 实例及 `sense`/`bell` schema;如果未来分库,必须发布新版本的网络契约,不能在 v1 下静默改变一致性和失败语义。
|
||||
|
||||
配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。
|
||||
|
||||
Area/capture policy 的投影形态不在 T-008 中冻结;Sense v1 仍保留 `area_policy_unavailable` 与 `area_policy_denied` 稳定错误语义,后续契约不得放宽同写路径校验要求。
|
||||
|
||||
## 兼容与废弃
|
||||
|
||||
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
|
||||
- 删除/重命名字段、收紧已接受输入、改变状态码/幂等作用域/配额计数或把只读视图改为远程调用,均属于破坏性变化,必须发布新版本。
|
||||
- 废弃版本应先在 OpenAPI 标记并公告迁移窗口;服务端在所有已声明消费者完成迁移前继续提供旧版本。
|
||||
- OpenAPI 中的稳定错误码用于程序判断,`message` 只用于人读,不得依赖其字面内容。
|
||||
|
||||
## 验证
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
||||
python -m unittest discover -s tests -p "test_sense_control_contract.py"
|
||||
```
|
||||
|
||||
测试校验本仓库依赖的 OpenAPI 结构与安全不变量,并不替代后续实现任务对完整 OpenAPI 标准验证器、HTTP handler 和 PostgreSQL migration 的验证。
|
||||
@@ -0,0 +1,691 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
|
||||
"info": {
|
||||
"title": "YoVision Sense Control API",
|
||||
"version": "1.0.0",
|
||||
"description": "受控的设备期望态与收敛查询契约。Tenant 只从认证上下文确定;Site、Area、RBAC 和配额真相仍由 Bell 持有。"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/",
|
||||
"description": "由部署入口提供 scheme、host 和认证终止"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Devices",
|
||||
"description": "设备台账、期望态和收敛状态"
|
||||
},
|
||||
{
|
||||
"name": "Operations",
|
||||
"description": "批量写入的逐项结果"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/sites/{site_id}/devices": {
|
||||
"get": {
|
||||
"tags": ["Devices"],
|
||||
"operationId": "listDevices",
|
||||
"summary": "分页查询当前认证租户下的站点设备",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/SiteID"},
|
||||
{"$ref": "#/components/parameters/Cursor"},
|
||||
{"$ref": "#/components/parameters/Limit"},
|
||||
{"$ref": "#/components/parameters/ModalityFilter"},
|
||||
{"$ref": "#/components/parameters/CapabilityFilter"},
|
||||
{"$ref": "#/components/parameters/DesiredStateFilter"},
|
||||
{"$ref": "#/components/parameters/ActualStateFilter"}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "按 created_at、id 稳定升序返回的一页设备",
|
||||
"headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}},
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/DevicePage"}}
|
||||
}
|
||||
},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"500": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Devices"],
|
||||
"operationId": "createDevice",
|
||||
"summary": "创建待探测设备",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/SiteID"},
|
||||
{"$ref": "#/components/parameters/IdempotencyKey"}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/DeviceCreate"}}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {"$ref": "#/components/responses/CreatedDevice"},
|
||||
"400": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"409": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"422": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"503": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sites/{site_id}/devices/{device_id}": {
|
||||
"get": {
|
||||
"tags": ["Devices"],
|
||||
"operationId": "getDevice",
|
||||
"summary": "读取设备期望态、实际态和收敛进度",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/SiteID"},
|
||||
{"$ref": "#/components/parameters/DeviceID"}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"$ref": "#/components/responses/CurrentDevice"},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"500": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Devices"],
|
||||
"operationId": "updateDevice",
|
||||
"summary": "按 ETag 修改设备可编辑配置",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/SiteID"},
|
||||
{"$ref": "#/components/parameters/DeviceID"},
|
||||
{"$ref": "#/components/parameters/IfMatch"}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/merge-patch+json": {"schema": {"$ref": "#/components/schemas/DevicePatch"}}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {"$ref": "#/components/responses/AcceptedMutation"},
|
||||
"400": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"409": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"412": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"422": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"428": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"503": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sites/{site_id}/devices/{device_id}/desired-state": {
|
||||
"put": {
|
||||
"tags": ["Devices"],
|
||||
"operationId": "setDeviceDesiredState",
|
||||
"summary": "启用或停用设备接入期望态",
|
||||
"description": "成功只表示期望态已持久化并进入对账,不表示实际态已经收敛。重复提交不会重复改变 generation,但每次请求均可产生审计事实。",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/SiteID"},
|
||||
{"$ref": "#/components/parameters/DeviceID"},
|
||||
{"$ref": "#/components/parameters/IfMatch"}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/DesiredStateChange"}}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {"$ref": "#/components/responses/AcceptedMutation"},
|
||||
"400": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"412": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"422": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"428": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"503": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sites/{site_id}/devices:batchDesiredState": {
|
||||
"post": {
|
||||
"tags": ["Devices"],
|
||||
"operationId": "batchSetDeviceDesiredState",
|
||||
"summary": "批量提交最多 128 个设备期望态变更",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/SiteID"},
|
||||
{"$ref": "#/components/parameters/IdempotencyKey"}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/BatchDesiredStateRequest"}}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "请求已接收;响应包含已知的逐项接受/拒绝结果,后续状态从 operation 读取",
|
||||
"headers": {
|
||||
"Location": {"$ref": "#/components/headers/OperationLocation"},
|
||||
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/BatchOperation"}}
|
||||
}
|
||||
},
|
||||
"400": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"409": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"422": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"503": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/operations/{operation_id}": {
|
||||
"get": {
|
||||
"tags": ["Operations"],
|
||||
"operationId": "getOperation",
|
||||
"summary": "查询批量写入和逐项结果",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/OperationID"}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "批量操作状态",
|
||||
"headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}},
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/BatchOperation"}}
|
||||
}
|
||||
},
|
||||
"401": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"404": {"$ref": "#/components/responses/ErrorResponse"},
|
||||
"500": {"$ref": "#/components/responses/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "opaque",
|
||||
"description": "认证实现与 claim 格式不属于 v1 契约;服务端必须从已验证主体确定 tenant 与权限。"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"SiteID": {
|
||||
"name": "site_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"description": "当前认证租户中的稳定站点 ID"
|
||||
},
|
||||
"DeviceID": {
|
||||
"name": "device_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/LogicalID"}
|
||||
},
|
||||
"OperationID": {
|
||||
"name": "operation_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string", "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"}
|
||||
},
|
||||
"Cursor": {
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "minLength": 16, "maxLength": 512},
|
||||
"description": "不透明游标;客户端不得解析或构造"
|
||||
},
|
||||
"Limit": {
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}
|
||||
},
|
||||
"ModalityFilter": {
|
||||
"name": "modality",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/Modality"}
|
||||
},
|
||||
"CapabilityFilter": {
|
||||
"name": "capability",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/Capability"}
|
||||
},
|
||||
"DesiredStateFilter": {
|
||||
"name": "desired_state",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/DesiredState"}
|
||||
},
|
||||
"ActualStateFilter": {
|
||||
"name": "actual_state",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/ActualState"}
|
||||
},
|
||||
"IdempotencyKey": {
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 16,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:-]+$"
|
||||
},
|
||||
"description": "同一认证主体、站点、operationId、key 和规范化 body 在 24 小时内返回同一结果;key 相同而 body 不同返回 idempotency_conflict。"
|
||||
},
|
||||
"IfMatch": {
|
||||
"name": "If-Match",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {"type": "string", "minLength": 3, "maxLength": 128},
|
||||
"description": "使用最近一次 GET/写响应的 opaque ETag;不匹配返回 412 etag_mismatch。"
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"description": "设备资源的不透明并发版本",
|
||||
"schema": {"type": "string"}
|
||||
},
|
||||
"TraceID": {
|
||||
"description": "不含租户、主机或凭据的排查 ID",
|
||||
"schema": {"type": "string", "minLength": 8, "maxLength": 128}
|
||||
},
|
||||
"DeviceLocation": {
|
||||
"description": "新建设备资源的相对路径",
|
||||
"schema": {"type": "string", "pattern": "^/api/v1/sites/[^/]+/devices/[^/]+$"}
|
||||
},
|
||||
"OperationLocation": {
|
||||
"description": "批量操作资源的相对路径",
|
||||
"schema": {"type": "string", "pattern": "^/api/v1/operations/op_[0-9A-HJKMNP-TV-Z]{26}$"}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"CurrentDevice": {
|
||||
"description": "设备当前表示;endpoint 与 credential ref 永不回显",
|
||||
"headers": {
|
||||
"ETag": {"$ref": "#/components/headers/ETag"},
|
||||
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/Device"}}
|
||||
}
|
||||
},
|
||||
"CreatedDevice": {
|
||||
"description": "设备已创建;启用设备仍需等待对账收敛",
|
||||
"headers": {
|
||||
"ETag": {"$ref": "#/components/headers/ETag"},
|
||||
"Location": {"$ref": "#/components/headers/DeviceLocation"},
|
||||
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/Device"}}
|
||||
}
|
||||
},
|
||||
"AcceptedMutation": {
|
||||
"description": "期望态/配置写入已接受,实际态异步收敛",
|
||||
"headers": {
|
||||
"ETag": {"$ref": "#/components/headers/ETag"},
|
||||
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/MutationReceipt"}}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"description": "稳定错误;不返回内部堆栈、资源存在性或连接秘密",
|
||||
"headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}},
|
||||
"content": {
|
||||
"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"LogicalID": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$"
|
||||
},
|
||||
"Modality": {
|
||||
"type": "string",
|
||||
"enum": ["video", "radar", "contact", "button", "wearable", "other"]
|
||||
},
|
||||
"Capability": {
|
||||
"type": "string",
|
||||
"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]
|
||||
},
|
||||
"DesiredState": {
|
||||
"type": "string",
|
||||
"enum": ["disabled", "enabled"]
|
||||
},
|
||||
"ActualState": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "online", "offline", "failed"]
|
||||
},
|
||||
"AdapterStatus": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "ready", "adapter_not_ready", "authentication_failed", "unavailable"]
|
||||
},
|
||||
"ErrorCode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_request",
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"conflict",
|
||||
"precondition_required",
|
||||
"etag_mismatch",
|
||||
"idempotency_conflict",
|
||||
"duplicate_serial_number",
|
||||
"quota_exceeded",
|
||||
"quota_projection_unavailable",
|
||||
"quota_projection_invalid",
|
||||
"area_policy_denied",
|
||||
"area_policy_unavailable",
|
||||
"adapter_not_ready",
|
||||
"authentication_failed",
|
||||
"endpoint_credentials_forbidden",
|
||||
"batch_too_large",
|
||||
"service_unavailable",
|
||||
"internal_error"
|
||||
]
|
||||
},
|
||||
"DeviceCreate": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["serial_number", "name", "modality", "capabilities", "area_id"],
|
||||
"properties": {
|
||||
"serial_number": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"modality": {"$ref": "#/components/schemas/Modality"},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {"$ref": "#/components/schemas/Capability"},
|
||||
"description": "请求接入的能力集合;适配器探测不支持时以稳定错误拒绝或标记 adapter_not_ready。"
|
||||
},
|
||||
"area_id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"endpoint_ref": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048,
|
||||
"writeOnly": true,
|
||||
"description": "绝对 URI,禁止 userinfo;只在受控写路径使用。"
|
||||
},
|
||||
"credential_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512,
|
||||
"writeOnly": true,
|
||||
"description": "不透明密钥引用,不是用户名、密码或 token。"
|
||||
},
|
||||
"profile_token": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256,
|
||||
"writeOnly": true
|
||||
},
|
||||
"desired_state": {
|
||||
"allOf": [{"$ref": "#/components/schemas/DesiredState"}],
|
||||
"default": "disabled"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["modality"],
|
||||
"properties": {"modality": {"const": "video"}}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"capabilities": {"contains": {"const": "video_capture"}, "minContains": 1}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["capabilities"],
|
||||
"properties": {
|
||||
"capabilities": {"contains": {"const": "video_capture"}, "minContains": 1}
|
||||
}
|
||||
},
|
||||
"then": {"required": ["endpoint_ref", "credential_ref"]}
|
||||
}
|
||||
]
|
||||
},
|
||||
"DevicePatch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"area_id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"endpoint_ref": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048,
|
||||
"writeOnly": true,
|
||||
"description": "绝对 URI,禁止 userinfo。"
|
||||
},
|
||||
"credential_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512,
|
||||
"writeOnly": true
|
||||
},
|
||||
"profile_token": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256,
|
||||
"writeOnly": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"DesiredStateChange": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["desired_state", "reason"],
|
||||
"properties": {
|
||||
"desired_state": {"$ref": "#/components/schemas/DesiredState"},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
|
||||
}
|
||||
},
|
||||
"Device": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id", "tenant_id", "site_id", "serial_number", "name", "modality",
|
||||
"capabilities", "area_id", "desired_state", "actual_state", "adapter_status",
|
||||
"endpoint_configured", "credential_configured", "generation", "observed_generation",
|
||||
"converged", "failure_count", "projection_versions", "created_at", "updated_at"
|
||||
],
|
||||
"properties": {
|
||||
"id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"tenant_id": {
|
||||
"allOf": [{"$ref": "#/components/schemas/LogicalID"}],
|
||||
"readOnly": true,
|
||||
"description": "只用于回显已授权上下文,不得由客户端写入。"
|
||||
},
|
||||
"site_id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"serial_number": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"modality": {"$ref": "#/components/schemas/Modality"},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/Capability"},
|
||||
"uniqueItems": true,
|
||||
"maxItems": 16,
|
||||
"readOnly": true
|
||||
},
|
||||
"area_id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"desired_state": {"$ref": "#/components/schemas/DesiredState"},
|
||||
"actual_state": {"$ref": "#/components/schemas/ActualState"},
|
||||
"adapter_status": {"$ref": "#/components/schemas/AdapterStatus"},
|
||||
"endpoint_configured": {"type": "boolean", "readOnly": true},
|
||||
"credential_configured": {"type": "boolean", "readOnly": true},
|
||||
"generation": {"type": "integer", "format": "int64", "minimum": 1, "readOnly": true},
|
||||
"observed_generation": {"type": "integer", "format": "int64", "minimum": 0, "readOnly": true},
|
||||
"converged": {
|
||||
"type": "boolean",
|
||||
"readOnly": true,
|
||||
"description": "期望代已被观察且当前无待重试;不等价于所有外部系统健康。"
|
||||
},
|
||||
"failure_count": {"type": "integer", "minimum": 0, "readOnly": true},
|
||||
"next_attempt_at": {"type": ["string", "null"], "format": "date-time", "readOnly": true},
|
||||
"last_error_code": {"type": ["string", "null"], "maxLength": 128, "readOnly": true},
|
||||
"projection_versions": {"$ref": "#/components/schemas/ProjectionVersions"},
|
||||
"created_at": {"type": "string", "format": "date-time", "readOnly": true},
|
||||
"updated_at": {"type": "string", "format": "date-time", "readOnly": true}
|
||||
}
|
||||
},
|
||||
"ProjectionVersions": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["quota_source_version", "area_policy_source_version", "synced_at"],
|
||||
"properties": {
|
||||
"quota_source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1, "readOnly": true},
|
||||
"area_policy_source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1, "readOnly": true},
|
||||
"synced_at": {"type": ["string", "null"], "format": "date-time", "readOnly": true}
|
||||
}
|
||||
},
|
||||
"SiteQuotaStatus": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "used_video_channels", "max_video_channels", "available_video_channels", "over_limit", "source_version", "synced_at"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["current", "unavailable", "invalid"]},
|
||||
"used_video_channels": {"type": "integer", "minimum": 0, "maximum": 128},
|
||||
"max_video_channels": {"type": ["integer", "null"], "minimum": 1, "maximum": 128},
|
||||
"available_video_channels": {"type": ["integer", "null"], "minimum": 0, "maximum": 128},
|
||||
"over_limit": {"type": "boolean"},
|
||||
"source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1},
|
||||
"synced_at": {"type": ["string", "null"], "format": "date-time"}
|
||||
}
|
||||
},
|
||||
"PageInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["limit", "has_more", "next_cursor"],
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||
"has_more": {"type": "boolean"},
|
||||
"next_cursor": {"type": ["string", "null"], "maxLength": 512}
|
||||
}
|
||||
},
|
||||
"DevicePage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items", "page", "quota"],
|
||||
"properties": {
|
||||
"items": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/Device"}},
|
||||
"page": {"$ref": "#/components/schemas/PageInfo"},
|
||||
"quota": {"$ref": "#/components/schemas/SiteQuotaStatus"}
|
||||
}
|
||||
},
|
||||
"MutationReceipt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["device", "accepted_at", "trace_id"],
|
||||
"properties": {
|
||||
"device": {"$ref": "#/components/schemas/Device"},
|
||||
"accepted_at": {"type": "string", "format": "date-time"},
|
||||
"trace_id": {"type": "string", "minLength": 8, "maxLength": 128}
|
||||
}
|
||||
},
|
||||
"BatchDesiredStateItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["device_id", "etag", "desired_state"],
|
||||
"properties": {
|
||||
"device_id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"etag": {"type": "string", "minLength": 3, "maxLength": 128},
|
||||
"desired_state": {"$ref": "#/components/schemas/DesiredState"}
|
||||
}
|
||||
},
|
||||
"BatchDesiredStateRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items", "reason"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"items": {"$ref": "#/components/schemas/BatchDesiredStateItem"}
|
||||
},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
|
||||
}
|
||||
},
|
||||
"BatchItemResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["device_id", "status", "error_code", "message", "generation"],
|
||||
"properties": {
|
||||
"device_id": {"$ref": "#/components/schemas/LogicalID"},
|
||||
"status": {"type": "string", "enum": ["accepted", "rejected", "succeeded", "failed"]},
|
||||
"error_code": {"oneOf": [{"$ref": "#/components/schemas/ErrorCode"}, {"type": "null"}]},
|
||||
"message": {"type": ["string", "null"], "maxLength": 500},
|
||||
"generation": {"type": ["integer", "null"], "format": "int64", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"BatchOperation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "status", "submitted_at", "completed_at", "results", "trace_id"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"},
|
||||
"status": {"type": "string", "enum": ["queued", "running", "succeeded", "partially_succeeded", "failed"]},
|
||||
"submitted_at": {"type": "string", "format": "date-time"},
|
||||
"completed_at": {"type": ["string", "null"], "format": "date-time"},
|
||||
"results": {"type": "array", "maxItems": 128, "items": {"$ref": "#/components/schemas/BatchItemResult"}},
|
||||
"trace_id": {"type": "string", "minLength": 8, "maxLength": 128}
|
||||
}
|
||||
},
|
||||
"FieldError": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "code", "message"],
|
||||
"properties": {
|
||||
"field": {"type": "string", "maxLength": 256},
|
||||
"code": {"type": "string", "maxLength": 128},
|
||||
"message": {"type": "string", "maxLength": 500}
|
||||
}
|
||||
},
|
||||
"Problem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "title", "status", "code", "message", "trace_id", "field_errors"],
|
||||
"properties": {
|
||||
"type": {"type": "string", "format": "uri-reference"},
|
||||
"title": {"type": "string", "maxLength": 200},
|
||||
"status": {"type": "integer", "minimum": 400, "maximum": 599},
|
||||
"code": {"$ref": "#/components/schemas/ErrorCode"},
|
||||
"message": {"type": "string", "maxLength": 1000},
|
||||
"trace_id": {"type": "string", "minLength": 8, "maxLength": 128},
|
||||
"field_errors": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/FieldError"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- YoVision Bell -> Sense site quota projection contract v1.
|
||||
--
|
||||
-- This file freezes the PostgreSQL object signature and privileges. It is not a
|
||||
-- migration: Bell must create the source table and install this view in a later
|
||||
-- implementation task. The source table must enforce a default of 16 and a
|
||||
-- CHECK constraint that keeps max_video_channels between 1 and 128 inclusive.
|
||||
-- source_version must increase monotonically for each logical site whenever a
|
||||
-- projected value changes.
|
||||
|
||||
CREATE VIEW bell.site_quota_v1 (
|
||||
tenant_id,
|
||||
site_id,
|
||||
max_video_channels,
|
||||
source_version,
|
||||
source_updated_at
|
||||
) AS
|
||||
SELECT
|
||||
site.tenant_id,
|
||||
site.id,
|
||||
site.max_video_channels,
|
||||
site.version,
|
||||
site.updated_at
|
||||
FROM bell.sites AS site
|
||||
WHERE site.deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW bell.site_quota_v1 IS
|
||||
'v1 read-only site video quota projection owned by Bell and consumed by Sense';
|
||||
|
||||
ALTER VIEW bell.site_quota_v1 OWNER TO bell_app;
|
||||
REVOKE ALL PRIVILEGES ON TABLE bell.site_quota_v1 FROM PUBLIC;
|
||||
GRANT USAGE ON SCHEMA bell TO sense_app;
|
||||
GRANT SELECT ON TABLE bell.site_quota_v1 TO sense_app;
|
||||
+23
-9
@@ -1,21 +1,27 @@
|
||||
# 当前实现状态
|
||||
|
||||
> 快照日期:2026-08-03。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
|
||||
> 快照日期:2026-08-07。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
|
||||
|
||||
## 当前阶段
|
||||
|
||||
- 阶段:M0 摄像头兼容性验证与需求定稿。
|
||||
- 生产代码:尚未开始。
|
||||
- 阶段:M0 指定摄像头型号准入已完成;M1 的“一实机 + 四合成源”实验室软件闭环已通过;M2 已完成首个控制面/配额契约门禁,但五条独立真实上游和生产 SLA 尚未验收。
|
||||
- 生产代码:Sense 已包含可构建进程、SQLite 台账、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、对账、探活和 MediaMTX 重启重建;公共设备管理 API 与生产部署能力仍未实现。
|
||||
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
|
||||
|
||||
## 仓库现实
|
||||
|
||||
- `Sense/`、`Brain/`、`Bell/` 只有目录占位。
|
||||
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Brain/`、`Bell/` 仍只有目录占位。
|
||||
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
|
||||
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure 与探活假 HTTP 测试。
|
||||
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。当前仍不枚举/删除孤儿。
|
||||
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
|
||||
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
||||
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
||||
- `docs/contracts/sense-control-v1.openapi.json` 已冻结站点作用域的设备查询、创建、修改、启停与最多 128 项批量操作;`site-quota-v1.sql` 已冻结 Bell 所有、Sense 只读的配额视图签名。两者当前只有契约测试,HTTP handler、认证、Bell 表和 migration 尚未实现。
|
||||
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
|
||||
- Gitea 已初始化 11 个协作标签;首批 Issue 已建立,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
||||
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
||||
- T-002 已关闭架构影响型问题:首期 S2 民办寄宿学校、客户侧私有部署、ONVIF/RTSP、NVIDIA 主路径、自研 Bell、Web/H5 与客户侧证据存储等边界已批准。
|
||||
- T-004 的 Sense/Bell 单文件交互原型已获产品确认;T-005 已归档 Claude 原稿并补录缺失评审文档,当前定稿与原始依据分开保存。
|
||||
- `_reference/mibeenvr` 为本地只读参考仓库,已被忽略;只允许 M0 实验与白名单借鉴。
|
||||
|
||||
## 当前可运行命令
|
||||
@@ -26,6 +32,12 @@ Windows:
|
||||
./init.ps1
|
||||
```
|
||||
|
||||
该入口会下载锁定 Go module,运行三条治理验证、MediaMTX 客户端生成漂移检查、`go test`、`go vet` 和 `go build`。只启动 Sense:
|
||||
|
||||
```powershell
|
||||
go -C Sense run ./cmd/sense-api
|
||||
```
|
||||
|
||||
跨平台直接验证:
|
||||
|
||||
```powershell
|
||||
@@ -34,20 +46,22 @@ python -m unittest discover -s tests -p "test_*.py"
|
||||
python scripts/validate_harness_governance.py
|
||||
```
|
||||
|
||||
当前没有 Go/Python 业务依赖安装、生产服务启动或端到端命令。M1 脚手架创建时必须同步更新标准入口。
|
||||
Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz` 与 `/readyz` 运维探针;它们不代表摄像头或 M1 里程碑健康。MediaMTX 获取、校验和独立启动方法见 `Sense/README.md`。
|
||||
|
||||
## 当前 blocker / 待确认
|
||||
|
||||
- 尚未完成 3–5 款候选摄像头的 ONVIF/RTSP 实机矩阵和采购白名单。
|
||||
- T-001 已完成一台 HIKVISION `DS-2CD3321FD-IW1-T`、硬件 `0x0`、固件 `V5.5.61 build 180929` 的 ONVIF/RTSP 指定基线准入;主/子码流、错误鉴权、三个 ONVIF 核心操作和时间漂移均有脱敏证据。项目负责人豁免了无法现场执行的三次真实断网恢复,该项没有原始时间线,单样机结论也不代表批次或多品牌兼容。
|
||||
- 后续本地开发统一使用现有一台 Hikvision IP Camera;T-006 已用该实机 + 4 条独立 FFmpeg 8.1.2 合成 RTSP 源完成五路软件闭环,不要求当前购买更多摄像头。
|
||||
- T-007 保留至少 5 条独立真实上游的客户现场门禁,可使用客户授权、借用或租赁设备;它不阻塞本地开发/M1 实验室出口,但继续阻塞生产试点和真实多路 SLA。
|
||||
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
|
||||
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
||||
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
||||
- Go/Python/PostgreSQL/MediaMTX/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结。
|
||||
- Python/PostgreSQL/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结。
|
||||
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
||||
|
||||
## 下一步
|
||||
|
||||
从 Gitea 的 `status/todo` 工单中由 dispatcher 分配依赖已满足、编号最靠前且写路径不冲突的任务。下一项优先完成 T-001(摄像头兼容性矩阵);T-003 仍需等待 T-001,T-002 已满足。不要仅凭本文宣称领取成功。
|
||||
下一步先按 Gitea 流程建立并领取 T-008 的实现后续任务:实现认证 tenant 上下文、Sense 设备管理 handler/PostgreSQL repository,以及 Bell 配额源表、migration 和 `bell.site_quota_v1`。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。实时领取状态仍以 Gitea 为准。
|
||||
|
||||
## 已知风险
|
||||
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>YoVision Bell · 预警处置原型</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #090d18;
|
||||
--surface: #101725;
|
||||
--surface-2: #151e2e;
|
||||
--surface-3: #1b2638;
|
||||
--text: #f5f7fb;
|
||||
--muted: #a3b0c2;
|
||||
--subtle: #73839a;
|
||||
--line: #29364a;
|
||||
--primary: #8b7cf6;
|
||||
--primary-strong: #7161e8;
|
||||
--primary-soft: rgba(139, 124, 246, .14);
|
||||
--cyan: #38bdf8;
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #fb7185;
|
||||
--shadow: 0 18px 46px rgba(0, 0, 0, .25);
|
||||
--radius: 14px;
|
||||
--sidebar: 216px;
|
||||
--banner: 28px;
|
||||
}
|
||||
html[data-theme="light"] {
|
||||
--bg: #f1f4f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--surface-3: #eaf0f6;
|
||||
--text: #131d2c;
|
||||
--muted: #52647a;
|
||||
--subtle: #738096;
|
||||
--line: #d2dbe6;
|
||||
--primary-soft: rgba(113, 97, 232, .1);
|
||||
--shadow: 0 14px 34px rgba(32, 52, 78, .12);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html { min-width: 320px; background: var(--bg); }
|
||||
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.5 Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, select, input[type="checkbox"], input[type="radio"] { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 3px solid rgba(139, 124, 246, .45); outline-offset: 2px; }
|
||||
.prototype-banner { position: fixed; z-index: 1000; inset: 0 0 auto 0; height: var(--banner); display: flex; align-items: center; justify-content: center; background: #f6c344; color: #211900; font-size: 12px; font-weight: 800; letter-spacing: .07em; }
|
||||
.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
|
||||
.app { min-height: 100dvh; padding-top: var(--banner); display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); }
|
||||
.sidebar { position: sticky; top: var(--banner); z-index: 30; height: calc(100dvh - var(--banner)); padding: 18px 12px; border-right: 1px solid var(--line); background: var(--surface); display: flex; flex-direction: column; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 2px 10px 20px; }
|
||||
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, #8b7cf6, #38bdf8); color: white; font-weight: 900; box-shadow: 0 8px 22px rgba(113, 97, 232, .25); }
|
||||
.brand strong { display: block; font-size: 16px; }
|
||||
.brand small { color: var(--muted); }
|
||||
.nav-label { margin: 8px 10px 6px; color: var(--subtle); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.nav { display: grid; gap: 4px; }
|
||||
.nav button { min-height: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--muted); display: flex; align-items: center; gap: 10px; text-align: left; transition: .2s ease; }
|
||||
.nav button:hover { color: var(--text); background: var(--surface-2); }
|
||||
.nav button[aria-current="page"] { color: var(--text); background: var(--primary-soft); box-shadow: inset 3px 0 var(--primary); }
|
||||
.nav .count { margin-left: auto; min-width: 22px; padding: 1px 6px; border-radius: 999px; background: var(--surface-3); color: var(--text); text-align: center; font-size: 12px; }
|
||||
.shift-card { margin-top: auto; padding: 12px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-2); }
|
||||
.shift-card strong { display: block; }
|
||||
.shift-card span { color: var(--muted); font-size: 12px; }
|
||||
.shift-bar { margin-top: 9px; height: 5px; border-radius: 99px; background: var(--surface-3); overflow: hidden; }
|
||||
.shift-bar i { display: block; width: 63%; height: 100%; background: var(--success); }
|
||||
.main { min-width: 0; }
|
||||
.topbar { position: sticky; top: var(--banner); z-index: 20; min-height: 64px; padding: 10px 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(12px); }
|
||||
.site strong { display: block; font-size: 15px; }
|
||||
.site span { color: var(--muted); font-size: 12px; }
|
||||
.top-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||||
.btn { min-height: 40px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text); display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 750; text-decoration: none; transition: .2s ease; }
|
||||
.btn:hover { border-color: var(--primary); }
|
||||
.btn:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.btn-primary { background: var(--primary-strong); border-color: var(--primary-strong); color: white; }
|
||||
.btn-success { background: #117a5d; border-color: #159271; color: white; }
|
||||
.btn-danger { background: rgba(251,113,133,.1); border-color: rgba(251,113,133,.38); color: #ffb4c2; }
|
||||
.btn-quiet { border-color: transparent; background: transparent; color: var(--muted); }
|
||||
.icon-btn { width: 42px; padding: 0; }
|
||||
.text-button { min-height: 32px; padding: 2px 6px; border: 0; background: transparent; color: #b8e5ff; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.select, .field, .textarea { width: 100%; min-height: 40px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
|
||||
.textarea { min-height: 80px; resize: vertical; }
|
||||
.top-actions .select { width: auto; min-width: 150px; }
|
||||
.content { padding: 18px; }
|
||||
.view { display: none; animation: reveal .22s ease-out; }
|
||||
.view.active { display: block; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
.page-head { margin-bottom: 15px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.page-head h1 { margin: 0 0 4px; font-size: 24px; line-height: 1.25; }
|
||||
.page-head p { margin: 0; color: var(--muted); }
|
||||
.page-actions, .toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.card-head { min-height: 52px; padding: 11px 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.card-head h2, .card-head h3 { margin: 0; font-size: 15px; }
|
||||
.card-body { padding: 14px; }
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 24px; padding: 2px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.badge.success { color: #78edc2; border-color: rgba(52,211,153,.34); background: rgba(52,211,153,.1); }
|
||||
.badge.warning { color: #ffe18a; border-color: rgba(251,191,36,.36); background: rgba(251,191,36,.1); }
|
||||
.badge.danger { color: #ff9eb0; border-color: rgba(251,113,133,.4); background: rgba(251,113,133,.1); }
|
||||
.badge.info { color: #a5e2ff; border-color: rgba(56,189,248,.35); background: rgba(56,189,248,.1); }
|
||||
.badge.violet { color: #c9c1ff; border-color: rgba(139,124,246,.38); background: var(--primary-soft); }
|
||||
.duty-grid { display: grid; grid-template-columns: 320px minmax(420px, 1fr) 330px; gap: 12px; min-height: calc(100dvh - 156px); }
|
||||
.queue, .evidence, .action-panel { min-width: 0; overflow: hidden; }
|
||||
.queue-head { padding: 10px; border-bottom: 1px solid var(--line); }
|
||||
.queue-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 9px; background: var(--surface-2); }
|
||||
.queue-tabs button { min-height: 34px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); }
|
||||
.queue-tabs button.active { background: var(--surface-3); color: var(--text); }
|
||||
.alert-list { padding: 8px; display: grid; gap: 7px; max-height: calc(100dvh - 265px); overflow-y: auto; }
|
||||
.alert-card { width: 100%; padding: 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text); text-align: left; transition: .18s ease; }
|
||||
.alert-card:hover { background: var(--surface-2); }
|
||||
.alert-card.selected { background: var(--primary-soft); border-color: rgba(139,124,246,.45); }
|
||||
.alert-card-top, .alert-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.alert-card h3 { margin: 8px 0 3px; font-size: 14px; }
|
||||
.alert-card p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.alert-card-foot { margin-top: 9px; color: var(--subtle); font-size: 12px; }
|
||||
.severity-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.evidence-summary { padding: 12px 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
|
||||
.evidence-summary h2 { margin: 0 0 5px; font-size: 18px; }
|
||||
.evidence-summary p { margin: 0; color: var(--muted); }
|
||||
.evidence-tabs { display: flex; gap: 4px; padding: 8px 12px 0; }
|
||||
.evidence-tabs button { min-height: 36px; padding: 0 12px; border: 0; border-radius: 8px 8px 0 0; background: transparent; color: var(--muted); }
|
||||
.evidence-tabs button.active { background: var(--surface-2); color: var(--text); }
|
||||
.video-stage { position: relative; aspect-ratio: 16/9; margin: 0 12px 12px; background: #02060d; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.video-stage svg { width: 100%; height: 100%; display: block; }
|
||||
.video-controls { position: absolute; inset: auto 0 0; min-height: 44px; padding: 6px 10px; display: flex; align-items: center; gap: 9px; color: white; background: linear-gradient(transparent, rgba(0,0,0,.88)); }
|
||||
.video-controls button { width: 34px; height: 34px; border: 0; border-radius: 7px; background: rgba(255,255,255,.08); color: white; }
|
||||
.timeline-bar { flex: 1; height: 5px; border-radius: 99px; background: rgba(255,255,255,.22); overflow: hidden; }
|
||||
.timeline-bar i { display: block; width: 54%; height: 100%; background: var(--primary); }
|
||||
.fact-grid { margin: 0 12px 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.fact { padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.fact span { display: block; color: var(--muted); font-size: 11px; }
|
||||
.fact strong { display: block; margin-top: 3px; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.privacy-note { margin: 0 12px 12px; padding: 9px 10px; display: flex; gap: 8px; border-left: 3px solid var(--cyan); background: rgba(56,189,248,.08); color: var(--muted); font-size: 12px; }
|
||||
.relation-panel { margin: 0 12px 12px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.relation-panel h3 { margin: 0 0 8px; font-size: 13px; }
|
||||
.relation-list { display: grid; gap: 7px; }
|
||||
.relation-item { min-height: 40px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--text); display: flex; align-items: center; justify-content: space-between; gap: 10px; text-align: left; }
|
||||
.relation-item span { color: var(--muted); font-size: 12px; }
|
||||
.action-scroll { max-height: calc(100dvh - 210px); overflow-y: auto; }
|
||||
.ack-box { padding: 14px; border-bottom: 1px solid var(--line); }
|
||||
.ack-box .btn { width: 100%; min-height: 48px; }
|
||||
.ack-box p { margin: 9px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.ack-tools { margin-top: 8px; display: flex; justify-content: flex-end; }
|
||||
.inline-alert { margin-top: 9px; padding: 9px 10px; border: 1px solid rgba(251,191,36,.4); border-radius: 8px; background: rgba(251,191,36,.09); color: #ffe8a8; font-size: 12px; }
|
||||
.inline-alert.danger { border-color: rgba(251,113,133,.42); background: rgba(251,113,133,.09); color: #ffc3ce; }
|
||||
.countdown { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; padding: 12px; margin: 12px; border: 1px solid rgba(251,191,36,.28); border-radius: 10px; background: rgba(251,191,36,.07); }
|
||||
.countdown strong { display: block; font-size: 20px; color: #ffe18a; font-variant-numeric: tabular-nums; }
|
||||
.countdown span { color: var(--muted); font-size: 12px; }
|
||||
.escalation { padding: 0 14px 14px; }
|
||||
.escalation h3 { margin: 0 0 10px; font-size: 14px; }
|
||||
.step { position: relative; padding: 0 0 16px 27px; }
|
||||
.step::before { content: ""; position: absolute; left: 7px; top: 17px; bottom: 0; width: 1px; background: var(--line); }
|
||||
.step:last-child::before { display: none; }
|
||||
.step-dot { position: absolute; left: 0; top: 3px; width: 15px; height: 15px; border-radius: 50%; border: 3px solid var(--surface); background: var(--subtle); box-shadow: 0 0 0 1px var(--line); }
|
||||
.step.done .step-dot { background: var(--success); }
|
||||
.step.current .step-dot { background: var(--warning); }
|
||||
.step strong { display: block; }
|
||||
.step span { color: var(--muted); font-size: 12px; }
|
||||
.delivery-facts { margin-top: 4px; display: grid; gap: 2px; }
|
||||
.delivery-facts span { display: block; }
|
||||
.disposition { padding: 14px; border-top: 1px solid var(--line); display: grid; gap: 9px; }
|
||||
.disposition label { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.disposition-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.grid { display: grid; gap: 12px; }
|
||||
.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 12px; }
|
||||
.stat { padding: 15px; min-height: 110px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.stat span { color: var(--muted); }
|
||||
.stat strong { font-size: 27px; font-variant-numeric: tabular-nums; }
|
||||
.search { position: relative; min-width: 230px; }
|
||||
.search .icon { position: absolute; left: 11px; top: 11px; color: var(--subtle); pointer-events: none; }
|
||||
.search input { padding-left: 38px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 870px; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
|
||||
th { background: var(--surface-2); color: var(--muted); font-size: 12px; }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tr:hover td { background: color-mix(in srgb, var(--primary-soft) 50%, transparent); }
|
||||
.pager { min-height: 58px; padding: 10px 14px; border-top: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.pager-actions { display: flex; gap: 7px; }
|
||||
.rule-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.rule-card { padding: 15px; }
|
||||
.rule-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.rule-card h2 { margin: 12px 0 5px; font-size: 16px; }
|
||||
.rule-card p { margin: 0 0 14px; color: var(--muted); }
|
||||
.rule-card dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 7px; color: var(--muted); }
|
||||
.rule-card dd { margin: 0; color: var(--text); }
|
||||
.rule-card-actions { margin-top: 14px; padding-top: 12px; display: flex; flex-wrap: wrap; gap: 7px; border-top: 1px solid var(--line); }
|
||||
.rule-context { margin-bottom: 12px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid rgba(56,189,248,.34); border-radius: 10px; background: rgba(56,189,248,.08); }
|
||||
.rule-context[hidden] { display: none; }
|
||||
.rule-context p { margin: 0; color: var(--muted); }
|
||||
.site-grid { grid-template-columns: minmax(260px,.72fr) minmax(520px,1.45fr); }
|
||||
.site-list { padding: 8px; display: grid; gap: 7px; }
|
||||
.site-item { width: 100%; min-height: 62px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left; }
|
||||
.site-item.active { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.site-item small { display: block; color: var(--muted); }
|
||||
.policy-note { margin: 12px 14px 0; padding: 10px 12px; border-left: 3px solid var(--warning); background: rgba(251,191,36,.08); color: var(--muted); }
|
||||
.context-strip { margin-bottom: 12px; padding: 11px 13px; border: 1px solid rgba(56,189,248,.34); border-radius: 10px; background: rgba(56,189,248,.08); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.context-strip p { margin: 0; color: var(--muted); }
|
||||
.linked-zone { grid-column: 1/-1; padding: 11px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.linked-zone strong, .linked-zone small { display: block; }
|
||||
.linked-zone small { color: var(--muted); }
|
||||
.workflow-note { grid-column: 1/-1; margin: 0; padding: 10px 11px; border-left: 3px solid var(--primary); background: var(--primary-soft); color: var(--muted); font-size: 12px; }
|
||||
.version-diff { display: grid; gap: 8px; }
|
||||
.version-diff article { padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.version-diff article strong { display: block; }
|
||||
.chain-subnav { margin-bottom: 12px; padding: 4px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }
|
||||
.chain-subnav button { min-height: 44px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-weight: 750; }
|
||||
.chain-subnav button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 0 0 1px rgba(139,124,246,.36); }
|
||||
.chain-panel { display: none; }
|
||||
.chain-panel.active { display: block; }
|
||||
.chain-grid { grid-template-columns: minmax(0, 1.4fr) minmax(300px, .8fr); }
|
||||
.chain-row { padding: 13px 14px; display: grid; grid-template-columns: 40px 1fr auto; gap: 11px; align-items: center; border-bottom: 1px solid var(--line); }
|
||||
.chain-row:last-child { border-bottom: 0; }
|
||||
.chain-num { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; background: var(--primary-soft); color: #cfc8ff; font-weight: 800; }
|
||||
.chain-row small { color: var(--muted); }
|
||||
.target-ref { display: inline-block; margin-top: 3px; color: #b8e5ff; font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.resolve-card { padding: 14px; }
|
||||
.resolve-card h2 { margin: 0 0 4px; font-size: 15px; }
|
||||
.resolve-card > p { margin: 0 0 12px; color: var(--muted); }
|
||||
.resolve-list { display: grid; gap: 8px; }
|
||||
.resolve-item { padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.resolve-item span, .resolve-item small { display: block; color: var(--muted); }
|
||||
.resolve-item strong { display: block; margin: 2px 0; }
|
||||
.roster-week { padding: 12px; display: grid; grid-template-columns: repeat(7, minmax(125px, 1fr)); gap: 8px; overflow-x: auto; }
|
||||
.roster-day { min-width: 125px; padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.roster-day.current { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.roster-day > strong { display: block; margin-bottom: 8px; }
|
||||
.shift-slot { padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.shift-slot:first-of-type { border-top: 0; }
|
||||
.shift-slot span, .shift-slot small { display: block; color: var(--muted); font-size: 12px; }
|
||||
.shift-slot strong { display: block; margin: 2px 0; font-size: 13px; }
|
||||
.object-note { padding: 11px 14px; border-top: 1px solid var(--line); color: var(--muted); }
|
||||
.contact-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }
|
||||
.contact-summary .stat { min-height: 96px; }
|
||||
.chart-grid { grid-template-columns: 1.35fr .65fr; }
|
||||
.chart { padding: 16px; }
|
||||
.chart h2 { margin: 0; font-size: 15px; }
|
||||
.chart p { margin: 4px 0 14px; color: var(--muted); }
|
||||
.bars { height: 220px; display: flex; align-items: end; gap: 16px; padding: 10px 6px 26px; border-bottom: 1px solid var(--line); }
|
||||
.bar { position: relative; flex: 1; min-width: 24px; height: var(--h); border-radius: 7px 7px 0 0; background: linear-gradient(180deg, var(--primary), #4f46a8); }
|
||||
.bar span { position: absolute; inset: calc(100% + 5px) 0 auto; text-align: center; color: var(--muted); font-size: 11px; }
|
||||
.donut { width: 170px; height: 170px; margin: 18px auto; border-radius: 50%; background: conic-gradient(var(--success) 0 61%, var(--warning) 61% 83%, var(--danger) 83%); position: relative; }
|
||||
.donut::after { content: "83%\A 24h 内闭环"; white-space: pre; position: absolute; inset: 27px; border-radius: 50%; display: grid; place-items: center; text-align: center; background: var(--surface); color: var(--text); font-weight: 800; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 12px; }
|
||||
.legend i { display: inline-block; width: 9px; height: 9px; margin-right: 5px; border-radius: 2px; }
|
||||
.metric-note { margin: 12px 0 0; padding: 10px 12px; border-left: 3px solid var(--cyan); background: rgba(56,189,248,.08); color: var(--muted); }
|
||||
.state-layer { display: none; min-height: 420px; place-items: center; padding: 24px; border: 1px dashed var(--line); border-radius: var(--radius); background: var(--surface); text-align: center; }
|
||||
.state-layer.show { display: grid; }
|
||||
.state-layer .icon { width: 40px; height: 40px; color: var(--primary); }
|
||||
.state-layer h2 { margin: 12px 0 4px; font-size: 18px; }
|
||||
.state-layer p { margin: 0 0 14px; max-width: 460px; color: var(--muted); }
|
||||
.skeleton { width: min(460px, 90%); display: grid; gap: 10px; }
|
||||
.skeleton i { height: 15px; border-radius: 6px; background: linear-gradient(90deg, var(--surface-3), var(--line), var(--surface-3)); background-size: 200% 100%; animation: shimmer 1.4s infinite; }
|
||||
.skeleton i:nth-child(2) { width: 72%; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
dialog { width: min(610px, calc(100% - 28px)); max-height: 88dvh; padding: 0; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 24px 80px rgba(0,0,0,.5); }
|
||||
#ruleDialog, #eventDialog, #handoverDialog, #rollbackDialog, #escalationDialog, #scheduleDialog, #substituteDialog, #contactDialog { width: min(760px, calc(100% - 28px)); }
|
||||
dialog::backdrop { background: rgba(4,7,15,.68); backdrop-filter: blur(4px); }
|
||||
.dialog-head, .dialog-foot { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.dialog-head h2 { margin: 0; font-size: 17px; }
|
||||
.dialog-body { padding: 18px; overflow: auto; }
|
||||
.dialog-foot { border: 0; border-top: 1px solid var(--line); justify-content: flex-end; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-field { display: grid; gap: 6px; }
|
||||
.form-field.full { grid-column: 1/-1; }
|
||||
.form-field label { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.radio-card { display: block; margin-bottom: 9px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 1100; max-width: min(420px, calc(100% - 40px)); padding: 12px 14px; border: 1px solid rgba(52,211,153,.35); border-radius: 10px; background: #0b2c25; color: #d9fff3; box-shadow: var(--shadow); transform: translateY(20px); opacity: 0; pointer-events: none; transition: .2s ease; }
|
||||
.toast.show { transform: none; opacity: 1; }
|
||||
.muted { color: var(--muted); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
||||
.mobile-nav { display: none; }
|
||||
.mobile-manage { display: none; }
|
||||
@media (max-width: 1240px) {
|
||||
:root { --sidebar: 78px; }
|
||||
.brand-copy, .nav span:not(.count), .nav-label, .shift-card { display: none; }
|
||||
.brand { justify-content: center; padding-inline: 0; }
|
||||
.nav button { justify-content: center; padding: 0; }
|
||||
.nav .count { position: absolute; margin: -24px 0 0 25px; }
|
||||
.duty-grid { grid-template-columns: 285px minmax(390px, 1fr); }
|
||||
.action-panel { grid-column: 1/-1; }
|
||||
.action-scroll { max-height: none; display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.disposition { border-top: 0; border-left: 1px solid var(--line); }
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
:root { --sidebar: 0px; }
|
||||
.app { display: block; padding-bottom: 66px; }
|
||||
.sidebar { display: none; }
|
||||
.topbar { padding: 8px 12px; }
|
||||
.site span, .top-actions .select { display: none; }
|
||||
.mobile-manage { display: inline-flex; min-width: 44px; }
|
||||
.content { padding: 12px; }
|
||||
.btn, .select, .field, .textarea { min-height: 44px; }
|
||||
.icon-btn { min-width: 44px; }
|
||||
.duty-grid, .chain-grid, .chart-grid { grid-template-columns: 1fr; }
|
||||
.alert-list, .action-scroll { max-height: none; }
|
||||
.action-scroll { display: block; }
|
||||
.disposition { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.site-grid { grid-template-columns: 1fr; }
|
||||
.roster-week { grid-template-columns: repeat(2, minmax(145px, 1fr)); }
|
||||
.rule-grid { grid-template-columns: 1fr 1fr; }
|
||||
.fact-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pager { align-items: flex-start; flex-direction: column; }
|
||||
.mobile-nav { position: fixed; z-index: 50; left: 8px; right: 8px; bottom: 8px; display: grid; grid-template-columns: repeat(5, 1fr); padding: 5px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.mobile-nav button { min-height: 48px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); display: grid; place-items: center; font-size: 11px; }
|
||||
.mobile-nav button.active { color: #c9c1ff; background: var(--primary-soft); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.page-head { display: grid; }
|
||||
.stats, .rule-grid { grid-template-columns: 1fr; }
|
||||
.chain-subnav { grid-template-columns: 1fr; }
|
||||
.contact-summary { grid-template-columns: 1fr; }
|
||||
.roster-week { grid-template-columns: 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-field.full { grid-column: auto; }
|
||||
.video-stage { margin-inline: 8px; }
|
||||
.fact-grid { margin-inline: 8px; }
|
||||
.evidence-summary { display: grid; }
|
||||
.disposition-actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg aria-hidden="true" width="0" height="0" style="position:absolute">
|
||||
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M4 4h16v16H4Z"/><path d="m4 14 5-1 2 3h2l2-3 5 1"/></symbol>
|
||||
<symbol id="i-event" viewBox="0 0 24 24"><path d="M6 3h12v18H6z"/><path d="M9 8h6m-6 4h6m-6 4h4"/></symbol>
|
||||
<symbol id="i-rule" viewBox="0 0 24 24"><path d="M4 6h7m6 0h3M4 12h3m6 0h7M4 18h9m6 0h1"/><circle cx="14" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="16" cy="18" r="2"/></symbol>
|
||||
<symbol id="i-phone" viewBox="0 0 24 24"><path d="M5 4h4l2 5-3 2a16 16 0 0 0 5 5l2-3 5 2v4c-8 2-17-7-15-15Z"/></symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M4 20V9m6 11V4m6 16v-7m4 7H2"/></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-3 8-8 9-5-1-8-4-8-9V6Z"/><path d="m9 12 2 2 4-5"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M10 21h4"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 3 2 20h20Z"/><path d="M12 9v4m0 3v.01"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 5 11 7-11 7Z"/></symbol>
|
||||
<symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5m13-5h5v5M8 21H3v-5m13 5h5v-5"/></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m0 0 5-5m-5 5-5-5M4 20h16"/></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v6l4 2"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></symbol>
|
||||
</svg>
|
||||
|
||||
<div class="prototype-banner">PROTOTYPE - 仅供枚举交互,非实现依据</div>
|
||||
<div class="app">
|
||||
<aside class="sidebar" aria-label="Bell 主导航">
|
||||
<div class="brand"><div class="brand-mark">Y</div><div class="brand-copy"><strong>YoVision</strong><small>Bell · Duty Console</small></div></div>
|
||||
<div class="nav-label">值班与业务</div>
|
||||
<nav class="nav">
|
||||
<button data-view="duty" aria-current="page"><svg class="icon"><use href="#i-inbox"/></svg><span>值班台</span><span class="count">6</span></button>
|
||||
<button data-view="events"><svg class="icon"><use href="#i-event"/></svg><span>事件中心</span></button>
|
||||
<button data-view="rules"><svg class="icon"><use href="#i-rule"/></svg><span>规则策略</span></button>
|
||||
<button data-view="chains"><svg class="icon"><use href="#i-phone"/></svg><span>升级链</span></button>
|
||||
<button data-view="reports"><svg class="icon"><use href="#i-chart"/></svg><span>运营报表</span></button>
|
||||
</nav>
|
||||
<div class="nav-label">管理</div>
|
||||
<nav class="nav">
|
||||
<button data-view="sites"><svg class="icon"><use href="#i-rule"/></svg><span>站点与 Area</span></button>
|
||||
<button data-view="audit"><svg class="icon"><use href="#i-shield"/></svg><span>审计日志</span></button>
|
||||
</nav>
|
||||
<div class="shift-card"><strong>安保值班组 · 白班</strong><span>08:00–20:00 · 4 人在线</span><div class="shift-bar"><i></i></div></div>
|
||||
</aside>
|
||||
|
||||
<main class="main" id="main-content" tabindex="-1">
|
||||
<header class="topbar">
|
||||
<div class="site"><strong>青藤教育集团 · 青藤寄宿学校</strong><span>当前账号:林工 · 租户管理员 · Asia/Shanghai</span></div>
|
||||
<div class="top-actions">
|
||||
<select class="select" id="stateSelect" aria-label="原型状态演示"><option value="normal">正常状态</option><option value="loading">加载中</option><option value="weak">弱网 / 证据失败</option><option value="empty">队列为空</option><option value="denied">无权限</option><option value="expired">会话已过期</option></select>
|
||||
<button class="btn mobile-manage" id="mobileManage" aria-haspopup="dialog">管理</button>
|
||||
<button class="btn icon-btn" id="themeToggle" aria-label="切换明暗主题" title="切换明暗主题"><svg class="icon"><use href="#i-sun"/></svg></button>
|
||||
<button class="btn icon-btn" aria-label="查看通知" title="查看通知"><svg class="icon"><use href="#i-bell"/></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<section class="state-layer" id="stateLayer" aria-live="polite"></section>
|
||||
|
||||
<section class="view active" data-page="duty">
|
||||
<div class="page-head"><div><h1>实时值班台</h1><p>先确认高危预警,再查看证据与升级状态;重复投递不会生成重复处置。</p></div><div class="page-actions"><span class="badge success">值班组在线</span><button class="btn" id="handoverButton">发起交接班</button><button class="btn" id="muteButton">限时静默</button></div></div>
|
||||
<div class="duty-grid">
|
||||
<section class="card queue" aria-label="待处置预警队列">
|
||||
<div class="card-head"><h2>待处置预警</h2><span class="badge danger">6 个未 ack</span></div>
|
||||
<div class="queue-head"><div class="queue-tabs" aria-label="预警队列筛选"><button class="active" aria-pressed="true">全部 9</button><button aria-pressed="false">高危 3</button><button aria-pressed="false">我的 2</button></div></div>
|
||||
<div class="alert-list" id="alertList"></div>
|
||||
</section>
|
||||
|
||||
<section class="card evidence" aria-label="事件事实与证据">
|
||||
<div class="evidence-summary"><div><span class="badge danger" id="selectedSeverity">高危 · S1</span><h2 id="selectedTitle">危险区域闯入</h2><p id="selectedMeta">北围墙 01 · 23:14:08 · EVT-01J8…7A2</p></div><button class="btn icon-btn" aria-label="更多事件操作"><svg class="icon"><use href="#i-more"/></svg></button></div>
|
||||
<div class="evidence-tabs" aria-label="证据类型筛选"><button class="active" aria-pressed="true">视频片段</button><button aria-pressed="false">抓拍 2</button><button aria-pressed="false">结构化事实</button></div>
|
||||
<div class="video-stage" id="videoStage">
|
||||
<svg viewBox="0 0 960 540" role="img" aria-label="北围墙 01 事件视频模拟画面,匿名目标进入危险区域">
|
||||
<defs><linearGradient id="bellSky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#102337"/><stop offset="1" stop-color="#060a11"/></linearGradient><pattern id="bellScan" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8h8" stroke="#fff" stroke-opacity=".035"/></pattern></defs>
|
||||
<rect width="960" height="540" fill="url(#bellSky)"/><path d="M0 340 180 260 355 316 520 232 690 306 830 230 960 285V540H0Z" fill="#142638"/><rect y="414" width="960" height="126" fill="#111820"/><path d="M0 416h960M0 474h960" stroke="#6d8496" stroke-opacity=".48"/><path d="M150 305v235m250-240v240m280-255v255m170-280v280" stroke="#758b9d" stroke-width="4"/><polygon points="92,362 305,318 558,342 525,480 126,478" fill="#fb7185" fill-opacity=".15" stroke="#fb7185" stroke-width="4" stroke-dasharray="10 7"/><rect x="354" y="330" width="63" height="148" fill="none" stroke="#fbbf24" stroke-width="4"/><text x="354" y="319" fill="#ffe18a" font-size="17">person · 0.92</text><circle cx="385" cy="310" r="24" fill="none" stroke="#fbbf24" stroke-width="4"/><rect width="960" height="540" fill="url(#bellScan)"/><text x="26" y="38" fill="#d6e8f5" font-size="18">北围墙 01 · 事件片段</text><text x="26" y="66" fill="#91a6b8" font-size="14">事发前 8s · 事发后 12s · 匿名目标</text>
|
||||
</svg>
|
||||
<div class="video-controls"><button aria-label="播放片段"><svg class="icon"><use href="#i-play"/></svg></button><span class="mono">00:11 / 00:20</span><div class="timeline-bar"><i></i></div><button aria-label="全屏查看"><svg class="icon"><use href="#i-expand"/></svg></button><button aria-label="下载授权证据"><svg class="icon"><use href="#i-download"/></svg></button></div>
|
||||
</div>
|
||||
<div class="fact-grid">
|
||||
<div class="fact"><span>规则</span><strong id="factRule">非授权人员进入</strong></div><div class="fact"><span>置信度</span><strong>0.92</strong></div><div class="fact"><span>持续时间</span><strong>8.4s</strong></div><div class="fact"><span>证据状态</span><strong style="color:var(--success)">已校验</strong></div>
|
||||
</div>
|
||||
<div class="relation-panel"><h3>关联事件 · <span id="relatedEventCount">2</span></h3><div class="relation-list" id="relatedEvents"></div></div>
|
||||
<div class="privacy-note"><svg class="icon"><use href="#i-lock"/></svg><span>仅显示与本事件关联的 20 秒证据。常态录像仍在客户 NVR,本页不暴露流地址或摄像头凭据。</span></div>
|
||||
</section>
|
||||
|
||||
<aside class="card action-panel" aria-label="预警确认与处置">
|
||||
<div class="card-head"><h2>处置与升级</h2><span class="badge warning" id="ackState">等待 ack</span></div>
|
||||
<div class="action-scroll">
|
||||
<div class="ack-box"><button class="btn btn-primary" id="ackButton"><svg class="icon"><use href="#i-check"/></svg>确认接手</button><p id="ackHelp">一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。</p><div class="ack-tools"><button class="text-button" id="simulateAckConflict">演示另一值班员抢先 ack</button></div><div class="inline-alert" id="ackConflict" role="alert" hidden></div></div>
|
||||
<div><div class="countdown"><svg class="icon" style="color:var(--warning);width:26px;height:26px"><use href="#i-clock"/></svg><div><strong id="countdown">01:42</strong><span>未 ack 将升级到校级负责人</span></div></div>
|
||||
<div class="escalation"><h3>投递时间线</h3>
|
||||
<div class="step done"><i class="step-dot"></i><strong>值班室 Web + 本地声光</strong><div class="delivery-facts"><span>已发出 23:14:10</span><span>已送达 23:14:11</span><span>已看到 23:14:31</span></div></div>
|
||||
<div class="step current"><i class="step-dot"></i><strong>值班员短信 · 可重试</strong><div class="delivery-facts"><span>已发出 23:14:13</span><span style="color:var(--warning)">未收到送达回执 · 已等待 42 秒</span><span>通道不支持“已看到” · 重试 2/3,23:15:25</span></div></div>
|
||||
<div class="step"><i class="step-dot"></i><strong>校级负责人语音</strong><div class="delivery-facts"><span>预计 23:16:10 · 尚未发送</span><span>号码无效将标记最终失败,不继续伪装送达</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="disposition"><label for="outcome">处置结果</label><select class="select" id="outcome"><option value="">待选择</option><option>已现场核查,无人员受伤</option><option>已通知安保人员处理</option><option>已转交校方负责人</option></select><label for="note">处置备注</label><textarea class="textarea" id="note" placeholder="记录已采取的动作,不改写原始事件事实"></textarea><div class="disposition-actions"><button class="btn btn-danger" id="falsePositive">标记误报</button><button class="btn btn-success" id="completeAlert">完成处置</button></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="events">
|
||||
<div class="page-head"><div><h1>事件中心</h1><p>事件是不可变事实;预警与处置结果作为关联记录单独展示。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-download"/></svg>导出筛选结果</button></div></div>
|
||||
<div class="grid stats">
|
||||
<article class="card stat"><span>今日事件</span><strong>47</strong><span class="muted">较昨日 -8%</span></article><article class="card stat"><span>已触发预警</span><strong>18</strong><span class="muted">规则命中后独立创建</span></article><article class="card stat"><span>误报反馈</span><strong>5</strong><span class="muted">进入标注队列</span></article><article class="card stat"><span>证据可用率</span><strong>97.9%</strong><span class="muted">1 个片段可重试</span></article>
|
||||
</div>
|
||||
<article class="card"><div class="card-head"><div class="toolbar"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" aria-label="搜索事件" placeholder="事件编号、点位或规则"></div><select class="select" aria-label="严重度筛选"><option>全部严重度</option><option>S1 高危</option><option>S2 重要</option></select><select class="select" aria-label="结果筛选"><option>全部结果</option><option>已处置</option><option>误报</option></select></div><span class="muted">共 47 条</span></div><div class="table-wrap"><table><thead><tr><th>事件编号</th><th>时间</th><th>点位</th><th>规则</th><th>事实</th><th>预警</th><th>结果</th><th>证据</th></tr></thead><tbody id="eventRows"></tbody></table></div><div class="pager"><span id="eventPageSummary">第 1 / 16 页 · 每页 3 条</span><div class="pager-actions"><button class="btn" id="eventPrev">上一页</button><button class="btn" id="eventNext">下一页</button></div></div></article>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="rules">
|
||||
<div class="rule-context" id="ruleContext" hidden><p><strong>已带入摄像头与区域上下文:</strong><span id="ruleContextCamera">北围墙 01</span> · <span id="ruleContextZone">ZONE-007</span>,可继续完成规则草稿。</p><button class="btn btn-primary" id="continueRuleDraft">继续配置</button></div>
|
||||
<div class="page-head"><div><h1>规则策略</h1><p>Bell 管理场景、范围、时段、持续时间、试运行与发布;检测区域来自对应摄像头的版本化空间配置。</p></div><div class="page-actions"><button class="btn btn-primary" id="openRuleDialog">新建规则</button></div></div>
|
||||
<div class="grid rule-grid">
|
||||
<article class="card rule-card"><div class="rule-card-top"><span class="badge success" id="publishedRuleBadge">正式生效 · v7</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>非授权人员进入</h2><p>危险区域 × 全天 × 持续 3 秒</p><dl><dt>覆盖范围</dt><dd>4 个点位</dd><dt>检测区域</dt><dd>北围墙危险区域 v7</dd><dt>继承来源</dt><dd>站点模板</dd><dt>今日命中</dt><dd>8</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>编辑规则</button><button class="btn" id="openRollback">版本历史 / 回滚</button><a class="btn" href="../sense/index.html?view=device-detail&camera=CAM-S2-001&zone=ZONE-007&return_to=bell-rule">校准检测区域</a></div></article>
|
||||
<article class="card rule-card"><div class="rule-card-top"><span class="badge warning" id="trialBadge">试运行</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>区域内聚集</h2><p>人数 ≥ 6 × 持续 20 秒 × 夜间</p><dl><dt>覆盖范围</dt><dd>3 个点位</dd><dt>检测区域</dt><dd>3 个区域版本</dd><dt>继承来源</dt><dd>设备覆盖</dd><dt>样本命中</dt><dd>14</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>查看试运行</button><button class="btn btn-primary" id="publishTrialRule">正式发布</button></div></article>
|
||||
<article class="card rule-card"><div class="rule-card-top"><span class="badge violet">草稿</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>方向越线</h2><p>警戒线 A → B × 上学时段</p><dl><dt>覆盖范围</dt><dd>1 个点位</dd><dt>检测区域</dt><dd>宿舍东通道 · 警戒线 v3</dd><dt>继承来源</dt><dd>设备草稿</dd><dt>最近修改</dt><dd>2h 前</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>继续编辑</button><a class="btn" href="../sense/index.html?view=device-detail&camera=CAM-S2-011&zone=ZONE-011&return_to=bell-rule">查看警戒线</a></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="chains">
|
||||
<div class="page-head"><div><h1>升级链</h1><p>联系人、值班排班和升级策略统一设计、分对象管理;策略引用动态目标,不写死手机号。</p></div><div class="page-actions"><span class="badge success">当前排班覆盖完整</span></div></div>
|
||||
<div class="chain-subnav" aria-label="升级链二级模块"><button class="active" data-chain-section="strategy" aria-pressed="true">升级策略</button><button data-chain-section="roster" aria-pressed="false">值班与排班</button><button data-chain-section="contacts" aria-pressed="false">联系人和通道</button></div>
|
||||
|
||||
<section class="chain-panel active" data-chain-panel="strategy">
|
||||
<div class="grid chain-grid"><article class="card"><div class="card-head"><div><h2>校园高危事件 · 默认链</h2><span class="muted">每一步引用类型化目标,解析后才创建投递</span></div><span class="badge success">已发布 v4</span></div>
|
||||
<div class="chain-row"><div class="chain-num">1</div><div><strong>值班室 Web + 本地声光</strong><small>立即 · 站点局域网内</small><span class="target-ref">team:DUTY-ROOM</span></div><span class="badge success">固定值班组</span></div>
|
||||
<div class="chain-row"><div class="chain-num">2</div><div><strong>当前排班值班员 · 短信</strong><small>立即 · 送达后等待 2 分钟</small><span class="target-ref">on_call_schedule:SCH-CAMPUS-SECURITY@v12</span></div><span class="badge info">动态排班</span></div>
|
||||
<div class="chain-row"><div class="chain-num">3</div><div><strong>校级负责人 · 语音</strong><small>未 ack 时升级 · 等待 3 分钟</small><span class="target-ref">person:USR-SCHOOL-LEAD</span></div><span class="badge warning">指定人员</span></div>
|
||||
<div class="object-note">升级策略只保存目标引用和通道偏好,不复制联系人号码或轮换字段。</div>
|
||||
</article><article class="card resolve-card"><div class="card-head" style="padding:0 0 11px;margin-bottom:12px"><div><h2>目标解析预览</h2><span class="muted">事件时间 2026-08-04 23:14 · Asia/Shanghai</span></div><button class="btn" id="editEscalation">编辑策略</button></div><div class="resolve-list"><div class="resolve-item"><span>排班目标</span><strong>校园安保值班表 · v12</strong><small class="mono">SCH-CAMPUS-SECURITY</small></div><div class="resolve-item"><span>当前解析结果</span><strong id="resolvedPerson">王值班员 · 晚班</strong><small>短信已验证 · 语音已验证</small></div><div class="resolve-item"><span>投递快照</span><strong>23:14:13 已固化</strong><small>收件人、通道、排班 v12;后续编辑不改写本次事实</small></div></div></article></div>
|
||||
<div class="grid chain-grid" style="margin-top:12px"><article class="card"><div class="card-head"><h2>解析与版本规则</h2><span class="badge info">不可变历史</span></div><div class="card-body"><p class="muted">每次投递创建时按站点时区解析当时生效的排班版本,并保存实际收件人、通道与版本快照。未来排班版本只影响其生效时间后的新投递。</p></div></article><article class="card"><div class="card-head"><h2>静默规则</h2><span class="badge info">无永久选项</span></div><div class="card-body"><label for="muteDuration" class="muted">维护窗口</label><select id="muteDuration" class="select" style="margin-top:6px"><option>30 分钟</option><option>1 小时</option><option>2 小时</option><option>4 小时(上限)</option></select><p class="muted">静默仅影响选定规则和点位,到时自动恢复;S1 事件不可静默。</p><button class="btn">创建维护窗口</button></div></article></div>
|
||||
</section>
|
||||
|
||||
<section class="chain-panel" data-chain-panel="roster">
|
||||
<div class="grid stats"><article class="card stat"><span>当前排班</span><strong id="scheduleVersion">v12</strong><span class="muted">已发布 · 08-01 生效</span></article><article class="card stat"><span>站点时区</span><strong style="font-size:18px">Asia/Shanghai</strong><span class="muted">跨午夜班次按站点时间</span></article><article class="card stat"><span>当前值班</span><strong style="font-size:18px">王值班员</strong><span class="muted">晚班 · 至明日 08:00</span></article><article class="card stat"><span>未来 14 天覆盖</span><strong>100%</strong><span class="muted">0 空档 · 0 重叠</span></article></div>
|
||||
<article class="card"><div class="card-head"><div><h2>校园安保值班表</h2><span class="muted">周轮换 · 白班 08:00–20:00 / 晚班 20:00–08:00</span></div><div class="page-actions"><button class="btn" id="simulateRosterConflict">演示冲突检查</button><button class="btn" id="openSubstitute">临时替班</button><button class="btn btn-primary" id="openSchedulePublish">发布新版本</button></div></div><div class="inline-alert danger" id="rosterConflict" role="alert" hidden style="margin:12px 12px 0"></div><div class="roster-week">
|
||||
<div class="roster-day current"><strong>今天 · 周二</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong id="currentNightShift">王值班员</strong><small>20:00–08:00</small></div></div>
|
||||
<div class="roster-day"><strong>周三</strong><div class="shift-slot"><span>白班</span><strong>赵值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong id="wednesdayNightShift">周值班员</strong><small>20:00–08:00</small></div></div>
|
||||
<div class="roster-day"><strong>周四</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>王值班员</strong><small>20:00–08:00</small></div></div>
|
||||
<div class="roster-day"><strong>周五</strong><div class="shift-slot"><span>白班</span><strong>赵值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>周值班员</strong><small>20:00–08:00</small></div></div>
|
||||
<div class="roster-day"><strong>周六</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>王值班员</strong><small>20:00–08:00</small></div></div>
|
||||
<div class="roster-day"><strong>周日</strong><div class="shift-slot"><span>白班</span><strong>赵值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>周值班员</strong><small>20:00–08:00</small></div></div>
|
||||
<div class="roster-day"><strong>下周一</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>王值班员</strong><small>20:00–08:00</small></div></div>
|
||||
</div><div class="object-note"><strong>边界:</strong>临时替班进入新排班草稿并经发布后影响未来班次;值班交接只转移进行中 Alert 的责任,不会暗改这里的轮换。</div></article>
|
||||
<article class="card" style="margin-top:12px"><div class="card-head"><h2>版本与例外</h2><span class="badge info">全量审计</span></div><div class="table-wrap"><table><thead><tr><th>版本</th><th>生效时间</th><th>变更</th><th>状态</th><th>发布人</th></tr></thead><tbody><tr><td>v12</td><td>2026-08-01 00:00</td><td>暑期周轮换</td><td><span class="badge success">当前生效</span></td><td>林工</td></tr><tr><td>v11</td><td>2026-07-01 00:00</td><td>赵值班员加入白班组</td><td><span class="badge">历史</span></td><td>周管理员</td></tr></tbody></table></div></article>
|
||||
</section>
|
||||
|
||||
<section class="chain-panel" data-chain-panel="contacts">
|
||||
<div class="grid contact-summary"><article class="card stat"><span>联系人</span><strong id="contactCount">6</strong><span class="muted">4 名值班员 · 2 名负责人</span></article><article class="card stat"><span>已验证通道</span><strong>11 / 12</strong><span class="muted">1 个语音号码待验证</span></article><article class="card stat"><span>被排班引用</span><strong>4</strong><span class="muted">删除前必须解除引用</span></article></div>
|
||||
<article class="card"><div class="card-head"><div><h2>联系人和通知通道</h2><span class="muted">只维护身份、角色、值班组和通道;班次与轮换在排班模块维护</span></div><button class="btn btn-primary" id="openContact">新建联系人</button></div><div class="table-wrap"><table><thead><tr><th>联系人</th><th>角色</th><th>值班组</th><th>通知通道</th><th>排班引用</th><th>状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>王值班员</strong><br><span class="mono muted">USR-DUTY-WANG</span></td><td>值班员</td><td>校园安保组</td><td>短信 ✓ · 语音 ✓ · Web ✓</td><td>SCH-CAMPUS-SECURITY</td><td><span class="badge success">可用</span></td><td><button class="btn" data-edit-contact="wang">编辑</button></td></tr><tr><td><strong>林值班员</strong><br><span class="mono muted">USR-DUTY-LIN</span></td><td>值班员</td><td>校园安保组</td><td>短信 ✓ · Web ✓</td><td>SCH-CAMPUS-SECURITY</td><td><span class="badge success">可用</span></td><td><button class="btn" data-edit-contact="lin">编辑</button></td></tr><tr><td><strong>校级负责人</strong><br><span class="mono muted">USR-SCHOOL-LEAD</span></td><td>站点负责人</td><td>校级负责人组</td><td>短信 ✓ · 语音待验证</td><td>升级策略 v4</td><td><span class="badge warning">部分可用</span></td><td><button class="btn" data-edit-contact="lead">编辑</button></td></tr></tbody></table></div><div class="object-note">联系人通道变更只影响后续新投递;已创建投递保留当时的收件人和通道快照。联系人被排班或策略引用时不能直接删除。</div></article>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="reports">
|
||||
<div class="page-head"><div><h1>运营报表</h1><p>算法效果和系统 SLA 分开统计;图表同时提供表格导出。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-download"/></svg>导出 CSV</button></div></div>
|
||||
<div class="grid chart-grid"><article class="card chart"><h2>近 7 日预警量</h2><p>按站点日期汇总;可切换到每路每天误报数。</p><div class="bars" role="img" aria-label="近七日预警量依次为 18、22、15、31、27、24、19"><div class="bar" style="--h:58%"><span>周一</span></div><div class="bar" style="--h:70%"><span>周二</span></div><div class="bar" style="--h:48%"><span>周三</span></div><div class="bar" style="--h:100%"><span>周四</span></div><div class="bar" style="--h:87%"><span>周五</span></div><div class="bar" style="--h:77%"><span>周六</span></div><div class="bar" style="--h:61%"><span>周日</span></div></div></article><article class="card chart"><h2>处置结果</h2><p>本月已闭环事件。</p><div class="donut" role="img" aria-label="正常处置 61%,误报 22%,升级未闭环 17%"></div><div class="legend"><span><i style="background:var(--success)"></i>正常处置 61%</span><span><i style="background:var(--warning)"></i>误报 22%</span><span><i style="background:var(--danger)"></i>未闭环 17%</span></div></article></div>
|
||||
<article class="card" style="margin-top:12px"><div class="card-head"><div><h2>规则验收效果</h2><span class="muted">冻结样本窗口:2026-07-01~2026-07-31 · 现场复核集 v3</span></div><button class="btn">导出验收附件</button></div><div class="table-wrap"><table><thead><tr><th>规则版本</th><th>样本窗口</th><th>正样本 / 漏检</th><th>召回率</th><th>通道天数</th><th>误报数</th><th>每路每天误报数</th></tr></thead><tbody><tr><td>非授权人员进入 · v7</td><td>07-01~07-31</td><td>186 / 8</td><td><strong>95.7%</strong></td><td>496</td><td>19</td><td><strong>0.038</strong></td></tr><tr><td>区域内聚集 · v4 试运行</td><td>07-15~07-31</td><td>72 / 6</td><td><strong>91.7%</strong></td><td>272</td><td>16</td><td><strong>0.059</strong></td></tr></tbody></table></div><p class="metric-note"><strong>口径说明:</strong>按规则版本分别报告召回率与每路每天误报数;不提供跨场景统一“准确率”。样本量和冻结窗口随导出保留。</p></article>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="sites">
|
||||
<div class="page-head"><div><h1>站点与 Area</h1><p>Bell 是 Tenant、Site、Area、配额与隐私准入策略的唯一真相源;Sense 只消费版本化投影。</p></div><div class="page-actions"><button class="btn">管理角色权限</button><button class="btn btn-primary" id="newSite">新建站点</button></div></div>
|
||||
<div class="grid stats"><article class="card stat"><span>租户</span><strong>1</strong><span class="muted">青藤教育集团</span></article><article class="card stat"><span>站点</span><strong>2</strong><span class="muted">1 个试点中</span></article><article class="card stat"><span>视频配额</span><strong>16 / 128</strong><span class="muted">默认 16,不是硬上限</span></article><article class="card stat"><span>策略投影</span><strong>v42</strong><span class="muted">Sense 2 分钟前已同步</span></article></div>
|
||||
<div class="grid site-grid"><article class="card"><div class="card-head"><h2>站点</h2><button class="btn btn-quiet">筛选</button></div><div class="site-list"><button class="site-item active"><span><strong>青藤寄宿学校</strong><small>site-ivy · S2 校园包</small></span><span class="badge success">启用</span></button><button class="site-item"><span><strong>城东试验点</strong><small>site-east · 未绑定场景包</small></span><span class="badge warning">准备中</span></button></div></article>
|
||||
<article class="card"><div class="card-head"><div><h2>青藤寄宿学校 · Area</h2><span class="muted">层级、报表口径和设备准入共用同一逻辑 ID</span></div><button class="btn btn-primary" id="newArea">新建 Area</button></div><div class="policy-note"><strong>策略变更不会静默处置已有设备。</strong> 若改为“仅非成像”且 Area 内已有摄像头,必须显式迁移设备或取消变更。</div><div class="table-wrap"><table><thead><tr><th>Area</th><th>父级</th><th>capture_policy</th><th>设备</th><th>策略版本</th><th>投影状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>北围墙</strong><br><span class="muted">area-north-wall</span></td><td>校园</td><td><span class="badge success">允许成像</span></td><td>4 视频</td><td>v42</td><td><span class="badge success">已同步</span></td><td><button class="btn" data-edit-area="north">编辑策略</button></td></tr><tr><td><strong>宿舍卧室</strong><br><span class="muted">area-dorm-room</span></td><td>宿舍楼</td><td><span class="badge warning">仅非成像</span></td><td>0 视频 · 1 雷达规划</td><td>v38</td><td><span class="badge success">已同步</span></td><td><button class="btn" data-edit-area="dorm">编辑策略</button></td></tr><tr><td><strong>实验楼入口</strong><br><span class="muted">area-lab-gate</span></td><td>实验楼</td><td><span class="badge success">允许成像</span></td><td>2 视频</td><td>v41</td><td><span class="badge warning">待 Sense 拉取</span></td><td><button class="btn" data-edit-area="lab">编辑策略</button></td></tr></tbody></table></div></article></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="audit">
|
||||
<div class="context-strip" id="auditContext" hidden><p><strong>来自 Sense 设备日志:</strong>租户 <span id="auditTenant">—</span> · 站点 <span id="auditSite">—</span> · 设备 <span id="auditDevice">—</span>。已锁定筛选上下文。</p><a class="btn" id="auditReturn" href="../sense/index.html?view=device-detail">返回设备详情 ↗</a></div>
|
||||
<div class="page-head"><div><h1>审计日志</h1><p>查询、ack、规则发布和证据下载均可追溯;不记录密码与可复用连接串。</p></div><div class="page-actions"><button class="btn">导出审计记录</button></div></div>
|
||||
<article class="card"><div class="card-head"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" id="auditSearch" aria-label="搜索审计日志" placeholder="操作、角色或对象编号"></div><span class="badge info">只读</span></div><div class="table-wrap"><table><thead><tr><th>时间</th><th>角色</th><th>动作</th><th>对象</th><th>结果</th><th>来源</th></tr></thead><tbody id="auditRows"></tbody></table></div><div class="pager"><span id="auditPageSummary">第 1 / 28 页 · 每页 3 条</span><div class="pager-actions"><button class="btn" id="auditPrev">上一页</button><button class="btn" id="auditNext">下一页</button></div></div></article>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav class="mobile-nav" aria-label="移动端主导航"><button class="active" data-view="duty">值班</button><button data-view="events">事件</button><button data-view="rules">规则</button><button data-view="chains">升级</button><button data-view="reports">报表</button></nav>
|
||||
|
||||
<dialog id="ruleDialog" aria-labelledby="ruleTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="ruleTitle">配置告警规则</h2><span class="muted">规则草稿 · 尚未正式生效</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><div class="form-grid">
|
||||
<div class="form-field"><label for="ruleScene">场景模板</label><select id="ruleScene" class="select"><option>非授权人员进入</option><option>区域内聚集</option><option>方向越线</option></select></div>
|
||||
<div class="form-field"><label for="ruleScope">继承与覆盖</label><select id="ruleScope" class="select"><option>当前设备覆盖</option><option>继承站点模板</option><option>覆盖站点模板</option></select></div>
|
||||
<div class="form-field"><label for="ruleCamera">摄像头</label><select id="ruleCamera" class="select"><option value="CAM-S2-001">北围墙 01</option><option value="CAM-S2-006">实验楼入口 02</option><option value="CAM-S2-011">宿舍东通道</option></select></div>
|
||||
<div class="form-field"><label for="ruleZone">检测区域</label><select id="ruleZone" class="select"><option value="ZONE-007">北围墙危险区域 · v7</option><option value="ZONE-011">北围墙方向警戒线 · v3</option></select></div>
|
||||
<div class="linked-zone"><span><strong>空间坐标由摄像头详情维护</strong><small>区域版本变化不会绕过规则试运行或自动正式发布。</small></span><a class="btn" id="editRuleZone" href="../sense/index.html?view=device-detail&camera=CAM-S2-001&zone=ZONE-007&return_to=bell-rule">编辑检测区域</a></div>
|
||||
<div class="form-field"><label for="ruleSchedule">生效时段</label><select id="ruleSchedule" class="select"><option>全天</option><option>夜间 22:00–06:00</option><option>自定义课表</option></select></div>
|
||||
<div class="form-field"><label for="ruleDuration">持续时间</label><select id="ruleDuration" class="select"><option>超过 3 秒</option><option>立即</option><option>超过 10 秒</option><option>超过 20 秒</option></select></div>
|
||||
<div class="form-field full"><label><input id="startDryRun" type="checkbox" checked aria-label="保存后开始试运行"> 保存后开始试运行,不触发正式预警</label></div>
|
||||
<div class="inline-alert danger form-field full" id="ruleConflict" role="alert" hidden><strong>规则已由周管理员发布为 v8。</strong><br>当前草稿基于 v7,不能覆盖。请刷新到 v8 后重新合并,或另存为新草稿。</div>
|
||||
<p class="workflow-note">产品流程:先确认摄像头和检测区域,再配置业务条件;试运行样本达到现场评审要求后,才允许正式发布。</p>
|
||||
</div></div><div class="dialog-foot"><button type="button" class="btn" id="simulateRuleConflict">演示版本冲突</button><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="saveRuleDraft" value="default">保存并开始试运行</button></div></form></dialog>
|
||||
|
||||
<dialog id="falseDialog" aria-labelledby="falseTitle"><form method="dialog"><div class="dialog-head"><h2 id="falseTitle">标记为误报</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><p>这只会追加事件 outcome,不会修改原始事件事实和证据。</p><div class="form-grid"><div class="form-field full"><label for="falseReason">误报原因</label><select id="falseReason" class="select" required><option value="">请选择</option><option>区域配置不准确</option><option>目标分类错误</option><option>时段策略不准确</option><option>其他</option></select></div><div class="form-field full"><label for="falseNote">补充说明(可选)</label><textarea id="falseNote" class="textarea" placeholder="便于算法团队归因,不填写真实个人信息"></textarea></div><div class="form-field full"><label><input type="checkbox" checked> 进入标注复核队列</label></div></div></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-danger" id="confirmFalse" value="default">确认标记</button></div></form></dialog>
|
||||
|
||||
<dialog id="muteDialog" aria-labelledby="muteTitle"><form method="dialog"><div class="dialog-head"><h2 id="muteTitle">创建限时静默</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="muteScope">影响范围</label><select id="muteScope" class="select"><option>北围墙 01 · 当前规则</option><option>北围墙区域</option></select></div><div class="form-field"><label for="muteTime">持续时间</label><select id="muteTime" class="select"><option>30 分钟</option><option>1 小时</option><option>2 小时</option><option>4 小时(上限)</option></select></div><div class="form-field full"><label for="muteCause">原因</label><textarea id="muteCause" class="textarea" required placeholder="例如:现场施工维护,已安排人工巡查"></textarea></div></div><p class="muted">到期自动恢复;S1 高危事件与设备离线运维告警不受此静默影响。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="confirmMute" value="default">确认并记录审计</button></div></form></dialog>
|
||||
|
||||
<dialog id="areaDialog" aria-labelledby="areaTitle"><form method="dialog"><div class="dialog-head"><h2 id="areaTitle">编辑 Area 准入策略</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="areaName">Area</label><input id="areaName" class="field" value="北围墙"></div><div class="form-field"><label for="areaParent">父级</label><select id="areaParent" class="select"><option>校园</option><option>教学区</option><option>宿舍楼</option></select></div><div class="form-field full"><label for="areaPolicy">capture_policy</label><select id="areaPolicy" class="select"><option value="video_allowed">允许成像设备</option><option value="non_imaging_only">仅允许非成像设备</option></select></div><div class="form-field full"><label for="areaReason">变更原因</label><textarea id="areaReason" class="textarea" placeholder="必填,进入全局审计"></textarea></div></div><p class="muted">保存生成新策略版本;Sense 投影同步前会显示旧版本,不允许绕过后端准入检查。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveAreaPolicy">保存策略版本</button></div></form></dialog>
|
||||
|
||||
<dialog id="policyConflictDialog" aria-labelledby="policyConflictTitle"><form method="dialog"><div class="dialog-head"><h2 id="policyConflictTitle">策略与已有设备冲突</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><p>北围墙当前有 4 台成像设备。改为“仅非成像”前必须选择显式处置;系统不会自动停用、移动或保留违规设备。</p><label class="radio-card"><input type="radio" name="conflictAction" value="move"> <strong>先迁移 4 台设备到允许成像的 Area</strong><br><span class="muted">创建迁移任务,全部成功后再发布策略。</span></label><label class="radio-card"><input type="radio" name="conflictAction" value="cancel" checked> <strong>取消本次策略变更</strong><br><span class="muted">保持 v42 和现有设备不变。</span></label></div><div class="dialog-foot"><button class="btn" value="cancel">返回编辑</button><button class="btn btn-primary" value="default" id="confirmPolicyConflict">确认选择</button></div></form></dialog>
|
||||
|
||||
<dialog id="manageDialog" aria-labelledby="manageTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="manageTitle">管理</h2><span class="muted">移动端保留 5 个主入口,低频管理能力从这里进入。</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭管理菜单">×</button></div><div class="dialog-body"><div class="relation-list"><button type="button" class="relation-item" data-manage-view="sites"><strong>站点与 Area</strong><span>配额、capture_policy、RBAC →</span></button><button type="button" class="relation-item" data-manage-view="audit"><strong>审计日志</strong><span>只读、筛选、分页 →</span></button></div></div></form></dialog>
|
||||
|
||||
<dialog id="eventDialog" aria-labelledby="eventDialogTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="eventDialogTitle">事件详情</h2><span class="muted" id="eventDialogSubtitle">不可变事实</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭事件详情">×</button></div><div class="dialog-body"><div class="fact-grid" style="margin:0 0 14px"><div class="fact"><span>事件编号</span><strong class="mono" id="eventDetailId">—</strong></div><div class="fact"><span>点位</span><strong id="eventDetailSite">—</strong></div><div class="fact"><span>规则</span><strong id="eventDetailRule">—</strong></div><div class="fact"><span>证据</span><strong id="eventDetailEvidence">—</strong></div></div><div class="relation-panel" style="margin:0"><h3>触发的预警</h3><div class="relation-list" id="eventAlerts"></div></div></div><div class="dialog-foot"><button class="btn" value="cancel">关闭</button></div></form></dialog>
|
||||
|
||||
<dialog id="handoverDialog" aria-labelledby="handoverTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="handoverTitle">值班交接</h2><span class="muted">白班 · 林值班员 → 晚班</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭交接班">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="handoverTo">接班人</label><select class="select" id="handoverTo"><option value="">请选择并确认本人已在场</option><option>王值班员 · 晚班</option><option>赵值班员 · 晚班</option></select></div><div class="form-field"><label for="handoverAt">交接时间</label><input class="field" id="handoverAt" value="2026-08-04 20:00" readonly></div><div class="form-field full"><label>待交接 Alert</label><div class="relation-list"><div class="relation-item"><strong>ALT-1842 · 未 ack</strong><span>下一次升级 01:42</span></div><div class="relation-item"><strong>ALT-1841 · 处置中</strong><span>王值班员现场核查</span></div><div class="relation-item"><strong>ALT-1839 · 升级中</strong><span>短信重试 2/3</span></div></div></div><div class="form-field full"><label for="handoverNote">交接备注</label><textarea class="textarea" id="handoverNote" placeholder="记录现场状态、已采取动作和待跟进事项"></textarea></div></div><p class="metric-note">交接期间升级链继续计时,不暂停、不重置;只转移这些进行中 Alert 的处置责任,不修改未来排班。未来班次变化请在“值班与排班”创建替班版本。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="confirmHandover">接班人确认并留痕</button></div></form></dialog>
|
||||
|
||||
<dialog id="rollbackDialog" aria-labelledby="rollbackTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="rollbackTitle">版本历史与回滚</h2><span class="muted">RULE-007 · 历史版本不可变</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭版本历史">×</button></div><div class="dialog-body"><div class="version-diff"><article><strong>当前 v7 · 2026-08-01 正式发布</strong><span class="muted">持续 3 秒 · 全天 · 4 个点位 · 区域 v7</span></article><article><strong>目标 v6 · 2026-07-18 历史版本</strong><span class="muted">持续 5 秒 · 夜间 22:00–06:00 · 3 个点位 · 区域 v6</span></article><article><strong>影响评估</strong><span class="muted">将减少 1 个点位并收窄时段;预计每日预警 -18%。确认后创建并发布 v8,使其配置等价于 v6,v6/v7 均保持不变。</span></article></div></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-danger" id="confirmRollback">创建并发布回滚版本 v8</button></div></form></dialog>
|
||||
|
||||
<dialog id="escalationDialog" aria-labelledby="escalationTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="escalationTitle">编辑升级步骤</h2><span class="muted">默认链 v5 草稿 · 不直接保存手机号</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭升级步骤编辑">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="escalationTargetType">目标类型</label><select class="select" id="escalationTargetType"><option value="schedule">值班排班</option><option value="team">固定值班组</option><option value="person">指定人员</option></select></div><div class="form-field"><label for="escalationTargetRef">目标</label><select class="select" id="escalationTargetRef"></select></div><div class="form-field"><label for="escalationChannel">首选通道</label><select class="select" id="escalationChannel"><option>短信 + 语音兜底</option><option>Web + 本地声光</option><option>语音 + 短信兜底</option></select></div><div class="form-field"><label for="escalationTimeout">等待 ack</label><select class="select" id="escalationTimeout"><option>2 分钟</option><option>3 分钟</option><option>5 分钟</option></select></div><div class="form-field full"><label>当前解析预览</label><div class="resolve-item" id="escalationPreview" aria-live="polite"></div></div></div><p class="workflow-note">保存的是类型化目标引用与通道偏好。每次投递创建时按站点时区解析生效排班,再固化收件人、通道和排班版本快照。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveEscalation">保存策略草稿</button></div></form></dialog>
|
||||
|
||||
<dialog id="scheduleDialog" aria-labelledby="scheduleTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="scheduleTitle">发布值班排班</h2><span class="muted">历史版本不可变</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭排班发布">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="scheduleName">排班计划</label><input class="field" id="scheduleName" value="校园安保值班表" readonly></div><div class="form-field"><label for="scheduleNextVersion">新版本</label><input class="field" id="scheduleNextVersion" value="v13" readonly></div><div class="form-field"><label for="scheduleTimezone">站点时区</label><select class="select" id="scheduleTimezone"><option>Asia/Shanghai</option></select></div><div class="form-field"><label for="scheduleEffective">生效时间</label><input class="field" id="scheduleEffective" type="datetime-local" value="2026-08-05T20:00"></div><div class="form-field full"><label for="scheduleReason">发布说明</label><textarea class="textarea" id="scheduleReason" placeholder="必填,例如:王值班员临时替班,覆盖周三晚班"></textarea></div></div><p class="metric-note">发布前重新检查空档、重叠、联系人停用和通道验证。新版本只影响生效时间后的投递,不回写 v12 或既有投递快照。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="confirmSchedulePublish">确认发布 v13</button></div></form></dialog>
|
||||
|
||||
<dialog id="substituteDialog" aria-labelledby="substituteTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="substituteTitle">创建临时替班</h2><span class="muted">保存到 v13 草稿,发布后生效</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭临时替班">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="substituteShift">被替换班次</label><select class="select" id="substituteShift"><option>周三晚班 · 周值班员 · 20:00–08:00</option><option>周四白班 · 林值班员 · 08:00–20:00</option></select></div><div class="form-field"><label for="substitutePerson">替班人</label><select class="select" id="substitutePerson"><option value="">请选择可用联系人</option><option>王值班员</option><option>赵值班员</option></select></div><div class="form-field full"><label for="substituteReason">替班原因</label><textarea class="textarea" id="substituteReason" placeholder="必填,写入排班版本审计"></textarea></div></div><p class="workflow-note">临时替班修改未来排班。若只是把正在处置的 Alert 交给下一班,请使用值班台“交接班”,不要改排班。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveSubstitute">保存到 v13 草稿</button></div></form></dialog>
|
||||
|
||||
<dialog id="contactDialog" aria-labelledby="contactTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="contactTitle">新建联系人</h2><span class="muted">联系人不包含班次和轮换字段</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭联系人编辑">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="contactName">姓名</label><input class="field" id="contactName" placeholder="联系人显示名"></div><div class="form-field"><label for="contactRole">角色</label><select class="select" id="contactRole"><option>值班员</option><option>站点负责人</option><option>租户管理员</option></select></div><div class="form-field"><label for="contactTeam">值班组</label><select class="select" id="contactTeam"><option>校园安保组</option><option>校级负责人组</option></select></div><div class="form-field"><label for="contactPhone">新通知号码</label><input class="field" id="contactPhone" type="tel" placeholder="输入后发送验证码;不回显旧值"></div><div class="form-field full"><label>启用通道</label><label class="radio-card"><input type="checkbox" id="contactSms" checked> 短信(需验证)</label><label class="radio-card"><input type="checkbox" id="contactVoice"> 语音(需验证)</label><label class="radio-card"><input type="checkbox" id="contactWeb" checked> Web 站内通知</label></div></div><p class="metric-note">联系人保存身份、角色、值班组与已验证通道。加入哪个班次、何时轮换由“值班与排班”模块引用联系人 ID 完成。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveContact">保存并发起通道验证</button></div></form></dialog>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script>
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
const toast = $('#toast');
|
||||
let toastTimer;
|
||||
function notify(message) {
|
||||
toast.textContent = message;
|
||||
toast.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toast.classList.remove('show'), 3800);
|
||||
}
|
||||
|
||||
const alerts = [
|
||||
{id:'ALT-1842',severity:'S1 高危',badge:'danger',title:'危险区域闯入',site:'北围墙 01',time:'23:14:08',age:'1 分钟',rule:'非授权人员进入',delivery:'短信等待回执',events:[{id:'EVT-01J8…7A2',reason:'同点位 30 秒窗口聚合'},{id:'EVT-01J8…7B4',reason:'同规则连续命中聚合'}]},
|
||||
{id:'ALT-1841',severity:'S1 高危',badge:'danger',title:'方向越线',site:'宿舍东通道',time:'23:12:46',age:'3 分钟',rule:'夜间越线 A → B',delivery:'声光已看到',events:[{id:'EVT-01J8…71M',reason:'单事件触发'}]},
|
||||
{id:'ALT-1839',severity:'S2 重要',badge:'warning',title:'区域聚集',site:'操场西北角',time:'23:08:21',age:'7 分钟',rule:'人数 ≥ 6 持续 20 秒',delivery:'Web 已看到',events:[{id:'EVT-01J8…52C',reason:'同站点 60 秒窗口聚合'}]},
|
||||
{id:'ALT-1836',severity:'S2 重要',badge:'warning',title:'危险区域滞留',site:'配电房门口',time:'22:57:03',age:'18 分钟',rule:'区域内停留 ≥ 10 秒',delivery:'短信最终失败',events:[{id:'EVT-01J8…44K',reason:'单事件触发'}]}
|
||||
];
|
||||
const eventRecords = [
|
||||
{id:'EVT-01J8…7A2',time:'23:14:08',site:'北围墙 01',rule:'非授权人员进入',fact:'已确认',factBadge:'danger',result:'处置中',evidence:'可用',evidenceBadge:'success',alerts:[{id:'ALT-1842',state:'等待 ack · 聚合 2 个事件'}]},
|
||||
{id:'EVT-01J8…7B4',time:'23:14:21',site:'北围墙 01',rule:'非授权人员进入',fact:'已确认',factBadge:'danger',result:'已聚合',evidence:'可用',evidenceBadge:'success',alerts:[{id:'ALT-1842',state:'等待 ack · 同点位窗口聚合'}]},
|
||||
{id:'EVT-01J8…69P',time:'22:48:31',site:'宿舍东通道',rule:'区域内聚集',fact:'已记录',factBadge:'info',result:'试运行样本',evidence:'可用',evidenceBadge:'success',alerts:[]},
|
||||
{id:'EVT-01J8…52C',time:'22:19:04',site:'实验楼入口',rule:'危险区域闯入',fact:'已确认',factBadge:'danger',result:'误报',evidence:'重试中',evidenceBadge:'warning',alerts:[{id:'ALT-1839',state:'处置完成 · 误报'}]},
|
||||
{id:'EVT-01J8…71M',time:'21:58:20',site:'宿舍东通道',rule:'夜间越线 A → B',fact:'已确认',factBadge:'danger',result:'处置中',evidence:'可用',evidenceBadge:'success',alerts:[{id:'ALT-1841',state:'已 ack · 现场核查'}]},
|
||||
{id:'EVT-01J8…44K',time:'21:31:02',site:'配电房门口',rule:'区域内停留 ≥ 10 秒',fact:'已确认',factBadge:'warning',result:'升级中',evidence:'仅抓拍',evidenceBadge:'warning',alerts:[{id:'ALT-1836',state:'短信最终失败 · 语音待发送'}]}
|
||||
];
|
||||
const auditRecords = [
|
||||
{time:'23:14:18',role:'站点值班员',action:'ACK_ALERT',object:'ALT-1842',result:'成功',badge:'success',source:'值班室 Web'},
|
||||
{time:'22:52:06',role:'站点管理员',action:'PUBLISH_RULE',object:'RULE-007',result:'成功',badge:'success',source:'管理端'},
|
||||
{time:'22:20:31',role:'只读用户',action:'VIEW_EVIDENCE',object:'EVT-01J8…52C',result:'拒绝',badge:'danger',source:'响应式 H5'},
|
||||
{time:'21:56:04',role:'站点值班员',action:'HANDOVER_SHIFT',object:'SHIFT-20260804-DAY',result:'成功',badge:'success',source:'值班室 Web'},
|
||||
{time:'21:41:12',role:'租户管理员',action:'ROLLBACK_RULE',object:'RULE-007:v8',result:'成功',badge:'success',source:'管理端'},
|
||||
{time:'20:05:55',role:'站点管理员',action:'UPDATE_AREA_POLICY',object:'area-lab-gate:v41',result:'成功',badge:'success',source:'管理端'}
|
||||
];
|
||||
|
||||
function makeTextCell(row, value, className = '') {
|
||||
const cell = document.createElement('td');
|
||||
cell.textContent = value;
|
||||
if (className) cell.className = className;
|
||||
row.append(cell);
|
||||
return cell;
|
||||
}
|
||||
function makeBadgeCell(row, value, badge) {
|
||||
const cell = document.createElement('td');
|
||||
const span = document.createElement('span');
|
||||
span.className = `badge ${badge}`;
|
||||
span.textContent = value;
|
||||
cell.append(span);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
let selectedAlertIndex = 0;
|
||||
let ackOwner = null;
|
||||
const alertList = $('#alertList');
|
||||
function renderAlerts(selected = selectedAlertIndex) {
|
||||
alertList.innerHTML = alerts.map((alert, index) => `<button class="alert-card ${index === selected ? 'selected' : ''}" data-index="${index}"><div class="alert-card-top"><span class="badge ${alert.badge}"><i class="severity-dot"></i>${alert.severity}</span><span class="muted">${alert.age}</span></div><h3>${alert.title}</h3><p>${alert.site} · ${alert.time}</p><div class="alert-card-foot"><span>${alert.delivery}</span><span class="mono">${alert.id}</span></div></button>`).join('');
|
||||
$$('.alert-card', alertList).forEach(button => button.addEventListener('click', () => selectAlert(Number(button.dataset.index))));
|
||||
}
|
||||
function renderRelatedEvents(alert) {
|
||||
const container = $('#relatedEvents');
|
||||
container.replaceChildren();
|
||||
alert.events.forEach(event => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'relation-item';
|
||||
const strong = document.createElement('strong');
|
||||
strong.className = 'mono';
|
||||
strong.textContent = event.id;
|
||||
const reason = document.createElement('span');
|
||||
reason.textContent = `${event.reason} →`;
|
||||
button.append(strong, reason);
|
||||
button.addEventListener('click', () => showEventDetail(event.id));
|
||||
container.append(button);
|
||||
});
|
||||
$('#relatedEventCount').textContent = String(alert.events.length);
|
||||
}
|
||||
function resetAck() {
|
||||
ackOwner = null;
|
||||
$('#ackState').className = 'badge warning';
|
||||
$('#ackState').textContent = '等待 ack';
|
||||
$('#ackButton').disabled = false;
|
||||
$('#ackButton').innerHTML = '<svg class="icon"><use href="#i-check"/></svg>确认接手';
|
||||
$('#ackHelp').textContent = '一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。';
|
||||
$('#ackConflict').hidden = true;
|
||||
}
|
||||
function selectAlert(index) {
|
||||
selectedAlertIndex = index;
|
||||
renderAlerts(index);
|
||||
const alert = alerts[index];
|
||||
$('#selectedSeverity').className = `badge ${alert.badge}`;
|
||||
$('#selectedSeverity').textContent = alert.severity;
|
||||
$('#selectedTitle').textContent = alert.title;
|
||||
$('#selectedMeta').textContent = `${alert.site} · ${alert.time} · ${alert.id}`;
|
||||
$('#factRule').textContent = alert.rule;
|
||||
renderRelatedEvents(alert);
|
||||
resetAck();
|
||||
}
|
||||
function openAlert(alertId) {
|
||||
const index = alerts.findIndex(alert => alert.id === alertId);
|
||||
if (index < 0) {
|
||||
notify(`预警 ${alertId} 不在当前队列,请从历史预警查询。`);
|
||||
return;
|
||||
}
|
||||
if ($('#eventDialog').open) $('#eventDialog').close();
|
||||
switchView('duty');
|
||||
selectAlert(index);
|
||||
notify(`已从事件打开关联预警 ${alertId}。`);
|
||||
}
|
||||
|
||||
function showEventDetail(eventId) {
|
||||
const event = eventRecords.find(item => item.id === eventId) || eventRecords[0];
|
||||
$('#eventDialogTitle').textContent = `${event.id} · 事件详情`;
|
||||
$('#eventDialogSubtitle').textContent = `${event.time} · 事实不可由处置改写`;
|
||||
$('#eventDetailId').textContent = event.id;
|
||||
$('#eventDetailSite').textContent = event.site;
|
||||
$('#eventDetailRule').textContent = event.rule;
|
||||
$('#eventDetailEvidence').textContent = event.evidence;
|
||||
const container = $('#eventAlerts');
|
||||
container.replaceChildren();
|
||||
if (!event.alerts.length) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'inline-alert';
|
||||
message.textContent = '未创建 Alert:当前为试运行样本或已被规则抑制;不伪装成投递成功。';
|
||||
container.append(message);
|
||||
} else {
|
||||
event.alerts.forEach(alert => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'relation-item';
|
||||
const strong = document.createElement('strong');
|
||||
strong.className = 'mono';
|
||||
strong.textContent = alert.id;
|
||||
const state = document.createElement('span');
|
||||
state.textContent = `${alert.state} →`;
|
||||
button.append(strong, state);
|
||||
button.addEventListener('click', () => openAlert(alert.id));
|
||||
container.append(button);
|
||||
});
|
||||
}
|
||||
$('#eventDialog').showModal();
|
||||
}
|
||||
|
||||
let eventPage = 1;
|
||||
const eventTotalPages = 16;
|
||||
function renderEventPage() {
|
||||
const start = ((eventPage - 1) * 3) % eventRecords.length;
|
||||
const pageItems = [0, 1, 2].map(offset => eventRecords[(start + offset) % eventRecords.length]);
|
||||
const body = $('#eventRows');
|
||||
body.replaceChildren();
|
||||
pageItems.forEach(event => {
|
||||
const row = document.createElement('tr');
|
||||
const idCell = document.createElement('td');
|
||||
const eventButton = document.createElement('button');
|
||||
eventButton.type = 'button';
|
||||
eventButton.className = 'text-button mono';
|
||||
eventButton.textContent = event.id;
|
||||
eventButton.addEventListener('click', () => showEventDetail(event.id));
|
||||
idCell.append(eventButton);
|
||||
row.append(idCell);
|
||||
makeTextCell(row, event.time);
|
||||
makeTextCell(row, event.site);
|
||||
makeTextCell(row, event.rule);
|
||||
makeBadgeCell(row, event.fact, event.factBadge);
|
||||
const alertCell = document.createElement('td');
|
||||
if (event.alerts.length) {
|
||||
event.alerts.forEach(alert => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'text-button mono';
|
||||
button.textContent = alert.id;
|
||||
button.addEventListener('click', () => openAlert(alert.id));
|
||||
alertCell.append(button);
|
||||
});
|
||||
} else {
|
||||
alertCell.textContent = '未触发';
|
||||
}
|
||||
row.append(alertCell);
|
||||
makeTextCell(row, event.result);
|
||||
makeBadgeCell(row, event.evidence, event.evidenceBadge);
|
||||
body.append(row);
|
||||
});
|
||||
$('#eventPageSummary').textContent = `第 ${eventPage} / ${eventTotalPages} 页 · 每页 3 条 · 共 47 条`;
|
||||
$('#eventPrev').disabled = eventPage === 1;
|
||||
$('#eventNext').disabled = eventPage === eventTotalPages;
|
||||
}
|
||||
|
||||
let auditPage = 1;
|
||||
const auditTotalPages = 28;
|
||||
function renderAuditPage() {
|
||||
const start = ((auditPage - 1) * 3) % auditRecords.length;
|
||||
const pageItems = [0, 1, 2].map(offset => auditRecords[(start + offset) % auditRecords.length]);
|
||||
const body = $('#auditRows');
|
||||
body.replaceChildren();
|
||||
pageItems.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
makeTextCell(row, item.time);
|
||||
makeTextCell(row, item.role);
|
||||
makeTextCell(row, item.action);
|
||||
makeTextCell(row, item.object, 'mono');
|
||||
makeBadgeCell(row, item.result, item.badge);
|
||||
makeTextCell(row, item.source);
|
||||
body.append(row);
|
||||
});
|
||||
$('#auditPageSummary').textContent = `第 ${auditPage} / ${auditTotalPages} 页 · 每页 3 条 · 共 84 条`;
|
||||
$('#auditPrev').disabled = auditPage === 1;
|
||||
$('#auditNext').disabled = auditPage === auditTotalPages;
|
||||
}
|
||||
|
||||
const validViews = new Set($$('.view').map(view => view.dataset.page));
|
||||
function switchView(requested) {
|
||||
const name = validViews.has(requested) ? requested : 'duty';
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.page === name));
|
||||
$$('[data-view]').forEach(button => {
|
||||
const active = button.dataset.view === name;
|
||||
button.toggleAttribute('aria-current', active);
|
||||
button.classList.toggle('active', active);
|
||||
});
|
||||
$('#stateSelect').value = 'normal';
|
||||
renderState('normal');
|
||||
$('#main-content').focus({preventScroll:true});
|
||||
}
|
||||
$$('[data-view]').forEach(button => button.addEventListener('click', () => switchView(button.dataset.view)));
|
||||
$$('.queue-tabs button').forEach(button => button.addEventListener('click', () => {
|
||||
$$('.queue-tabs button').forEach(item => { const active = item === button; item.classList.toggle('active', active); item.setAttribute('aria-pressed', String(active)); });
|
||||
notify(`队列已切换到“${button.textContent}”`);
|
||||
}));
|
||||
$$('.evidence-tabs button').forEach(button => button.addEventListener('click', () => {
|
||||
$$('.evidence-tabs button').forEach(item => { const active = item === button; item.classList.toggle('active', active); item.setAttribute('aria-pressed', String(active)); });
|
||||
notify(`正在演示“${button.textContent}”标签结构`);
|
||||
}));
|
||||
|
||||
function renderState(state) {
|
||||
const layer = $('#stateLayer');
|
||||
const views = $$('.view');
|
||||
layer.classList.toggle('show', state !== 'normal');
|
||||
views.forEach(view => { view.style.display = state === 'normal' ? '' : 'none'; });
|
||||
if (state === 'normal') return;
|
||||
const states = {
|
||||
loading:'<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在读取最新预警状态</h2><p>超过 300ms 显示进度;ack 和升级事实始终从服务端重新读取。</p></div>',
|
||||
weak:'<div><svg class="icon" style="color:var(--warning)"><use href="#i-alert"/></svg><h2>结构化事件已到达,视频证据暂不可用</h2><p>弱网下先显示时间、点位、规则和升级链。视频片段可单独重试,不阻塞确认接手。</p><button class="btn" data-state-recover="weak">后台重试证据</button></div>',
|
||||
empty:'<div><svg class="icon"><use href="#i-check"/></svg><h2>当前没有待处置预警</h2><p>系统在线,新的 Alert 会实时进入队列。历史事件仍可从事件中心查询。</p><button class="btn" data-empty-events>查看事件中心</button></div>',
|
||||
denied:'<div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>没有查看此站点证据的权限</h2><p>当前账号只能查看被授权站点。系统不会泄露事件是否存在、流地址或设备信息。</p><button class="btn" data-state-recover="denied">返回可访问范围</button></div>',
|
||||
expired:'<div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>会话已过期</h2><p>为保护预警和证据,页面已停止提交 ack 与处置。重新登录后从服务端读取最新责任人和升级状态,不重放旧操作。</p><button class="btn" data-state-recover="expired">重新登录并刷新状态</button></div>'
|
||||
};
|
||||
layer.innerHTML = states[state];
|
||||
$('[data-state-recover]', layer)?.addEventListener('click', () => { $('#stateSelect').value = 'normal'; renderState('normal'); notify(state === 'weak' ? '证据重试任务已加入后台队列。' : '已刷新当前权限与服务端状态。'); });
|
||||
$('[data-empty-events]', layer)?.addEventListener('click', () => switchView('events'));
|
||||
}
|
||||
|
||||
$('#stateSelect').addEventListener('change', event => renderState(event.target.value));
|
||||
$('#themeToggle').addEventListener('click', () => { const root = document.documentElement; root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark'; notify(`已切换为${root.dataset.theme === 'dark' ? '深色' : '浅色'}主题`); });
|
||||
$('#eventPrev').addEventListener('click', () => { if (eventPage > 1) { eventPage -= 1; renderEventPage(); } });
|
||||
$('#eventNext').addEventListener('click', () => { if (eventPage < eventTotalPages) { eventPage += 1; renderEventPage(); } });
|
||||
$('#auditPrev').addEventListener('click', () => { if (auditPage > 1) { auditPage -= 1; renderAuditPage(); } });
|
||||
$('#auditNext').addEventListener('click', () => { if (auditPage < auditTotalPages) { auditPage += 1; renderAuditPage(); } });
|
||||
|
||||
$('#ackButton').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
button.textContent = '确认中…';
|
||||
await new Promise(resolve => setTimeout(resolve, 650));
|
||||
if (ackOwner && ackOwner !== 'current') {
|
||||
button.textContent = '当前预警已由他人接手';
|
||||
$('#ackConflict').hidden = false;
|
||||
$('#ackConflict').textContent = '确认未生效:王值班员已于 23:15:01 抢先 ack。已刷新为服务端结果,没有覆盖对方。';
|
||||
notify('并发确认未覆盖:请与当前处置人协作。');
|
||||
return;
|
||||
}
|
||||
ackOwner = 'current';
|
||||
button.innerHTML = '<svg class="icon"><use href="#i-check"/></svg>已由当前值班员接手';
|
||||
$('#ackState').className = 'badge success';
|
||||
$('#ackState').textContent = '已 ack';
|
||||
$('#ackHelp').textContent = '23:15:02 · 当前值班员 · 服务端确认成功;通道回执仍单独保留。';
|
||||
notify('确认成功,升级计时已停止并记录审计。');
|
||||
});
|
||||
$('#simulateAckConflict').addEventListener('click', () => {
|
||||
ackOwner = 'other';
|
||||
$('#ackButton').disabled = true;
|
||||
$('#ackButton').textContent = '已由王值班员接手';
|
||||
$('#ackState').className = 'badge info';
|
||||
$('#ackState').textContent = '已 ack · 他人';
|
||||
$('#ackHelp').textContent = '23:15:01 · 王值班员 · 服务端首个成功结果';
|
||||
$('#ackConflict').hidden = false;
|
||||
$('#ackConflict').textContent = '你的 ack 未生效:王值班员抢先成功。页面已刷新实际处置人,没有静默覆盖或显示双成功。';
|
||||
});
|
||||
$('#completeAlert').addEventListener('click', () => {
|
||||
if (!$('#outcome').value) { notify('请先选择处置结果。'); $('#outcome').focus(); return; }
|
||||
if (ackOwner !== 'current') { notify(ackOwner === 'other' ? '当前处置人为王值班员,你不能覆盖其处置结果。' : '请先确认接手,再完成处置。'); $('#ackButton').focus(); return; }
|
||||
notify('处置结果已追加,原始事件事实未被修改。');
|
||||
});
|
||||
$('#falsePositive').addEventListener('click', () => $('#falseDialog').showModal());
|
||||
$('#confirmFalse').addEventListener('click', event => { if (!$('#falseReason').value) { event.preventDefault(); notify('请选择误报原因。'); $('#falseReason').focus(); return; } notify('已标记误报并进入复核队列,原始事件保持不变。'); });
|
||||
$('#muteButton').addEventListener('click', () => $('#muteDialog').showModal());
|
||||
$('#confirmMute').addEventListener('click', event => { if (!$('#muteCause').value.trim()) { event.preventDefault(); notify('请填写静默原因。'); $('#muteCause').focus(); return; } notify('已创建限时静默,到期将自动恢复并通知值班组。'); });
|
||||
$('#handoverButton').addEventListener('click', () => $('#handoverDialog').showModal());
|
||||
$('#confirmHandover').addEventListener('click', () => { if (!$('#handoverTo').value) { notify('请选择接班人并由本人确认。'); $('#handoverTo').focus(); return; } $('#handoverDialog').close(); notify('交接已由接班人确认并写入审计;3 条 Alert 的升级链继续运行。'); });
|
||||
|
||||
function switchChainSection(name) {
|
||||
const target = ['strategy', 'roster', 'contacts'].includes(name) ? name : 'strategy';
|
||||
$$('[data-chain-panel]').forEach(panel => panel.classList.toggle('active', panel.dataset.chainPanel === target));
|
||||
$$('[data-chain-section]').forEach(button => {
|
||||
const active = button.dataset.chainSection === target;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
$$('[data-chain-section]').forEach(button => button.addEventListener('click', () => switchChainSection(button.dataset.chainSection)));
|
||||
|
||||
const escalationTargets = {
|
||||
schedule: [
|
||||
{value:'SCH-CAMPUS-SECURITY',label:'校园安保值班表 · v12',preview:'王值班员 · 晚班 · 短信/语音已验证'},
|
||||
{value:'SCH-SCHOOL-LEAD',label:'校级负责人轮值表 · v6',preview:'陈负责人 · 当前轮值 · 语音已验证'}
|
||||
],
|
||||
team: [
|
||||
{value:'DUTY-ROOM',label:'值班室固定组',preview:'4 人在线 · Web + 本地声光'},
|
||||
{value:'SCHOOL-LEADS',label:'校级负责人组',preview:'3 名成员 · 顺序通知'}
|
||||
],
|
||||
person: [
|
||||
{value:'USR-SCHOOL-LEAD',label:'校级负责人',preview:'指定人员 · 短信已验证 · 语音待验证'},
|
||||
{value:'USR-DUTY-WANG',label:'王值班员',preview:'指定人员 · 短信/语音/Web 已验证'}
|
||||
]
|
||||
};
|
||||
function renderEscalationTargets() {
|
||||
const type = $('#escalationTargetType').value;
|
||||
const select = $('#escalationTargetRef');
|
||||
select.replaceChildren();
|
||||
escalationTargets[type].forEach(target => {
|
||||
const option = document.createElement('option');
|
||||
option.value = target.value;
|
||||
option.textContent = target.label;
|
||||
select.append(option);
|
||||
});
|
||||
renderEscalationPreview();
|
||||
}
|
||||
function renderEscalationPreview() {
|
||||
const type = $('#escalationTargetType').value;
|
||||
const target = escalationTargets[type].find(item => item.value === $('#escalationTargetRef').value) || escalationTargets[type][0];
|
||||
const typeLabel = type === 'schedule' ? '值班排班' : type === 'team' ? '固定值班组' : '指定人员';
|
||||
$('#escalationPreview').textContent = `${typeLabel} · ${target.label} → ${target.preview}。实际投递时固化解析快照。`;
|
||||
}
|
||||
$('#editEscalation').addEventListener('click', () => { renderEscalationTargets(); $('#escalationDialog').showModal(); });
|
||||
$('#escalationTargetType').addEventListener('change', renderEscalationTargets);
|
||||
$('#escalationTargetRef').addEventListener('change', renderEscalationPreview);
|
||||
$('#saveEscalation').addEventListener('click', () => { $('#escalationDialog').close(); notify('升级策略草稿已保存为类型化目标引用;未复制联系人号码。'); });
|
||||
|
||||
let rosterHasConflict = false;
|
||||
$('#simulateRosterConflict').addEventListener('click', event => {
|
||||
rosterHasConflict = !rosterHasConflict;
|
||||
$('#rosterConflict').hidden = !rosterHasConflict;
|
||||
$('#rosterConflict').textContent = rosterHasConflict ? '发布已阻止:周三 20:00–21:00 存在排班重叠,同时安排了周值班员和王值班员;周四 08:00–09:00 存在空档。请调整后重新检查。' : '';
|
||||
event.currentTarget.textContent = rosterHasConflict ? '恢复无冲突排班' : '演示冲突检查';
|
||||
$('#openSchedulePublish').disabled = rosterHasConflict;
|
||||
notify(rosterHasConflict ? '发现 1 个重叠和 1 个空档,排班不能发布。' : '冲突已修复,未来 14 天覆盖完整。');
|
||||
});
|
||||
$('#openSchedulePublish').addEventListener('click', () => { if (rosterHasConflict) { notify('请先修复排班空档和重叠。'); $('#rosterConflict').focus(); return; } $('#scheduleDialog').showModal(); });
|
||||
$('#confirmSchedulePublish').addEventListener('click', () => {
|
||||
if (!$('#scheduleReason').value.trim()) { notify('请填写排班发布说明。'); $('#scheduleReason').focus(); return; }
|
||||
$('#scheduleDialog').close();
|
||||
$('#scheduleVersion').textContent = 'v13';
|
||||
notify('排班 v13 已发布,将在设定时间生效;v12 与既有投递快照保持不变。');
|
||||
});
|
||||
$('#openSubstitute').addEventListener('click', () => $('#substituteDialog').showModal());
|
||||
$('#saveSubstitute').addEventListener('click', () => {
|
||||
if (!$('#substitutePerson').value) { notify('请选择替班人。'); $('#substitutePerson').focus(); return; }
|
||||
if (!$('#substituteReason').value.trim()) { notify('请填写替班原因。'); $('#substituteReason').focus(); return; }
|
||||
$('#substituteDialog').close();
|
||||
$('#wednesdayNightShift').textContent = `${$('#substitutePerson').value}(替班草稿)`;
|
||||
$('#scheduleVersion').textContent = 'v13 草稿';
|
||||
notify('临时替班已保存到 v13 草稿,发布前不会影响实际通知。');
|
||||
});
|
||||
|
||||
let editingContact = false;
|
||||
function openContactEditor(key = '') {
|
||||
editingContact = Boolean(key);
|
||||
const names = {wang:'王值班员',lin:'林值班员',lead:'校级负责人'};
|
||||
$('#contactTitle').textContent = editingContact ? `编辑联系人 · ${names[key]}` : '新建联系人';
|
||||
$('#contactName').value = editingContact ? names[key] : '';
|
||||
$('#contactPhone').value = '';
|
||||
$('#contactPhone').placeholder = editingContact ? '留空则保留已验证号码;旧值不回显' : '输入后发送验证码;不回显旧值';
|
||||
$('#contactDialog').showModal();
|
||||
}
|
||||
$('#openContact').addEventListener('click', () => openContactEditor());
|
||||
$$('[data-edit-contact]').forEach(button => button.addEventListener('click', () => openContactEditor(button.dataset.editContact)));
|
||||
$('#saveContact').addEventListener('click', () => {
|
||||
if (!$('#contactName').value.trim()) { notify('请填写联系人姓名。'); $('#contactName').focus(); return; }
|
||||
const needsPhone = $('#contactSms').checked || $('#contactVoice').checked;
|
||||
if (!editingContact && needsPhone && !$('#contactPhone').value.trim()) { notify('短信或语音通道需要填写新号码并完成验证。'); $('#contactPhone').focus(); return; }
|
||||
$('#contactDialog').close();
|
||||
if (!editingContact) $('#contactCount').textContent = '7';
|
||||
notify(editingContact ? '联系人主数据已更新;既有投递快照未改变。' : '联系人已保存,短信/语音通道进入验证流程;尚未加入任何班次。');
|
||||
});
|
||||
|
||||
$('#mobileManage').addEventListener('click', () => $('#manageDialog').showModal());
|
||||
$$('[data-manage-view]').forEach(button => button.addEventListener('click', () => { $('#manageDialog').close(); switchView(button.dataset.manageView); notify(`已进入${button.textContent.trim()}。`); }));
|
||||
|
||||
$$('[data-edit-area]').forEach(button => button.addEventListener('click', () => { const isDorm = button.dataset.editArea === 'dorm'; $('#areaName').value = isDorm ? '宿舍卧室' : button.dataset.editArea === 'lab' ? '实验楼入口' : '北围墙'; $('#areaPolicy').value = isDorm ? 'non_imaging_only' : 'video_allowed'; $('#areaDialog').showModal(); }));
|
||||
$('#newArea').addEventListener('click', () => { $('#areaName').value = ''; $('#areaPolicy').value = 'video_allowed'; $('#areaDialog').showModal(); });
|
||||
$('#newSite').addEventListener('click', () => notify('新建站点需先填写时区、场景包、配额和管理员,本原型只枚举入口。'));
|
||||
$('#saveAreaPolicy').addEventListener('click', () => { if (!$('#areaReason').value.trim()) { notify('请填写变更原因。'); $('#areaReason').focus(); return; } if ($('#areaName').value === '北围墙' && $('#areaPolicy').value === 'non_imaging_only') { $('#areaDialog').close(); $('#policyConflictDialog').showModal(); return; } $('#areaDialog').close(); notify('Area 策略新版本已保存,等待 Sense 拉取投影。'); });
|
||||
$('#confirmPolicyConflict').addEventListener('click', () => { const action = $('input[name="conflictAction"]:checked').value; notify(action === 'move' ? '已创建 4 台设备迁移任务;完成前不会发布策略。' : '已取消策略变更,v42 和已有设备保持不变。'); });
|
||||
|
||||
const ruleDialog = $('#ruleDialog');
|
||||
function openRuleEditor() { ruleDialog.showModal(); }
|
||||
$('#openRuleDialog').addEventListener('click', openRuleEditor);
|
||||
$('#continueRuleDraft').addEventListener('click', openRuleEditor);
|
||||
$$('[data-edit-rule]').forEach(button => button.addEventListener('click', openRuleEditor));
|
||||
function syncSenseZoneLink() { $('#editRuleZone').href = `../sense/index.html?view=device-detail&camera=${encodeURIComponent($('#ruleCamera').value)}&zone=${encodeURIComponent($('#ruleZone').value)}&return_to=bell-rule`; }
|
||||
$('#ruleCamera').addEventListener('change', () => { syncSenseZoneLink(); notify('已切换摄像头,请确认检测区域版本与画面仍匹配。'); });
|
||||
$('#ruleZone').addEventListener('change', syncSenseZoneLink);
|
||||
$('#simulateRuleConflict').addEventListener('click', () => { $('#ruleConflict').hidden = false; $('#saveRuleDraft').disabled = true; notify('已读取服务端 v8;当前 v7 草稿被阻止覆盖。'); });
|
||||
$('#saveRuleDraft').addEventListener('click', event => { if (!$('#ruleConflict').hidden) { event.preventDefault(); notify('版本冲突未解决,不能覆盖 v8。'); return; } notify($('#startDryRun').checked ? '规则草稿已保存并进入试运行,不会触发正式预警。' : '规则草稿已保存,尚未开始试运行。'); });
|
||||
$('#publishTrialRule').addEventListener('click', () => { if (confirm('正式发布后将影响 3 个点位并启动预警链。确认发布当前试运行版本?')) { $('#trialBadge').className = 'badge success'; $('#trialBadge').textContent = '正式生效'; notify('规则版本已正式发布,变更已记录审计并可回滚。'); } });
|
||||
$('#openRollback').addEventListener('click', () => $('#rollbackDialog').showModal());
|
||||
$('#confirmRollback').addEventListener('click', () => { $('#rollbackDialog').close(); $('#publishedRuleBadge').textContent = '正式生效 · v8(回滚)'; notify('已创建并发布 v8,其配置等价于 v6;v6、v7 历史保持不变。'); });
|
||||
|
||||
let remain = 102;
|
||||
setInterval(() => { if (ackOwner || remain <= 0) return; remain -= 1; const minutes = String(Math.floor(remain / 60)).padStart(2, '0'); const seconds = String(remain % 60).padStart(2, '0'); $('#countdown').textContent = `${minutes}:${seconds}`; }, 1000);
|
||||
|
||||
renderAlerts();
|
||||
renderRelatedEvents(alerts[0]);
|
||||
renderEventPage();
|
||||
renderAuditPage();
|
||||
|
||||
const routeParams = new URLSearchParams(location.search);
|
||||
if (routeParams.get('view')) switchView(routeParams.get('view'));
|
||||
if (routeParams.get('view') === 'chains' && routeParams.get('section')) switchChainSection(routeParams.get('section'));
|
||||
if (routeParams.get('view') === 'audit' && (routeParams.has('device') || routeParams.has('site'))) {
|
||||
const tenant = routeParams.get('tenant') || '当前租户';
|
||||
const site = routeParams.get('site') || '当前站点';
|
||||
const device = routeParams.get('device') || '全部设备';
|
||||
$('#auditContext').hidden = false;
|
||||
$('#auditTenant').textContent = tenant;
|
||||
$('#auditSite').textContent = site;
|
||||
$('#auditDevice').textContent = device;
|
||||
$('#auditSearch').value = device;
|
||||
auditRecords.unshift({time:'10:47:36',role:'站点管理员',action:'PAUSE_INFERENCE',object:device,result:'成功',badge:'success',source:'Sense outbox · relay 已送达'});
|
||||
renderAuditPage();
|
||||
$('#auditReturn').href = `../sense/index.html?view=device-detail&device=${encodeURIComponent(device)}&return_to=bell-audit`;
|
||||
notify('已安全应用来自 Sense 的租户、站点和设备审计筛选。');
|
||||
}
|
||||
if (routeParams.has('event')) {
|
||||
switchView('events');
|
||||
showEventDetail(routeParams.get('event'));
|
||||
}
|
||||
if (routeParams.has('alert')) openAlert(routeParams.get('alert'));
|
||||
if (routeParams.has('camera')) {
|
||||
switchView('rules');
|
||||
$('#ruleContext').hidden = false;
|
||||
const camera = routeParams.get('camera');
|
||||
if ([...$('#ruleCamera').options].some(option => option.value === camera)) $('#ruleCamera').value = camera;
|
||||
const zone = routeParams.get('zone');
|
||||
if (zone && [...$('#ruleZone').options].some(option => option.value === zone)) $('#ruleZone').value = zone;
|
||||
$('#ruleContextCamera').textContent = $('#ruleCamera').selectedOptions[0].textContent;
|
||||
$('#ruleContextZone').textContent = zone || $('#ruleZone').value;
|
||||
syncSenseZoneLink();
|
||||
notify('已保留摄像头与检测区域上下文,可继续配置规则。');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@
|
||||
推荐标签:
|
||||
|
||||
- 类型:`kind/task` 标识可执行任务,并从 `type/docs`、`type/code` 中选择一个主要变更类型。
|
||||
- 状态:`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
|
||||
- 状态:`status/waiting`、`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
|
||||
- 优先级:`priority/p0`、`priority/p1`、`priority/p2`。
|
||||
|
||||
`status/*`、`type/*`、`priority/*` 分别使用 Gitea exclusive scoped labels,同一分组任一时刻最多一个;`kind/task` 为普通标签。
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
| 阶段 | 任务文件 | Issue | 分支 / PR |
|
||||
| --- | --- | --- | --- |
|
||||
| 等待条件 | `TODO` | open + `status/waiting` | 无 claim;依赖或外部条件满足后由 dispatcher 转为 todo |
|
||||
| 待领取 | `TODO` | open + `status/todo` | 无 claim 分支 |
|
||||
| 开发中 | 工作分支上 `DOING` | open + `status/doing` | claim 与工作分支存在 |
|
||||
| 阻塞 | `BLOCKED` | open + `status/blocked` | 默认保留 claim,避免误领 |
|
||||
@@ -40,9 +41,9 @@ Issue 是实时状态权威;默认分支尚未合入工作提交时,其任
|
||||
|
||||
1. 先创建任务文件,写清规格、依赖和初始 `write_paths`,合入默认分支;此时 `issue`、`context_ref`、claim / 工作分支均为 `null`。
|
||||
2. 用 Issue 模板创建唯一主 Issue。新 Issue 只有 `kind/task`,尚未带 `status/todo`。
|
||||
3. 把 Issue 编号回填任务文件,并让 Issue 链接该文件;映射提交合入默认分支后,再选择 `type/*`、`priority/*` 和 `status/todo`。
|
||||
3. 把 Issue 编号回填任务文件,并让 Issue 链接该文件;映射提交合入默认分支后,再选择 `type/*`、`priority/*`。依赖已全部 `DONE` 且未被人工暂缓时使用 `status/todo`,否则使用 `status/waiting`。
|
||||
|
||||
因此 dispatcher 能从默认分支可靠定位任务 ↔ Issue;没有双向映射或没有 `status/todo` 的任务都不可领取。
|
||||
因此 dispatcher 能从默认分支可靠定位任务 ↔ Issue;没有双向映射或不是 `status/todo` 的任务都不可领取。`status/waiting` 不占用 claim,恢复条件必须写在 Issue 评论或任务执行记录中。
|
||||
|
||||
## 串行分配与防重复领取
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| 领域 | 评级 | 验证状态 | Agent 可读性 | 测试稳定性 | 关键缺口 | 上次更新 |
|
||||
|------|------|---------|-------------|-----------|---------|---------|
|
||||
| Harness 文档与任务治理 | A | 三条治理命令通过 | A | A | 需观察真实多轮任务是否顺畅 | 2026-08-03 |
|
||||
| 摄像头接入与媒体 | N/A | M0/M1 未完成 | B(设计清楚) | N/A | 实机兼容矩阵、Sense 代码与 5 路 smoke | 2026-08-03 |
|
||||
| 摄像头接入与媒体 | C | M0 单海康指定基线已准入,M1 未完成 | B(设计与单实机证据清楚) | B(单次 605 秒双码流) | T-006 单实机混合五路 smoke、真实断网恢复;生产前补 T-007 真实多路现场证据 | 2026-08-05 |
|
||||
| 推理与事件契约 | C | 契约已冻结,生产者未实现 | A | N/A | Brain mapper、契约测试和硬件基准 | 2026-08-03 |
|
||||
| 规则、预警与处置 | N/A | M3 未开始 | B(设计清楚) | N/A | Bell、ack/升级、投递与 UI | 2026-08-03 |
|
||||
| 多租户、RBAC 与审计 | N/A | M3 未开始 | B(设计清楚) | N/A | schema、鉴权和隔离测试 | 2026-08-03 |
|
||||
|
||||
+15
-7
@@ -125,8 +125,8 @@ erDiagram
|
||||
| --- | --- | --- |
|
||||
| **Tenant 租户** | 计费与数据隔离的最小单位 | 所有查询必须带 tenant_id,无例外 |
|
||||
| **Site 站点** | 一个物理场所(一户 / 一所学校 / 一个小区) | 升级链、时段策略挂在这一层 |
|
||||
| **Area 区域** | 站点内的逻辑分区(教学楼 / 单元 / 客厅) | 用于权限范围与报表口径 |
|
||||
| **Device 设备** | **不叫 Camera**,`device_type` 取 camera/radar/door/button | 为异构传感器预留(继承参考项目 §4.5) |
|
||||
| **Area 区域** | 站点内的逻辑分区(教学楼 / 单元 / 客厅),由 Bell 统一管理 | 用于权限范围、报表口径与设备准入;`capture_policy` 表达是否仅允许非成像设备,Sense 只消费带版本的策略投影 |
|
||||
| **Device 设备** | **不叫 Camera**;`modality` 表达 video/radar/contact/button/wearable/other,`capabilities` 表达成像、媒体、遥测等具体能力 | 模态负责稳定分类,能力负责决定字段与操作,避免接入新设备时重做台账和页面 |
|
||||
| **Zone 画面区域** | 画在某路画面上的多边形或警戒线 | 与 Area 是两回事:Area 是物理概念,Zone 是像素坐标 |
|
||||
| **Rule 规则** | 「主体 × 条件 × 时空 × 动作」的可配置组合 | 三级覆盖:租户默认 → 站点 → 设备 |
|
||||
| **Event 事件** | 一次被判定成立的客观发生 | **不可变**。误判只能被标记,不能被删改 |
|
||||
@@ -608,21 +608,27 @@ GPU 型号和数量不在需求阶段写死。M1 用 5 路打通,M3 以 16 路
|
||||
在参考项目基础上扩展多租户与规则/事件。**字段级设计见《03》,此处只给结构与关键约束。**
|
||||
|
||||
```sql
|
||||
-- ── 组织(Bell 为真相源;Sense 只读配额投影)────────
|
||||
-- ── 组织(Bell 为真相源;Sense 不直接写这些表)────────
|
||||
tenants(id, code, name, status, plan, created_at)
|
||||
sites(id, tenant_id, code, name, scene_pack, subnet CIDR, timezone, status,
|
||||
max_video_channels INT NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128))
|
||||
areas(id, site_id, name, parent_id)
|
||||
areas(id, site_id, name, parent_id,
|
||||
capture_policy) -- video_allowed | non_imaging_only
|
||||
|
||||
-- ── 设备(不叫 cameras,为异构传感器预留)────
|
||||
-- ── Sense 只读投影(来源版本与同步时间必须可追溯)────
|
||||
site_quota_projections(site_id, max_video_channels, source_version, synced_at)
|
||||
area_policy_projections(area_id, site_id, capture_policy, source_version, synced_at)
|
||||
|
||||
-- ── Sense 设备(不叫 cameras,为异构传感器预留)────
|
||||
devices(
|
||||
id, tenant_id, site_id, area_id,
|
||||
device_type, -- camera | radar | door | button
|
||||
modality, -- video | radar | contact | button | wearable | other
|
||||
device_kind, -- camera | mmwave_radar | door_contact | panic_button | ...
|
||||
capabilities JSONB, -- captures_image / media_stream / telemetry / battery / spatial_config / ...
|
||||
serial UNIQUE, -- ⚠️ 身份用序列号,不用 IP
|
||||
vendor, model,
|
||||
onvif_addr, onvif_user, onvif_secret, -- secret 加密存储
|
||||
state, -- pending_activation | active | offline | disabled
|
||||
privacy_flag, -- 隐私区域标记,为 true 时拒绝 camera 类型
|
||||
last_seen_at
|
||||
)
|
||||
stream_bindings(id, device_id, mtx_instance, inference_shard, path_name, rtsp_url, profile_token, enabled,
|
||||
@@ -701,6 +707,8 @@ CREATE INDEX ON alerts(state) WHERE state NOT IN ('closed','suppressed');
|
||||
7. 人脸相关表独立 schema、独立加密、独立审计;`persons.valid_until` 必填,无"永久有效"选项
|
||||
8. `sites.max_video_channels` 默认 16、最大 128;配额在写入设备时校验,分片容量由运行时配置与压测决定
|
||||
9. `sites.max_video_channels` 由 Bell 持有,Sense 通过版本化内部 API 或只读投影校验设备新增/启用;不得跨 schema 直接写入
|
||||
10. Tenant/Site/Area/RBAC、配额与全局审计属于 Bell;Sense 仅使用稳定逻辑 ID 关联并保存带 `source_version` / `synced_at` 的执行投影
|
||||
11. 高风险设备写操作与本地审计 outbox 在 Sense 同一事务提交,再由幂等 relay 异步汇入 Bell;审计接口字段、签名和重放规则另立契约任务冻结
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -921,7 +921,7 @@ scene_packs/campus@1.2/
|
||||
|
||||
| 约束 | 实现 |
|
||||
| --- | --- |
|
||||
| 卧室、卫生间**禁装摄像头** | `devices.privacy_flag = true` 时系统拒绝创建 `device_type = camera`(代码级硬约束) |
|
||||
| 卧室、卫生间**禁装摄像头** | `areas.capture_policy = non_imaging_only` 时,Sense 在设备新增/启用写路径拒绝具有 `captures_image` 能力的设备(代码级硬约束);策略读取失败时拒绝新变更并告警,不静默切断已有链路 |
|
||||
| 平时不解码不上传 | 触发式推理 + `record: no` + 边缘环形缓冲 |
|
||||
| 家属只能看自己家的告警关联片段 | RBAC:`family` 角色的证据访问范围 = `site_id` ∩ `alert_events` |
|
||||
| 3 分钟内必有真人接手 | 4 级升级链(30s/60s/90s → 呼叫中心) |
|
||||
@@ -1067,7 +1067,7 @@ capacity:
|
||||
|
||||
`stream_bindings` 同时记录 `mtx_instance` 与 `inference_shard`。默认按 `site_id + device_id` 稳定分配;扩容时允许迁移,但同一设备任一时刻只能归属一个有效媒体分片和一个推理分片。
|
||||
|
||||
`sites.max_video_channels` 的唯一真相源在 Bell;Sense 在设备新增/启用写路径通过版本化内部 API 或只读投影执行配额校验,不跨 schema 直接写入。配额依赖暂时不可用时只拒绝新的容量变更,已有视频链路继续运行。
|
||||
`sites.max_video_channels`、Site/Area 层级和 `areas.capture_policy` 的唯一真相源在 Bell;Sense 在设备新增/启用写路径通过版本化内部 API 或带 `source_version` / `synced_at` 的只读投影执行校验,不跨 schema 直接写入。配额或 Area 策略依赖暂时不可用时只拒绝相关新变更,已有视频链路继续运行并产生运维告警。
|
||||
|
||||
> **不要单实例硬扛 128 路**:扩容必须通过增加分片与 Worker 完成。单分片故障只允许影响该分片,不能拖垮整个站点。GPU 型号与数量不在架构文档写死,M1 用 5 路打通,M3 建立 16 路基线,M4/M5 再验证 64/128 路。
|
||||
|
||||
@@ -1115,7 +1115,8 @@ OpenTelemetry → Jaeger,排查单帧处理延迟与端到端事件链路。
|
||||
|
||||
| 端 | 核心功能 |
|
||||
| --- | --- |
|
||||
| **管理系统(Web)** | 租户/站点/设备管理、批量开通、规则配置(含试运行)、Zone 画图、升级链配置、事件看板与筛选、事件详情(视频+观测回放)、处置与工单、误报标记、统计报表、审计查询、RBAC |
|
||||
| **Sense 接入工作台(Web)** | 当前租户/站点上下文、设备台账与批量开通、能力探测、Zone 画图、对账/分片/边缘隧道/补传/运维告警;不管理 Tenant/Site/Area/RBAC 或全局审计 |
|
||||
| **Bell 统一管理端(Web)** | Tenant/Site/Area/配额/`capture_policy`、RBAC、规则配置(含试运行)、升级链、事件看板与筛选、事件详情(视频+观测回放)、处置与工单、误报标记、统计报表、全局审计;设备操作日志从 Sense 汇入并可深链返回 |
|
||||
| **App(家属/值班员)** | 预警接收与 ack、事件详情与视频回看、一键呼叫、误报反馈、限时静默、设备状态、(家属)多老人切换 |
|
||||
| **大屏/值班台** | 实时事件流、地图/平面图点位、声光联动、当班交接 |
|
||||
| **Webhook/开放 API** | 对接客户既有平台、门禁、消防、工单系统 |
|
||||
@@ -1144,7 +1145,9 @@ OpenTelemetry → Jaeger,排查单帧处理延迟与端到端事件链路。
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **Sense** | 感知系统 | L1 接入 | Go | 有(设备台账) | M1 |
|
||||
| **Brain** | 推理系统 | L2 流水线 + L3 算法 | Python / CUDA | **无状态** | M3 |
|
||||
| **Bell** | 管理系统 | L4 业务 + L0 基座 | Go + 前端 | 有(事件、告警) | M3 → M4 |
|
||||
| **Bell** | 管理系统 | L4 业务 + L0 基座 | Go + 前端 | 有(事件、告警、组织与策略真相) | M3 → M4 |
|
||||
|
||||
Sense 的高风险设备操作不能采用“先执行、再尽力记录审计”。实现时应在本地事务内同时写期望态与持久化 outbox,再由幂等 relay 异步汇入 Bell 全局审计。该跨系统接口的字段、签名、重放和留存必须另立 API/契约任务,不在 UI 原型任务中顺带冻结。
|
||||
|
||||
三者之间只有一条契约——**事件实例 JSON**(§2.6,规范本体见 `contracts/event-v0.1.schema.json`)。Sense 向 Brain 供流与信号,Brain 产出事件,Bell 消费事件。这条边界让推理侧换模型、换框架(Savant → Pipeless、YOLO → YOLOX)时,另两个系统一行不改。
|
||||
|
||||
@@ -1228,7 +1231,7 @@ redis 消息总线
|
||||
- [ ] 终止服务时的底库彻底删除义务与证明方式已约定
|
||||
|
||||
## 架构
|
||||
- [ ] `devices` 表而非 `cameras`,含 `device_type`
|
||||
- [ ] `devices` 表而非 `cameras`,含 `modality + capabilities`;Bell 持有 Area 与 `capture_policy`,Sense 只存带版本的准入投影
|
||||
- [ ] 设备身份用序列号
|
||||
- [ ] 触发入口独立成 handler
|
||||
- [ ] `sync_state` 每系统一行
|
||||
|
||||
+10
-7
@@ -23,7 +23,7 @@
|
||||
|
||||
**Sense —— 把现场的流和信号稳定地拿进来并管住**
|
||||
|
||||
- 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable)
|
||||
- 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable / other,以及决定成像、媒体、遥测、空间配置等页面与操作的 `capabilities`)
|
||||
- ONVIF 客户端:连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
|
||||
- mediamtx API 客户端(**自行用 oapi-codegen 从其 OpenAPI 生成**,不依赖第三方 SDK)
|
||||
- **对账器**:水平触发、`sync_state`、独立信号量、10% 孤儿删除安全闸、只收敛不回滚
|
||||
@@ -31,6 +31,7 @@
|
||||
- WireGuard 控制面隧道
|
||||
- mediamtx 鉴权回调(401 + 踢流是四种停用粒度之一)
|
||||
- 容量配额执行:设备新增/启用时读取 Bell 的站点配额,只拒绝超额变更;配额服务暂时不可用时不影响已有流
|
||||
- Area 准入执行:消费 Bell 的版本化 `capture_policy` 投影;策略不可用时只拒绝相关新写入,不静默中断已有设备
|
||||
- 流绑定与媒体分片调度:维护 `mtx_instance`,默认 16 路交付;扩到 128 路时按 `media_shard.max_streams` 横向分片
|
||||
- 边缘节点 agent:推流、本地环形缓冲、断网续传
|
||||
- **设备型触发源**:雷达、门磁、按钮、ONVIF 事件订阅
|
||||
@@ -54,8 +55,8 @@
|
||||
- 告警状态机:升级链、ack、抑制、静默(**≤4h,无永久选项**)
|
||||
- 投递:push / 短信 / 语音,**双供应商**
|
||||
- 误报反馈闭环:`outcome` 回写 Brain
|
||||
- 多租户 RBAC、`tenant_features`(人脸授权开关:未授权时能力在 API 与 UI 中**不可见**,不是禁用)
|
||||
- 审计日志
|
||||
- Tenant/Site/Area、`capture_policy`、多租户 RBAC、`tenant_features`(人脸授权开关:未授权时能力在 API 与 UI 中**不可见**,不是禁用)
|
||||
- 全局审计日志;接收 Sense 设备操作审计并保留来源上下文
|
||||
- 站点容量配额的唯一真相源:`site.max_video_channels` 默认 16、上限 128;提供版本化内部读取接口/投影给 Sense 执行
|
||||
- 管理后台前端 + 大屏:按 128 路设计分页/虚拟列表、筛选与批量操作,不一次性加载全部视频
|
||||
|
||||
@@ -80,7 +81,7 @@ Sense/
|
||||
│ ├── sense-api/ 控制面服务:设备台账、鉴权回调、对账器
|
||||
│ └── sense-agent/ 边缘节点:推流、环形缓冲、隧道
|
||||
├── internal/
|
||||
│ ├── device/ 设备台账(devices,含 modality)
|
||||
│ ├── device/ 设备台账(devices,含 modality + capabilities)
|
||||
│ ├── onvif/ 连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
|
||||
│ ├── mtx/ oapi-codegen 生成的 mediamtx 客户端 + 薄封装
|
||||
│ ├── reconcile/ 对账循环(幂等 + 退避 + 安全闸)
|
||||
@@ -138,7 +139,7 @@ Bell/
|
||||
|
||||
---
|
||||
|
||||
## 3. 七条边界(不写死就会漂)
|
||||
## 3. 八条边界(不写死就会漂)
|
||||
|
||||
| # | 边界 | 定论 |
|
||||
| --- | --- | --- |
|
||||
@@ -149,8 +150,10 @@ Bell/
|
||||
| 5 | 一个 PostgreSQL 还是三个 | **一个实例,schema 分离**(`sense` / `bell`),Brain 无 schema。"DB 是唯一真相源"是对账器成立的前提,拆库即失效 |
|
||||
| 6 | 16 路与 128 路分别指什么 | **16 路是默认交付规格,128 路是本阶段单逻辑站点上限**,都不是“单进程/单服务器保证值”。媒体与推理必须横向分片;单机承载量由分辨率、码率、帧率、模型和硬件基准测试决定 |
|
||||
| 7 | 站点配额谁拥有、谁执行 | **Bell 拥有 `sites.max_video_channels`,Sense 在设备新增/启用写路径执行**。通过版本化内部 API 或只读投影同步,不允许 Sense 直接写 Bell schema;依赖暂时不可用时拒绝新变更,但已有视频链路继续运行 |
|
||||
| 8 | 设备模态与隐私准入怎么建模 | 台账使用 `modality + capabilities`,不以摄像头作为唯一根实体;**Bell 拥有 Area 与 `capture_policy`,Sense 消费带版本投影并按设备 `captures_image` 能力在新增/启用写路径执行**。策略不可用时拒绝相关新变更并告警,已有链路不静默停用;M1 固化模型,M6 再增加非视频适配器 |
|
||||
| 9 | 接入操作审计怎么进入 Bell | Sense 在同一事务写设备期望态和持久化 outbox,再由幂等 relay 异步汇入 Bell 全局审计;不得先执行高风险操作再尽力入队。字段、签名、重放和留存由独立 API/契约任务冻结 |
|
||||
|
||||
第 2、3、6、7 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。
|
||||
第 2、3、6、7、8、9 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。
|
||||
|
||||
---
|
||||
|
||||
@@ -179,6 +182,6 @@ DB schema sense — bell
|
||||
| **M3** 默认 16 路推理 + 规则引擎 + 事件预警 | **Brain + Bell 同时起步**,契约在此首次被真实使用 |
|
||||
| **M4** 64 路分片 + 灰度 + 管理系统 | Sense 与 Brain 完成横向分片,Bell 完成 128 路规模下的列表与批量交互;验证单分片故障隔离 |
|
||||
| **M5** 128 路容量验收 + 第二三场景包 + 触发式推理 + 人脸 | Sense/Brain 验证扩容不改业务代码;Bell 的 `packs/`(**纯配置**)+ Brain 的模型与触发 |
|
||||
| **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/`(modality 扩展)+ Brain 两级判定 |
|
||||
| **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/` 增加非视频协议适配器与能力实现(`modality + capabilities` 模型已从 M1 保留)+ Brain 两级判定 |
|
||||
|
||||
> M3 是契约第一次被真实使用的时刻。**在此之前契约允许原地修订,之后版本递增规则绝对生效**(契约 README §1)。
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Sense 原型评审 → IX 修订稿
|
||||
|
||||
> 评审对象:`docs/design/sense/index.html`(T-004)
|
||||
> 初始输入:Claude 评审草稿,2026-08-04
|
||||
> 复核:Codex 从产品、全栈架构与 UI/UX 角度对照《02》《03》《08-三系统职责划分》、US/IX 和当前原型核验
|
||||
> 状态:项目负责人已确认按本修订稿继续;最终行为已同步到 `docs/08-interaction-checklist.md`
|
||||
> 后续:第二轮缺口复核与最终 IA 见 `11-Sense原型-第二轮缺口.md`、`12-Sense原型-待办清单.md`;若与本文原型范围冲突,以第二轮修订为准
|
||||
|
||||
---
|
||||
|
||||
## 1. 评审结论
|
||||
|
||||
初始评审指出的五类缺口方向成立:设备模态、停用收敛、隐私准入、检测区域几何编辑、草稿与跨系统上下文都需要进入正式交互清单。但原稿不能直接合入,原因有三类:
|
||||
|
||||
1. 部分证据与当前代码不一致:Sense 页内导航不会清空几何点,Sense→Bell 已携带 `camera + zone`,单媒体分片 32 路已有架构来源。
|
||||
2. 部分方案把后端字段直接等同产品能力:非视频设备仍需要连接与健康,隐私准入不能只看显示名称或单一模态。
|
||||
3. 部分行为不符合审计和草稿体验:幂等不等于不记审计,避免丢草稿应优先自动保存,而不是每次导航都弹确认。
|
||||
|
||||
原产品边界保持不变:
|
||||
|
||||
- Sense 管设备接入、健康、媒体与设备空间配置。
|
||||
- 视频设备的检测区域继续位于设备详情,不恢复一级“区域规则”。
|
||||
- Bell 管场景、继承、时段、持续时间、试运行、发布和升级链。
|
||||
- 两个系统通过稳定深链和可恢复草稿保持连续体验。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终新增 IX
|
||||
|
||||
| ID | 场景 | 最终约定 | 关联 US | 阶段 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| IX-014 | 设备模态与能力 | 一级入口为“设备”;列表可按 `modality` 筛选,添加时先选模态,详情按 `capabilities` 渐进展示;视频设备显示画面/媒体/检测区域,非视频设备保留连接与健康并显示对应遥测,不用禁用的视频页签占位 | US-008 | M2/M6 |
|
||||
| IX-015 | 停用与收敛 | 明确区分暂停推理、停用设备接入和仅踢当前会话;操作前展示对观看、推理、录制与证据回捞的影响并二次确认;期望态立即改变、实际态经对账收敛,部分失败可逐项重试;重复请求不产生重复副作用,但每次请求均留审计结果 | US-001、US-002 | M2 |
|
||||
| IX-016 | 隐私区域设备准入 | `capture_policy = non_imaging_only` 的 Area 不允许新增/启用具有成像能力的设备;前端就地禁用并解释,后端返回稳定错误码;策略读取失败时拒绝新变更并告警,已有设备遇到区域策略变化时进入显式处置流程,不静默留存或停用 | US-008 | M2/M6 |
|
||||
| IX-017 | 检测区域几何编辑 | 多边形和方向警戒线支持绘制、显式完成、撤销、清空、顶点编辑;警戒线在草稿/已保存态均显示方向箭头并可反转;鼠标与键盘坐标操作等效;画面参数变化后标记待校准且不自动改坐标;区域版本与规则版本独立 | US-005 | M3 |
|
||||
| IX-018 | 草稿与跨系统上下文 | 几何草稿自动保存并可恢复;只有持久化失败或切换空间类型等会破坏草稿的动作才拦截确认;Sense↔Bell 双向携带并消费 `camera + zone + return_to` 上下文;返回后恢复草稿,版本已被他人更新时提示冲突且不覆盖 | US-005 | M3 |
|
||||
|
||||
`IX-003` 已覆盖设备列表分页/筛选/批量选择和 128 路按需加载,因此分页属于原型漏画,不新增 IX。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键设计修订
|
||||
|
||||
### 3.1 设备使用 `modality + capabilities`
|
||||
|
||||
`modality` 用于稳定分类:video / radar / contact / button / wearable / other。`capabilities` 决定字段、页签和动作,例如:
|
||||
|
||||
- `captures_image`
|
||||
- `media_stream`
|
||||
- `telemetry`
|
||||
- `battery`
|
||||
- `spatial_config`
|
||||
- `directional_line`
|
||||
|
||||
M2 的一级导航与台账统一使用“设备”,默认筛选视频设备;M1~M5 只完整实现 video 适配器。M6 增加非视频协议适配器,不重新设计根实体与页面层级。
|
||||
|
||||
非视频设备不展示画面、Profile 和媒体分片,但仍展示“连接与健康”,并根据能力显示最后上报、信号质量、电量、遥测或传感器校准。
|
||||
|
||||
### 3.2 隐私策略属于 Area
|
||||
|
||||
隐私是物理位置的准入策略,不是设备已经创建后的标签。统一模型为:
|
||||
|
||||
```text
|
||||
Area.capture_policy = video_allowed | non_imaging_only
|
||||
Device.modality
|
||||
Device.capabilities.captures_image
|
||||
```
|
||||
|
||||
Sense 在新增/启用写路径执行校验。后端返回稳定错误码,前端负责本地化说明。策略不可读取时对新成像变更 fail closed 并告警;已有链路保持当前状态并进入待处置/未收敛列表,不能因一次依赖失败被静默切断。
|
||||
|
||||
### 3.3 停用动作不能混为一个开关
|
||||
|
||||
| 用户动作 | 期望效果 |
|
||||
| --- | --- |
|
||||
| 暂停推理 | 卸载推理 source;接入和授权观看按策略保留 |
|
||||
| 停用设备接入 | 踢当前发布会话、拒绝后续发布、卸载推理;期望态先变,实际态由对账收敛 |
|
||||
| 仅踢当前会话 | 运维诊断动作;客户端可能重连,不改变持久期望态 |
|
||||
|
||||
每种动作展示独立影响范围。重复请求不得生成重复资源变更或状态副作用,但所有操作尝试都应进入审计并标明“已处于目标状态”或实际结果。
|
||||
|
||||
### 3.4 几何编辑提供显式完成与等效操作
|
||||
|
||||
- 多边形至少 3 点,方向警戒线恰好 2 点。
|
||||
- 双击可作为快捷方式,但必须有可见的“完成绘制”按钮;双击事件不得额外追加顶点。
|
||||
- 警戒线草稿和保存态都渲染方向箭头,并提供“反转方向”。
|
||||
- 顶点列表支持添加、编辑、删除、撤销,键盘与鼠标结果等效。
|
||||
- 画面分辨率或旋转变化后标记待校准,不自动修改坐标,也不自动发布 Bell 规则。
|
||||
|
||||
### 3.5 草稿优先自动保存
|
||||
|
||||
页内导航不应反复打断用户。几何草稿在本地/服务端自动保存并显示状态;只有自动保存失败、切换不兼容空间类型或明确放弃草稿时才确认。关闭标签页使用平台允许的离开保护作为兜底,不能把它当主保存机制。
|
||||
|
||||
深链统一携带并消费:
|
||||
|
||||
```text
|
||||
camera=<stable-device-id>
|
||||
zone=<zone-id>
|
||||
return_to=<trusted-route-token>
|
||||
```
|
||||
|
||||
`return_to` 只能使用受信路由 token,不能接受任意外部 URL。保存时使用区域版本做乐观并发校验;发现他人已保存新版本时提示重新加载或另存草稿,不覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 4. 对初始评审事实的纠正
|
||||
|
||||
| 初始判断 | 核验结果 | 最终处理 |
|
||||
| --- | --- | --- |
|
||||
| 128 路列表缺分页需要新 IX | `IX-003` 已覆盖 | 只补原型分页 |
|
||||
| 单媒体分片 32 路无出处 | 《03》§4.2 已定义 `media_shard_max_streams: 32`,128 路建议 4 个 32 路分片 | 保留 32;原型标明当前启用数与可扩展上限 |
|
||||
| 侧栏/面包屑会清空草稿 | 当前 `switchView()` 不清空 `points` | 不作为现状缺陷;仍要求跨刷新/跨系统恢复 |
|
||||
| Sense→Bell 缺少 camera/zone | Sense 已发送两者,但 Bell 未消费 `zone`;Bell→Sense 缺 `zone` | 修复双方生成与消费 |
|
||||
| 非视频设备不展示健康页签 | 非视频设备仍有连接、上报、信号、电量与故障状态 | 使用通用“连接与健康”,能力驱动内容 |
|
||||
| 幂等操作不生成重复审计 | 不符合审计目标 | 幂等只约束副作用,每次请求保留审计结果 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 无障碍与安全收紧
|
||||
|
||||
- 正文、按钮文字和关键状态对比度不低于 4.5:1;大字号不低于 3:1;非文本控件、状态边界和焦点指示器不低于 3:1。
|
||||
- 当前导航显式设置 `aria-current="page"`,不能用空值属性表示当前页。
|
||||
- 响应式隐藏可见文字时,按钮仍保留 `aria-label` 等稳定可访问名。
|
||||
- Tab 必须完整实现 `tablist` / `tab` / `tabpanel`、关联属性、单一 Tab 停靠点与方向键;否则不声明 tab 角色。
|
||||
- 移动端主要操作目标不小于 44×44px,输入正文不小于 16px;几何绘制提供不依赖精确点击的表单等效路径。
|
||||
|
||||
---
|
||||
|
||||
## 6. 原型落地范围
|
||||
|
||||
Sense 原型重新生成并覆盖:
|
||||
|
||||
1. 一级“设备”入口、模态筛选、分页和视频/雷达示例。
|
||||
2. 能力驱动详情:视频显示画面与检测区域,雷达显示遥测,二者都有连接与健康。
|
||||
3. 添加设备先选模态,隐私 Area 对成像设备就地拒绝。
|
||||
4. 暂停推理、停用接入、踢当前会话的独立影响确认与期望/实际收敛反馈。
|
||||
5. 显式完成绘制、方向箭头/反转、顶点编辑、自动草稿与冲突状态。
|
||||
6. Sense/Bell 双向 `camera + zone + return_to` 深链。
|
||||
7. 对比度、可访问名、完整 Tab 模式、44px 移动触控目标。
|
||||
|
||||
Bell 原型只调整深链消费和返回上下文,不改变“规则业务归 Bell、空间几何归 Sense”的产品边界。
|
||||
|
||||
---
|
||||
|
||||
## 7. 后续兼容债务(不在本原型任务改契约)
|
||||
|
||||
事件 v0.1 的 schema 描述与契约 README 仍使用 `privacy_flag` 表示区域是否允许视频模态,而本次领域模型已收敛为 Area 的 `capture_policy`。两者当前语义可一一映射:`privacy_flag = true` 等价于 `capture_policy = non_imaging_only`,但不能长期保留两套真相源。
|
||||
|
||||
M2 开始 schema/API 实现前应单独建立契约任务,选择“保持 v0.1 线格式并在投影层兼容”或“按版本治理规则新增字段/版本”,同步《06》与 `docs/raw/contracts/` 后再写代码。T-004 只更新产品与架构决策,不修改已经声明为既有契约的 event v0.1。
|
||||
@@ -0,0 +1,264 @@
|
||||
# Sense 原型:功能与模块缺口分析
|
||||
|
||||
> 对标:`docs/02-requirements.md` §3.1/§3.5/§4、`docs/04-architecture.md` §3/§6/§7/§8/§10、`docs/07-user-stories.md` US-001/002/007、`docs/routes.md`、`docs/api.md` §2
|
||||
> 基准:`yovision-T-004/docs/design/sense/index.html`(codex,2026-08-04)
|
||||
> 与《09-Sense原型评审-IX草稿》的区别:09 谈**行为缺口**(某状态没画),本文谈**模块缺口**(整个页面或实体不存在)
|
||||
> 日期:2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 判定基准
|
||||
|
||||
Sense 的交付窗口是 M1–M2(架构 §10):
|
||||
|
||||
> M1 只动 Sense,5 路接入骨架与 MediaMTX。
|
||||
> M2 仍以 Sense 为主,完成 **16 路开通/停用、对账、多租户投影与隧道**。
|
||||
|
||||
所以判定标准是 **M2 出口**,不是 M1。原型现在覆盖的是"设备列表 + 详情 + 导入",大致相当于 M1 的一半。
|
||||
|
||||
### 现有信息架构
|
||||
|
||||
```
|
||||
运行总览 · 实时监控 · 摄像头 · 接入任务 · 系统状态
|
||||
└─ 详情:基本信息 / 画面与检测区域 / 流与健康 / 操作记录
|
||||
```
|
||||
|
||||
### 实体链断了两级
|
||||
|
||||
架构 §8 定义的核心实体是:
|
||||
|
||||
```
|
||||
Tenant → Site → Area/Device → StreamBinding/Zone
|
||||
```
|
||||
|
||||
原型里 **Tenant 不存在**,**Site 是顶栏一行静态文字**,**Area 退化成添加对话框里的一个下拉**。只有 Device 和 Zone 是真的。
|
||||
|
||||
---
|
||||
|
||||
## 1. P0 缺口 —— M2 出口必需,现在完全没有
|
||||
|
||||
### 1.1 站点管理(模块级缺失)
|
||||
|
||||
顶栏写死「青藤寄宿学校 / 主校区 · 16 / 16 路」,不可切换,没有站点列表。`routes.md` 有 `/sites`(站点列表、配额与状态)。
|
||||
|
||||
M2 的出口标准里明写"多租户投影",而多站点是它的最小可见形态。
|
||||
|
||||
| 需要什么 | 依据 |
|
||||
| --- | --- |
|
||||
| 站点列表:名称、配额已用/上限、在线率、未收敛数、边缘节点状态 | `routes.md` `/sites` |
|
||||
| 站点切换器(顶栏),切换后所有列表按站点过滤 | 架构 §3 SaaS-ready 边界 |
|
||||
| 站点配额的**只读**展示 + 来源标注「由 Bell 持有」 | 架构 §5 第 7 条 |
|
||||
| 跨站点的设备总览(实施工程师同时开通多个站点) | US-001 |
|
||||
|
||||
### 1.2 对账器运维视图(Sense 的心脏,只有一个数字)
|
||||
|
||||
总览有「待收敛项 **2**」,**点不进去**。架构 §7 用了整节讲对账语义,UI 只暴露了一个计数。
|
||||
|
||||
`routes.md` 有 `/operations`(设备/流/分片/对账运维,不与业务预警混在同一队列)。
|
||||
|
||||
| 需要什么 | 依据 |
|
||||
| --- | --- |
|
||||
| 未收敛项列表,每项显示**期望态 vs 实际态的具体差异** | 架构 §7「PostgreSQL 是期望态真相源」 |
|
||||
| 每项的重试次数、当前退避间隔、下次重试时间 | 架构 §7「幂等、指数退避、限制并发」 |
|
||||
| **孤儿资源列表 + 10% 安全闸触发告警** | 架构 §7「孤儿删除必须有 10% 安全闸和人工可观察指标」 |
|
||||
| 手动触发单项收敛(总览的"立即对账"是全局的,粒度太粗) | — |
|
||||
| 收敛持续失败的升级路径:多久算异常、通知谁 | §3.5 运维告警独立 |
|
||||
|
||||
> 这是原型最大的单点缺口。对账器是 Sense 区别于普通 NVR 的**全部理由**,现在在 UI 上等于不存在。
|
||||
|
||||
### 1.3 待激活设备的处置闭环
|
||||
|
||||
`02-requirements` §3.1 明确「支持待激活中间态」,添加对话框的按钮也确实写着「保存为待激活」。但:
|
||||
|
||||
- 设备表 5 行假数据里**没有一行是待激活**
|
||||
- 筛选下拉只有「全部状态 / 异常 / 重连中」,**没有待激活**
|
||||
- 待激活设备如何激活、如何补凭据、如何丢弃——零流程
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 待激活作为一级筛选项与独立计数 |
|
||||
| 待激活 → 激活的校验流程(重新探测 Profiles / 校时 / 配额复核) |
|
||||
| 批量激活与逐项结果 |
|
||||
| 长期待激活的过期策略(放着不管会变成配额占用的幽灵) |
|
||||
|
||||
### 1.4 凭据更新与认证失败恢复
|
||||
|
||||
详情页写「凭据:已安全保存,页面不可见」——处理正确,但**只有读没有写**。
|
||||
|
||||
M0 验收专门要求记录「认证失败」(`02-requirements` §2),而现场改密码是最高频的运维事件。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 单设备「更新凭据」动作(只写不读,不回显任何已存值) |
|
||||
| 认证失败作为独立的实际态(区别于「离线」——原因不同,处置不同) |
|
||||
| 批量更新凭据(一批摄像头同时改密是常态) |
|
||||
| 更新后自动重试接入,结果可见 |
|
||||
|
||||
### 1.5 权限与角色(完全没有)
|
||||
|
||||
`02-requirements` §3.5 定义最小 RBAC 五角色:平台管理员 / 租户管理员 / 站点管理员 / 值班员 / 只读。`routes.md` 有完整的路由守卫要求。
|
||||
|
||||
原型没有登录、没有当前用户、没有任何权限差异表达。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 顶栏当前用户与角色 |
|
||||
| 只读角色下:添加/停用/删除/更新凭据**不出现**(不是置灰) |
|
||||
| 越权深链的统一拒绝态(`routes.md`「深链打开无权限或已删除资源时给出统一、安全的反馈」) |
|
||||
| 站点管理员只能看到自己站点 |
|
||||
|
||||
> 权限不是"以后加的一层"。它决定页面上**有没有**这个按钮,是信息架构问题,必须在原型里枚举。
|
||||
|
||||
### 1.6 配额服务降级态
|
||||
|
||||
`api.md` §2 精确定义了这个失败语义:
|
||||
|
||||
> Sense → Bell 读取站点视频配额:失败时**拒绝新增/启用但不影响已有流**
|
||||
|
||||
原型显示「已用 16 / 128」「配额 16 / 128」,但错误态只有一个「边缘节点不可达」。配额服务不可达是**另一种**故障,表现完全不同:视频照常播、列表照常看、只有写操作被拒。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 配额不可读时的横幅:说明"当前无法新增或启用设备,已有视频不受影响" |
|
||||
| 添加/激活按钮在该状态下禁用并说明原因 |
|
||||
| 与「边缘节点不可达」区分开——后者是读故障,前者是写故障 |
|
||||
|
||||
---
|
||||
|
||||
## 2. P1 缺口 —— M2 内补齐
|
||||
|
||||
### 2.1 边缘节点与隧道
|
||||
|
||||
侧栏底部只有一行 `sense-edge-01 · 在线`。但边缘节点在架构里是独立实体,承担推流、本地环形缓冲、断网续传、WireGuard 隧道。
|
||||
|
||||
`02-requirements` §3.5:**断网时边缘缓存事件,恢复后补传。**
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 边缘节点列表:版本、在线时长、隧道状态、承载设备数 |
|
||||
| 本地缓存水位 + 补传进度(断网 2 小时后恢复,用户要看到"正在补传 380 条") |
|
||||
| 隧道断开时的明确表达:**控制面断了但视频还在推**(这是控制面/数据面分离的核心,UI 不体现等于白设计) |
|
||||
| 边缘节点升级/重启动作 |
|
||||
|
||||
### 2.2 设备能力档案
|
||||
|
||||
添加时「测试连接」返回「2 个 Profile,时间漂移 +0.4s」,但这个结果**没有落地成设备的持久属性**。
|
||||
|
||||
US-007 要求记录:型号、固件、认证方式、Profiles/StreamUri/校时、主子码流、掉线恢复——形成采购白名单。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 详情页「设备能力」区:厂商、型号、固件版本、认证方式 |
|
||||
| Profile 列表(分辨率/帧率/编码),标注当前使用哪个 |
|
||||
| 能力标记:是否支持校时、**是否支持 ONVIF 事件订阅**(决定它能否作为设备型触发源) |
|
||||
| 与采购白名单的关联:该型号是否在白名单内 |
|
||||
|
||||
### 2.3 主/子码流切换
|
||||
|
||||
详情写死「Profile:子码流 704 × 576 · 5 FPS」,没有切换动作。
|
||||
|
||||
架构 §6:`media_shard.max_streams` 由**码率**决定。码流选择是容量的直接变量,必须可配。切换还会踢掉当前发布者(mediamtx 的硬约束),需要影响范围提示。
|
||||
|
||||
### 2.4 分片详情与设备迁移
|
||||
|
||||
总览显示 `media-01 8/32`、`media-02 8/32`,只读。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 分片详情:承载设备清单、实际码率、重连历史 |
|
||||
| 把设备从一个分片迁到另一个(迁移会中断该路,需确认) |
|
||||
| 单分片故障时的影响范围展示(架构 §6「单分片故障不能扩散到其他分片」) |
|
||||
| **`media_shard.max_streams` 的 32 需与《04》§6 对齐**——文档写「初始建议 32,可按故障域降为 16」,原型的 32 有据,但需标注它是可配置项而非固定值 |
|
||||
|
||||
### 2.5 批量任务列表与逐项结果
|
||||
|
||||
「当前任务」是单个卡片,「查看逐项结果」是个死按钮。
|
||||
|
||||
IX-001 要求:下载模板、上传校验、**逐行错误**、重复序列号、待激活、确认写入。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 任务列表(历史任务可查、可重试、可导出错误清单) |
|
||||
| 逐项结果页:每行的校验结果、失败原因、单行重试 |
|
||||
| 上传前的本地预校验结果(原型文案已提到,但没有页面) |
|
||||
| 部分成功的语义:8 路里 5 成功 3 失败,成功的已生效 |
|
||||
|
||||
### 2.6 Sense 运维告警
|
||||
|
||||
总览有「今日设备告警 3」和「与业务预警分开」的说明——**这个说明写得很好**,但点不进去。
|
||||
|
||||
`02-requirements` §3.4:业务预警与运维告警使用**不同通道和值班配置**。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 运维告警列表(设备离线、时间漂移超阈、收敛失败、分片异常、隧道断开) |
|
||||
| 静默与通知配置(运维侧的,与 Bell 的业务静默是两套) |
|
||||
| 明确标注"这些不会推给家属/值班员" |
|
||||
|
||||
---
|
||||
|
||||
## 3. P2 缺口 —— M3 留出信息架构位置
|
||||
|
||||
不必现在实现,但导航和详情页要留位,否则 M3 时又是整页重做。
|
||||
|
||||
| # | 模块 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **推理绑定视图**:设备 ↔ inference worker 的绑定、worker 注册/心跳/容量、绑定失败态 | `api.md` §2「Worker → 控制面:注册、心跳、容量」;总览已有「推理绑定 16/16」但无下钻 |
|
||||
| 2 | **pre-roll 切片运维**:切片请求量、失败率、耗时 | 架构 §5 第 3 条「pre-roll 由 Bell 发起、Sense 切片」 |
|
||||
| 3 | **设备型触发源**:雷达/门磁/按钮/ONVIF 事件订阅的配置与状态 | 《08》§3 边界 2;与 IX-014 的 modality 是一体的 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 两个横切问题
|
||||
|
||||
### 4.1 时间显示口径没有定义
|
||||
|
||||
操作记录显示裸时间「22:52」。但 Sense 这个系统里同时存在**三个时钟**:
|
||||
|
||||
- 设备时间(ONVIF 报的,可能漂 +3.8s)
|
||||
- 服务器时间(期望态真相源)
|
||||
- 浏览器本地时间(用户看到的)
|
||||
|
||||
而「时间漂移」正是原型自己列为一级列的指标。三个时钟不区分,运维会误判。
|
||||
|
||||
**需要**:统一时间显示口径(建议全部显示服务器时间 + 时区标注,设备时间只在漂移列出现),相对时间与绝对时间并存("2 分钟前"悬停显示完整时间戳)。
|
||||
|
||||
### 4.2 租户维度不存在
|
||||
|
||||
架构 §3:首期一客户一套私有实例,但「数据库实体、RBAC、配置与 API 从第一版携带 `tenant_id` 并保持 SaaS-ready 边界」。
|
||||
|
||||
原型没有任何租户表达。首期不需要租户**切换器**,但需要:当前租户的显示、以及所有列表默认按租户过滤的事实在 UI 上可见。否则实现时容易写成全局查询,SaaS-ready 边界在第一版就破了。
|
||||
|
||||
---
|
||||
|
||||
## 5. 汇总:建议加入的导航
|
||||
|
||||
现有五项 → 建议八项(新增三项加粗):
|
||||
|
||||
```
|
||||
运行总览
|
||||
实时监控
|
||||
设备 ← 原「摄像头」,改名以容纳 modality(见 IX-014)
|
||||
接入任务
|
||||
**站点与区域** ← 新增:站点列表、配额、Area 管理(含隐私区域标记)
|
||||
**运维** ← 新增:对账队列、孤儿资源、分片、边缘节点与隧道、运维告警
|
||||
系统状态
|
||||
**审计** ← 新增:设备操作审计入口(主真相源在 Bell,Sense 需入口)
|
||||
```
|
||||
|
||||
顶栏需要补:站点切换器、当前用户与角色。
|
||||
|
||||
---
|
||||
|
||||
## 6. 优先级建议
|
||||
|
||||
| 顺序 | 做什么 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **对账器运维视图**(§1.2) | 单点最大缺口。这是 Sense 的存在理由,现在 UI 上等于不存在 |
|
||||
| 2 | **权限与角色**(§1.5) | 决定页面上有没有按钮,是 IA 问题不是加一层 |
|
||||
| 3 | **站点与区域**(§1.1) | 实体链断了两级,Area 还是隐私区域约束的挂载点 |
|
||||
| 4 | 待激活闭环、凭据更新、配额降级态(§1.3–1.6) | 都是需求文档已明确定义、原型未表达的行为 |
|
||||
| 5 | P1 六项 | M2 出口前补齐 |
|
||||
| 6 | P2 三项只留导航位 | 不实现,避免 M3 整页重做 |
|
||||
|
||||
> §1.5(权限)与《09》的 IX-014(modality)都是**改信息架构**的改动,建议合并到同一次原型重生成里,不要分两次打补丁。
|
||||
@@ -0,0 +1,139 @@
|
||||
# Sense 原型第二轮评审复核版
|
||||
|
||||
> 原始输入:Claude《Sense 原型第二轮评审》,2026-08-04
|
||||
> 复核基准:`docs/design/sense/index.html`,提交 `e18a1ea`
|
||||
> 复核视角:产品边界、全栈架构、M2 用户流程与 UI/UX
|
||||
> 状态:项目负责人已确认按本复核结论继续修改
|
||||
|
||||
---
|
||||
|
||||
## 1. 总结
|
||||
|
||||
Claude 指出的对账运维、设备能力来源、非视频适配器阶段、待激活、凭据恢复、写入降级和边缘隧道等缺口方向成立;但原稿不能直接作为执行清单,主要有三类问题:
|
||||
|
||||
1. **基准落后**:设备分页和推理收敛成功态已经实现,分片数据也不再是原稿引用的 `8 / 32`。
|
||||
2. **范围扩大**:T-004 当前关联 US-001、US-002、US-008 和部分 US-005,并未承诺一次画完完整 M2/M4 管理端。新增 P0 页面必须先同步 US/IX 与任务范围。
|
||||
3. **系统归属混淆**:`routes.md` 的 `/sites`、`/operations`、`/audit` 是统一管理 Web 的候选页面职责,不等于都应成为 Sense 一级导航。Bell 拥有租户/RBAC、审计、配额真相源和管理端;Sense 负责设备接入与运行态运维。
|
||||
|
||||
因此,本轮采纳“补核心流程、收敛导航、冻结归属”的方案,不采用“Sense 导航机械扩为 8 项”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 对原评审事实的纠正
|
||||
|
||||
| 原判断 | 当前事实 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 设备列表分页未实现 | 已有 5 条/页、上一页/下一页、页数和总数 | 标记完成;批量选择未来新增时再定义跨页语义 |
|
||||
| “推理绑定 16/16”仍是紫色满格 | 已改为“推理配置收敛 16/16”绿色成功态 | 标记完成 |
|
||||
| 分片仍显示 `8 / 32` | 当前为 `16 / 32`,并显示“当前 1 / 最多 4” | 只补“32 是可配置建议值、最终由压测确定” |
|
||||
| T-004 `write_paths` 只有三个文件 | 已扩展到需求、架构、US/IX、评审稿和两个原型 | 更新清单基准 |
|
||||
| modality 筛选包含 ONVIF/MQTT | modality 是 video/radar;ONVIF/RTSP/MQTT 属于 protocol | 文档严格区分模态、设备种类、协议和能力 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 最终产品信息架构
|
||||
|
||||
### 3.1 Sense Edge Console
|
||||
|
||||
Sense 保持最多五个一级入口,移动端底部导航不超过五项:
|
||||
|
||||
```text
|
||||
运行总览
|
||||
实时监控
|
||||
设备
|
||||
接入任务
|
||||
运维
|
||||
├─ 对账与孤儿资源
|
||||
├─ 媒体分片
|
||||
├─ 边缘节点与隧道
|
||||
├─ 运维告警
|
||||
└─ 系统状态
|
||||
```
|
||||
|
||||
原“系统状态”并入“运维”。总览的待收敛项、设备告警和分片状态均可下钻到运维的对应子页。Sense 顶栏显示当前租户、站点、用户和角色,并消费 Bell 下发的授权上下文,但不自建租户/RBAC 真相源。
|
||||
|
||||
### 3.2 Bell / 统一管理端
|
||||
|
||||
以下能力归 Bell/统一管理端,而不是在 Sense 重复建设一级页面:
|
||||
|
||||
- Tenant、Site、Area 管理和 RBAC。
|
||||
- Area 的 `capture_policy` 编辑与已有成像设备的显式处置流程。
|
||||
- 全局审计查询;Sense 设备操作记录只提供带设备筛选的深链。
|
||||
- `site.max_video_channels` 的配置与真相源。
|
||||
|
||||
Area 与策略由 Bell 持有,Sense 使用带版本的只读投影执行新增/启用准入;投影未知或过期时 fail closed,只阻止新的成像变更,不中断已有链路。
|
||||
|
||||
---
|
||||
|
||||
## 4. 采纳的真实缺口
|
||||
|
||||
### 4.1 对账与运维中心(P0)
|
||||
|
||||
- 未收敛项展示期望态/实际态具体差异、重试次数、退避间隔和下次重试时间。
|
||||
- 手动动作语义为“提高优先级并重新触发收敛”,不能绕过对账器直接修改实际资源。
|
||||
- 展示孤儿资源、占比、10% 安全闸及人工处置提示;不提供绕过安全闸的一键删除。
|
||||
- 展示媒体分片、边缘节点、控制隧道、数据面、补传队列和独立运维告警。
|
||||
- 明确“控制隧道断开但已有视频数据面仍正常”的组合态。
|
||||
|
||||
### 4.2 设备能力来源(P0)
|
||||
|
||||
`capabilities` 必须来自探测/适配器结果并保存来源、探测时间与版本,不能由前端名称猜测。详情展示“已探测能力”和“当前生效能力”;重新探测显示新增/消失差异,能力降级时列出受影响的触发、Profile 或空间配置。
|
||||
|
||||
US-007 的采购白名单当前仍是版本化测试文档,无产品 UI;运行设备可只读显示“白名单型号/未验证型号”,不把白名单管理升级成 M2 P0 页面。
|
||||
|
||||
### 4.3 非视频适配器阶段态(P0)
|
||||
|
||||
M1~M5 可登记 radar 等模态以验证信息架构和隐私准入,但实际态必须是 `adapter_not_ready`(用户文案“适配器未就绪 · M6”),不得显示在线/离线或模拟 MQTT 遥测连接,也不计入视频配额。
|
||||
|
||||
### 4.4 设备生命周期(P0)
|
||||
|
||||
- 待激活作为可筛选状态,支持重新探测、校时、配额/策略复核、激活和逐项失败原因。
|
||||
- 认证失败与离线分开;更新凭据只写不读、不回显旧值,更新后显示重试结果。
|
||||
- 批量凭据仅允许安全导入逐设备凭据,不提供把同一密码覆盖到多台设备的一键操作。
|
||||
- Profile 切换展示对当前观看、推理、区域校准与短暂中断的影响。
|
||||
|
||||
### 4.5 写入降级与权限(P0)
|
||||
|
||||
- 配额不可读:列表与已有视频可用,新增/启用被阻止。
|
||||
- Area 策略投影不可读/过期:只阻止新的成像变更,已有链路保持现状并告警。
|
||||
- 只读角色隐藏写操作;越权或资源不存在的深链使用统一安全拒绝态。
|
||||
- 原型角色切换器只用于枚举状态,不代表 Sense 拥有账号或权限数据。
|
||||
|
||||
### 4.6 措辞与可观察结果(P0)
|
||||
|
||||
“暂停推理”统一为:
|
||||
|
||||
> 解除推理侧对该路的订阅;有其他观看者时上游继续拉流,无其他 reader 时上游按需停止。
|
||||
|
||||
执行结果同时显示推理订阅、reader 数和上游拉流状态,避免实现成推理插件内部直接 `return` 而持续浪费带宽。
|
||||
|
||||
---
|
||||
|
||||
## 5. 降级或后置的建议
|
||||
|
||||
| 原建议 | 最终处理 |
|
||||
| --- | --- |
|
||||
| Sense 新增“站点与区域”一级导航 | 不采纳;Bell/统一管理端持有管理页,Sense 顶栏只做站点上下文切换 |
|
||||
| Sense 新增“审计”一级导航 | 不采纳;保留设备操作记录并深链 Bell 全局审计 |
|
||||
| 直接批量覆盖同一设备密码 | 不采纳;仅做逐设备写入或安全文件导入 |
|
||||
| M2 提供跨媒体分片迁移 | 后置 M4;本轮只展示分片详情与影响范围 |
|
||||
| M2 提供节点升级/重启 | 后置独立高风险运维任务;本轮只展示状态 |
|
||||
| 提前为推理绑定、pre-roll、设备型触发创建空页面 | 不采纳;能力模型与运维子导航已保留扩展,不创建无操作价值的占位页 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 审计接口的独立架构任务
|
||||
|
||||
Bell 是全局审计真相源,但 T-004 不修改 `docs/api.md`。后续任务需要冻结 Sense → Bell 审计投递或等价投影契约,至少包含:
|
||||
|
||||
- Sense 在执行高风险写操作前,把操作意图与审计 outbox 在本地持久化;不能“先执行后尝试记录”。
|
||||
- Bell 不可达时,只要本地 outbox 已持久化,业务操作可继续;恢复后幂等补投。
|
||||
- 明确租户/站点范围、事件 ID、操作者、目标、请求结果、重试与保留策略。
|
||||
|
||||
此项属于 API/一致性设计,不用原型中的 toast 代替契约。
|
||||
|
||||
---
|
||||
|
||||
## 7. 本轮实施边界
|
||||
|
||||
T-004 重新生成 Sense 原型并补 Bell 管理端入口,同时更新正式 US/IX 和本复核稿。`docs/api.md`、生产代码、事件 v0.1 与真实认证实现不在本任务修改;人工确认前 T-004 继续保持 `DOING`。
|
||||
@@ -0,0 +1,189 @@
|
||||
# Sense 原型待办清单(第二轮复核后执行版)
|
||||
|
||||
> 来源:《09-Sense原型评审-IX草稿》《10-Sense原型-功能模块缺口》《11-Sense原型-第二轮缺口》
|
||||
> 基准:Sense 提交 `e18a1ea`
|
||||
> 适用任务:T-004
|
||||
> 原则:按产品边界执行,不把 Sense 一级导航扩成 8 项
|
||||
|
||||
---
|
||||
|
||||
## 1. 状态说明
|
||||
|
||||
- `本轮`:T-004 第三轮原型必须完成。
|
||||
- `后置`:已确认方向,但不在本轮画完整动作。
|
||||
- `独立任务`:写路径或契约不同,不在 T-004 顺带修改。
|
||||
- `完成`:当前原型已有,不重复实现。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终导航与系统归属
|
||||
|
||||
### Sense(五个一级入口)
|
||||
|
||||
```text
|
||||
运行总览 · 实时监控 · 设备 · 接入任务 · 运维
|
||||
```
|
||||
|
||||
“运维”内部使用二级 Tab:对账、分片、边缘节点、运维告警、系统状态。移动端底部导航保持五项。
|
||||
|
||||
### Bell / 统一管理端
|
||||
|
||||
- Tenant / Site / Area 与 `capture_policy` 管理。
|
||||
- RBAC 与当前用户授权上下文。
|
||||
- 全局审计查询和设备筛选深链。
|
||||
- 站点视频配额真相源。
|
||||
|
||||
Sense 只消费上述上下文和带版本投影,不复制真相源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本轮执行项
|
||||
|
||||
### S-01 运维中心与对账队列 · P0 · 本轮
|
||||
|
||||
- 总览“待收敛项”可下钻。
|
||||
- 展示期望态/实际态差异、重试次数、退避间隔、下次重试时间。
|
||||
- “重新触发收敛”只提高任务优先级,不直接绕过对账器修改资源。
|
||||
- 展示孤儿资源与 10% 安全闸;安全闸触发时禁止删除并说明人工恢复路径。
|
||||
- 分片、边缘节点、运维告警和系统状态作为运维二级页。
|
||||
|
||||
验收:能打开某个未收敛项并查看差异;能看到安全闸和下一次重试时间。
|
||||
|
||||
### S-02 角色与安全拒绝态 · P0 · 本轮
|
||||
|
||||
- 顶栏显示当前用户和角色,提供“站点管理员/只读”原型切换。
|
||||
- 只读模式隐藏新增、激活、停用、更新凭据、保存区域等写操作。
|
||||
- 越权深链显示统一拒绝态,不泄露资源是否存在。
|
||||
- 角色切换只是原型演示,授权真相源仍在 Bell。
|
||||
|
||||
### S-03 租户/站点上下文与 Area 归属 · P0 · 本轮
|
||||
|
||||
- Sense 顶栏显示当前租户并支持站点切换;切换后示例列表与计数同步更新。
|
||||
- Bell 增加站点/Area 管理入口,展示配额来源与 `capture_policy`。
|
||||
- 把已有视频设备的 Area 改为 `non_imaging_only` 时,要求先迁移设备、取消修改或进入显式处置流程,不静默停用。
|
||||
|
||||
### S-04 全局审计深链 · P1 · 本轮
|
||||
|
||||
- Sense 不新增一级审计页。
|
||||
- 设备详情“操作记录”增加“在 Bell 查看全部审计”,携带 tenant/site/device 筛选上下文。
|
||||
- Bell 审计页消费这些参数并显示来源为 Sense。
|
||||
|
||||
### S-05 capabilities 探测与差异 · P0 · 本轮
|
||||
|
||||
- 详情显示厂商、型号、固件、认证方式、Profile、校时与 ONVIF 事件订阅能力。
|
||||
- 区分已探测能力与当前生效能力,记录上次探测时间。
|
||||
- “重新探测”显示新增/消失能力及受影响配置,不自动覆盖。
|
||||
- 只读显示采购白名单结果;白名单维护继续使用 M0 文档,不新增管理页。
|
||||
|
||||
### S-06 适配器未就绪 · P0 · 本轮
|
||||
|
||||
- radar 可登记并进入台账,但实际态为“适配器未就绪 · M6”。
|
||||
- 不显示在线/离线、MQTT 实时遥测或测试连接成功。
|
||||
- 不计入视频配额,仍参与 `non_imaging_only` 准入判断。
|
||||
|
||||
### S-07 待激活闭环 · P0 · 本轮
|
||||
|
||||
- 状态筛选包含待激活,列表至少有一条示例。
|
||||
- 详情提供重新探测、校时、配额/区域策略复核和激活。
|
||||
- 批量激活展示逐项成功/失败及可重试原因。
|
||||
|
||||
### S-08 认证失败与凭据更新 · P0 · 本轮
|
||||
|
||||
- 认证失败作为独立实际态。
|
||||
- 凭据更新只写不读,不回显旧值;更新后自动重试并显示结果。
|
||||
- 批量场景仅演示安全文件逐设备导入,不提供同一密码批量覆盖。
|
||||
|
||||
### S-09 写入降级态 · P0 · 本轮
|
||||
|
||||
- 状态演示新增“配额不可读”和“区域策略不可读”。
|
||||
- 两态下实时监控和设备读取保持可用;新增/启用等相关写操作禁用并就地解释。
|
||||
- 已有设备不被伪装为停用或已收敛。
|
||||
|
||||
### S-10 边缘节点与隧道 · P1 · 本轮
|
||||
|
||||
- 展示节点版本、在线时长、承载数、控制隧道、视频数据面和补传队列。
|
||||
- 至少演示“隧道断开 / 视频数据面正常”和“恢复后补传”组合态。
|
||||
- 节点升级/重启只显示为后置能力,不提供可执行按钮。
|
||||
|
||||
### S-11 Profile 切换 · P1 · 本轮
|
||||
|
||||
- 展示主/子 Profile 和当前用途。
|
||||
- 切换前说明短暂中断、reader、推理和区域待校准影响。
|
||||
- 原型不执行真实媒体操作。
|
||||
|
||||
### S-12 分片详情 · P1 · 本轮(迁移后置)
|
||||
|
||||
- 展示承载设备、实际码率、重连历史与单分片故障影响范围。
|
||||
- 标注 32 为可配置建议容量,最终由压测确定。
|
||||
- 跨分片迁移动作后置 M4,本轮不提供按钮。
|
||||
|
||||
### S-13 批量任务与逐项结果 · P0 · 本轮
|
||||
|
||||
- 任务列表、预校验结果、逐行失败原因、单行/失败项重试、错误清单导出。
|
||||
- 明确部分成功语义:成功项已经生效,失败项可重试,不做整体回滚。
|
||||
|
||||
### S-14 运维告警 · P1 · 本轮
|
||||
|
||||
- 展示离线、认证失败、时间漂移、收敛失败、分片异常和隧道断开。
|
||||
- 明确运维告警不会进入 Bell 业务预警队列或通知家属。
|
||||
- 运维静默/通知配置后置,原型只显示入口归属。
|
||||
|
||||
### S-16 时间显示口径 · P1 · 本轮
|
||||
|
||||
- 默认显示服务器时间与时区。
|
||||
- 相对时间同时提供完整时间戳;设备时间只在漂移语义中出现。
|
||||
- 审计和对账示例使用同一口径。
|
||||
|
||||
### S-17 当前租户 · P1 · 本轮
|
||||
|
||||
- 顶栏显示当前租户;首期私有部署不提供租户切换器。
|
||||
- 原型文案说明列表已经按认证上下文的 tenant/site 过滤。
|
||||
|
||||
### S-18 暂停推理措辞与结果 · P0 · 本轮
|
||||
|
||||
- 文案改为“解除推理侧订阅;无其他 reader 时上游按需停止”。
|
||||
- 结果展示推理订阅、reader 数和上游拉流状态。
|
||||
|
||||
### S-19 分片容量标注 · P2 · 本轮
|
||||
|
||||
- `32` 标注为“当前配置/建议容量”,不是产品硬上限。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已完成,不重复执行
|
||||
|
||||
| 编号 | 状态 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| S-15 设备分页 | 完成 | 5 条/页、上一页/下一页、页数和总数已实现 |
|
||||
| S-20 推理收敛视觉 | 完成 | 已改为绿色“推理配置收敛 16/16” |
|
||||
| 第一轮 IX-014~IX-018 | 完成 | 模态/能力、三种停用、隐私准入、几何方向、草稿和深链均已落地 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 后置与独立任务
|
||||
|
||||
| 编号 | 处理 |
|
||||
| --- | --- |
|
||||
| S-21 Sense → Bell 审计契约 | 独立任务修改 `docs/api.md`;采用本地持久化 transactional outbox + 幂等补投 |
|
||||
| S-22 推理绑定运维 | M3 在“运维”内新增二级能力,不预建空页面 |
|
||||
| S-23 pre-roll 运维 | M3 需求稳定后新增,不预建空页面 |
|
||||
| S-24 设备型触发配置 | M6 随非视频适配器实现,不预建空页面 |
|
||||
| 跨媒体分片迁移 | M4 容量/分片任务 |
|
||||
| 节点升级/重启 | 独立高风险运维任务,需要权限、回滚和维护窗口 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 总验收
|
||||
|
||||
- [x] Sense 一级导航保持 5 项,移动端不出现第 6 个底部入口。
|
||||
- [x] 运维中心可查看未收敛差异、退避、孤儿与安全闸。
|
||||
- [x] 只读角色下所有写操作消失,越权深链显示安全拒绝态。
|
||||
- [x] radar 显示“适配器未就绪 · M6”,不显示在线或模拟遥测成功。
|
||||
- [x] 能力重新探测显示差异,能力降级不自动覆盖依赖配置。
|
||||
- [x] 待激活、认证失败、凭据更新和逐项激活结果可演示。
|
||||
- [x] 配额/策略不可读时只阻止相关写操作,已有流与读取可用。
|
||||
- [x] “暂停推理”展示订阅、reader 和上游拉流结果。
|
||||
- [x] Bell 可管理 Site/Area 策略并显式处理已有成像设备。
|
||||
- [x] Sense 设备审计深链到 Bell,并保留 tenant/site/device 筛选。
|
||||
- [x] 1440×900、375×812、812×375 无页面级横向溢出;主要移动控件不小于 44px。
|
||||
- [x] 运行时无异常,`git diff --check` 与 `./init.ps1` 通过。
|
||||
@@ -0,0 +1,289 @@
|
||||
# 架构评审与修订建议
|
||||
|
||||
> 评审对象:《03-通用场景应用方案》《08-三系统职责划分》,以及工程侧 `04-architecture.md`
|
||||
> 状态:**全部为建议,均未生效。** 每条给出现状、问题、建议、影响面和我的把握程度,供逐条裁决。
|
||||
> 裁决后:采纳的条目改写对应文档正文,本文保留为决策依据;未采纳的条目保留并记明理由,避免以后重复讨论。
|
||||
> 日期:2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 总评
|
||||
|
||||
架构的骨架是对的,尤其三处:
|
||||
|
||||
1. **控制面/数据面分离解 NAT** —— 隧道只走 ONVIF 控制流量,视频边缘主动推、绝不过隧道,且明确"控制面丢失不停视频"。多数团队会把视频塞进 VPN 然后在带宽和单点上栽跟头
|
||||
2. **水平触发对账器,只收敛不回滚** —— DB 单一真相源、幂等、退避、独立信号量、10% 孤儿删除安全闸。"不做跨系统回滚"是分布式里最易做错的决定,这里做对了
|
||||
3. **Event / Alert 分离** —— 事件不可变,告警是带状态机的响应过程,投递三个事实分开存。多数系统合成一个 `notified` 布尔,然后永远回答不了"到底有没有人管"
|
||||
|
||||
下面 13 条是我认为需要修订或补空的地方。**A 组影响架构决策,B 组是数据层空缺。**
|
||||
|
||||
---
|
||||
|
||||
## A 组 · 影响架构决策
|
||||
|
||||
### A-1 Bell 的职责边界应按变化频率再切一刀
|
||||
|
||||
**把握程度:中**(这是判断,不是硬伤)
|
||||
|
||||
**现状**:Bell = 事件校验/存储 + 规则引擎 + 告警状态机 + 投递 + 反馈闭环 + 租户 RBAC + 审计 + 配额真相源 + 管理后台 + 前端 + 大屏 + 场景包。
|
||||
|
||||
**问题**:Sense 与 Brain 的边界很干净——按运行时和语言切,边界落在阻抗真正变化的地方。Bell 没有用同一把尺子。它内部有一条清楚的断层:
|
||||
|
||||
| | 事件/告警内核 | 管理面 |
|
||||
| --- | --- | --- |
|
||||
| 内容 | ingest、event、rule、alert、deliver、feedback | tenant、RBAC、audit、配额、web、packs |
|
||||
| 变化频率 | 低 | 高 |
|
||||
| 正确性要求 | 极高(状态机、幂等、重启续跑) | 常规 CRUD |
|
||||
| 出错后果 | 告警丢失 | 页面报错 |
|
||||
|
||||
捆在一起意味着改一个权限要碰告警内核的部署。
|
||||
|
||||
**建议**:不必现在拆成两个部署单元,但**在 `Bell/internal/` 内部先立起这条边界**:核心包不得依赖管理面包,管理面通过接口调用核心。M4 若确需拆分,成本接近零;不拆也没有损失。
|
||||
|
||||
**影响面**:《08》§1.1 Bell 条目、§2.3 目录结构;`04-architecture.md` §2。
|
||||
|
||||
---
|
||||
|
||||
### A-2 配额不该走跨系统 API,应改为同库只读视图
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:《08》§3 边界 7、`04-architecture.md` §5 第 7 条——Bell 拥有 `site.max_video_channels`,Sense 通过"版本化内部 API 或只读投影"读取,并定义了配额服务不可达时的降级语义;`api.md` §2 已把它列为待 M2 设计的接口。
|
||||
|
||||
**问题**:为了一个整数,引入了三个活动部件(接口、超时重试、降级态)。而架构第 5 条已经定了**一个 PostgreSQL 实例,schema 分离**——同实例意味着这次跨界根本不需要网络。
|
||||
|
||||
现在的设计同时付两份成本:既没有独立库的隔离好处,又背上了微服务的仪式感。
|
||||
|
||||
**建议**:改为只读视图,视图名带版本号以保住"版本化"这个要求:
|
||||
|
||||
```sql
|
||||
CREATE VIEW bell.site_quota_v1 AS
|
||||
SELECT id, tenant_id, max_video_channels FROM bell.sites;
|
||||
GRANT SELECT ON bell.site_quota_v1 TO sense_app;
|
||||
```
|
||||
|
||||
Bell 改表结构时只要视图签名不变,Sense 不受影响——这正是版本化想要的效果。
|
||||
|
||||
**连带收益**:省掉一个内部接口、一套超时重试,以及《12》S-09 里一半的降级态工作量(区域策略那半仍需保留,因为它可能来自 Sense 自己的写路径)。
|
||||
|
||||
**影响面**:《08》§3 边界 7;`04-architecture.md` §4 步骤 1、§5 第 7 条、§7;`api.md` §2 删去 `Sense → Bell 读取站点视频配额` 一行;《12》S-09 缩小范围。
|
||||
|
||||
> ⚠️ 若未来确定 Sense 与 Bell 分库部署,本条自动失效,回退到 API 方案。裁决时请一并确认"一个实例"这个前提的有效期。
|
||||
|
||||
---
|
||||
|
||||
### A-3 「Brain 无状态」的表述需要收紧
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:`04-architecture.md` §2 称 Brain「Python/CUDA,业务无状态」;《08》§1 称「无状态」。
|
||||
|
||||
**问题**:判定内核是 `NORMAL → SUSPECT → CONFIRMED → RECOVERING`,**每个 track 一份、带时间窗**。这就是状态。文档说的"业务无状态"实际含义是"无 DB schema",但两者不等价,差别会在 M4 分片时显现:
|
||||
|
||||
- 进程在某人处于 SUSPECT 时重启,那次判定怎么办?
|
||||
- 分片再平衡时,track 状态机迁不迁移?
|
||||
- 同一个人从 media-01 机位走到 media-02,两个 worker 各持一份状态,如何不重复报警?
|
||||
|
||||
**建议**:把表述改为——
|
||||
|
||||
> Brain **无持久化业务状态**;判定状态机是**进程内易失状态**,重启即丢失。已接受的代价是:重启瞬间正在进行的单次判定丢失,不影响已产出事件与后续判定。跨 worker 的同一目标关联由 `anon_id`(ReID)在 Bell 侧聚合承担,不依赖 Brain 之间共享状态。
|
||||
|
||||
若这个代价不可接受,需在 M4 分片前给出方案,不能靠"无状态"这个词绕过去。
|
||||
|
||||
**影响面**:《08》§1 总表与 §1.1 Brain 条目;`04-architecture.md` §2 表格。
|
||||
|
||||
---
|
||||
|
||||
### A-4 M1 必须引入一个假 reader,否则对账器在真空里开发
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:`04-architecture.md` §10——M1、M2 只动 Sense,M3 才有 Brain 和 Bell。
|
||||
|
||||
**问题**:对账器的全部意义是把 mediamtx 收敛到 DB 期望态。但 `sourceOnDemand: yes` 的语义是**有 reader 才拉流**,而 Brain 不在就没有 reader。于是 M1–M2 的对账器是在对着一个什么都不做的系统收敛。
|
||||
|
||||
四条硬约束里最要命的两条——**改 path 配置会踢掉当前发布者**、**推理 source 挂上去就是一个 reader**——都要到 M3 才第一次真正暴露。M2 的出口标准会给出虚假的安全感。
|
||||
|
||||
**建议**:M1 引入一个最小假 reader(`ffmpeg -i rtsp://… -f null -`,或一个只拉不解码的 gortsplib 客户端),作为 Sense 测试装置的一部分。目的不是功能,是**让 sourceOnDemand 的语义在 M1 就受力**:验证有 reader 时才拉流、卸载 reader 后 30s 自动停、改 path 配置会踢掉发布者。
|
||||
|
||||
**影响面**:`04-architecture.md` §10;《03》§6.1 M1 骨架增加 `testutil/fakereader`;M1/M2 出口标准增加一条。
|
||||
|
||||
---
|
||||
|
||||
### A-5 先做纵向切片,不要等到 M3 才有端到端
|
||||
|
||||
**把握程度:中高**(这是排期建议,不是架构缺陷)
|
||||
|
||||
**现状**:`Sense/`、`Brain/`、`Bell/` 是三个空目录。围绕它们已有 13 份 raw 文档、12 份工程文档、一份冻结契约(schema + 负面测试 + 三个夹具)、两个原型、三轮原型评审、一份 24 条待办清单。按现有顺序,第一个端到端价值出现在 M3。
|
||||
|
||||
**问题**:设计密度显著领先于证据。所有架构判断——分片、配额、对账、多租户——在 M3 前都拿不到反馈。
|
||||
|
||||
缓解因素是真实的:silver_pose 已验证、文档大量源自一个交付过的项目。所以这不是空想,但风险形状很具体。
|
||||
|
||||
**建议**:在 M1 内插入一条**纵向切片**,复用 silver_pose 已跑通的链路:
|
||||
|
||||
```
|
||||
1 路摄像头 → mediamtx → silver_pose(出 v0.1 事件)→ 最小 Bell(一张表 + 一条通知)
|
||||
```
|
||||
|
||||
对账器可以只有 50 行,Bell 可以只有一张表。目的不是交付,是**让契约、sourceOnDemand 语义、事件流转在 M1 就受一次真实的力**。
|
||||
|
||||
现有架构完全支持这么做——三系统边界清楚、契约已冻结。缺的不是设计,是把"先纵切一刀"排进里程碑。
|
||||
|
||||
**影响面**:`04-architecture.md` §10;`06-tasks.md` 路线图。
|
||||
|
||||
---
|
||||
|
||||
### A-6 L2/L3 的分层边界是名义上的,真边界只有一条
|
||||
|
||||
**把握程度:中**
|
||||
|
||||
**现状**:分层 L0–L5,L2 是流水线(Savant),L3 是能力(检测/姿态/跟踪/ReID)。
|
||||
|
||||
**问题**:Savant 的模型就挂在 pipeline 里,这条线在实现时会消失。把它当作可独立替换的边界会产生错误预期。
|
||||
|
||||
**建议**:文档中明确——L2/L3 是**认知分层**,便于讨论职责;**唯一可独立替换的技术边界是 `Detector` / `PoseEstimator` 接口**。那条接口设计得早、目的明确(AGPL 逃生),是真的边界,应单独强调而不是淹没在六层里。
|
||||
|
||||
**影响面**:《03》§1.1–1.2 分层说明;`04-architecture.md` §1。
|
||||
|
||||
---
|
||||
|
||||
## B 组 · 数据层空缺
|
||||
|
||||
### B-1 设备遥测的历史数据没有归属 · 建议优先处理
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:原型已展示「时间漂移 +3.8s」「重连历史」「最后遥测」「分片实际码率」。这些是**时序业务数据**,当前文档中无任何归属。
|
||||
|
||||
**问题**:
|
||||
|
||||
- 全量塞 Postgres:128 路 × 每分钟一条 ≈ 一年 6700 万行。会成为库里最大的表,却是价值密度最低的数据
|
||||
- 塞 Prometheus:那是运维指标,保留期短,且不适合按设备做业务查询("这台摄像头上个月的漂移趋势")
|
||||
|
||||
**建议**:
|
||||
|
||||
| 数据 | 存哪 |
|
||||
| --- | --- |
|
||||
| 当前值(最后一次漂移、当前分片、实际态) | `sense` schema,随设备行 |
|
||||
| 最近 N 条(默认 100)事件式记录:重连、校时、状态跃迁 | `sense` schema,独立表 + 定期裁剪 |
|
||||
| 连续趋势(码率、漂移曲线) | Prometheus |
|
||||
| 若确需长期业务查询 | **TimescaleDB 扩展**——仍是同一个 PG 实例,不违反"一个实例"的决定 |
|
||||
|
||||
**影响面**:《08》§1.1 Sense 条目;`04-architecture.md` §8;`03-tech-stack.md` 增加 TimescaleDB 作为条件性选项。
|
||||
|
||||
### B-2 Brain 的本地重试队列形态未定
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:`04-architecture.md` §7「Brain 投递失败落本地队列重试,不阻塞实时推理主链路」——只有语义,没有形态。
|
||||
|
||||
**问题**:Brain 号称无 schema、无持久化状态,**这个队列是它唯一的持久化**,而且直接决定 Brain 崩溃时丢不丢事件。
|
||||
|
||||
**建议**:明确选型(文件追加 / SQLite / BadgerDB 任一皆可,但必须选定),并定义:队列上限、超限后的丢弃策略(**建议丢最旧,且丢弃必须产生运维告警**)、重启后的恢复顺序、重复投递由 Bell 侧 `source_event_id` 去重。
|
||||
|
||||
**影响面**:《08》§2.2 Brain 目录(`emit/publisher.py` 旁增加队列实现);`04-architecture.md` §7;`api.md` §2。
|
||||
|
||||
### B-3 边缘节点的断网缓存形态未定,且需与环形缓冲区分
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:需求 §3.5「断网时边缘缓存事件,恢复后补传」;《03》另有"边缘环形缓冲"用于 pre-roll。
|
||||
|
||||
**问题**:这是**两种不同的东西**,文档中容易混为一谈:
|
||||
|
||||
| | 环形缓冲 | 事件缓存 |
|
||||
| --- | --- | --- |
|
||||
| 内容 | 视频(最近 N 秒) | 结构化事件 + 元数据 |
|
||||
| 覆盖策略 | 循环覆盖,丢失可接受 | **不可静默丢失** |
|
||||
| 用途 | pre-roll 证据回捞 | 断网续传 |
|
||||
|
||||
**建议**:文档中显式区分两者,并为事件缓存定义容量上限、超限策略与补传进度的可观测指标(对应《12》S-10)。
|
||||
|
||||
**影响面**:《03》§2.6 证据回捞;《08》§1.1 Sense 条目。
|
||||
|
||||
### B-4 M1 SQLite → Postgres 的切换时机未定
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:《03》§6.1 与《08》§2.1 都写「M1 先 SQLite,schema 与生产 Postgres 保持一致」,但没写何时切、是否一次性、有无迁移脚本。
|
||||
|
||||
**建议**:钉在 **M2 入口**,且切换前 schema 必须已用 Postgres 语法编写(SQLite 只作为运行时,不作为 schema 方言的来源)。迁移脚本从第一天就写 Postgres 版本,SQLite 通过兼容子集运行。
|
||||
|
||||
**影响面**:`04-architecture.md` §10;《08》§5 里程碑表。
|
||||
|
||||
### B-5 用数据库角色强制 schema 边界
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:架构第 7 条写「不跨 schema 直接写」。
|
||||
|
||||
**问题**:只靠约定,迟早会被一个赶工的 JOIN 破掉,而且破掉时没有任何信号。
|
||||
|
||||
**建议**:在 DB 层强制:
|
||||
|
||||
```sql
|
||||
CREATE ROLE sense_app; GRANT USAGE ON SCHEMA sense TO sense_app;
|
||||
CREATE ROLE bell_app; GRANT USAGE ON SCHEMA bell TO bell_app;
|
||||
-- sense_app 对 bell schema 无任何权限,A-2 的 site_quota_v1 视图除外
|
||||
```
|
||||
|
||||
这样"不跨 schema 写"从纪律问题变成权限问题——写错了连不上,而不是上线后才发现。
|
||||
|
||||
**影响面**:`04-architecture.md` §5 第 5、7 条;部署清单。
|
||||
|
||||
### B-6 建库、建角色、建 extension 的归属无人认领
|
||||
|
||||
**把握程度:高**
|
||||
|
||||
**现状**:`sense` schema 的迁移在 `Sense/`,`bell` 的在 `Bell/`。但**建数据库本身、建角色、安装 pgvector / TimescaleDB extension** 不属于任何一方。
|
||||
|
||||
**建议**:归入独立部署清单(与 MediaMTX、MinIO、Prometheus 同级),在 M2 之前指定归属。否则会变成"谁先跑谁建",各环境不一致。
|
||||
|
||||
**影响面**:《08》§1.2 基础设施行;部署清单。
|
||||
|
||||
### B-7 人脸相关存储的位置需在 M5 前复核
|
||||
|
||||
**把握程度:中**
|
||||
|
||||
**现状**:《02》定人脸底库「独立 schema、独立加密、独立审计」,`face_vectors.embedding` 用 `VECTOR`(pgvector)。
|
||||
|
||||
**问题**:「独立 schema」与「独立加密」在同一个 PG 实例内能做到什么程度,需要具体方案(列级加密?TDE?还是独立实例?)。现在的表述在合规评审时会被追问。
|
||||
|
||||
**建议**:M5 前明确——若"独立加密"要求达到密钥与业务库分离的程度,则人脸库应是**独立 PG 实例**,此时它是"一个实例"决定的合法例外(因为它不参与对账,不破坏单一真相源前提)。
|
||||
|
||||
**影响面**:《02》§11 SQL 模型;`04-architecture.md` §5 第 5 条增加例外说明。
|
||||
|
||||
---
|
||||
|
||||
## 汇总:需要修订的文档章节
|
||||
|
||||
| 建议 | 《03》 | 《08》 | `04-architecture.md` | 其他 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A-1 Bell 内部边界 | — | §1.1、§2.3 | §2 | — |
|
||||
| A-2 配额改视图 | — | §3 边界 7 | §4、§5-7、§7 | `api.md` §2 删一行;《12》S-09 缩范围 |
|
||||
| A-3 Brain 状态表述 | — | §1、§1.1 | §2 | — |
|
||||
| A-4 M1 假 reader | §6.1 | — | §10 | M1/M2 出口标准 |
|
||||
| A-5 纵向切片 | — | — | §10 | `06-tasks.md` |
|
||||
| A-6 L2/L3 边界 | §1.1–1.2 | — | §1 | — |
|
||||
| B-1 遥测归属 | — | §1.1 | §8 | `03-tech-stack.md` |
|
||||
| B-2 Brain 队列 | — | §2.2 | §7 | `api.md` §2 |
|
||||
| B-3 两种缓存 | §2.6 | §1.1 | — | — |
|
||||
| B-4 SQLite 切换 | §6.1 | §5 | §10 | — |
|
||||
| B-5 DB 角色 | — | — | §5 | 部署清单 |
|
||||
| B-6 建库归属 | — | §1.2 | — | 部署清单 |
|
||||
| B-7 人脸存储 | — | — | §5 | 《02》§11 |
|
||||
|
||||
---
|
||||
|
||||
## 建议裁决顺序
|
||||
|
||||
| 顺序 | 条目 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **A-2**(配额视图) | 影响 `api.md` 待设计接口清单与《12》S-09 的范围,越早定省的工作越多 |
|
||||
| 2 | **A-4**(M1 假 reader) | 影响 M1 任务拆分,且成本极低 |
|
||||
| 3 | **B-1、B-2、B-3、B-4**(数据层四空缺) | 都是补空不是改决策,无争议 |
|
||||
| 4 | **B-5、B-6**(DB 角色与建库归属) | 部署侧,M2 前必须有 |
|
||||
| 5 | **A-3**(Brain 状态表述) | 改表述,但会牵出 M4 分片的真问题,宜早不宜迟 |
|
||||
| 6 | **A-5**(纵向切片) | 排期决策,需与商务节奏一起看 |
|
||||
| 7 | **A-1、A-6、B-7** | 判断性/远期,可延后 |
|
||||
@@ -0,0 +1,57 @@
|
||||
# Bell 原型评审(复核定稿)
|
||||
|
||||
> 评审对象:`docs/design/bell/index.html`
|
||||
> 输入:Claude 首轮评审草稿;YoVision 需求、架构、US、IX 与路由文档
|
||||
> 复核日期:2026-08-04
|
||||
|
||||
## 1. 结论
|
||||
|
||||
Claude 指出的投递状态、Event↔Alert 关系、交接班、分页、规则回滚和验收报表缺口成立,应在 T-004 内修复。Area 归属、并发 ack、失败态和重启可观测性的部分结论需要校正;另外原评审遗漏了移动端管理入口与审计深链注入风险。
|
||||
|
||||
## 2. 本轮采纳
|
||||
|
||||
| 优先级 | 项目 | 最终要求 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 投递事实 | `sent`、`delivered`、`seen`、`ack` 分开;不支持回执、状态未知、可重试失败和最终失败不得伪装为成功。ack 属于 Alert 处置事实,不是通道投递状态。 |
|
||||
| P0 | Event↔Alert | Alert 详情展示关联 Event 与聚合原因;Event 详情展示触发的 Alert 与最终状态;两边可导航。抑制可能不创建 Alert,不能把“已处置抑制”误写成聚合原因。 |
|
||||
| P1 | 并发 ack | 首个服务端成功者成为处置人;后到者看到当前处置人与确认时间,不能覆盖,也不能显示双成功。 |
|
||||
| P1 | 交接班 | 展示未 ack、处置中和升级中的 Alert;明确交出人、接手人、备注和确认审计;交接期间升级链不中断。 |
|
||||
| P1 | 局部失败 | 保留弱网证据降级;新增会话过期、ack 竞争结果、规则版本冲突,以及投递的可重试/最终失败。失败应靠近对应操作,不扩展成无意义的全页状态集合。 |
|
||||
| P1 | 分页 | 事件中心和审计日志均提供总数、页码、上一页与下一页,不一次加载全部记录。 |
|
||||
| P2 | 规则回滚 | 查看版本差异与影响范围;回滚通过创建并发布新版本完成,历史版本不可变。 |
|
||||
| P2 | 验收报表 | 按规则版本和冻结样本窗口报告召回率、每路每天误报数及样本量;明确不提供跨场景统一“准确率”。 |
|
||||
|
||||
## 3. 校正与不采纳
|
||||
|
||||
1. Area 已在 `raw/12`、US-010、IX-016 和架构文档中冻结为 Bell 管理,Sense 只消费版本化投影;“重写 S-03”属于过期建议,本轮不重复修改。
|
||||
2. 《13》A-2 的同库只读视图是独立架构裁决,不因原型评审直接采用。本轮继续保持 Bell 真相源与受控 API/投影边界,不修改 schema、API 或跨库访问方式。
|
||||
3. 原型已有并发 ack 文案,但没有可演示的竞争结果,因此补交互而不是从零新增概念。
|
||||
4. `weak` 已覆盖结构化事件成功、视频证据失败的部分成功态;“一个失败态都没有”不准确。本轮补操作级失败和会话过期,不机械增加三个全局错误页。
|
||||
5. 进程重启续跑是后端恢复、指标和测试要求。正常值班时间线不展示内部服务重启;只有确实影响用户的异常恢复才进入业务时间线,本轮不添加常态重启节点。
|
||||
|
||||
## 4. 原评审遗漏
|
||||
|
||||
- 移动端底部主导航继续限制为 5 项,但通过顶部“管理”入口访问站点与 Area、审计日志,避免管理能力在窄屏不可发现。
|
||||
- 桌面侧栏分成“值班与业务”和“管理”两组,避免权限与运营入口混在同一层级。
|
||||
- Sense→Bell 审计深链的 `device` 参数不得通过 `innerHTML`/`insertAdjacentHTML` 拼接;必须按文本节点写入并对返回链接编码,防止原型把不可信查询参数变成 DOM 注入。
|
||||
|
||||
## 5. 实施顺序
|
||||
|
||||
1. 修复审计深链注入。
|
||||
2. 补投递状态模型和 Event↔Alert 双向关系。
|
||||
3. 补并发 ack、交接班与操作级失败。
|
||||
4. 补事件/审计分页和移动端管理入口。
|
||||
5. 补规则回滚与按规则版本的验收报表。
|
||||
6. 用桌面、竖屏手机和横屏手机验证可访问性、无横向溢出及运行时安全;人工确认前 T-004 继续保持 `DOING`。
|
||||
|
||||
## 6. 产品裁决补充:联系人、排班与升级策略
|
||||
|
||||
采纳“联系人管理和排班一次设计到位”的方向,但将“同源”修正为“共享主数据、分对象建模”:
|
||||
|
||||
- 联系人/成员保存身份、角色、值班组和已验证通知通道,不承载班次与轮换字段。
|
||||
- 值班排班引用联系人或值班组,保存站点时区、班次、周轮换、生效日期、临时替班、版本和冲突校验,不复制手机号。
|
||||
- 升级策略的每一步使用 `person / team / on_call_schedule` 类型化目标,不直接写号码;界面提供当前解析人与通道预览。
|
||||
- 每次投递创建时固化实际收件人、通道与排班版本快照,后续联系人或排班变更不改写历史事实。
|
||||
- 交接班只转移进行中 Alert 的处置责任;未来班次变化必须走临时替班并发布新排班版本。
|
||||
|
||||
信息架构继续保留一级“升级链”,内部以“升级策略 / 值班与排班 / 联系人与通道”三个二级模块渐进披露。M3 原型覆盖周轮换、时区、生效日期、替班、空档/重叠冲突、值班人预览、版本发布和审计;自动排班优化、外部日历同步、工时合规与自助换班后置,不在 T-004 扩展。
|
||||
@@ -0,0 +1,182 @@
|
||||
# Bell 原型:功能与模块缺口分析(第二轮)
|
||||
|
||||
> 基准:`yovision-T-004/docs/design/bell/index.html`(codex,2026-08-04 11:43,842 行 / 90 KB)
|
||||
> 对标:`02-requirements` §3.3/§3.4/§3.6/§8、`04-architecture` §7/§8、IX-005~013 / IX-019~022、US-003~006 / US-009~012、`routes.md`
|
||||
> 前置:《14-Bell原型评审》
|
||||
> 与《14》的区别:14 谈**行为缺口**,本文谈**模块缺口**——整块功能不存在
|
||||
> 日期:2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论
|
||||
|
||||
《14》提的问题基本全部落地,且清单侧同步新增了 IX-019~022、US-009~012。实测:
|
||||
|
||||
| 《14》findings | 本轮 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| §2.1 投递四态 | ✅ | 已发出 / 已送达 / 已看到 / 无回执 / 不支持 / 可重试 / 最终失败 均出现 |
|
||||
| §2.2 Event↔Alert 双向 | ⚠️ 部分 | 「关联事件」「未触发」有;**「聚合原因」0 次**,IX-008 明确要求 |
|
||||
| §2.3 Area 归属 | ✅ | IX-020 定为 Bell 管理,Sense 只消费 |
|
||||
| §3.1 交接班 | ✅ | `handoverDialog` + IX-021 |
|
||||
| §3.2 并发 ack | ✅ | 「并发」5 次 + IX-006 细化 |
|
||||
| §3.3 重启续跑可观测 | ❌ | **「重启」「续跑」各 0 次,且 IX 清单里也没有这一条** |
|
||||
| §3.4 状态覆盖 | ⚠️ 部分 | 新增 `expired`;仍无 `conflict` 页面级态 |
|
||||
| §3.5 分页 | ⚠️ | 「分页」仅 1 次 |
|
||||
| §4.1 回滚 | ✅ | `rollbackDialog` + 版本冲突 + IX-011 |
|
||||
| §4.2 报表口径 | ✅ | 召回率 / 每路每天 / 样本量 / 导出 + IX-022 |
|
||||
|
||||
**下面是从模块轴看仍然整块缺失的部分。**
|
||||
|
||||
---
|
||||
|
||||
## 1. P0 模块缺口
|
||||
|
||||
### 1.1 联系人与通道管理(升级链能看不能编)
|
||||
|
||||
**实测**:`联系人` **0** 次;`通道` 8 次;「编辑升级链」是死按钮。
|
||||
|
||||
**现状**:升级链页能展示三级链路(值班室 Web+声光 → 值班员短信 → 校级负责人语音),但**联系人不是一个实体**——没有人员列表、没有电话/账号、没有角色绑定、没有按时段轮换。
|
||||
|
||||
**依据**:
|
||||
|
||||
- `02-requirements` §3.4:「升级链、超时、**联系人和时段**可按租户/站点配置」
|
||||
- IX-012:「**联系人顺序**、超时、双通道」
|
||||
- US-005 的一半是「配置……联系人升级链」
|
||||
|
||||
**需要什么**:
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 联系人实体 | 姓名、角色、可用通道(push / 短信 / 语音)、所属站点 |
|
||||
| 升级链编辑器 | 每级:等待时长、联系人或角色、通道组合;至少两条独立路径的校验 |
|
||||
| **按时段配置** | 需求明写「时段可配置」——夜间链路与白天链路不同,是校园场景的核心 |
|
||||
| 变更影响提示 | 改升级链会影响正在升级中的 Alert 吗(建议:不影响,已在途的沿用旧版) |
|
||||
|
||||
> 这是 Bell 最核心的可配置项之一,现在只有展示没有管理。
|
||||
|
||||
### 1.2 投递供应商与故障切换
|
||||
|
||||
**实测**:`供应商` / `provider` / `故障切换` 各 **0** 次。
|
||||
|
||||
**依据**:
|
||||
|
||||
- `02-requirements` §3.6:「投递层必须使用**供应商无关的 provider 接口**;试点至少有本地声光/Web 与一条短信或语音,**生产前补齐两条独立路径及故障切换**」
|
||||
- `04-architecture` §5 第 9 条:「投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换」
|
||||
- `02-requirements` §3.4:「至少两条独立投递路径,**其中一条可绕过互联网**」
|
||||
|
||||
**需要什么**:
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| provider 列表 | 每条通道当前用哪家、健康状态、余额/配额(短信有量) |
|
||||
| 主备与切换策略 | 切换条件、切换历史、当前生效的是主还是备 |
|
||||
| **绕过互联网的那条路径** | 本地声光 / 局域网广播,其可用性必须单独可见——断网时它是唯一还能工作的 |
|
||||
| 连通性自检 | 定期发测试消息并记录结果,避免"用的时候才发现短信欠费" |
|
||||
|
||||
> 「至少两条独立路径」是写进需求的硬约束,但现在**无处配置、无处验证**。
|
||||
|
||||
### 1.3 重启续跑的可观测(双缺:原型 + 清单)
|
||||
|
||||
**实测**:`重启` / `续跑` 各 **0** 次。而且遍查 IX-001~022,**没有任何一条覆盖它**。
|
||||
|
||||
**依据**:
|
||||
|
||||
- `02-requirements` §3.4:「预警必须有 ack;未 ack 自动升级,**进程重启后能续跑**」
|
||||
- `04-architecture` §7:「Alert 先落库再投递,**进程重启恢复未完成升级链**」
|
||||
|
||||
**问题**:这是一条很强的可靠性承诺,但没有任何可验证的表达。运维无法确认它真的生效,验收也无从下手——而这类承诺不验证就等于没有。
|
||||
|
||||
**需要什么**:
|
||||
|
||||
| 项 |
|
||||
| --- |
|
||||
| 升级时间线中标注服务重启事件与恢复结果:「服务重启于 23:15:02,本 Alert 升级链已恢复,下一次升级 23:16:10」 |
|
||||
| 系统状态/运维处给出「重启后待恢复升级链 N 条 / 已恢复 M 条」指标 |
|
||||
| 恢复失败的 Alert 单独可见(落库了但恢复不了,必须暴露而不是静默) |
|
||||
| **建议同时补一条 IX 条目**——清单缺这条比原型缺更严重 |
|
||||
|
||||
---
|
||||
|
||||
## 2. P1 模块缺口
|
||||
|
||||
### 2.1 事件证据的保留策略与存储
|
||||
|
||||
**实测**:`存储` / `留存` 各 **0** 次;`保留` 5 次(多为其他语境)。
|
||||
|
||||
**依据**:`02-requirements` §8——
|
||||
|
||||
> 事件片段默认存客户侧 MinIO/S3 兼容对象存储……技术默认 30 天并在目的完成后删除;**客户/法务确认最终期限**。元数据、审计、人脸与训练样本使用独立策略。
|
||||
|
||||
**需要什么**:保留期配置(按类别:事件片段 / 抓拍 / 元数据 / 审计 / 训练样本,各自独立);**法务确认状态**(技术默认值不能覆盖法务结论,这个状态必须可见);存储用量与增长趋势;到期删除的执行记录(合规举证要用)。
|
||||
|
||||
### 2.2 场景包管理
|
||||
|
||||
**实测**:`场景包` 2 次(疑似文案)。
|
||||
|
||||
**依据**:M5 出口标准——「场景包为**纯配置交付**,无需改核心代码」,这是整个架构的验收点;目录 `Bell/packs/`。
|
||||
|
||||
**需要什么**:包列表与版本;导入/导出;应用到站点时的**差异对比**(这个包会新增/修改哪些规则);已应用包的升级路径。没有这个模块,M5 的验收标准无法演示。
|
||||
|
||||
### 2.3 对外集成:Webhook / OpenAPI
|
||||
|
||||
**实测**:`Webhook` / `OpenAPI` 各 **0** 次。
|
||||
|
||||
**依据**:
|
||||
|
||||
- `02-requirements` §3.6:「对外使用**版本化 OpenAPI/Webhook**;M3 客户端为值班室 Web + 响应式移动 H5,**可嵌入客户系统**」
|
||||
- `04-architecture` §3:「客户平台通过版本化 OpenAPI/Webhook 集成,**不反向接管核心状态机**」
|
||||
|
||||
**需要什么**:Webhook 端点配置、签名密钥(只写不读)、订阅的事件类型、投递状态与重试队列、失败告警。这是"可嵌入客户系统"这句话的落地形态,四线城市客户往往已有一套平台。
|
||||
|
||||
### 2.4 用户与角色管理
|
||||
|
||||
**实测**:`用户` 1 次、`账号` 2 次、`角色` 2 次;有 `manageDialog`,但看不出 CRUD。
|
||||
|
||||
**依据**:IX-020——「Tenant/Site/Area/**RBAC**/配额/全局审计由 Bell 管理」;`02-requirements` §3.5 五角色。
|
||||
|
||||
**需要什么**:用户列表与邀请/停用;角色分配(含站点范围);会话管理(`expired` 态已有,但没有主动踢下线);密码/MFA 策略。
|
||||
|
||||
### 2.5 去重与聚合的配置面
|
||||
|
||||
**实测**:`聚合` 4 次、`抑制` 1 次、`冷却` **0** 次。
|
||||
|
||||
**依据**:《03》§2.6 定义了四层去重——同设备冷却期(默认 5min)、同站点聚合、已处置抑制、静默窗口。**目前只有静默有 UI。**
|
||||
|
||||
**需要什么**:前三条的配置入口(是规则的一部分,还是站点级全局配置?现在无处设置);以及 IX-008 要求的**聚合原因**在 Alert 详情中的展示(`聚合原因` 实测 0 次)。
|
||||
|
||||
---
|
||||
|
||||
## 3. P2 · 只留位
|
||||
|
||||
| 模块 | 依据 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 大屏与平面图 | `routes.md` 值班台/大屏:「地图/平面图点位和列表**双向定位**,但点位缺失时仍可从列表处置」 | `地图`/`平面图`/`大屏` 各 0 次。M4 才要,但导航留位 |
|
||||
| 排班表 | 需求 §3.4「联系人和**时段**可按租户/站点配置」;交接班已有但排班没有 | 与 1.1 联系人管理同源,可合并设计 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 页内小缺口(不是模块,但 IX 明确要求)
|
||||
|
||||
| # | 缺什么 | 依据 | 实测 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Alert 详情的**聚合原因** | IX-008「Alert 展示关联 Event 与**聚合原因**」 | `聚合原因` 0 次 |
|
||||
| 2 | 试运行的**命中样本** | IX-011「展示**命中样本**与影响范围」 | `命中样本` 0 次;`影响范围` 1 次 |
|
||||
| 3 | 事件中心的**批量处置** | `routes.md` `/events`「事件筛选与**批量处置**入口」 | `批量` 0 次 |
|
||||
| 4 | 分页 | `routes.md` `/events` 与 `/audit` 均要求 | `分页` 1 次,覆盖不明 |
|
||||
| 5 | 页面级 `conflict` 态 | IX 全局状态「数据已被他人修改」 | 仅回滚对话框内有版本冲突 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 建议顺序
|
||||
|
||||
| 顺序 | 条目 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **1.1 联系人与通道管理** | Bell 最核心的可配置项,US-005 的一半,现在只有展示 |
|
||||
| 2 | **1.2 供应商与故障切换** | 「至少两条独立路径」是需求硬约束,现在无处配置、无处验证 |
|
||||
| 3 | **1.3 重启续跑可观测 + 补 IX 条目** | 清单缺这条比原型缺更严重;先补清单再改原型 |
|
||||
| 4 | §4 五个页内小缺口 | 都是 IX 已写明的,成本低 |
|
||||
| 5 | 2.1 保留策略 · 2.5 去重配置 | 前者关合规举证,后者关误报体感 |
|
||||
| 6 | 2.2 场景包 · 2.3 对外集成 · 2.4 用户角色 | M4/M5 前补齐 |
|
||||
| 7 | P2 两项留位 | 不实现 |
|
||||
|
||||
> 1.1 与 P2 的排班表同源(联系人 + 时段 + 轮换),建议一次设计到位,不要先做联系人再回头加时段。
|
||||
@@ -0,0 +1,187 @@
|
||||
# 三系统职责划分:Sense / Brain / Bell
|
||||
|
||||
> 本文档回答一个问题:**一段代码该写进哪个目录。**
|
||||
> 划分依据来自《03-通用场景应用方案》的 L0–L5 分层与组件选型,本文档只是把它按三个可独立开发的系统重新切分。
|
||||
> 定稿:2026-08-03
|
||||
> ⚠️ 有 8 条待裁决的修订建议涉及本文档(§1.1、§1.2、§2.2、§2.3、§3 边界 7、§5),见《13-架构评审与修订建议》汇总表。**裁决前本文内容全部有效。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 三系统总表
|
||||
|
||||
| | **Sense** | **Brain** | **Bell** |
|
||||
| --- | --- | --- | --- |
|
||||
| 中文 | 感知系统 | 推理系统 | 管理系统 |
|
||||
| 小学生版 | 感觉到 | 想一想 | 打铃叫人 |
|
||||
| 对应层 | L1 接入 | L2 流水线 + L3 算法 | L4 业务 + L0 基座 |
|
||||
| 语言 | Go | Python / CUDA | Go + 前端 |
|
||||
| 有无状态 | 有(设备台账) | **无状态** | 有(事件、告警) |
|
||||
| DB schema | `sense` | 无 | `bell` |
|
||||
| 契约角色 | 供流与信号 → Brain | **产出**事件 | **消费**事件 |
|
||||
| 首次交付 | M1 | M3 | M3(最小)→ M4(完整) |
|
||||
|
||||
### 1.1 各自装什么
|
||||
|
||||
**Sense —— 把现场的流和信号稳定地拿进来并管住**
|
||||
|
||||
- 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable)
|
||||
- ONVIF 客户端:连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
|
||||
- mediamtx API 客户端(**自行用 oapi-codegen 从其 OpenAPI 生成**,不依赖第三方 SDK)
|
||||
- **对账器**:水平触发、`sync_state`、独立信号量、10% 孤儿删除安全闸、只收敛不回滚
|
||||
- 探活与断线重建
|
||||
- WireGuard 控制面隧道
|
||||
- mediamtx 鉴权回调(401 + 踢流是四种停用粒度之一)
|
||||
- 容量配额执行:设备新增/启用时读取 Bell 的站点配额,只拒绝超额变更;配额服务暂时不可用时不影响已有流
|
||||
- 流绑定与媒体分片调度:维护 `mtx_instance`,默认 16 路交付;扩到 128 路时按 `media_shard.max_streams` 横向分片
|
||||
- 边缘节点 agent:推流、本地环形缓冲、断网续传
|
||||
- **设备型触发源**:雷达、门磁、按钮、ONVIF 事件订阅
|
||||
- 非视频传感器的信号接收(signal plane,不走 mediamtx)
|
||||
|
||||
**Brain —— 看画面、出判定**
|
||||
|
||||
- Savant 流水线与适配器(ZeroMQ 边界,适配器故障隔离,实时模式丢帧)
|
||||
- 模型:检测 / 姿态 / 跟踪 / ReID(`anon_id`)/(M5)人脸
|
||||
- **`Detector` 与 `PoseEstimator` 接口解耦**——这是 AGPL 逃生通道的前提,换 YOLOX + RTMPose 时判定算法不动
|
||||
- 判定内核:几何证据 + 时间窗状态机(从 silver_pose 抽取,两边共用)
|
||||
- 事件 mapper:判定结果 → 事件契约 v0.1
|
||||
- **像素级**运动侦测触发(要解码,所以在这里)
|
||||
- 推理 worker 注册与分片:单逻辑推理分片默认最多 16 路;64/128 路由多个 worker 承担,路由变更不改判定代码
|
||||
|
||||
**Bell —— 记录、派发、追到人确认**
|
||||
|
||||
- 事件接收与校验(schema 校验 + 契约 README §5 那六条代码级断言 + 生成 ULID)
|
||||
- 事件存储:**不可变**,误判只改 `outcome`
|
||||
- 规则引擎 + 场景包加载
|
||||
- 告警状态机:升级链、ack、抑制、静默(**≤4h,无永久选项**)
|
||||
- 投递:push / 短信 / 语音,**双供应商**
|
||||
- 误报反馈闭环:`outcome` 回写 Brain
|
||||
- 多租户 RBAC、`tenant_features`(人脸授权开关:未授权时能力在 API 与 UI 中**不可见**,不是禁用)
|
||||
- 审计日志
|
||||
- 站点容量配额的唯一真相源:`site.max_video_channels` 默认 16、上限 128;提供版本化内部读取接口/投影给 Sense 执行
|
||||
- 管理后台前端 + 大屏:按 128 路设计分页/虚拟列表、筛选与批量操作,不一次性加载全部视频
|
||||
|
||||
### 1.2 不属于任何一个目录的东西
|
||||
|
||||
| 组件 | 说明 |
|
||||
| --- | --- |
|
||||
| **mediamtx** | 独立二进制,外部依赖。Sense 管它的配置与生命周期,`Sense/deploy/` 放基线配置 |
|
||||
| **PostgreSQL / MinIO / Prometheus** | 基础设施,独立部署清单 |
|
||||
| **silver_pose** | 独立仓库,是 Brain 判定内核的来源与单机演示形态。**不改名、不合并**——它现在能卖,Brain 还不能 |
|
||||
| **`_reference/`** | 只读参考源码(如 MiBeeNvr)。按《03》§1.4 白名单借鉴 ONVIF、IP 自愈、健康检测、测试组织与 UI 交互;禁止复制媒体内核、SQLite 真相源、AI/用户体系,禁止整仓进生产或加入 go.mod |
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
### 2.1 Sense(Go)
|
||||
|
||||
```
|
||||
Sense/
|
||||
├── cmd/
|
||||
│ ├── sense-api/ 控制面服务:设备台账、鉴权回调、对账器
|
||||
│ └── sense-agent/ 边缘节点:推流、环形缓冲、隧道
|
||||
├── internal/
|
||||
│ ├── device/ 设备台账(devices,含 modality)
|
||||
│ ├── onvif/ 连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
|
||||
│ ├── mtx/ oapi-codegen 生成的 mediamtx 客户端 + 薄封装
|
||||
│ ├── reconcile/ 对账循环(幂等 + 退避 + 安全闸)
|
||||
│ ├── probe/ 探活
|
||||
│ ├── trigger/ 设备型触发源(雷达/门磁/按钮/ONVIF 事件)
|
||||
│ ├── tunnel/ WireGuard 控制面
|
||||
│ ├── authcb/ mediamtx 鉴权回调
|
||||
│ └── store/ M1 先 SQLite,schema 与生产 Postgres 保持一致
|
||||
├── deploy/
|
||||
│ └── mediamtx.yml 基线配置
|
||||
└── api/openapi.yaml
|
||||
```
|
||||
|
||||
### 2.2 Brain(Python / CUDA)
|
||||
|
||||
```
|
||||
Brain/
|
||||
├── pipeline/ Savant 流水线定义与适配器
|
||||
├── models/
|
||||
│ ├── detector/ Detector 接口 + YOLO / YOLOX 实现
|
||||
│ ├── pose/ PoseEstimator 接口 + YOLO-pose / RTMPose 实现
|
||||
│ ├── tracker/ ByteTrack / BoT-SORT
|
||||
│ └── reid/ 匿名同一性,产出 anon_id
|
||||
├── judge/
|
||||
│ ├── evidence.py 几何证据(躯干角、髋部下坠比)
|
||||
│ └── state.py 时间窗状态机 NORMAL/SUSPECT/CONFIRMED/RECOVERING
|
||||
├── emit/
|
||||
│ ├── mapper.py 判定结果 → 契约 v0.1
|
||||
│ └── publisher.py 投递(HTTP 起步 → ZeroMQ);**失败落本地队列重试,不阻塞主链路**
|
||||
├── trigger/ 像素级运动侦测
|
||||
└── contracts/ ← 与 Bell/contracts/ 逐字节相同
|
||||
```
|
||||
|
||||
### 2.3 Bell(Go + 前端)
|
||||
|
||||
```
|
||||
Bell/
|
||||
├── cmd/bell-api/
|
||||
├── internal/
|
||||
│ ├── ingest/ 接收事件、schema 校验、六条断言、生成 ULID
|
||||
│ ├── event/ 事件存储(不可变,只允许追加 outcome)
|
||||
│ ├── rule/ 规则引擎 + 场景包加载
|
||||
│ ├── alert/ 告警状态机、升级链、ack、静默
|
||||
│ ├── deliver/ push / 短信 / 语音,双供应商
|
||||
│ ├── feedback/ 误报回流,outcome 回写 Brain
|
||||
│ ├── tenant/ 多租户 RBAC、tenant_features
|
||||
│ ├── audit/ 审计日志
|
||||
│ └── store/ Postgres(schema: bell)
|
||||
├── web/ 管理后台前端
|
||||
├── packs/ 场景包(**纯配置** —— M5 架构验收点)
|
||||
└── contracts/ ← 与 Brain/contracts/ 逐字节相同
|
||||
```
|
||||
|
||||
> `contracts/` 只在 Brain 与 Bell 各存一份(生产者与消费者),**Sense 不需要**——它不碰事件契约。
|
||||
|
||||
---
|
||||
|
||||
## 3. 七条边界(不写死就会漂)
|
||||
|
||||
| # | 边界 | 定论 |
|
||||
| --- | --- | --- |
|
||||
| 1 | mediamtx 二进制归谁 | 独立进程。**Sense** 管配置与生命周期,部署清单放 `Sense/deploy/` |
|
||||
| 2 | 触发源归谁 | 按性质切:**设备型**(雷达/门磁/按钮/ONVIF 事件)→ Sense,它们本来就是设备;**像素级**运动侦测 → Brain,它要解码 |
|
||||
| 3 | pre-roll 证据切片谁裁 | 录制在 Sense(mediamtx),事件在 Brain 产出。**Bell 发起回捞**(它拥有事件),Sense 提供切片接口 |
|
||||
| 4 | ULID 谁生成 | **Bell**。Brain 只填 `source_event_id`。见契约 README §4 |
|
||||
| 5 | 一个 PostgreSQL 还是三个 | **一个实例,schema 分离**(`sense` / `bell`),Brain 无 schema。"DB 是唯一真相源"是对账器成立的前提,拆库即失效 |
|
||||
| 6 | 16 路与 128 路分别指什么 | **16 路是默认交付规格,128 路是本阶段单逻辑站点上限**,都不是“单进程/单服务器保证值”。媒体与推理必须横向分片;单机承载量由分辨率、码率、帧率、模型和硬件基准测试决定 |
|
||||
| 7 | 站点配额谁拥有、谁执行 | **Bell 拥有 `sites.max_video_channels`,Sense 在设备新增/启用写路径执行**。通过版本化内部 API 或只读投影同步,不允许 Sense 直接写 Bell schema;依赖暂时不可用时拒绝新变更,但已有视频链路继续运行 |
|
||||
|
||||
第 2、3、6、7 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。
|
||||
|
||||
> ⚠️ **边界 7(站点配额)有一条待裁决的修订建议**:既然已定"一个 PostgreSQL 实例、schema 分离",跨系统读一个整数不必走版本化 API,可改为同库只读视图 `bell.site_quota_v1`。见《13-架构评审与修订建议》A-2。裁决前本表内容仍然有效。
|
||||
|
||||
---
|
||||
|
||||
## 4. 标识符约定
|
||||
|
||||
```
|
||||
仓库/目录 Sense Brain Bell
|
||||
镜像 yov/sense:x.y yov/brain:x.y yov/bell:x.y
|
||||
日志/指标前缀 sense_ brain_ bell_
|
||||
三字母码 SEN BRN BEL
|
||||
DB schema sense — bell
|
||||
```
|
||||
|
||||
**命名规则:系统名不得是该系统领域模型里的实体名。**
|
||||
`bell` 域内有 `alerts` 表 → 系统不能叫 Alert(`alert.alerts` 无法读,且撞 Prometheus 内置的 `ALERTS` 序列)。另两个同样干净:`sense` 域内是 `devices`/`streams`,`brain` 域内是 `models`/`detections`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 里程碑与目录的对应
|
||||
|
||||
| 阶段 | 动哪个目录 |
|
||||
| --- | --- |
|
||||
| **M0** 摄像头兼容性验证 | 无生产代码;可直接运行 MiBeeNvr 等 `_reference/` 项目作为隔离实验室测试台,产物允许丢弃 |
|
||||
| **M1** 5 路接入骨架 + mediamtx | **仅 Sense**;MediaMTX 正式进入,完整 MiBeeNvr 不得替代生产媒体数据面 |
|
||||
| **M2** 对账器 + 多租户 + 隧道 + 16 路开通 | **仅 Sense**;至少一个站点验证默认 16 路配额与全流程 |
|
||||
| **M3** 默认 16 路推理 + 规则引擎 + 事件预警 | **Brain + Bell 同时起步**,契约在此首次被真实使用 |
|
||||
| **M4** 64 路分片 + 灰度 + 管理系统 | Sense 与 Brain 完成横向分片,Bell 完成 128 路规模下的列表与批量交互;验证单分片故障隔离 |
|
||||
| **M5** 128 路容量验收 + 第二三场景包 + 触发式推理 + 人脸 | Sense/Brain 验证扩容不改业务代码;Bell 的 `packs/`(**纯配置**)+ Brain 的模型与触发 |
|
||||
| **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/`(modality 扩展)+ Brain 两级判定 |
|
||||
|
||||
> M3 是契约第一次被真实使用的时刻。**在此之前契约允许原地修订,之后版本递增规则绝对生效**(契约 README §1)。
|
||||
@@ -0,0 +1,150 @@
|
||||
# Sense 原型评审 → IX 条目草稿
|
||||
|
||||
> 来源:`yovision-T-004/docs/design/sense/index.html`(codex,2026-08-04)
|
||||
> 用途:补齐原型枚举缺口对应的交互清单条目,供并入 `docs/08-interaction-checklist.md`
|
||||
> **写入约束**:T-004 的 `write_paths` 只含 `docs/tasks/T-004.md`、`docs/design/sense/index.html`、`docs/design/bell/index.html`,且唯一写入者为 codex。本文件是草稿输入,**由 08 文件的所有者合入**,不要由本会话直接改那个 worktree。
|
||||
> 状态:全部条目标【待确认】,按 `docs/design/README.md` 工作流第 5 步逐条人工确认。
|
||||
|
||||
---
|
||||
|
||||
## 0. 先纠正一条
|
||||
|
||||
原评审列的「128 路列表缺分页」**不需要新条目**——`IX-003` 已覆盖「分页/筛选/批量选择;128 路不一次加载所有视频和详情」。那是**原型漏画**,回原型补即可,清单无需改动。
|
||||
|
||||
因此新增 **5 条**(IX-014 ~ IX-018),另有 2 处全局章节需要收紧。
|
||||
|
||||
---
|
||||
|
||||
## 1. 新增条目(可直接贴入主表)
|
||||
|
||||
```markdown
|
||||
| IX-014 | 设备模态与准入 | 设备台账按 modality(video/radar/contact/button/wearable)建模;列表、筛选、添加流程不得以"摄像头"为唯一形态;非成像设备无画面、无 Profile、无分片,其详情页不展示视频相关页签 | US-001 | M2/M6 |
|
||||
| IX-015 | 停用与踢流 | 停用需展示影响范围(是否中断在途会话、是否影响推理)并二次确认;停用后期望态与实际态分别收敛,实际态不得立即伪装为"已停止";重新启用为幂等操作 | US-001、US-002 | M2 |
|
||||
| IX-016 | 隐私区域设备准入 | 标记为隐私区域的位置只接受非成像模态;选择该位置时成像类设备不可选并说明原因;越过前端的写请求由后端拒绝并回显同一措辞 | US-006 | M2 |
|
||||
| IX-017 | 检测区域几何编辑 | 多边形与方向警戒线的绘制、撤销、清空、闭合;警戒线必须渲染方向箭头;键盘坐标输入与鼠标绘制等效;画面参数变化后既有区域标记为待校准且不静默失效 | US-005 | M3 |
|
||||
| IX-018 | 草稿与跨系统上下文 | 存在未保存几何草稿时,离开页面(导航、面包屑、切换绘制工具、跳转 Bell)必须拦截并确认;Sense↔Bell 深链双向携带摄像头与区域上下文,返回后草稿不丢失 | US-005 | M3 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. P0 条目详情展开
|
||||
|
||||
### IX-014 设备模态与准入
|
||||
|
||||
**为什么是 P0**:原型把「摄像头」写进了导航名、表名、添加对话框标题和协议下拉(只有 ONVIF/RTSP)。《08-三系统职责划分》§1.1 已定 `devices` 含 `modality`,且隐私区域约束现在按模态判定。信息架构一旦以摄像头为唯一形态定稿,接入雷达时是整页重做而非加字段——这与事件契约 v0.1 在 2026-08-03 因同一假设被迫原地修订是同构问题。
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 取值 | `video` / `radar` / `contact` / `button` / `wearable` / `other` |
|
||||
| 列表 | 模态是可筛选维度;设备图标按模态区分,不复用摄像头图标 |
|
||||
| 添加流程 | 先选模态,再按模态渲染字段。`radar`/`contact` 无 Profile、无码流、无分片绑定 |
|
||||
| 详情页 | 非成像模态**不展示**「画面与检测区域」「流与健康」页签,而非展示为禁用 |
|
||||
| 命名 | 一级导航不叫「摄像头」。建议「设备」,摄像头作为模态筛选项 |
|
||||
| 阶段 | 建模与命名在 M2 落地;雷达等真实接入在 M6。**建模不能等到 M6** |
|
||||
|
||||
**状态覆盖**:模态不支持该操作 / 模态与站点策略冲突 / 老数据无 modality 的迁移默认值。
|
||||
|
||||
### IX-015 停用与踢流
|
||||
|
||||
**为什么是 P0**:原型的「期望态」列有"停用"这个**值**,但没有停用**动作**,也没有任何关于"停用会不会踢掉正在观看的流"的表达。这是 Sense 最有运维后果的行为,且《03》§2.1 定义了四种粒度(卸载推理源 / 踢会话 / 鉴权回调 401 + 踢流 / DELETE path),标准组合是**先踢再拒**。原型不画,IX 就不会生成,实现时会退化成一个静默的布尔开关。
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 确认前必须说明 | 是否中断当前在途会话、是否影响正在运行的推理、是否影响录制与证据回捞 |
|
||||
| 期望态与实际态 | 点击停用后期望态立即变更,实际态由对账器收敛;**实际态不得立即显示"已停止"** |
|
||||
| 收敛可见 | 停用中的设备计入「待收敛项」,收敛超时有明确提示 |
|
||||
| 幂等 | 重复停用/启用不产生副作用,不生成重复审计条目 |
|
||||
| 批量 | 批量停用逐项结果可见,部分失败可重试 |
|
||||
|
||||
**状态覆盖**:正在被其他人观看时停用 / 对账器不可达时停用 / 停用后又被上游策略重新启用。
|
||||
|
||||
### IX-016 隐私区域设备准入
|
||||
|
||||
**为什么是 P0**:这是**代码级**硬约束(《01》RQ-S1-05、《02》NFR-CMP-05、《03》§3.2)。原型的「区域」下拉只是普通选项,没有任何拒绝路径。合规约束在 UI 上不可见时,实现者不会知道它存在。
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 判定依据 | 按 `modality` 判定,**不按设备类型名判定**。`privacy_flag = true` 的位置允许 `radar`/`contact`/`button`/`wearable`,拒绝 `video` |
|
||||
| 前端 | 成像模态在隐私位置**不可选**,并就地说明原因,而非提交后报错 |
|
||||
| 后端 | 绕过前端的写请求必须拒绝,措辞与前端一致 |
|
||||
| 位置属性变更 | 已有成像设备的位置被改标为隐私区域时,必须走显式处置流程,不允许静默留存 |
|
||||
|
||||
**状态覆盖**:位置属性读取失败时默认按最严处理(拒绝成像)。
|
||||
|
||||
### IX-017 检测区域几何编辑
|
||||
|
||||
**为什么是 P0**:原型的草稿层(`drawDraft`)把警戒线画成无向线段,但**方向是警戒线唯一的语义**——越线方向决定是否触发。静态示例 SVG 里画了箭头,草稿里没有,说明这是实现遗漏而非设计取舍。
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 方向 | 警戒线草稿与已保存态**都**必须渲染方向箭头;提供反转方向的操作 |
|
||||
| 最小点数 | 多边形 ≥3,警戒线 =2;不足时保存被拒并说明 |
|
||||
| 闭合交互 | 双击闭合**不得先追加顶点**(原型 534–535 行:dblclick 前两次 click 会多加 2 个点) |
|
||||
| 键盘等效 | 坐标输入(0–100%)与鼠标绘制完全等效,含撤销与删除单点 |
|
||||
| 待校准 | 画面分辨率/旋转变化后既有区域标记为待校准,**不静默失效也不自动改坐标** |
|
||||
| 版本 | 区域版本与告警规则版本是不同对象;保存区域不发布规则 |
|
||||
|
||||
### IX-018 草稿与跨系统上下文
|
||||
|
||||
**为什么是 P0**:T-004 任务书自己写明"后端系统边界不得迫使用户丢失当前摄像头或规则草稿上下文"。实测原型有三条路径会静默丢弃草稿:面包屑返回(502 行)、侧栏导航(493 行)、**切换绘制工具**(536 行 `points.length = 0`)。而 `clearPoints` 与 `discardDraft` 都有 `confirm`——同一类破坏性操作,三处有确认、三处没有。
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 拦截范围 | 页内导航、面包屑、切换绘制工具、跳转 Bell、关闭标签页 |
|
||||
| 一致性 | 所有丢弃草稿的路径使用**同一个**确认组件与措辞 |
|
||||
| 深链 | Sense→Bell 与 Bell→Sense **双向**携带 `camera` + `zone`;原型目前只有 Bell→Sense 带 `return` 参数,反向缺失 |
|
||||
| 返回 | 从 Bell 返回后草稿仍在;若期间区域被他人保存,提示冲突而非覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 全局章节需要收紧的两处
|
||||
|
||||
### 3.1 「无障碍与安全」——把对比度写成可验证的数字
|
||||
|
||||
现文:「文本和关键状态满足可读对比度」。建议改为:
|
||||
|
||||
```markdown
|
||||
- 正文与关键状态对比度 ≥ 4.5:1,大字号(≥18.66px 粗体或 ≥24px)≥ 3:1;**主操作按钮同样受此约束**。
|
||||
```
|
||||
|
||||
**实测依据**(对原型配色计算,非估计):
|
||||
|
||||
| 用途 | 前景/背景 | 实测 | 判定 |
|
||||
| --- | --- | --- | --- |
|
||||
| `.btn-primary` 主 CTA | `#ffffff` / `#0ea5e9` | **2.77:1** | ❌ 深浅两个主题都不过(浅色主题未覆盖 `--primary-strong`) |
|
||||
| 浅色主题 `--subtle` | `#718096` / `#ffffff` | **4.02:1** | ❌ 用于 `.nav-label`、搜索图标 |
|
||||
| 其余抽查 6 项 | — | 4.6–16.3:1 | ✅ |
|
||||
|
||||
主按钮那条影响最大:「添加摄像头」「保存区域版本」「保存为待激活」全是它。修法是把 `--primary-strong` 压到 `#0369a1` 一带(≈5.9:1),或主按钮改深底浅字。
|
||||
|
||||
### 3.2 「无障碍与安全」——补三条可测项
|
||||
|
||||
原型实测出的问题,与其修原型不如钉进清单(生产要重写):
|
||||
|
||||
```markdown
|
||||
- 当前导航项使用 `aria-current="page"`;不得用空值属性表达(空串等于 false)。
|
||||
- 图标按钮在任何断点下都必须保留可访问名;隐藏文字标签时须提供 aria-label。
|
||||
- 采用 tab 模式时必须完整:`role="tabpanel"` + `aria-controls` 关联 + 方向键导航,不做半套。
|
||||
```
|
||||
|
||||
对应原型位置:`aria-current` 见 489 行 `toggleAttribute`;图标按钮见 236 行 `≤1180px` 隐藏 `.nav span`;tab 半套见 372–377 与 503–507 行。
|
||||
|
||||
---
|
||||
|
||||
## 4. 不进清单、但要回原型补的
|
||||
|
||||
| # | 内容 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 设备列表分页控件 | IX-003 已覆盖,仅原型漏画 |
|
||||
| 2 | 「推理绑定 16/16」画成 100% 满格紫条 | 正常态显示成满载,易误读为告警。换表现,不进清单 |
|
||||
| 3 | 分片容量 `8/32` 的 **32** 无出处 | 《08》只定义站点 16 默认 / 128 上限,未定义单分片容量。**要么把 `media_shard.max_streams` 补进《08》§3,要么原型改用文档里的数**——两个分片 ×32 = 64,与站点上限 128 也对不齐 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 建议的落地顺序
|
||||
|
||||
1. IX-014(模态)先定——它影响导航命名与表结构,**建议整页重生成原型而非打补丁**
|
||||
2. IX-015 / IX-016 补进原型(停用动作 + 隐私区域拒绝态),二者都只是加控件与一个拒绝态
|
||||
3. IX-017 / IX-018 可以只写清单不改原型——都是行为约定,生产实现时按清单做
|
||||
4. §3 两处全局收紧直接合入
|
||||
5. §4 第 3 条的分片容量需要一个数字决定,然后同步《08》
|
||||
@@ -0,0 +1,136 @@
|
||||
# Sense 原型第二轮评审
|
||||
|
||||
> 基准:`yovision-T-004/docs/design/sense/index.html`(codex,2026-08-04 09:55,78 KB)
|
||||
> 对标:`02-requirements`、`04-architecture`、`07-user-stories`、`08-interaction-checklist`(均于 09:41 更新)、`routes.md`、`api.md`
|
||||
> 前置:《09-Sense原型评审-IX草稿》《10-Sense原型-功能模块缺口》
|
||||
> 日期:2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论
|
||||
|
||||
| 轮次 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 《09》行为缺口 | IX-014~018 + 无障碍三条 + 对比度 | ✅ **基本全部落地**,且清单版本比我的草稿更好 |
|
||||
| 《10》模块缺口 | 6 个 P0 模块 | ❌ **0 / 6 落地**,导航仍是 5 项 |
|
||||
| 本轮新增 | 由 modality/capabilities 模型带来的新问题 | ⚠️ **5 处**,见 §3 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 已落地的(有证据,不必再提)
|
||||
|
||||
| 项 | 证据 |
|
||||
| --- | --- |
|
||||
| 一级入口改名「设备」,`modality` 成为一等公民 | 原型内 `modality` 出现 29 次;筛选含 `onvif`/`radar`/`mqtt` |
|
||||
| `capabilities` 驱动详情页签 | 「能力」11 次;页签改为「连接与健康」而非「流与健康」 |
|
||||
| 隐私区域准入 | `updatePrivacyAdmission()`;区域筛选含 `privacy`;导入文案含「区域隐私策略」 |
|
||||
| **三种停用粒度**(IX-015) | `operationDialog`:暂停推理 / 停用接入 / 断开当前会话,每项带影响说明 + 幂等声明 + 审计声明 |
|
||||
| tab 模式补完整 | `aria-controls` + `aria-selected` + roving `tabindex` |
|
||||
| 草稿自动保存(IX-018) | 检出自动保存逻辑 |
|
||||
|
||||
清单侧的 IX-015 拆成三粒度、IX-018 改成"自动保存 + 只在破坏性动作时拦截",都比我原草稿准确。
|
||||
|
||||
---
|
||||
|
||||
## 2. 《10》的模块缺口:一处未动
|
||||
|
||||
导航仍是 `运行总览 · 实时监控 · 设备 · 接入任务 · 系统状态`。逐项核对:
|
||||
|
||||
| 《10》 | 缺口 | 本轮 | 关键词实测 |
|
||||
| --- | --- | --- | --- |
|
||||
| §1.2 | **对账器运维视图** | 未动 | 「孤儿」0 次、「退避」0 次;待收敛项仍不可下钻 |
|
||||
| §1.5 | **权限与角色** | 未动 | 「角色」「只读」「权限」各 **0** 次 |
|
||||
| §1.1 | 站点与 Area 管理 | 未动 | 「站点」仅顶栏 1 次;无 Area 增删改 |
|
||||
| §1.3 | 待激活闭环 | 未动 | 筛选仍无「待激活」 |
|
||||
| §1.4 | 凭据更新 | 未动 | 仍只有「已安全保存,页面不可见」,无写入路径 |
|
||||
| §1.6 | 配额降级态 | 未动 | 状态演示仍是 normal/loading/empty/error 四态 |
|
||||
| §2.1 | 边缘节点与隧道 | 未动 | 「隧道」0 次、「补传」0 次 |
|
||||
| §2.3/2.4 | 主子码流切换 / 分片迁移 | 未动 | 「主码流」0 次、「迁移」0 次 |
|
||||
| — | 设备列表分页(IX-003 明确要求) | 未动 | 「分页」0 次 |
|
||||
|
||||
不重复推导,理由见《10》。**优先级不变:对账器视图 > 权限 > 站点与 Area。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 本轮更新新产生 / 新暴露的 5 处
|
||||
|
||||
### 3.1 `capabilities` 成了一等公民,却没有产生它的流程 ⚠️ P0
|
||||
|
||||
`capabilities` 现在决定详情页显示哪些页签——这是正确的设计。但**谁写入 capabilities?**
|
||||
|
||||
原型的「测试连接」仍然只是一次性反馈("2 个 Profile,时间漂移 +0.4s"),结果没有落地成设备的持久属性。于是 `capabilities` 变成一个没有来源的字段。
|
||||
|
||||
而且它会**变化**:固件升级后可能新支持 ONVIF 事件订阅,换 Profile 后分辨率变化会让检测区域待校准(IX-017 已覆盖后半段,但没覆盖能力本身的变化)。
|
||||
|
||||
| 需要什么 |
|
||||
| --- |
|
||||
| 探测结果落地为设备的 `capabilities`,详情页「设备能力」区可见 |
|
||||
| **重新探测**动作,以及探测结果与既有 `capabilities` 的**差异提示** |
|
||||
| 能力降级的处置:原本支持事件订阅、现在探测不到了,依赖它的设备型触发怎么办 |
|
||||
| 与采购白名单关联(US-007 要求形成白名单,现在白名单和设备台账没有连接) |
|
||||
|
||||
### 3.2 modality 可选 radar,但适配器 M6 才有 ⚠️ P0
|
||||
|
||||
`02-requirements` §3.1 写明:
|
||||
|
||||
> M1~M5 只完整实现 video 适配器,M6 再接入非视频设备,但不得因此把数据模型和一级信息架构写死为摄像头。
|
||||
|
||||
原型的模态下拉已经能选 `radar`/`mqtt`。**那么现在选了会怎样?** 如果能一路添加成功,原型就在承诺一个 M6 才有的能力;如果直接不可选,又违背了"信息架构不写死为摄像头"。
|
||||
|
||||
正解是第三种状态:**模态已建模、适配器未就绪**——可以录入、进台账、占位、参与隐私准入校验,但明确标注"适配器 M6 交付,当前不建立连接"。这个态现在不存在。
|
||||
|
||||
### 3.3 「暂停推理」的措辞与实际机制不符 ⚠️ P1
|
||||
|
||||
对话框写:
|
||||
|
||||
> **暂停推理**:保留媒体接入与实时预览,只停止新推理任务
|
||||
|
||||
但《03》§2.1 的机制是**卸载推理 source**,而且原文特别警告:
|
||||
|
||||
> `sourceOnDemand: yes` 的语义是「有 reader 才拉流」,而**推理 source 挂上去就是一个 reader**。因此「暂停分析」必须通过**卸载 source** 实现,不能只在推理插件里 `return`——后者会让 mediamtx 持续拉流、带宽白烧,而监控上看起来一切正常。
|
||||
|
||||
「停止新推理任务」这个措辞暗示的正是被明令禁止的那种实现(在推理侧跳过)。原型是 IX 的输入物,措辞会直接变成实现者的心智模型。
|
||||
|
||||
**建议改为**:「解除推理侧对该路的订阅;无其他观看者时上游自动停止拉流」——同时把"是否仍在拉流"作为可观察结果显示,这正是那条血泪教训要防的。
|
||||
|
||||
### 3.4 Area 策略可校验,但没有地方修改策略 ⚠️ P1
|
||||
|
||||
隐私准入在**添加**路径上做对了。但 IX-016 还要求:
|
||||
|
||||
> 已有设备遇到区域策略变化时进入**显式处置流程**,不静默留存或停用
|
||||
|
||||
这个流程的**触发点**——把某个 Area 从 `video_allowed` 改成 `non_imaging_only`——在 UI 上不存在,因为 Area 根本不可管理(《10》§1.1)。等于规则写好了但没有触发它的按钮。
|
||||
|
||||
同理,IX-016 的「策略读取失败时拒绝新变更并告警」也需要一个降级态,与《10》§1.6 的配额降级是同类问题:**读故障 vs 写故障表现完全不同**,而状态演示仍只有四态。
|
||||
|
||||
### 3.5 `api.md` 缺一行:Sense → Bell 的审计投递 ⚠️ P1(文档缺口,非原型缺口)
|
||||
|
||||
原型现在多处承诺审计:「每次请求均审计」、执行操作后「已记录本次审计」。
|
||||
|
||||
但架构 §2 定 Bell 是审计真相源,而 `api.md` §2「待冻结的内部接口」表只有五行:
|
||||
|
||||
```
|
||||
Sense → Bell 读取站点视频配额
|
||||
Bell → Sense 请求事件证据 / pre-roll 切片
|
||||
Bell → Brain outcome / 误报反馈
|
||||
Sense → Brain 流绑定与设备型触发
|
||||
Worker → 控制面 注册、心跳、容量
|
||||
```
|
||||
|
||||
**没有 Sense → Bell 的审计投递。** 于是原型承诺的审计要么落在 Sense 本地(架构说不行,Bell 才是真相源),要么走一条没定义的接口。这行需要补进 `api.md`,并明确:异步还是同步、Bell 不可达时 Sense 的操作是否仍然执行(我的建议:**执行,审计落本地队列重传**,与 Brain 投递失败的处理一致)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议顺序
|
||||
|
||||
| 顺序 | 做什么 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **对账器运维视图**(《10》§1.2) | 两轮未动,仍是单点最大缺口。Sense 的存在理由在 UI 上等于不存在 |
|
||||
| 2 | **权限与角色**(《10》§1.5) | 决定页面上有没有按钮,是 IA 问题;两轮为 0 |
|
||||
| 3 | **capabilities 的产生流程**(§3.1) | 本轮新引入的字段没有来源,会变成永远为空的装饰 |
|
||||
| 4 | **站点与 Area 管理**(《10》§1.1 + §3.4) | 现在有了明确触发点:改 `capture_policy` 需要显式处置流程 |
|
||||
| 5 | 「暂停推理」措辞(§3.3) | 一句话的改动,但防的是一个会烧带宽且监控看不出来的实现错误 |
|
||||
| 6 | `api.md` 补审计接口行(§3.5) | 文档改动,不影响原型 |
|
||||
| 7 | 模态适配器未就绪态(§3.2)、配额/策略降级态、分页 | 补齐 |
|
||||
|
||||
> 第 1、2、4 项都是**改信息架构**,建议合并进同一次原型重生成。这是第二次提出——分次打补丁的成本会持续累积。
|
||||
@@ -0,0 +1,255 @@
|
||||
# Sense 原型待办清单(合并 09 / 10 / 11)
|
||||
|
||||
> 合并来源:《09-Sense原型评审-IX草稿》《10-Sense原型-功能模块缺口》《11-Sense原型-第二轮缺口》
|
||||
> 基准:`yovision-T-004/docs/design/sense/index.html`(2026-08-04 09:55)
|
||||
> 用途:给 codex 的单一执行清单。理由与推导见来源文档,本文只给**做什么 + 依据 + 验收**。
|
||||
> 日期:2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 使用说明
|
||||
|
||||
- 编号 `S-xx` 稳定,后续讨论只引用编号。
|
||||
- **A 组必须一次重生成**,不要逐项打补丁——三项都改信息架构,分次改的返工成本已经累积两轮。
|
||||
- **写入范围**:T-004 的 `write_paths` 只含 `docs/tasks/T-004.md`、`docs/design/sense/index.html`、`docs/design/bell/index.html`。标记「⚠️ 超出 write_paths」的条目**需另开任务**,不要在本任务内改。
|
||||
- 优先级:**P0** = M2 出口必需;**P1** = M2 内补齐;**P2** = 只留信息架构位,不实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 已完成(核对用,不执行)
|
||||
|
||||
第一轮《09》的全部条目已落地,实测确认:
|
||||
|
||||
| 项 | 验证方式 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| IX-014 modality 建模与「设备」改名 | `modality` 出现 29 次;筛选含「视频 / 雷达」,添加对话框可选「视频设备 / 毫米波雷达」 | ✅ |
|
||||
| IX-014 capabilities 驱动页签 | 页签改为「连接与健康」 | ✅ |
|
||||
| IX-015 三种停用粒度 | `operationDialog`:暂停推理 / 停用接入 / 断开会话,各带影响说明 + 幂等 + 审计声明 | ✅ |
|
||||
| IX-016 隐私准入(新增路径) | `updatePrivacyAdmission()`;区域筛选含 privacy | ✅ |
|
||||
| IX-017 警戒线方向 | 检出 marker / 反转相关实现 | ✅ |
|
||||
| IX-018 草稿自动保存 | 检出自动保存逻辑 | ✅ |
|
||||
| a11y:tab 模式 | `aria-controls` + `aria-selected` + roving `tabindex` | ✅ |
|
||||
| **对比度:主按钮** | `--primary-strong` 改为 `#0369a1` / `#075985` | ✅ **5.93:1 / 7.56:1**(原 2.77:1) |
|
||||
|
||||
---
|
||||
|
||||
## 2. A 组 · 信息架构级(P0,一次重生成)
|
||||
|
||||
导航从 5 项扩到 8 项:
|
||||
|
||||
```
|
||||
运行总览 · 实时监控 · 设备 · 接入任务 · 【站点与区域】 · 【运维】 · 系统状态 · 【审计】
|
||||
顶栏补:站点切换器 · 当前用户与角色
|
||||
```
|
||||
|
||||
### S-01 运维视图:对账队列 · P0
|
||||
|
||||
**两轮未动,单点最大缺口。** 总览的「待收敛项」目前不可下钻;对账器是 Sense 区别于普通 NVR 的全部理由,UI 上等于不存在。
|
||||
|
||||
| 必须包含 | 依据 |
|
||||
| --- | --- |
|
||||
| 未收敛项列表,逐项显示**期望态 vs 实际态的具体差异** | 架构 §7「PostgreSQL 是期望态真相源」 |
|
||||
| 每项的重试次数、当前退避间隔、下次重试时间 | 架构 §7「幂等、指数退避、限制并发」 |
|
||||
| **孤儿资源列表 + 10% 安全闸触发告警** | 架构 §7「孤儿删除必须有 10% 安全闸和人工可观察指标」 |
|
||||
| 单项手动触发收敛(现有「立即对账」是全局的,粒度过粗) | — |
|
||||
| 收敛持续失败的升级路径:多久算异常、通知谁 | 需求 §3.5 运维告警独立 |
|
||||
|
||||
**验收**:从总览「待收敛项 N」可点击进入;任选一项能看到差异、退避与下次重试时间;孤儿列表与安全闸状态可见。
|
||||
|
||||
### S-02 权限与角色 · P0
|
||||
|
||||
**两轮为 0。**「角色」「只读」「权限」在原型中各出现 0 次。权限决定页面上**有没有**按钮,是信息架构问题,不是加一层。
|
||||
|
||||
| 必须包含 | 依据 |
|
||||
| --- | --- |
|
||||
| 顶栏显示当前用户与角色 | 需求 §3.5 五角色 RBAC |
|
||||
| 只读角色下:添加 / 停用 / 删除 / 更新凭据**不出现**(不是置灰) | `routes.md` 路由守卫 |
|
||||
| 越权深链的统一拒绝态 | `routes.md`「深链打开无权限或已删除资源时给出统一、安全的反馈」 |
|
||||
| 站点管理员只能看到自己站点 | 需求 §3.5 |
|
||||
| 角色切换器(原型演示用,同状态演示下拉的做法) | — |
|
||||
|
||||
**验收**:切到「只读」角色后,设备页所有写操作控件消失;越权深链显示统一拒绝态。
|
||||
|
||||
### S-03 站点与区域(Area)管理 · P0
|
||||
|
||||
实体链 `Tenant → Site → Area/Device` 目前断了两级:Site 是顶栏一行静态字,Area 只是一个下拉选项。
|
||||
|
||||
| 必须包含 | 依据 |
|
||||
| --- | --- |
|
||||
| 站点列表:配额已用/上限、在线率、未收敛数、边缘节点状态 | `routes.md` `/sites` |
|
||||
| 顶栏站点切换器,切换后所有列表按站点过滤 | 架构 §3 SaaS-ready 边界 |
|
||||
| 配额只读展示 + 标注「由 Bell 持有」 | 架构 §5 第 7 条 |
|
||||
| **Area 增删改,含 `capture_policy = video_allowed \| non_imaging_only` 的编辑** | 需求 §3.1;架构 §8 |
|
||||
| **改 `capture_policy` 时的显式处置流程**(该 Area 已有成像设备怎么办) | IX-016「不静默留存或停用」——规则已写,触发点当前不存在 |
|
||||
|
||||
**验收**:能新建 Area 并设为 `non_imaging_only`;把一个已有摄像头的 Area 改成该策略时,出现显式处置流程而非静默通过。
|
||||
|
||||
### S-04 审计入口 · P1
|
||||
|
||||
原型多处承诺「每次请求均审计」,但没有查询入口。
|
||||
|
||||
**必须包含**:设备操作审计列表(时间 / 操作 / 对象 / 执行人 / 结果),可按设备、操作类型、时间筛选;标注真相源在 Bell。
|
||||
**验收**:从设备详情「操作记录」可跳到全局审计页并保留该设备筛选。
|
||||
|
||||
---
|
||||
|
||||
## 3. B 组 · 页内新增(不改 IA)
|
||||
|
||||
### S-05 capabilities 的产生流程 · P0
|
||||
|
||||
本轮新引入的 `capabilities` 决定页签显示,但**没有写入它的流程**——「测试连接」仍只是一次性 toast,结果不落地。
|
||||
|
||||
| 必须包含 |
|
||||
| --- |
|
||||
| 探测结果落地为设备持久属性,详情页「设备能力」区可见(厂商、型号、固件、认证方式、Profile 列表、是否支持校时、**是否支持 ONVIF 事件订阅**) |
|
||||
| **重新探测**动作 + 与既有 `capabilities` 的**差异提示** |
|
||||
| 能力降级处置:原本支持事件订阅、重探后不支持了,依赖它的设备型触发怎么办 |
|
||||
| 与采购白名单关联(US-007 要求形成白名单,当前白名单与台账无连接) |
|
||||
|
||||
**验收**:添加设备后「设备能力」区有内容;重新探测能显示"新增/消失了哪些能力"。
|
||||
|
||||
### S-06 模态适配器未就绪态 · P0
|
||||
|
||||
需求 §3.1:M1~M5 只完整实现 video 适配器,M6 才接非视频;但信息架构不得写死为摄像头。
|
||||
|
||||
现在模态下拉已能选 `radar`/`mqtt`——**选了会怎样没有定义**。能一路添加成功就是承诺 M6 能力;直接禁用又违背 IA 要求。
|
||||
|
||||
**需要第三态**:模态已建模、适配器未就绪——可录入、进台账、占位、参与隐私准入校验,但明确标注「适配器 M6 交付,当前不建立连接」,且不计入视频配额。
|
||||
**验收**:选 radar 添加后设备进入台账并显示该标注;实际态不显示为「在线」或「离线」,而是「适配器未就绪」。
|
||||
|
||||
### S-07 待激活闭环 · P0
|
||||
|
||||
需求 §3.1 明确的中间态,但设备表无待激活行、筛选无该项、无处置流程。
|
||||
|
||||
**必须包含**:待激活作为一级筛选项与独立计数;激活流程(重新探测 / 校时 / 配额复核);批量激活与逐项结果;长期待激活的过期策略。
|
||||
**验收**:筛选出待激活设备并完成一次激活,失败时可看到具体原因。
|
||||
|
||||
### S-08 凭据更新与认证失败恢复 · P0
|
||||
|
||||
详情页「凭据:已安全保存,页面不可见」——处理正确,但**只有读没有写**。现场改密码是最高频运维事件。
|
||||
|
||||
**必须包含**:单设备「更新凭据」(只写不读,不回显已存值);**认证失败作为独立实际态**(区别于「离线」,原因和处置都不同);批量更新凭据;更新后自动重试接入且结果可见。
|
||||
**验收**:认证失败设备在列表中可识别,能就地更新凭据并看到重试结果。
|
||||
|
||||
### S-09 降级态:配额不可读 / 区域策略不可读 · P0
|
||||
|
||||
状态演示仍是 `normal / loading / empty / error` 四态。缺的是**写故障**——与「边缘节点不可达」(读故障)表现完全不同:视频照常播、列表照常看,只有写操作被拒。
|
||||
|
||||
| 必须包含 | 依据 |
|
||||
| --- | --- |
|
||||
| 配额不可读:横幅说明「当前无法新增或启用设备,已有视频不受影响」,添加/激活按钮禁用并说明原因 | `api.md` §2;架构 §7 |
|
||||
| 区域策略不可读:拒绝新的成像设备变更并告警,已有链路不静默停用 | IX-016 |
|
||||
|
||||
**验收**:状态演示下拉新增这两态,切换后写操作被拒而视频与列表正常。
|
||||
|
||||
### S-10 边缘节点与隧道 · P1
|
||||
|
||||
侧栏底部只有一行 `sense-edge-01 · 在线`。但**控制面/数据面分离**是整个 NAT 方案的核心,UI 不体现等于白设计。
|
||||
|
||||
**必须包含**:边缘节点列表(版本、在线时长、隧道状态、承载设备数);**隧道断开但视频仍在推**的明确表达;本地缓存水位 + 断网恢复后的补传进度;节点升级/重启动作。
|
||||
**依据**:需求 §3.5「断网时边缘缓存事件,恢复后补传」。
|
||||
**验收**:能演示「隧道断开 / 视频正常」这个组合态,并显示补传队列长度。
|
||||
|
||||
### S-11 主 / 子码流切换 · P1
|
||||
|
||||
详情写死「子码流 704×576 · 5 FPS」,无切换动作。码流是容量的直接变量(架构 §6:`max_streams` 由码率决定),切换还会踢掉当前发布者,需影响范围提示。
|
||||
|
||||
### S-12 分片详情与设备迁移 · P1
|
||||
|
||||
分片健康只读。需要:分片详情(承载设备、实际码率、重连历史)、设备跨分片迁移(会中断该路,需确认)、单分片故障的影响范围展示(架构 §6「单分片故障不能扩散」)。
|
||||
|
||||
### S-13 批量任务列表与逐项结果 · P1
|
||||
|
||||
「当前任务」是单卡片,「查看逐项结果」是死按钮。IX-001 要求逐行错误、重复序列号、待激活、确认写入。
|
||||
|
||||
**必须包含**:任务列表(历史可查、可重试、可导出错误清单);逐项结果页(每行校验结果、失败原因、单行重试);上传前本地预校验结果页;部分成功语义(8 路里 5 成功 3 失败,成功的已生效)。
|
||||
|
||||
### S-14 Sense 运维告警列表 · P1
|
||||
|
||||
总览「今日设备告警 3」与「与业务预警分开」的说明写得对,但点不进去。
|
||||
|
||||
**必须包含**:运维告警列表(离线、时间漂移超阈、收敛失败、分片异常、隧道断开);运维侧的静默与通知配置(与 Bell 的业务静默是两套);明确标注「不会推给家属/值班员」。
|
||||
**依据**:需求 §3.4「业务预警与运维告警使用不同通道和值班配置」。
|
||||
|
||||
### S-15 设备列表分页 · P1
|
||||
|
||||
「分页」在原型中出现 0 次。IX-003 明确要求,阶段 M2/M4。含批量选择的跨页语义(「全选」到底选了当前页还是全部)。
|
||||
|
||||
### S-16 时间显示口径 · P1
|
||||
|
||||
系统内同时存在三个时钟:设备时间(可能漂 +3.8s)、服务器时间(期望态真相源)、浏览器本地时间。而「时间漂移」是原型自己列的一级指标,三者不区分会导致运维误判。
|
||||
|
||||
**约定**:统一显示服务器时间 + 时区标注;设备时间只出现在漂移列;相对时间悬停显示完整时间戳。
|
||||
|
||||
### S-17 租户维度 · P1
|
||||
|
||||
首期一客户一套私有实例,但架构 §3 要求「从第一版携带 `tenant_id` 并保持 SaaS-ready 边界」。不需要租户切换器,但需要当前租户可见、且所有列表按租户过滤这件事在 UI 上成立。
|
||||
|
||||
---
|
||||
|
||||
## 4. C 组 · 措辞与文档
|
||||
|
||||
### S-18 「暂停推理」措辞 · P0(一句话,但防的是重大实现错误)
|
||||
|
||||
现文:「只停止新推理任务」。这暗示的正是《03》§2.1 明令禁止的实现:
|
||||
|
||||
> `sourceOnDemand: yes` 的语义是「有 reader 才拉流」,而**推理 source 挂上去就是一个 reader**。因此「暂停分析」必须通过**卸载 source** 实现,不能只在推理插件里 `return`——后者会让 mediamtx 持续拉流、带宽白烧,**而监控上看起来一切正常**。
|
||||
|
||||
**改为**:「解除推理侧对该路的订阅;无其他观看者时上游自动停止拉流」。
|
||||
并把「是否仍在拉流」作为可观察结果显示——这正是那条教训要防的。
|
||||
|
||||
### S-19 `media_shard.max_streams` 标注 · P2
|
||||
|
||||
分片显示 `8/32`。架构 §6 写「初始建议 32,可按故障域降为 16,最终由压测确定」——数值有据,但 UI 需标注它是**可配置项**而非固定值,避免被当成硬上限。
|
||||
|
||||
### S-20 「推理绑定 16/16」的视觉 · P2
|
||||
|
||||
正常状态画成 100% 满格紫条,易被误读为告警/满载。换表现。
|
||||
|
||||
### S-21 ⚠️ 超出 write_paths:`api.md` 补审计投递接口 · P1
|
||||
|
||||
原型承诺「每次请求均审计」,架构 §2 定 Bell 是审计真相源,但 `api.md` §2 的五行内部接口**没有 Sense → Bell 的审计投递**。
|
||||
|
||||
需补一行并明确失败语义。**建议**:Bell 不可达时操作照常执行、审计落本地队列重传——与 Brain 投递失败的处理保持一致(架构 §7)。
|
||||
**处理方式**:另开任务,不在 T-004 内改。
|
||||
|
||||
---
|
||||
|
||||
## 5. D 组 · 只留信息架构位(P2,不实现)
|
||||
|
||||
避免 M3 时整页重做。三项各留一个导航位或详情页签占位即可:
|
||||
|
||||
| 编号 | 模块 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| S-22 | 推理绑定视图(设备 ↔ worker 绑定、注册/心跳/容量、绑定失败态) | `api.md` §2「Worker → 控制面」 |
|
||||
| S-23 | pre-roll 切片运维(请求量、失败率、耗时) | 架构 §5 第 3 条 |
|
||||
| S-24 | 设备型触发源配置(雷达/门磁/按钮/ONVIF 事件订阅) | 《08》§3 边界 2;与 S-06 同源 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 执行顺序
|
||||
|
||||
| 批次 | 条目 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **第 1 批** | S-01 · S-02 · S-03 · S-04 | **一次整页重生成**。四项都改信息架构,分次打补丁的返工成本已累积两轮 |
|
||||
| **第 2 批** | S-05 · S-06 · S-07 · S-08 · S-09 · S-18 | P0 页内新增 + 措辞。S-18 只是改文案,可随手带上 |
|
||||
| **第 3 批** | S-10 ~ S-17 | P1,M2 出口前补齐 |
|
||||
| **第 4 批** | S-19 · S-20 · S-22 · S-23 · S-24 | P2,留位与视觉 |
|
||||
| **独立任务** | S-21 | 超出 T-004 write_paths |
|
||||
|
||||
---
|
||||
|
||||
## 7. 总验收
|
||||
|
||||
第 1、2 批完成后应满足:
|
||||
|
||||
- [ ] 导航 8 项;顶栏有站点切换器、当前用户与角色
|
||||
- [ ] 「待收敛项」可下钻,能看到期望态/实际态差异、退避与孤儿资源
|
||||
- [ ] 切到只读角色,设备页所有写操作控件**消失**(非置灰)
|
||||
- [ ] 能新建 `non_imaging_only` 的 Area,并触发既有成像设备的显式处置流程
|
||||
- [ ] 「设备能力」区有内容,重新探测能显示能力差异
|
||||
- [ ] 选 radar 添加后进入台账,实际态为「适配器未就绪」,不计入视频配额
|
||||
- [ ] 待激活可筛选、可激活、失败有原因
|
||||
- [ ] 认证失败可识别、可就地更新凭据、可看到重试结果
|
||||
- [ ] 状态演示含「配额不可读」「区域策略不可读」,写操作被拒而视频与列表正常
|
||||
- [ ] 「暂停推理」措辞已改,且能观察到「是否仍在拉流」
|
||||
@@ -0,0 +1,165 @@
|
||||
# Bell 原型评审
|
||||
|
||||
> 基准:`yovision-T-004/docs/design/bell/index.html`(codex,2026-08-04 09:54,525 行 / 57 KB)
|
||||
> 对标:T-004 任务书方案 4 与不可变约束、`02-requirements` §3.3/§3.4/§8、`04-architecture` §8、IX-005~IX-013、`routes.md`
|
||||
> 日期:2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 总评
|
||||
|
||||
**质量明显高于 Sense 第一版。** 七项导航(值班台 / 事件中心 / 规则策略 / 升级链 / 运营报表 / 站点与 Area / 审计)基本覆盖 `routes.md` 的页面职责,而且几处最容易做错的领域约束都做对了。
|
||||
|
||||
问题集中在三点:**投递事实少了一态**、**事件与 Alert 的关联无法双向导航**、**值班场景缺交接班**。另外 Area 的归属需要一次跨文档裁决——并且要修正我此前在《12》S-03 里的建议。
|
||||
|
||||
---
|
||||
|
||||
## 1. 做对的(点名保护,重写时别丢)
|
||||
|
||||
| 项 | 证据 |
|
||||
| --- | --- |
|
||||
| **静默对话框** | 4h 写死在选项里标「(上限)」、原因必填、到期自动恢复,且明确**排除 S1 高危事件与设备离线运维告警** |
|
||||
| **误报对话框** | 「这只会追加事件 outcome,不会修改原始事件事实和证据」;原因必填;进复核队列 |
|
||||
| **规则对话框** | 场景模板 / 继承与覆盖 / 摄像头 / **检测区域带版本号 `ZONE-007 · v7`** / 生效时段 / 持续时间;试运行复选框**默认勾选**;主按钮是「保存并开始试运行」而不是「保存」 |
|
||||
| 规则与区域解耦 | 「空间坐标由摄像头详情维护」「**区域版本变化不会绕过规则试运行或自动正式发布**」——精确回应 T-004 不可变约束 |
|
||||
| **人脸能力零出现** | 全文 `人脸` 出现 **0** 次。IX-013 要求未授权租户「能力不存在而非按钮置灰」,零出现就是正确实现 |
|
||||
| Area 策略对话框 | `capture_policy` 二选一、变更原因必填进审计、生成新策略版本;并写明「**Sense 投影同步前会显示旧版本,不允许绕过后端准入检查**」 |
|
||||
| 策略冲突处置 | `policyConflictDialog` = IX-016 要求的「已有设备遇策略变化的显式处置流程」 |
|
||||
| 弱网与权限状态 | 「结构化事件已到达,视频证据暂不可用」(渐进加载)、「没有查看此站点证据的权限」 |
|
||||
| 投递时间线 | 三级链、每级时间、当前级「已送达,未确认」、下一级「预计 23:16:10 · 尚未发送」 |
|
||||
|
||||
---
|
||||
|
||||
## 2. P0
|
||||
|
||||
### 2.1 「已发出」与「已送达」被合并了
|
||||
|
||||
**证据**:全文 `已发出` 出现 **0** 次,`已送达` 4 次,`已看到` 1 次。
|
||||
|
||||
**依据**:
|
||||
|
||||
- `02-requirements` §3.4:「区分**已发出、已送达、已看到**;**没有回执不能当成功**」
|
||||
- T-004 不可变约束:「Alert 的**首次投递、送达、看到、ack 和升级**必须分开展示」
|
||||
|
||||
**问题**:时间线目前只有「已送达」这一档,无法表达**发出了但没拿到回执**——而这恰恰是告警系统最重要的失败态。短信网关返回 `202 Accepted` 不等于用户手机收到;语音呼叫接通不等于有人听。把 sent 直接显示成 delivered,正是那句「没有回执不能当成功」要防的事。
|
||||
|
||||
**建议**:投递时间线每一级至少四态,且**未拿到回执时必须显式呈现**:
|
||||
|
||||
```
|
||||
已发出 23:14:10 → 已送达 23:14:12 → 已看到 23:14:31 → 已确认 23:14:40
|
||||
已发出 23:14:13 → ⚠ 未收到回执(已等待 42s)
|
||||
```
|
||||
|
||||
对应 IX-007「失败不能伪装成功」,建议把这条从抽象要求细化为四态时间线的明确规格。
|
||||
|
||||
### 2.2 事件与 Alert 的关联无法双向导航
|
||||
|
||||
**依据**:`04-architecture` §8 明写——
|
||||
|
||||
> Event 与 Alert 不合并:**一个事件可触发多次预警与投递,一次预警也可聚合多个事件**。
|
||||
|
||||
**问题**:原型有「事件中心」和「值班台(待处置预警)」两个入口,方向是对的。但看不到这一对多/多对一关系的表达:
|
||||
|
||||
- 从一条 Alert 打开,它聚合了哪几条 Event?
|
||||
- 从一条 Event 打开,它触发过几次 Alert、分别什么结局?
|
||||
|
||||
这是 Bell 最核心的数据模型。如果 UI 上表达不出来,实现时极易退化成 Event 与 Alert 一对一,而聚合、去重、已处置抑制全部依赖这个多对多关系。
|
||||
|
||||
**建议**:Alert 详情增加「关联事件」列表(含聚合原因:同设备冷却 / 同站点聚合 / 已处置抑制);Event 详情增加「触发的预警」列表(含每次的最终状态)。两边互为深链。
|
||||
|
||||
### 2.3 Area 的归属需要裁决 —— 并修正我此前的建议
|
||||
|
||||
**现状**:Bell 原型把 Area 的 CRUD 与 `capture_policy` 编辑放在「站点与 Area」页。
|
||||
|
||||
**我的判断:这个归属比我此前的建议更对。** 我在《12》S-03 里把「站点与 Area 管理」列为 Sense 的待办,那是错的——Area 是带合规策略的**组织实体**,而 Bell 拥有 Site(配额真相源)、租户、RBAC 和审计。Area 作为 Site 的子级归 Bell 一致性更好。
|
||||
|
||||
**但由此产生两个后果,必须一并处理**:
|
||||
|
||||
1. **《12》S-03 需改写**:Sense 侧应为 Area **只读** + 设备写路径的准入拒绝;CRUD 归 Bell。原文的「Area 增删改」要移出 Sense 待办
|
||||
2. **跨 schema 读从一处变成两处**(配额 + Area 策略)。Bell 原型自己写了「Sense 投影同步前会显示旧版本」——这个窗口期正是投影方案的固有代价。而《13》A-2 建议的同库只读视图**可以直接消除它**:视图无同步延迟,Sense 读到的永远是当前策略版本
|
||||
|
||||
A-2 的价值因此翻倍,建议提到裁决队列最前。
|
||||
|
||||
---
|
||||
|
||||
## 3. P1
|
||||
|
||||
### 3.1 缺交接班
|
||||
|
||||
`routes.md`「值班台/大屏」节明写:「声光提示、ack 与**交接班记录**」。全文 `交接班` 出现 **0** 次。
|
||||
|
||||
这是值班室最真实的场景:22:00 换班,上一班未处置的预警怎么移交、新一班如何确认接手。没有交接班,「有人负责」这个产品承诺在班次边界上断掉。
|
||||
|
||||
**建议**:值班台增加交接班动作——列出未关闭预警、交接人与接手人、接手确认进审计;交接期间的升级链不中断。
|
||||
|
||||
### 3.2 并发 ack 的结果未表达
|
||||
|
||||
IX-006 要求「并发 ack 有清晰结果」。两个值班员同时点 ack 会怎样,原型没有表达。
|
||||
|
||||
**建议**:明确为「先到者成为处置人,后到者收到明确提示并看到当前处置人」,而不是静默覆盖或双双成功。
|
||||
|
||||
### 3.3 重启续跑不可观测
|
||||
|
||||
`02-requirements` §3.4「进程重启后能续跑」、`04-architecture` §7「Alert 先落库再投递,进程重启恢复未完成升级链」。
|
||||
|
||||
这是一条很强的可靠性承诺,但 UI 上没有任何可验证的表达。运维无法确认它真的生效。
|
||||
|
||||
**建议**:升级时间线中标注「服务重启于 23:15:02,升级链已恢复」这类事件;系统状态页给出「待恢复升级链 N 条」的指标。
|
||||
|
||||
### 3.4 状态覆盖:多了两个好的,少了整组失败态
|
||||
|
||||
**实测状态演示**:`normal / loading / empty / denied / weak` —— 五态,比 Sense 的四态更好:
|
||||
|
||||
- `denied` 无权限 → 对应 IX-013
|
||||
- `weak` 结构化事件已到、视频证据暂不可用 → 对应 App 节「弱网下先显示结构化事件,再渐进加载抓拍/视频」
|
||||
|
||||
这两个是一等演示态,不只是文案。
|
||||
|
||||
**但一个失败态都没有。** IX 清单「全局状态」节要求每个页面至少评估:
|
||||
|
||||
> 初始、加载、空数据、成功、**部分成功、可重试失败、不可重试失败**;无权限、**会话过期、网络断开、后端超时、数据已被他人修改**。
|
||||
|
||||
Bell 覆盖了加载 / 空 / 无权限,缺整组失败语义。其中两个对 Bell 尤其关键:
|
||||
|
||||
| 缺失态 | 为什么对 Bell 关键 |
|
||||
| --- | --- |
|
||||
| **数据已被他人修改** | 正是 §3.2 并发 ack 的表现形式,也是规则版本冲突的表现形式。缺它,两个问题都没有落点 |
|
||||
| **可重试 / 不可重试失败** | 投递失败要区分「短信网关超时,会重试」与「号码无效,不会重试」。IX-007「失败不能伪装成功」的另一半 |
|
||||
|
||||
**建议**:状态演示补 `conflict`(数据已被他人修改)与 `error-retryable` / `error-final` 两类失败态。
|
||||
|
||||
### 3.5 事件中心与审计缺分页
|
||||
|
||||
全文 `分页` 出现 **0** 次。`routes.md` 对 `/events` 要求分页与筛选,对 `/audit` 要求「只读、分页、按权限脱敏」。与 Sense 是同一个问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. P2
|
||||
|
||||
### 4.1 规则回滚偏薄
|
||||
|
||||
`回滚` 出现 1 次。IX-011 要求「支持取消/回滚」。试运行做得很扎实,但从「已正式发布」回退到「试运行」或「上一版本」的路径没有展开。
|
||||
|
||||
### 4.2 报表口径未对齐验收要求
|
||||
|
||||
`02-requirements` §8 规定:
|
||||
|
||||
> 效果指标**按规则分别报告召回率与每路每天误报数**;不使用跨场景统一「准确率」。
|
||||
|
||||
报表页目前是「近 7 日预警量」「处置结果」。这两个是运营口径,不是**验收口径**。而验收口径是要签进站点验收附件的,报表必须能直接出。
|
||||
|
||||
**建议**:报表页增加按规则维度的召回率与「每路每天误报数」,并显式标注「不提供跨场景统一准确率」——这句话本身就是对客户预期的管理。
|
||||
|
||||
---
|
||||
|
||||
## 5. 建议顺序
|
||||
|
||||
| 顺序 | 条目 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 1 | §2.1 投递四态 | 需求原文的硬要求,且是告警系统最重要的失败态 |
|
||||
| 2 | §2.2 Event↔Alert 双向导航 | 核心数据模型,画不出来实现就会退化成一对一 |
|
||||
| 3 | §2.3 Area 归属裁决 + 修订《12》S-03 + 提前 A-2 | 一次裁决消掉三处不一致 |
|
||||
| 4 | §3.1 交接班 | 值班场景的真实断点 |
|
||||
| 5 | §3.4 状态覆盖 | 补 `conflict` 态可同时给 §3.2 并发 ack 和规则版本冲突一个落点,一处改动解两个问题 |
|
||||
| 6 | §3.2、§3.3、§3.5 | 并发 ack、重启续跑可观测、分页 |
|
||||
| 6 | §4.1、§4.2 | 回滚与报表口径 |
|
||||
@@ -0,0 +1,109 @@
|
||||
# 指定摄像头型号准入记录
|
||||
|
||||
## 1. 结论
|
||||
|
||||
- 准入状态:`approved`,包含项目负责人对“三次真实网络断开恢复”的一次性证据豁免。
|
||||
- 准入范围:仅限本记录冻结的一台 HIKVISION `DS-2CD3321FD-IW1-T`、硬件版本 `0x0`、固件 `V5.5.61 build 180929`。
|
||||
- 不适用范围:其他型号、硬件版本、固件、生产批次、厂商、多台真实设备故障隔离、16/64/128 路容量和生产 SLA。
|
||||
- 测试日期:2026-08-04 至 2026-08-05(Asia/Shanghai)。
|
||||
|
||||
## 2. 脱敏设备与网络基线
|
||||
|
||||
| 项目 | 冻结值 |
|
||||
|---|---|
|
||||
| 厂商 | HIKVISION |
|
||||
| 型号 | `DS-2CD3321FD-IW1-T` |
|
||||
| 设备类型 | `IPCamera` |
|
||||
| 硬件版本 | `0x0`;ONVIF HardwareId 为 `88` |
|
||||
| 固件 | `V5.5.61 build 180929` |
|
||||
| 编码器 | `V7.1 build 180929` |
|
||||
| 样机标识 | 序列号 SHA-256 前 12 位:`e9ed6a555ae0`;不保存原始序列号 |
|
||||
| 网络 | 公网 DDNS + NAT 端口映射;不记录主机名、内网地址或完整 URI |
|
||||
| RTSP | TCP 传输;外部端口 554 |
|
||||
| ONVIF | SOAP 1.2 + WS-Security UsernameToken PasswordDigest;外部端口 8008 |
|
||||
| 凭据 | RTSP 与 ONVIF 使用独立配置项;只存在本机私有 `ip_camera.env`,不得提交仓库 |
|
||||
|
||||
摄像头通过 ONVIF 返回内网 XAddr 和内网 RTSP 地址。公网/NAT 接入时,Sense 必须保留服务路径并把主机与端口重写为部署配置;不得把本次私有地址或海康域名写死在业务代码中。
|
||||
|
||||
## 3. 可重复测试步骤
|
||||
|
||||
1. 在仓库外或被 Git 忽略的 `ip_camera.env` 中配置主机、RTSP/ONVIF 端口以及两组独立凭据。
|
||||
2. 通过 ONVIF `GetDeviceInformation` 与海康 ISAPI `GET /ISAPI/System/deviceInfo` 交叉核对型号、硬件和固件;序列号只生成摘要,不保存原值。
|
||||
3. 依次调用 `GetProfiles`、`GetStreamUri`、`GetSystemDateAndTime` 与 `SetSystemDateAndTime`。时间设置测试先读取模式、时区和夏令时,再原样写回;手动模式只校准 UTC。
|
||||
4. 使用一次性不存在的用户名分别请求 RTSP 与 ONVIF,确认被拒绝;随后立即使用正确凭据请求,确认没有误锁定。
|
||||
5. 使用 FFmpeg 通过 RTSP over TCP 同时解码主、子码流,不保存视频:
|
||||
|
||||
```text
|
||||
ffmpeg -hide_banner -loglevel warning -nostats -progress pipe:1 \
|
||||
-rtsp_transport tcp -i "rtsp://<user>:<password>@<host>:<port>/<path>" \
|
||||
-map 0:v:0 -an -t 605 -f null -
|
||||
```
|
||||
|
||||
6. 在持续测试前后分别读取摄像头 UTC,记录相对测试机 UTC 的偏差。
|
||||
7. 原规格要求真实断网并恢复至少三次;本轮因项目负责人不在摄像头现场未执行,按第 6 节的负责人裁决处理。
|
||||
|
||||
命令行示例只使用占位符。实际账号、密码、主机名、完整 RTSP URI、ONVIF XAddr、可复用 token 和视频画面均未写入本记录。
|
||||
|
||||
## 4. 实测结果
|
||||
|
||||
### 4.1 ONVIF 与设备身份
|
||||
|
||||
| 检查项 | 结果 | 脱敏证据摘要 |
|
||||
|---|---|---|
|
||||
| `GetDeviceInformation` | PASS | HTTP 200;厂商/型号/固件与 ISAPI 一致 |
|
||||
| `GetProfiles` | PASS | HTTP 200;返回 `mainStream`、`subStream` 两个 Profile |
|
||||
| `GetStreamUri` | PASS | HTTP 200;分别指向通道 `101`、`102`,URI 不内嵌凭据 |
|
||||
| `SetSystemDateAndTime` | PASS | HTTP 200;写后读取成功,Manual、`CST-8:00:00`、关闭夏令时均保持不变 |
|
||||
| NAT 地址处理 | OBSERVED | 返回地址需要重写主机与端口;服务路径和通道路径可保留 |
|
||||
|
||||
### 4.2 RTSP 主/子码流
|
||||
|
||||
| 通道 | 媒体属性 | 连续媒体时长 | 解码帧数 | 退出码 | 警告/错误 |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| 主码流 `101` | H.264,1920×1080,18 fps;AAC 16 kHz 单声道 | 605.0 秒 | 10,890 | 0 | 0 |
|
||||
| 子码流 `102` | H.264,704×576,15 fps | 605.0 秒 | 9,076 | 0 | 0 |
|
||||
|
||||
两路同时运行,墙钟时长约 607 秒。测试只解码并丢弃输出,没有保存人物画面或媒体文件。
|
||||
|
||||
### 4.3 认证失败与恢复
|
||||
|
||||
| 协议 | 错误凭据 | 随后正确凭据 |
|
||||
|---|---|---|
|
||||
| RTSP | `401 Unauthorized`,PASS | 主码流探测成功,PASS |
|
||||
| ONVIF | SOAP 未授权,HTTP 400,PASS | `GetDeviceInformation` HTTP 200,PASS |
|
||||
|
||||
每个协议只执行一次无效身份请求,并使用不存在的临时用户名,避免连续错误触发真实账号锁定。
|
||||
|
||||
### 4.4 时间漂移
|
||||
|
||||
| 采样点 | 摄像头相对测试机 UTC 偏差 |
|
||||
|---|---:|
|
||||
| 605 秒双码流测试前 | 2.3 秒 |
|
||||
| 测试后 | 2.8 秒 |
|
||||
| 偏差增长 | 0.5 秒 |
|
||||
|
||||
摄像头时间接口只提供整秒精度,网络往返也计入样本;本结果证明本次观察窗口未出现明显漂移,不构成长周期时钟稳定性承诺。
|
||||
|
||||
## 5. 失败与工具兼容记录
|
||||
|
||||
- 首次 605 秒测试在打开输入前失败:本机 FFmpeg 不支持 `-rw_timeout` 参数,两路均未连接摄像头。删除不兼容参数后重新从零执行完整 605 秒测试并通过;失败记录不计为摄像头故障,也不删除。
|
||||
- ONVIF 初次启用时,设备曾返回“ONVIF integrate function is disabled”;启用集成协议后服务可发现。独立 ONVIF 用户初次权限不足,后续调整后设备信息、媒体读取和时间设置均通过。
|
||||
|
||||
## 6. 断网恢复负责人豁免
|
||||
|
||||
- 原门禁:真实网络断开与恢复至少三次,并保留每次时间线。
|
||||
- 实际情况:项目负责人不在摄像头旁边,2026-08-05 明确要求“第 4 点当作已完成”。本轮没有执行摄像头断电、物理断网或端口映射中断,也没有三次恢复原始时间线。
|
||||
- 治理处理:该项标记为 `WAIVED`,由项目负责人承担验收裁决;T-001 可关闭,但证据不得表述为 `PASS` 或“已验证自动恢复”。
|
||||
- 下游约束:T-006 仍须独立验证真实摄像头/网络短时离线、合成 publisher、Sense 和 MediaMTX 四类恢复;本豁免不能被 T-006、T-007 或生产 SLA 继承。
|
||||
|
||||
## 7. 安全与局限
|
||||
|
||||
- `ip_camera.env` 已加入 `.gitignore`;仓库只记录字段语义,不记录实际凭据。
|
||||
- 本次通过公网/NAT 测试,不能替代隔离局域网、客户现场弱网或跨 VLAN 发现测试。
|
||||
- 单台旧固件样机只能证明这一精确基线可接入,不能证明同型号其他批次或更新固件兼容。
|
||||
- 本记录没有评估画质、码率上限、夜视、音频质量、存储、AI 检出率或多路容量。
|
||||
- 公网不应长期裸露 RTSP/ONVIF;生产部署需要 VPN、专网或来源 IP 白名单。
|
||||
|
||||
## 8. 最终准入裁决
|
||||
|
||||
精确基线 HIKVISION `DS-2CD3321FD-IW1-T` / 硬件 `0x0` / 固件 `V5.5.61 build 180929` 标记为 `approved`。该裁决包含第 6 节的负责人证据豁免;任何型号、硬件或固件变化均恢复为未验证状态,并至少重跑受影响矩阵。
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sense 单实机与五路混合源集成记录
|
||||
|
||||
> 执行日期:2026-08-07
|
||||
>
|
||||
> 结论:PASS(M1 实验室集成 smoke)
|
||||
|
||||
## 1. 验收范围与结论
|
||||
|
||||
本轮使用 T-001 已批准的 1 台真实 HIKVISION `DS-2CD3321FD-IW1-T`(硬件 `0x0`、固件 `V5.5.61 build 180929`)和 4 个可独立启停的 FFmpeg 合成 RTSP publisher,完成 5 路期望态、MediaMTX path、运行态探活和自动恢复闭环。
|
||||
|
||||
正式观察持续 `1806.6` 秒,共读取 `180` 个脱敏收敛快照;`maximum_unconverged = 0`、`final_unconverged = 0`。真实摄像头网络路径、单个合成 publisher、Sense 进程和 MediaMTX 进程四类恢复均通过,无需人工改 MediaMTX 配置或重建 SQLite。
|
||||
|
||||
该结论只证明“一台已准入实机 + 四条无人物合成源”的 M1 软件闭环,不证明五台真实设备兼容、故障隔离、默认 16 路或最大 128 路容量,也不构成生产 SLA。五条独立真实上游仍由 T-007 验收。
|
||||
|
||||
## 2. 实测架构
|
||||
|
||||
```text
|
||||
真实摄像头 -- RTSP/TCP -- 故障代理 :10554 --+
|
||||
|
|
||||
4 × FFmpeg -- publisher --> 源 MediaMTX :8555 +--> 生产 MediaMTX :8554
|
||||
^
|
||||
SQLite 期望态 --> Sense reconcile --> ONVIF/router ----+ API :9997
|
||||
^ |
|
||||
+---- Sense probe +---- 运行态反馈/重新对账
|
||||
```
|
||||
|
||||
- SQLite 是期望态真相源;设备台账只保存 `env://camera` 凭据引用。
|
||||
- HTTP/HTTPS endpoint 进入标准 ONVIF SOAP 1.2 + WS-Security PasswordDigest adapter;合成 `rtsp://` endpoint 由 router 直接映射,不伪装 ONVIF。
|
||||
- ONVIF adapter 获取设备信息、服务、profile 和 stream URI,在内存中注入 RTSP 凭据,并支持显式 NAT 主机/端口重写。错误不回显 endpoint、URI 或凭据。
|
||||
- 生产 MediaMTX path 只能由 Sense 对账通过 API 创建;`sense-lab` 只负责实验室播种和读取脱敏状态,不是冻结的公共设备管理 API。
|
||||
- probe 发现 MediaMTX path 丢失时使已观察代失效,触发重新对账,因此 MediaMTX 空配置重启后能从 SQLite 自动恢复五条 path。
|
||||
- 真实网络故障由本地 TCP 代理进程启停注入;不修改摄像头配置,也不继承 T-001 的物理断网豁免。
|
||||
|
||||
## 3. 固定版本与产物
|
||||
|
||||
| 组件 | 实测版本 / 摘要 |
|
||||
|---|---|
|
||||
| Sense | `dev`,Go `1.23.0 windows/amd64`;同源重建 `sense-api.exe` SHA-256 `5d49b91ff6c76802da3096e9155f6496187059a6a97114fbeb4e9b5a39128983` |
|
||||
| MediaMTX | `v1.19.3`,Windows amd64 发布包 SHA-256 `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe` |
|
||||
| FFmpeg | `8.1.2-full_build-www.gyan.dev`,4 个独立 `libx264` publisher |
|
||||
| SQLite driver | `modernc.org/sqlite v1.38.2`,随 Sense module 锁定 |
|
||||
|
||||
二进制、数据库、临时清单、日志和媒体都位于系统临时目录并在运行后删除;仓库不保存摄像头主机、内网地址、账号、密码、完整 RTSP/ONVIF URI、人物画面或可复用 token。
|
||||
|
||||
## 4. 可重复步骤
|
||||
|
||||
1. 将 T-001 格式的 `ip_camera.env` 放在仓库外或 Git 忽略路径;字段为 `host`、`username`、`password`、`rtspport`、`onvif`、`onvifuser`、`onvifpwd`。
|
||||
2. 确保本机 `8554`、`8555`、`9997`、`10554`、`18080` 未被占用,并安装 Go 1.23 与带 `libx264` 的 FFmpeg。
|
||||
3. 执行正式脚本;默认观察 30 分钟:
|
||||
|
||||
```powershell
|
||||
./Sense/scripts/t006-integration.ps1 -CameraEnv D:\path\to\ip_camera.env
|
||||
```
|
||||
|
||||
4. 脚本校验 MediaMTX 发布包 SHA-256,构建三个 Go 命令,启动两套 MediaMTX 和四个独立 publisher,播种一条真实设备和四条合成设备,再依次执行四类故障。
|
||||
5. 成功输出必须同时满足:`source_count = 5`、`synthetic_publishers = 4`、`configured_paths = 5`、每类恢复 `unconverged = 0`、观察至少 1800 秒、`maximum_unconverged = 0`、`final_unconverged = 0`。
|
||||
|
||||
调试时可加 `-ObservationMinutes 1`,但短窗口不能替代正式证据。本轮先完成 `60.6` 秒、6 次采样的 smoke,随后才执行正式窗口。
|
||||
|
||||
## 5. 正式结果
|
||||
|
||||
| 项目 | 结果 | 实测值 |
|
||||
|---|---|---:|
|
||||
| 初始五路自动收敛 | PASS | `8.2 s` |
|
||||
| 真实摄像头网络路径离线探测 | PASS | `1.0 s` |
|
||||
| 真实摄像头网络路径恢复 | PASS | `5.1 s` |
|
||||
| 单个合成 publisher 离线探测 | PASS | `1.0 s` |
|
||||
| 单个合成 publisher 恢复 | PASS | `5.1 s` |
|
||||
| Sense 重启到健康 | PASS | `0.5 s` |
|
||||
| Sense 重启后重新收敛 | PASS | `0.0 s`(SQLite 已保持一致) |
|
||||
| MediaMTX 空配置重启后恢复 | PASS | `2.1 s` |
|
||||
| 自动配置 path 数 | PASS | `5` |
|
||||
| 连续观察 | PASS | `1806.6 s` / `180` 次采样 |
|
||||
| 观察期最大 / 最终未收敛数 | PASS | `0 / 0` |
|
||||
|
||||
一分钟 smoke 的恢复值为:初始 `5.1 s`、真实网络 `5.1 s`、合成 publisher `5.1 s`、Sense `0.0 s`、MediaMTX `3.1 s`,观察 `60.6 s` / 6 次采样,最大和最终未收敛数均为 0。
|
||||
|
||||
## 6. 实机兼容发现与裁决
|
||||
|
||||
该旧固件通过 ONVIF 返回的主码流路径正确,但 URI 携带 `transportmode/profile` 查询串;使用正确 RTSP 账号请求该完整 URI仍返回 `401 Unauthorized`。相同凭据、主机、端口和通道路径仅移除查询串后,可立即解码 H.264 1920×1080。
|
||||
|
||||
因此实现增加 `SENSE_ONVIF_RTSP_STRIP_QUERY`,默认 `false`,仅对已经实测需要该兼容行为的部署显式设为 `true`。默认仍保留 ONVIF URI 查询语义;代码不按厂商名或型号硬编码,也不把该发现外推到其他海康型号、固件或品牌。
|
||||
|
||||
## 7. 失败记录与修正
|
||||
|
||||
| 现象 | 原因 | 修正与防回归 |
|
||||
|---|---|---|
|
||||
| 第二套 MediaMTX API 未启动 | 合成实例同时占用默认 UDP、RTMP/HLS/WebRTC/SRT/MoQ 端口 | 合成 fixture 限制为 RTSP over TCP,并关闭无关协议 |
|
||||
| Sense 已监听但 PowerShell 健康检查超时 | 测试机配置全局 HTTP 代理,本地请求未显式绕过 | 本地 `Invoke-RestMethod` 使用 `-NoProxy` |
|
||||
| Sense 未读取实验环境变量 | PowerShell 空数组位置参数被省略,哈希表错绑为命令参数 | `Start-ManagedProcess` 改用显式命名参数 |
|
||||
| 真实 path 长期 offline | 准入旧固件返回的 RTSP 查询串触发 401 | 增加默认关闭、按部署启用的 strip-query 兼容开关和单测 |
|
||||
| 观察期一次状态读取非零 | 独立状态进程与单写者 SQLite 短暂竞争 | 状态读取增加 5 次、250 ms 有界重试;持续失败仍终止验收 |
|
||||
|
||||
失败样本未从矩阵删除;每次修正后均从头重跑四类故障。正式结果来自最终完整运行,不拼接前序成功片段。
|
||||
|
||||
## 8. 限制与后续
|
||||
|
||||
- 公网/NAT 样机只用于受控开发验证;生产环境必须使用 VPN、专网或来源白名单,不应长期裸露 ONVIF/RTSP。
|
||||
- 单台精确固件基线不能证明批次差异、多品牌兼容或五台真实设备的并发故障隔离。
|
||||
- 合成流是 640×360、10 fps、H.264 无音频测试图案,不代表真实码率、音频、夜视、弱网、存储或 AI 负载。
|
||||
- 本任务没有验证 16/64/128 路容量;默认 16、单站点最大 128 的产品配额语义保持不变,容量与 GPU 承诺需独立压测。
|
||||
- `/healthz` 只表示 Sense 进程存活;设备收敛必须继续查看脱敏快照/指标,不能把进程健康等同于业务健康。
|
||||
+9
-6
@@ -9,13 +9,14 @@
|
||||
| `/overview` | 站点、设备、事件和预警摘要 | 业务预警与运维告警分区 |
|
||||
| `/sites` | 站点列表、配额与状态 | 显示 16 默认/128 上限,不暗示单机能力 |
|
||||
| `/sites/:siteId/devices` | 设备列表、批量导入/启停、健康 | 分页/虚拟列表、逐项结果、不泄露凭据 |
|
||||
| `/events` | 事件筛选与批量处置入口 | 事件与预警状态分开显示 |
|
||||
| `/events/:eventId` | 事实、证据、时间线、outcome | 权限最小化;弱网渐进加载 |
|
||||
| `/alerts` | 待 ack、升级中、已结束预警 | 清楚区分投递/送达/看到/ack |
|
||||
| `/rules` | 场景包、规则继承、区域/时段配置 | 显示继承来源与试运行状态 |
|
||||
| `/escalations` | 联系人、通道、超时与静默 | 双路径;静默 ≤4h,无永久项 |
|
||||
| `/events` | 事件筛选与批量处置入口 | 事件与预警状态分开显示;服务端分页,不一次加载全量 |
|
||||
| `/events/:eventId` | 事实、证据、时间线、outcome 与触发的 Alert | 权限最小化;弱网渐进加载;与 Alert 双向导航 |
|
||||
| `/alerts` | 待 ack、升级中、已结束预警 | 清楚区分已发出/已送达/已看到/ack;支持值班交接 |
|
||||
| `/alerts/:alertId` | Alert 处置、投递/升级事实与关联 Event | 展示聚合原因;ack 竞争不覆盖;与 Event 双向导航 |
|
||||
| `/rules` | 场景包、规则继承、区域/时段配置 | 显示继承来源、试运行、不可变版本和回滚状态 |
|
||||
| `/escalations` | 升级策略、值班排班、联系人和通道 | 一个一级入口下渐进展示三个二级模块;共享人员/值班组/已验证通道主数据但分对象建模;策略使用人员/组/排班类型化目标,支持解析预览、排班版本/替班/冲突与投递快照;双路径,静默 ≤4h,无永久项 |
|
||||
| `/operations` | 设备/流/分片/对账运维 | 不与业务预警混在同一队列 |
|
||||
| `/audit` | 审计查询 | 只读、分页、按权限脱敏 |
|
||||
| `/audit` | 审计查询 | 只读、服务端分页、按权限脱敏;深链筛选参数仅按文本渲染 |
|
||||
|
||||
这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。
|
||||
|
||||
@@ -26,6 +27,8 @@
|
||||
- 设备:最小健康状态,不提供未经授权的常态监控。
|
||||
- 我的:联系人、通知偏好和限时静默。
|
||||
|
||||
Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审计通过顶部“管理”菜单进入,不能因为窄屏隐藏侧栏而失去入口。桌面侧栏把值班/事件/规则/升级/报表与 Site/Area/审计分组显示。
|
||||
|
||||
## 值班台/大屏
|
||||
|
||||
- 实时事件流与高优先级预警。
|
||||
|
||||
+52
-18
@@ -1,22 +1,28 @@
|
||||
---
|
||||
id: T-001
|
||||
title: 建立摄像头兼容性实验矩阵与采购白名单
|
||||
title: 完成指定摄像头型号准入验证
|
||||
phase: 0
|
||||
deps: []
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
issue: 1
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
context_ref: 56c07427c86222f6dccaf0574f59cfaba9e3103c
|
||||
claim_branch: claims/T-001
|
||||
work_branch: agent/codex/T-001
|
||||
write_paths:
|
||||
- .gitignore
|
||||
- docs/tasks/T-001.md
|
||||
- docs/tasks/T-006.md
|
||||
- docs/research/camera-compatibility.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/06-tasks.md
|
||||
- docs/current-state.md
|
||||
- docs/quality-document.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
YoVision 尚未用真实候选摄像头验证 ONVIF/RTSP 厂商差异。直接开始生产接入会把未知兼容性问题带进 Sense,M0 出口要求先验证 3–5 款设备并形成采购白名单。
|
||||
YoVision 尚未用真实摄像头验证 ONVIF/RTSP 接入。项目负责人决定首期统一采购一个指定型号,不在 M0 承担多品牌兼容成本;因此 M0 出口改为使用至少一台实物,对明确的“厂商 + 型号 + 硬件版本 + 固件版本”组合完成准入验证。该结论只证明指定基线可用,不能外推到同厂商其他型号、其他固件或多品牌兼容。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
@@ -26,28 +32,29 @@ YoVision 尚未用真实候选摄像头验证 ONVIF/RTSP 厂商差异。直接
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在隔离实验室选定 3–5 款候选设备,记录型号、固件、认证方式和网络条件。
|
||||
2. 为每款设备执行 GetProfiles、GetStreamUri、SetSystemDateAndTime、主/子码流、认证失败、掉线恢复和时间漂移测试。
|
||||
3. 在 `docs/research/camera-compatibility.md` 固化步骤、原始证据摘要、差异、限制和采购结论。
|
||||
4. MiBeeNvr 可直接运行作测试台,也可按白名单阅读参考代码;不修改 `_reference/`,临时配置和代码不进入生产基线。
|
||||
1. 在隔离实验室冻结首期指定设备的厂商、型号、硬件版本、固件版本、认证方式和网络条件;至少使用一台可核验身份的真实样机。
|
||||
2. 对该基线执行 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`、主/子码流、认证失败、掉线恢复和时间漂移测试。主/子码流分别连续观察至少 10 分钟,网络断开与恢复至少重复 3 次,并保留每次时间线。
|
||||
3. 在 `docs/research/camera-compatibility.md` 固化可重复步骤、脱敏原始证据摘要、逐项结果、限制和准入结论;文档标题和结论使用“指定型号准入”,不宣称多品牌兼容矩阵。
|
||||
4. 必过项任一失败时,该型号不得准入;项目负责人可改选另一个指定型号,但必须对新型号从头执行完整矩阵,失败记录不得删除。
|
||||
5. MiBeeNvr 可直接运行作测试台,也可按白名单阅读参考代码;不修改 `_reference/`,临时配置和代码不进入生产基线。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:候选设备 3–5 款;每款三个 ONVIF 核心操作全部有证据。
|
||||
- 判定式 / 状态转换:只有必过项全部通过才进入采购白名单;失败设备记录原因而不是删除记录。
|
||||
- 阈值 / 数值边界:一个指定型号、至少一台真实样机即可验收;三个 ONVIF 核心操作全部有证据,主/子码流各观察至少 10 分钟,断线恢复至少 3 次。
|
||||
- 判定式 / 状态转换:准入对象是精确的“厂商 + 型号 + 硬件版本 + 固件版本”组合;只有必过项全部通过才标记 `approved`。型号、硬件版本或固件发生变化时默认视为未验证,至少重跑受影响项并由硬件负责人重新批准。
|
||||
- 安全边界:只用自购实验设备和隔离网络;不接真实住户、学校或客户摄像头;不记录密码、完整 RTSP 凭据或可复用 token。
|
||||
- 既有契约:`_reference/` 只读,M0 产物不得成为生产 NVR 或长期依赖。
|
||||
- 既有契约:`_reference/` 只读,M0 产物不得成为生产 NVR 或长期依赖;业务代码仍按标准 ONVIF/RTSP 和 adapter 边界实现,不得把准入厂商、型号或地址写死。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:文档包含设备矩阵、可重复命令/步骤、逐项结果和采购白名单;运行三条 harness 治理命令。
|
||||
- 完整门禁:涉及脚本时在隔离环境对全部 3–5 款设备重复执行;没有生产代码则不触发 Go/Python 完整门禁。
|
||||
- 人工 / 设备验收:必需;由实施/硬件负责人核对型号、固件和原始测试证据。
|
||||
- 任务相关验证:文档包含指定基线身份、样机标识的脱敏摘要、可重复命令/步骤、逐项结果、失败记录、单样本限制和准入结论;运行三条 harness 治理命令。
|
||||
- 完整门禁:涉及脚本时在隔离环境对指定样机重复执行完整矩阵;没有生产代码则不触发 Go/Python 完整门禁。
|
||||
- 人工 / 设备验收:必需;由实施/硬件负责人核对实物、型号、硬件版本、固件和原始时间线,并书面确认单样本不能证明批次一致性或多型号兼容。
|
||||
- 构建产物:不适用;交付物为 `docs/research/camera-compatibility.md`。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不创建 Sense 生产脚手架,不修改 `_reference/`,不评估 AI 检出率,不承诺 16/128 路容量。
|
||||
不创建 Sense 生产脚手架,不修改 `_reference/`,不评估 AI 检出率,不承诺 16/128 路容量,不输出多品牌兼容结论,也不把一台样机结果当作批次质量抽检。
|
||||
|
||||
## 协作约束
|
||||
|
||||
@@ -60,4 +67,31 @@ YoVision 尚未用真实候选摄像头验证 ONVIF/RTSP 厂商差异。直接
|
||||
|
||||
## 执行记录
|
||||
|
||||
尚未领取。
|
||||
### 2026-08-04 暂缓
|
||||
|
||||
- 项目负责人决定暂缓 T-001;任务尚未领取,也未产生摄像头兼容性结论。
|
||||
- 当前阻塞条件:暂不安排 3–5 款候选摄像头、对应固件/ONVIF 账号和隔离实验室网络进行实测。
|
||||
- 解除条件:上述设备与测试条件准备完成。恢复后仍须完成原任务的逐款证据和人工/设备验收,不得用模拟器结果替代采购白名单。
|
||||
- 本阻塞不妨碍独立的 Sense 离线软件骨架,但继续阻止真实设备兼容性结论和 5 路实机验收。
|
||||
|
||||
### 2026-08-04 重构为指定型号准入
|
||||
|
||||
- 项目负责人确认首期只有一台摄像头,并决定统一采购一个指定型号;T-001 从“3–5 款兼容性矩阵”收敛为“一个精确型号/固件基线、至少一台真实样机”的准入验证。
|
||||
- 数量门槛降低不等于降低必过项:保留三个 ONVIF 核心操作、主/子码流、认证失败、校时/漂移和掉线恢复,增加主/子码流各 10 分钟及 3 次断线恢复的最低证据要求。
|
||||
- 准入结论不得外推到其他型号、硬件版本、固件、生产批次或多品牌兼容。后续 T-006 使用该一台实机 + 至少 4 条独立合成 RTSP 源完成五路软件闭环;真实多设备证据由 T-007 在客户/借用/租赁条件具备后完成。
|
||||
|
||||
### 2026-08-04 单海康样机开发基线
|
||||
|
||||
- 项目负责人确认当前唯一实机为 Hikvision IP Camera,后续本地硬件开发均使用该样机;领取 T-001 后仍须从实物读取并冻结精确型号、硬件版本和固件,不能只记录品牌。
|
||||
- 多路功能、配额和容量验证允许使用无人物合成 RTSP 源;合成结果不扩展 T-001 的准入范围,也不证明多台真实设备兼容或故障隔离。
|
||||
|
||||
### 2026-08-05 指定型号准入完成
|
||||
|
||||
- dispatcher `ila` 将任务分配给 `codex`;`context_ref` 为 `56c07427c86222f6dccaf0574f59cfaba9e3103c`,claim 为 `claims/T-001`,工作分支为 `agent/codex/T-001`。
|
||||
- 冻结基线为 HIKVISION `DS-2CD3321FD-IW1-T`、硬件版本 `0x0`、固件 `V5.5.61 build 180929`、编码器 `V7.1 build 180929`;序列号仅保留 SHA-256 前 12 位摘要,不保存原值。
|
||||
- ONVIF `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime` 均返回成功;主/子 Profile 分别映射到脱敏后的 `101`/`102` 通道。设备返回内网 XAddr,公网/NAT 环境必须由接入层重写主机和端口。
|
||||
- 错误 RTSP 和 ONVIF 凭据均被拒绝,随后正确凭据立即恢复;未发现错误账号被接受或有效账号锁定。
|
||||
- 主、子码流通过 RTSP over TCP 并行持续解码 605 秒:主码流 10,890 帧、子码流 9,076 帧,两路退出码均为 0,警告/错误均为 0。
|
||||
- 流测试前后摄像头 UTC 偏差分别为 2.3 秒和 2.8 秒,10 分钟增长 0.5 秒;时间模式、`CST-8:00:00` 时区和夏令时配置在权限测试后保持不变。
|
||||
- 项目负责人因不在摄像头现场,明确裁决将“3 次物理网络断开与恢复”按完成处理。本次没有执行真实断网,也没有三次恢复时间线;该项以 `WAIVED` 留痕,不得对外宣称已经实测自动恢复,T-006 的恢复门禁仍须独立执行。
|
||||
- 脱敏步骤、结果、限制和准入结论见 `docs/research/camera-compatibility.md`。本任务结论为 `approved`(含上述负责人豁免),仅适用于本文件冻结的单台、单型号、单硬件和单固件基线。
|
||||
|
||||
+55
-21
@@ -1,19 +1,21 @@
|
||||
---
|
||||
id: T-003
|
||||
title: 建立 Sense M1 接入骨架
|
||||
title: 建立 Sense M1 无实机接入骨架
|
||||
phase: 1
|
||||
deps: [T-001, T-002]
|
||||
status: TODO
|
||||
deps: [T-002]
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
issue: null
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
issue: 3
|
||||
context_ref: e28070dd035cef3ff3e4a2dad879a482c41dac3a
|
||||
claim_branch: claims/T-003
|
||||
work_branch: agent/codex/T-003
|
||||
write_paths:
|
||||
- docs/tasks/T-003.md
|
||||
- Sense/
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/api.md
|
||||
- docs/06-tasks.md
|
||||
- docs/current-state.md
|
||||
- init.ps1
|
||||
- init.sh
|
||||
@@ -21,7 +23,7 @@ write_paths:
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
当前 `Sense/` 只有占位文件。M1 需要在不继承完整 MiBeeNvr 架构的前提下建立可持续演进的 Go 生产骨架,并正式引入 MediaMTX 数据面。
|
||||
当前 `Sense/` 只有占位文件。M1 需要在不继承完整 MiBeeNvr 架构的前提下建立可持续演进的 Go 生产骨架,并正式引入 MediaMTX 数据面。T-001 的真实摄像头兼容性验证已由项目负责人暂缓,因此本任务只建立可由 fake ONVIF、MediaMTX 假服务和合成 RTSP 源验证的无实机骨架;不得据此宣称摄像头兼容、5 路实机验收、M0 出口或完整 M1 出口已经完成。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
@@ -31,40 +33,72 @@ write_paths:
|
||||
|
||||
## 方案
|
||||
|
||||
1. 冻结 Go、SQLite 和 MediaMTX 的精确版本,记录许可证与升级策略。
|
||||
2. 建立 `cmd/sense-api` 与 `internal/onvif`、`store`、`mtx`、`reconcile`、`probe` 的最小目录和测试。
|
||||
3. 从 MediaMTX 官方 OpenAPI 生成客户端,手写代码只放薄封装。
|
||||
4. 设备台账先使用 SQLite,但 schema 语义与生产 PostgreSQL `sense` schema 保持一致。
|
||||
5. 跑通 5 路自动建 path、探活和断线重建的可重复集成测试。
|
||||
6. 将真实安装、验证和启动命令同步到标准入口与文档。
|
||||
1. 冻结 Go、SQLite driver、MediaMTX 和 `oapi-codegen` 的精确版本,记录许可证、校验来源、升级策略与退出路线。
|
||||
2. 建立 `cmd/sense-api` 与 `internal/onvif`、`store`、`mtx`、`reconcile`、`probe` 的最小目录、配置边界和测试;入口只绑定可信网络,日志不得泄露凭据或完整流地址。
|
||||
3. 从锁定版本的 MediaMTX 官方 OpenAPI 可重复生成客户端,生成代码与手写薄封装分离;使用假 HTTP 服务验证创建、读取和删除 path 的请求/响应映射。
|
||||
4. 以 port/adapter 隔离 ONVIF,提供确定性的 fake adapter 和脱敏 fixture,验证 profile、stream URI、校时、认证失败和超时的领域映射,但不伪造厂商兼容结论。
|
||||
5. 设备台账先使用 SQLite migration,schema 语义与生产 PostgreSQL `sense` schema 保持一致;建模使用 `modality + capabilities`,配额默认 16、最大 128,并覆盖 17/128/129 边界。
|
||||
6. 建立数据库期望态驱动的最小对账/探活循环,使用 fake ONVIF、MediaMTX 假服务和可选合成 RTSP 源验证幂等、退避、取消与重启恢复;本任务不接真实摄像头,也不实现孤儿删除。
|
||||
7. 将真实安装、生成、验证、构建和启动命令同步到标准入口与文档;后续 T-006 使用 T-001 指定实机 + 独立合成 RTSP 源完成五路实验室集成,T-007 再补真实多路现场证据。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:验收 5 路;`site.max_video_channels` 默认 16、最大 128,必须测试 17/128/129 边界,不能把 5 或 16 写成架构上限。
|
||||
- 阈值 / 数值边界:本任务不验收真实 5 路;`site.max_video_channels` 默认 16、最大 128,必须测试 17/128/129 边界,不能把 fake 数量、5 或 16 写成架构上限。
|
||||
- 判定式 / 状态转换:数据库是期望态真相源;对账幂等、只收敛不跨系统回滚;配额不可用不影响已有流。
|
||||
- 安全边界:不提交摄像头凭据;调试/MediaMTX 管理端口不暴露到非可信网络;孤儿删除暂不实现或必须有 10% 安全闸。
|
||||
- 既有契约:M1 只动 Sense,不创建 Brain/Bell 业务代码;完整 MiBeeNvr 不进入依赖;MediaMTX 独立二进制。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:`go test ./...`、`go vet ./...`、5 路集成 smoke、断线恢复测试,以及三条 harness 治理命令。
|
||||
- 任务相关验证:`go test ./...`、`go vet ./...`、SQLite migration/配额边界、ONVIF fake、MediaMTX 假服务、对账幂等与重启恢复测试,以及三条 harness 治理命令。
|
||||
- 完整门禁:公共 API、schema 或生成客户端变化时运行全部 Sense 测试和契约/迁移检查。
|
||||
- 人工 / 设备验收:必需;使用 T-001 白名单中至少一款设备验证 5 路流程,记录 MediaMTX 与探活证据。
|
||||
- 构建产物:Sense 二进制/镜像路径、生成命令和哈希在实施任务中冻结;本任务开工前补齐。
|
||||
- 人工 / 设备验收:设备验收不适用且不得用模拟结果替代;维护者必须人工核对生成命令、版本/许可证与本地启动说明。单实机五路混合源验收由 T-006 承担,真实多设备现场验收由 T-007 承担。
|
||||
- 构建产物:冻结 Sense 二进制路径、MediaMTX 获取/校验方式、生成命令和版本信息;是否交付镜像若未在技术评审确定则不得自行扩展。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不开发 Brain、Bell、正式管理端、规则引擎、人脸识别、64/128 路容量实现;不修改 `_reference/`。
|
||||
不开发 Brain、Bell、正式管理端、规则引擎、人脸识别、真实摄像头兼容性、5 路实机验收或 64/128 路容量实现;不修改 `_reference/`,不把 MiBeeNvr 引入生产依赖。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:由 dispatcher 分配。
|
||||
- 唯一写入者:同责任 Agent。
|
||||
- 委派:默认不启用;需要只读调研时结论先回填本文。
|
||||
- Gitea:已预建候选 Issue #3,但依赖 T-001/T-002 均 DONE 前不回填映射、不加 `status/todo`、不得领取;进入可领取队列时再记录 Issue、`context_ref`、claim 和工作分支。
|
||||
- Gitea:主 Issue 为 #3;本次重构合入默认分支并读回后才加入 `status/todo`。领取时再记录 `context_ref`、claim 和工作分支。
|
||||
|
||||
任何新增写路径先由 dispatcher 与活跃任务做前缀冲突检查。
|
||||
|
||||
## 执行记录
|
||||
|
||||
尚未领取,依赖未完成。
|
||||
### 2026-08-04 领取与基线
|
||||
|
||||
- dispatcher `ila` 已将 Issue #3 分配给 `codex`;claim 与工作分支均从 `e28070dd035cef3ff3e4a2dad879a482c41dac3a` 创建并读回一致。
|
||||
- 在独立工作树 `D:\OPC\yovision-T-003` 开工,唯一写入范围为本任务声明的 `Sense/`、任务/技术/API/路线图/当前状态文档和根初始化脚本。
|
||||
- 开工基线 `./init.ps1` 通过,18 项治理测试成功;仓库尚无 Sense 生产代码,代码知识图谱工具未提供,本轮按仓库规则降级到文件检查。
|
||||
|
||||
### 2026-08-04 无实机拆分
|
||||
|
||||
- 项目负责人决定暂缓 T-001,并批准继续推进 Sense 离线软件骨架。T-003 的开工依赖调整为仅依赖已完成的 T-002,但真实摄像头验证仍是后续集成门禁。
|
||||
- 本任务收敛为 fake ONVIF、MediaMTX 假服务、SQLite 和可选合成 RTSP 可验证的生产骨架;不得用 mock 测试宣称 M0 或 M1 里程碑完成。
|
||||
- 指定实机 + 合成源的五路自动建 path、探活、断线恢复和 MediaMTX 实验室证据拆到 T-006;真实多设备现场证据后续拆到 T-007。
|
||||
- 任务尚未领取;Issue #3 在本次规格合入默认分支前不进入 `status/todo`。
|
||||
|
||||
### 2026-08-04 版本与实现决策
|
||||
|
||||
- 冻结 Go `1.26.5`、MediaMTX `v1.19.3`、`oapi-codegen v2.8.0` 与 `modernc.org/sqlite v1.54.0`;许可证、module sum、MediaMTX 二进制 SHA-256、OpenAPI SHA-256、升级策略与退出路线已写入 `docs/03-tech-stack.md`。
|
||||
- SQLite driver 使用无 CGO 的 `modernc.org/sqlite`,但业务代码只依赖 `database/sql` repository;migration 避免 SQLite 专有业务语义,后续映射到 PostgreSQL `sense` schema。
|
||||
- 设备模型使用 `modality + capabilities`;视频配额只统计期望启用且具备视频采集能力的设备。站点默认 16、配置允许 1~128,超出时拒绝新增/启用,已有流不受影响。
|
||||
- 数据库持有期望态;ONVIF、MediaMTX 均由 port/adapter 隔离。T-003 实现确定性 fake 与假 HTTP 契约测试,不提供真实厂商兼容结论;对账只创建/修正应有 path,不做孤儿删除。
|
||||
- MediaMTX 官方 OpenAPI 按 tag vendoring,生成代码不可手改;薄封装负责状态码、幂等与领域错误映射。服务默认只监听 `127.0.0.1`,非回环监听必须显式开启。
|
||||
- 实现中发现标准 AI 入口仍会宣称“没有生产代码”。dispatcher 串行读回 Gitea 后确认只有 T-003 处于活跃状态、无路径冲突,并以完整 `CLAIM RENEWAL` 将 `docs/00-ai-start-here.md` 加入写入范围;worker 已同步本文件后才修改该入口。
|
||||
|
||||
### 2026-08-04 实现与验证证据
|
||||
|
||||
- 建立 `Sense/go.mod`、`cmd/sense-api`、SQLite v1 migration 与 device/store/onvif/mtx/reconcile/probe 包。进程默认回环监听,只提供 `/healthz`、`/readyz`;真实 ONVIF adapter 显式返回 unavailable,避免把 fake 冒充生产兼容实现。
|
||||
- vendoring MediaMTX `v1.19.3` 官方 OpenAPI,输入 SHA-256 为 `a2b58195f1ec76541e124b5de4ee54645e5a3e25f70c4a73acc4a44d6f2b9c52`;`go generate ./internal/mtx` 生成文件 SHA-256 在重复生成前后均为 `9e10d96eac1b332783d7cbfaba5872fede62a16ef6ccc1062cdb246607db7dac`。
|
||||
- SQLite 测试覆盖默认第 17 路拒绝、配置 128 路成功、第 129 路拒绝、禁用第 17 路重新启用拒绝、非视频设备不占额度,以及下调配额不关闭已有流。migration、唯一性、期望态 generation 和持久化退避由同一 repository 测试链路执行。
|
||||
- ONVIF fixture 覆盖 profile、脱敏 stream URI、校时、认证失败和取消/超时;MediaMTX 假 HTTP 服务覆盖官方生成客户端的 create/read/delete、ensure 幂等/patch、runtime path 探活和错误脱敏。
|
||||
- 对账测试覆盖成功收敛后不重复、指数退避、取消不消耗重试预算和关闭/重开 SQLite 后恢复;探活测试覆盖 online/offline 映射。对账器不调用 `DeletePath`,没有孤儿删除旁路。
|
||||
- `go test -race ./...` 全部通过;`go test ./... -count=2` 连续两轮通过;`go vet ./...`、`go build ./...` 通过。实际构建 `sense-api` 后在随机回环端口用绕过系统代理的 curl 验证 `health=ok`、`ready=ready`,临时 EXE、SQLite 和日志随后逐项删除。
|
||||
- 根标准入口 `./init.ps1` 通过:agent-context 校验、18 项治理测试、harness 治理校验、生成漂移检查、Sense 全量测试、vet 与 build 均成功。人工核对生成头为 `oapi-codegen v2.8.0`、OpenAPI/二进制下载使用固定 `v1.19.3` URL 和 SHA-256、MediaMTX/Sense 管理端默认只绑定回环地址。
|
||||
- 设备人工验收不适用:未连接摄像头,未宣称 T-001 准入、五路混合源或 M0/M1 出口完成;这些证据由 T-001/T-006 提供,真实多设备现场证据由 T-007 提供。
|
||||
|
||||
+116
-17
@@ -3,47 +3,67 @@ id: T-004
|
||||
title: 设计 Sense 与 Bell 交互原型
|
||||
phase: 0
|
||||
deps: [T-002]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
issue: 6
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
context_ref: 32b9859a405a4d64c03b6d3ae765340bc37fdfa0
|
||||
claim_branch: claims/T-004
|
||||
work_branch: agent/codex/T-004
|
||||
write_paths:
|
||||
- docs/tasks/T-004.md
|
||||
- docs/design/sense/index.html
|
||||
- docs/design/bell/index.html
|
||||
- docs/raw/09-Sense原型评审-IX草稿.md
|
||||
- docs/raw/11-Sense原型-第二轮缺口.md
|
||||
- docs/raw/12-Sense原型-待办清单.md
|
||||
- docs/raw/14-Bell原型评审.md
|
||||
- docs/routes.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/07-user-stories.md
|
||||
- docs/02-requirements.md
|
||||
- docs/04-architecture.md
|
||||
- docs/raw/02-需求分析.md
|
||||
- docs/raw/03-通用场景应用方案.md
|
||||
- docs/raw/08-三系统职责划分.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
Sense 与 Bell 尚无可供产品确认的页面结构。生产 UI 开工前,需要用两个零构建依赖的单文件 HTML 原型枚举导航、控件、主要状态与高频工作流,避免直接从实现代码猜交互。Sense 可参考 MiBeeNvr 的监控、摄像头与仪表盘信息架构,但必须补齐 YoVision 的设备健康、分片/期望态、批量开通和区域/警戒线编辑;Bell 必须体现事件与 Alert 分离、ack、升级链、证据、误报和权限边界。
|
||||
|
||||
产品评审进一步区分“检测区域”和“告警规则”:前者依附具体摄像头画面、分辨率与坐标系,应在摄像头详情中编辑;后者包含场景、继承、时段、持续时间、试运行、发布与升级链,应由 Bell 管理。后端系统边界不得迫使用户丢失当前摄像头或规则草稿上下文。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:Sense 关联 US-001、US-002、US-005;Bell 关联 US-003~US-006。
|
||||
- 交互清单:Sense 关联 IX-001~IX-004、IX-010~IX-011;Bell 关联 IX-005~IX-013。
|
||||
- 相关页面 / 路由:原型阶段为 `docs/design/sense/index.html` 与 `docs/design/bell/index.html`,不定义生产路由。
|
||||
- 用户故事:Sense 关联 US-001、US-002、US-008、US-009,并为 US-005 提供视频设备空间配置;Bell 关联 US-003~US-006、US-010~US-013,并拥有 US-005 的业务规则工作流及 Tenant/Site/Area/RBAC/配额/全局审计真相。
|
||||
- 交互清单:Sense 关联 IX-001~IX-004、IX-014~IX-020,并提供 IX-010 所需的区域/警戒线几何;Bell 关联 IX-005~IX-013、IX-016、IX-020~IX-023,负责规则继承/试运行/发布/回滚、联系人/排班/升级策略、站点与 Area 策略、值班交接、验收报表和全局审计。
|
||||
- 相关页面 / 路由:原型阶段为 `docs/design/sense/index.html` 与 `docs/design/bell/index.html`;同步补充 `docs/routes.md` 的候选页面职责,但不冻结或实现生产 URL。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 使用统一的 YoVision 深色安防控制台设计系统,Sense 与 Bell 保持品牌、状态色、控件和导航规律一致。
|
||||
2. Sense 制作为单页可交互工作台:总览、监控墙、设备、区域规则、接入任务和系统状态;区域编辑器支持多边形、警戒线、撤销、清空、键盘替代和保存反馈。
|
||||
3. Bell 制作为单页可交互值班台:预警队列、事件证据、ack/处置、升级时间线、误报反馈、规则和升级链概览。
|
||||
4. 两个原型均内联 CSS/JS,使用假数据,不加载真实视频、客户信息、生产地址或外部依赖;顶部固定标注原型用途。
|
||||
5. 完成桌面、窄屏、键盘焦点、加载/空态/错误切换和 `prefers-reduced-motion` 检查,提交人工评审;产品确认前不把任务标为 `DONE`。
|
||||
2. Sense 制作为单页可交互工作台:总览、监控墙、设备、接入任务和运维中心,移动端也固定为这 5 个入口;不设置一级“区域规则”或全局管理页。运维中心聚合对账、分片、边缘/隧道、运维告警和系统状态。统一设备台账使用 `modality + capabilities` 决定详情页和操作,M1~M5 完整展示视频设备,非视频设备在 M6 前明确显示 `adapter_not_ready`,不得虚构遥测。
|
||||
3. 视频设备在“画面与检测区域”页签完成多边形、方向警戒线、显式完成、方向反转、逐点编辑、会话草稿恢复、乐观并发冲突和待校准反馈;Sense 只保存空间几何并只读显示关联规则,不提供业务规则绑定、时段、持续时间、试运行或正式发布。
|
||||
4. Bell 制作为单页可交互值班台和统一管理端:通道的已发出/已送达/已看到与 Alert ack 分开,Event↔Alert 可双向导航;并发 ack 显示真实处置人,班次交接不暂停升级链;规则编辑必须选择摄像头与检测区域版本,并负责继承、时段、持续时间、试运行、发布和以新版本完成回滚;Bell 同时管理 Site/Area/配额/`capture_policy`,策略与已有成像设备冲突时必须显式迁移或取消。
|
||||
5. Sense 摄像头详情与 Bell 规则草稿通过可演示的相对深链互相进入,携带摄像头/区域上下文;原型可以跨文件,但生产产品不得要求用户重新查找同一摄像头或丢失未完成草稿。
|
||||
6. 两个原型均内联 CSS/JS,使用假数据,不加载真实视频、客户信息、生产地址或外部依赖;顶部固定标注原型用途。
|
||||
7. 添加设备时先选择类型,依据能力渐进展示字段;区域的 `capture_policy` 负责隐私准入,视频设备在“仅非成像设备”区域中的新增或启用必须被阻止,策略不可用时对新写入失败关闭。
|
||||
8. 全局头部展示租户、站点、账号和角色;只读角色隐藏写操作,深链越权不泄露资源是否存在。Sense 设备日志带租户/站点/设备条件跳到 Bell 全局审计并可返回。
|
||||
9. 完成桌面、窄屏、横屏、键盘焦点、加载/空态/错误/依赖降级切换和 `prefers-reduced-motion` 检查,提交人工评审;产品确认前不把任务标为 `DONE`。
|
||||
10. Bell 事件与审计列表必须分页;规则验收报表按规则版本和冻结样本窗显示召回率与每路每天误报数,不提供跨场景统一准确率;移动端底部主导航保持 5 项,通过顶部“管理”入口访问 Site/Area 与审计。
|
||||
11. Bell 一级“升级链”内部统一设计升级策略、值班与排班、联系人和通道三个二级模块:共享人员/值班组/已验证通道主数据但分对象维护;升级步骤引用人员/组/排班类型化目标,排班支持时区、周轮换、生效日期、临时替班、冲突检查、解析预览、版本发布和审计,投递保留解析快照。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:默认 16 路、单站点上限 128 路;静默最长 4 小时;原型不得暗示 128 路同时播放主码流。
|
||||
- 判定式 / 状态转换:事件事实不可被处置改写;Alert 的首次投递、送达、看到、ack 和升级必须分开展示;区域规则正式发布与试运行必须可区分。
|
||||
- 阈值 / 数值边界:默认 16 路视频、单站点视频上限 128 路;非视频设备不占视频路数;单媒体分片初始配置建议 `max_streams=32`、当前示例承载 16、最多 4 个分片,最终以压测为准;静默最长 4 小时;原型不得暗示 128 路同时播放主码流。
|
||||
- 判定式 / 状态转换:事件事实不可被处置改写;Alert 的首次投递、送达、看到、ack 和升级必须分开展示;检测区域版本与告警规则版本是不同对象;规则正式发布与试运行必须可区分,保存区域不得自动发布规则。
|
||||
- 安全边界:不得展示摄像头密码、完整 RTSP/ONVIF 地址、token、真实客户/未成年人数据或人脸入口;破坏性动作须确认。
|
||||
- 既有契约:原型不修改事件 v0.1、API、路由和生产技术栈,不作为生产实现代码。
|
||||
- 既有契约:原型不修改事件 v0.1、API、schema、生产路由实现和生产技术栈;仅补充候选路由职责,不作为生产实现代码。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:两个 HTML 可本地打开、无外部依赖;关键按钮和状态切换可用;运行 HTML 结构检查、`git diff --check` 与 `./init.ps1`。
|
||||
- 完整门禁:本任务不修改 schema/API/路由,因此不触发契约与导航实现门禁;若范围变化则先更新任务。
|
||||
- 任务相关验证:两个 HTML 可本地打开、无外部依赖;关键按钮和状态切换可用;Sense 一级导航固定 5 项且无“区域规则”,设备列表可按类型/生命周期筛选与分页,视频详情可编辑检测区域,非视频适配器未交付时不伪装在线;运维中心覆盖对账/分片/边缘/运维告警/系统状态;Bell 可演示投递事实、Event↔Alert、并发 ack、交接班、事件/审计分页、试运行、正式发布、新版本回滚、验收报表、移动端管理入口、Site/Area 策略冲突,以及升级策略/排班/联系人二级模块、动态目标解析、排班发布/替班/冲突和投递快照;Sense↔Bell 规则深链保留 `camera + zone + return_to`,审计深链保留 `tenant + site + device + return_to` 且只按文本渲染参数;运行 HTML 结构检查、`git diff --check` 与 `./init.ps1`。
|
||||
- 完整门禁:本任务不修改 schema/API 或生产路由实现,因此不触发契约实现门禁;候选路由职责已随 IX 更新。若实现范围变化则另立任务。
|
||||
- 人工 / 设备验收:必需。项目负责人分别打开 Sense 与 Bell 原型,确认页面结构、控件集合和主要工作流;未确认前不得标记 `DONE` 或合并为最终设计。
|
||||
- 构建产物:`docs/design/sense/index.html`、`docs/design/bell/index.html`;双击浏览器打开,零构建依赖。
|
||||
|
||||
@@ -56,13 +76,92 @@ Sense 与 Bell 尚无可供产品确认的页面结构。生产 UI 开工前,
|
||||
- 责任 Agent:codex。
|
||||
- 唯一写入者:codex。
|
||||
- 委派:不启用。
|
||||
- Gitea:主 Issue 为 #6;领取必须从精确 `context_ref` 建立 claim 与工作分支。
|
||||
- Gitea:主 Issue 为 #6;`context_ref` 为 `32b9859a405a4d64c03b6d3ae765340bc37fdfa0`,claim 为 `claims/T-004`,工作分支为 `agent/codex/T-004`。
|
||||
|
||||
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04 人工验收通过与任务完成
|
||||
|
||||
- 项目负责人确认 Sense 与 Bell 原型均可接受,人工验收门禁通过;确认范围包括最终页面信息架构、控件集合、主要工作流,以及 Bell 最新的“升级策略 / 值班与排班 / 联系人和通道”分对象设计。
|
||||
- 两份原型的任务相关自动化验证已在前述执行记录中完成:HTML/脚本静态检查、桌面与窄屏浏览器交互、无横向溢出、运行时无异常、`git diff --check` 与 `./init.ps1` 均通过。
|
||||
- T-004 状态更新为 `DONE`。本次确认冻结原型阶段的产品方向;后续生产实现、API/schema 契约和高级排班能力仍须另立任务,不由本任务隐含扩展。
|
||||
|
||||
### 2026-08-04 联系人、排班与升级策略产品裁决
|
||||
|
||||
- 采纳“一次设计到位”的方向,并将“同源”修正为“共享主数据、分对象建模”:Contact/Team 管身份和已验证通道,OnCallSchedule/Version/Exception 管时区、轮换和替班,EscalationStep 使用人员/组/排班类型化目标;DeliveryAttempt 固化实际收件人、通道与排班版本快照。
|
||||
- 正式需求、架构、US-005/US-013、IX-012/IX-023、候选路由和 `raw/14` 已同步。交接班继续只转移进行中 Alert,未来替班必须形成排班草稿并发布新版本;高级自动排班、外部日历、工时合规和自助换班后置。
|
||||
- Bell 保留一级“升级链”,内部新增“升级策略 / 值班与排班 / 联系人和通道”三个二级模块。策略可切换 `person / team / on_call_schedule` 并预览解析结果;排班覆盖 Site 时区、周轮换、覆盖率、空档/重叠阻断、临时替班、v13 发布与版本审计;联系人表单不含班次/轮换字段,通道需要验证且被引用时不可直接删除。
|
||||
- Node.js 内联脚本、167 个唯一 ID、114 个按钮可访问名、全部表单标签、外部依赖和危险 DOM API 检查通过。Edge/CDP 在 1440×900、375×812、812×375 验证动态目标、解析快照、排班冲突、替班草稿、版本发布、联系人边界、交接边界和二级模块深链:页面级无横向溢出,移动端二级按钮不小于 44px,运行时 0 exception;临时 QA 脚本未写入仓库。
|
||||
- 自动化验收完成后 T-004 仍保持 `DOING`;需项目负责人打开 Bell 原型人工确认升级链信息架构和排班边界,确认前不合并 PR、不标记 `DONE`。
|
||||
|
||||
### 2026-08-04 Bell 原型评审复核与修复
|
||||
|
||||
- 将 Claude 评审复核为最终裁决并保存到 `raw/14`:采纳投递状态、Event↔Alert、交接班、分页、回滚和验收报表;纠正 Area 归属、A-2、并发 ack 现状、弱网失败态和重启 UI 建议;额外识别移动端管理入口与审计深链 DOM 注入。
|
||||
- Bell 桌面侧栏分为“值班与业务”和“管理”,移动端底部导航继续固定 5 项并增加顶部“管理”菜单。投递时间线分开展示已发出、已送达、已看到、无回执、通道不支持、可重试与最终失败,Alert ack 继续独立。
|
||||
- Alert 详情与 Event 详情支持双向打开并展示聚合/未触发原因;补齐服务端首个成功者语义的并发 ack 演示、值班交接、事件/审计分页、会话过期、规则版本冲突、不可变新版本回滚和按规则版本的验收效果表。
|
||||
- 审计深链不再把 `device` 查询参数拼入 HTML,改为 `textContent` 与 DOM 节点构造;恶意 `<img onerror>` 参数专项验证未创建节点、未执行脚本且保持安全文本显示。本轮不采纳 A-2 数据库视图,不修改 API/schema,也不在正常值班时间线展示内部服务重启。
|
||||
- Node.js 内联脚本语法、124 个唯一 ID、按钮可访问名、外部依赖和危险 DOM API 静态检查通过。Edge/CDP 在 1440×900、375×812、812×375 验证投递事实、双向关系、并发 ack、交接、分页、规则冲突/回滚、验收报表、会话过期、移动管理与深链注入:页面级无横向溢出,移动导航与管理入口不小于 44px,运行时 0 exception;桌面值班台和窄屏管理页已目检,临时 QA 脚本未写入仓库。
|
||||
- 自动化验收完成后任务仍保持 `DOING`;项目负责人必须打开 Bell 原型确认信息架构、投递语义和交接/回滚流程,确认前不合并 PR、不标记 `DONE`。
|
||||
|
||||
### 2026-08-04 第二轮缺口复核与最终实现
|
||||
|
||||
- 复核 `raw/11`、`raw/12` 后保留对账、能力探测、设备生命周期、RBAC、写入降级、边缘隧道与运维告警等真实缺口;纠正“原型无分页”“分片 8 / 32”“雷达已有真实能力”“T-004 只允许 3 个写路径”等过期事实,并把 M4/M6 能力标为后续阶段而非本轮失败。
|
||||
- 产品 IA 冻结为 Sense 5 个一级入口:运行总览、实时监控、设备、接入任务、运维中心。Tenant/Site/Area/RBAC/配额/全局审计归 Bell;Sense 仅呈现当前管理上下文并消费版本化配额/Area 策略投影。该决定已同步 US-009/US-010、IX-019/IX-020、正式需求、架构及三份原始分析文档。
|
||||
- Sense 按显著改版规则整页重新生成:补齐待激活、认证失败、写入型凭据更新、能力探测来源/时间/生效差异、`adapter_not_ready`、逐项任务结果、暂停推理 reader/upstream 结果、对账差异/退避/孤儿安全闸、16/32 分片注释、边缘隧道/补传、独立运维告警、配额/策略不可用时只禁用相关写入,以及只读/越权状态。
|
||||
- Bell 增加 Site/Area 管理、配额/策略投影状态与策略冲突显式迁移/取消;审计页能消费 Sense 的 `tenant + site + device + return_to` 上下文并返回设备详情。跨系统审计实现约定为 Sense 本地事务 outbox + 幂等 relay,但具体 API/签名/重放/留存仍需独立契约任务,本任务不修改 `docs/api/`。
|
||||
- Node.js 内联脚本语法、唯一 ID、表单标签、按钮可访问名、Tab 契约和外部资源静态检查通过。Edge Headless/CDP 在 1440×900、375×812、812×375 验证上述关键流程:两页页面级无横向溢出,移动端可见主要控件不小于 44×44px,控制台与运行时均 0 exception;运维中心与 Bell Site/Area 页面截图已人工目检,临时 QA 文件未写入仓库。
|
||||
- `./init.ps1` 通过:上下文/治理检查、16 个治理单测全部成功;`git diff --check` 通过。自动化验收已完成,任务继续保持 `DOING`,等待项目负责人打开原型进行必需人工确认;确认前不合并 PR。
|
||||
|
||||
### 2026-08-04 Claude 评审续修立项
|
||||
|
||||
- 项目负责人提供 `docs/raw/09-Sense原型评审-IX草稿.md` 并确认按复核后的最终建议继续修改。复核结论为:设备模态、停用收敛、隐私准入、几何编辑、草稿恢复与无障碍问题方向成立,但分片容量来源、页内草稿丢失和双向深链现状等证据需纠正。
|
||||
- T-004 继续保持唯一活跃任务;dispatcher 已在 Issue #6 发布 `CLAIM RENEWAL`,将本轮涉及的评审稿、US/IX、需求/架构与 Sense/Bell 原型加入 `write_paths`。当前没有其他活跃任务占用这些路径。
|
||||
- 本轮按 `docs/design/README.md` 的显著改版规则重新生成 Sense 页面结构,不恢复一级“区域规则”;一级入口改为“设备”,详情按 `modality + capabilities` 渐进展示,视频设备仍在详情中维护画面检测区域,Bell 继续独占业务规则与发布。
|
||||
|
||||
### 2026-08-04 Claude 评审续修实现与自测
|
||||
|
||||
- 将评审结论回写到需求、架构、用户故事和 IX:设备以 `modality + capabilities` 建模,隐私策略归属 Area;M2 冻结通用信息架构与视频准入,M6 再实现非视频适配器。明确幂等只消除重复副作用,每次请求仍写审计;暂停推理、停用接入和断开会话为三个不同动作。
|
||||
- Sense 已重构为通用设备台账:17 台示例设备、5 条/页、类型/状态/搜索筛选,视频配额单独显示为 16 / 128;雷达详情展示遥测能力和通用健康且隐藏视频区域页签。添加设备对话框按能力切换字段,并验证视频设备会被“仅非成像设备”区域阻止、雷达仍可接入。
|
||||
- 区域编辑补齐显式完成、警戒线箭头与方向反转、逐点键盘编辑/删除、双击防重复顶点、`sessionStorage` 会话草稿恢复、版本冲突阻止覆盖;工具切换会先确认,页内导航不清除草稿。Sense 与 Bell 双向链接统一携带 `camera + zone + return_to`,Bell 会读入并保留区域上下文。
|
||||
- Node.js 内联脚本编译、唯一 ID/标签目标/外部资源静态审计均通过;关键前景/背景组合对比度为 5.93~16.43:1。Edge Headless/CDP 在 1440×900、375×812 与 812×375 实测设备筛选、雷达详情、隐私准入、区域方向与冲突、Tab 方向键、停止操作收敛和双向深链;页面级无横向溢出、移动端无低于 44px 的可见主要控件、运行时 0 exception。截图已人工检查且未写入仓库。
|
||||
- 任务仍保持 `DOING`:需项目负责人打开两份原型确认最终信息架构与主要工作流,确认前不合并 PR、不标记 `DONE`。
|
||||
|
||||
### 2026-08-03 任务定义
|
||||
|
||||
- 依据用户请求、US-001~US-006、IX-001~IX-013 和 MiBeeNvr 本地只读截图定义两个单文件原型范围。
|
||||
- 人工验收为必需门禁;原型完成后先进入评审,不自动视为最终设计。
|
||||
|
||||
### 2026-08-03 领取与基线
|
||||
|
||||
- dispatcher `ila` 已将 Issue #6 分配给 `codex`,claim 与工作分支均从精确 `context_ref` 创建并读回一致。
|
||||
- 在独立 worktree `D:\OPC\yovision-T-004` 开始工作;写入范围仅限本任务声明的三个路径。
|
||||
|
||||
### 2026-08-04 原型实现
|
||||
|
||||
- 使用 `ui-ux-pro-max` 生成并复核统一设计系统:深色专业安防控制台、高信息密度、系统字体、语义状态色、150–300ms 微交互、明确焦点和 reduced-motion;Sense 使用青蓝强调接入/画面,Bell 使用紫色强调处置/状态机。
|
||||
- 首版 `docs/design/sense/index.html` 覆盖运行总览、4 路按需监控、摄像头期望态/实际态、批量接入、系统健康,以及多边形/方向警戒线的鼠标绘制、坐标键盘替代、撤销、草稿、试运行和发布确认;后续产品评审修订见下文。
|
||||
- `docs/design/bell/index.html` 已覆盖预警队列、事件事实与证据、一次点击 ack、升级倒计时和投递事实、处置结果、误报反馈、规则、升级链、报表和审计;弱网时先显示结构化事实并允许证据后台重试。
|
||||
- 两个文件均为单文件内联 CSS/JS,无外部依赖、真实视频、客户数据、连接串或人脸入口;顶部均固定显示原型用途提示。
|
||||
|
||||
### 2026-08-04 自测结果
|
||||
|
||||
- 标准库 HTML 结构审计通过:两文件均有 viewport、原型横幅、reduced-motion,无重复 `id`、无未命名按钮、无不可识别表单控件和外部 HTTP 资源;Sense 统计 40 个按钮/21 个控件,Bell 统计 42 个按钮/14 个控件。
|
||||
- Playwright 在 1440×900 与 375×812 视口实测:两页页面级 `scrollWidth == clientWidth`,桌面三栏和移动底部导航正常,浏览器控制台 0 error / 0 warning;桌面与移动关键状态截图已人工检查,临时 QA 图片未写入仓库。
|
||||
- Sense 实测坐标点添加、草稿状态、保存反馈、设备导航、添加摄像头对话框和 Esc 退出;Bell 实测一键 ack、升级计时停止、弱网状态、误报必填校验、错误焦点和 Esc 退出。
|
||||
- 尚待项目负责人分别打开两个 HTML,确认页面结构、控件集合和主要工作流;在收到确认前任务保持 `DOING`。
|
||||
|
||||
### 2026-08-04 产品评审修订
|
||||
|
||||
- 项目负责人指出 Sense 的区域规则没有融合到摄像头模块。复核 US-001/US-002 与 US-005 的角色和任务后,确认原型把“摄像头空间几何”和“业务告警规则”混为同一一级模块,且让微服务边界影响了用户操作路径。
|
||||
- 采纳“领域分离、体验连续”的产品方案:Sense 摄像头详情维护检测区域与校准状态;Bell 维护业务规则、试运行和正式发布;通过携带摄像头/区域上下文的深链连接两个原型。
|
||||
- `ui-ux-pro-max` 的导航层级、渐进披露、深链与状态保持原则用于本轮修订;默认 16 路从摄像头详情进入,128 路仍通过设备列表的筛选、分页与区域状态列治理,不恢复独立的一级区域编辑入口。
|
||||
|
||||
### 2026-08-04 修订后自测
|
||||
|
||||
- 标准库 HTML 审计通过:Sense 69 个唯一 `id`、40 个有名称按钮、16 个有标签控件;Bell 67 个唯一 `id`、50 个有名称按钮、21 个有标签控件;两页均无外部 HTTP 资源。专项断言确认 Sense 不再存在一级 `zones` 页面、规则发布按钮或试运行开关。
|
||||
- Node.js 对两个内联脚本执行语法编译检查通过。Edge Headless/CDP 在 1440×900 与 375×812 验证摄像头详情、坐标绘制、区域保存、画面参数变化后待校准、Sense→Bell 深链、Bell 规则草稿、Bell→Sense 返回链和正式发布确认;页面无横向溢出,控制台 0 error,运行时 0 exception。
|
||||
- 浏览器测试首次发现 Sense 移动端区域画布受 `min-height + aspect-ratio` 影响发生横向溢出,并发现条件上下文条的 `hidden` 被组件 `display` 覆盖;两项均已修复并重新通过桌面/移动截图人工检查。临时 QA 截图未写入仓库。
|
||||
- `./init.ps1` 通过:上下文校验、16 个治理单测与完整治理检查均成功;`git diff --check` 通过。任务继续保持 `DOING`,等待项目负责人确认修订后的信息架构与工作流。
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: T-005
|
||||
title: 归档原型评审原稿并补录评审文档
|
||||
phase: 0
|
||||
deps: [T-004]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
issue: 10
|
||||
context_ref: 697e652e9b9b884551850583712cdcd875c474b9
|
||||
claim_branch: claims/T-005
|
||||
work_branch: agent/codex/T-005
|
||||
write_paths:
|
||||
- docs/tasks/T-005.md
|
||||
- docs/raw/10-Sense原型-功能模块缺口.md
|
||||
- docs/raw/13-架构评审与修订建议.md
|
||||
- docs/raw/15-Bell原型-功能模块缺口.md
|
||||
- docs/raw/archive/2026-08-04/08-三系统职责划分-评审前快照.md
|
||||
- docs/raw/archive/2026-08-04/09-Sense原型评审-IX草稿-Claude原稿.md
|
||||
- docs/raw/archive/2026-08-04/11-Sense原型-第二轮缺口-Claude原稿.md
|
||||
- docs/raw/archive/2026-08-04/12-Sense原型-待办清单-Claude原稿.md
|
||||
- docs/raw/archive/2026-08-04/14-Bell原型评审-Claude原稿.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-004 已将 Codex 复核后的 Sense/Bell 评审定稿合入 `main`,但主工作区仍保留 Claude 原始评审稿,以及尚未进入版本库的 `raw/10`、`raw/13`、`raw/15`。直接提交同名原稿会覆盖已确认的裁决结论;不提交又会丢失评审依据。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 保持 `main` 上 `raw/08`、`raw/09`、`raw/11`、`raw/12`、`raw/14` 的最终定稿不变。
|
||||
2. 将对应本地原稿按原始字节归档到 `docs/raw/archive/2026-08-04/`,文件名明确标注“评审前快照”或“Claude原稿”。
|
||||
3. 将没有同名定稿冲突的 `raw/10`、`raw/13`、`raw/15` 补录到 `docs/raw/`。
|
||||
4. 以远程备份提交 `2e6bf8e` 为唯一恢复源,通过 Git blob 哈希核对归档内容,避免人工重写。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 不覆盖或改写 T-004 已确认的需求、架构、US/IX、原型和评审定稿。
|
||||
- 不把 Claude 原稿重新解释为当前产品真相;原稿只用于审计和追溯。
|
||||
- 不修改 API、schema、生产代码或任务 T-004 的完成状态。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 归档的五份同名原稿与备份提交 `2e6bf8e` 对应文件 blob 完全一致。
|
||||
- 新增的 `raw/10`、`raw/13`、`raw/15` 与备份提交对应文件 blob 完全一致。
|
||||
- `main` 现有五份定稿与本任务 `context_ref` 的 blob 完全一致。
|
||||
- `git diff --check` 与 `./init.ps1` 通过。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不修改 T-004 定稿正文、Sense/Bell 原型、正式需求与架构;不裁决 `raw/10`、`raw/13`、`raw/15` 中的新建议。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:codex。
|
||||
- 唯一写入者:codex。
|
||||
- 委派:不启用。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04 归档完成与验证
|
||||
|
||||
- 从远程备份提交 `2e6bf8e0ce80929b6756e0ecc11261cc9b2553a7` 恢复八份材料:`raw/10`、`raw/13`、`raw/15` 以原路径补录;`raw/08`、`raw/09`、`raw/11`、`raw/12`、`raw/14` 的本地版本以“评审前快照 / Claude原稿”名称归档到 `docs/raw/archive/2026-08-04/`。
|
||||
- 对八份新增文件逐一执行 Git blob 核对,全部与备份提交对应源文件一致;同时核对五份现行定稿,全部与 `context_ref` 对应 blob 一致,证明本任务没有覆盖 T-004 定稿。
|
||||
- `git diff --check` 与 `git diff --cached --check` 通过;`./init.ps1` 通过上下文/治理校验及 16 项治理单测。
|
||||
- 本任务只保存评审依据,不对新增评审建议作产品或架构裁决;T-005 状态更新为 `DONE`。
|
||||
|
||||
### 2026-08-04 领取
|
||||
|
||||
- dispatcher `ila` 已在 Issue #10 发布完整 CLAIM;claim 分支与工作分支均从 `697e652e9b9b884551850583712cdcd875c474b9` 创建。
|
||||
- Issue #10 已切换为 `status/doing`;本轮仅修改任务文件声明的归档和缺失评审文档路径。
|
||||
|
||||
### 2026-08-04 任务定义
|
||||
|
||||
- 项目负责人确认采用“保留定稿、原稿另行归档、补录缺失评审文档”的方案。
|
||||
- 当前八份本地材料已原样推送到远程备份分支 `backup/raw-reviews-20260804`,提交为 `2e6bf8e`。
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: T-006
|
||||
title: 完成 Sense 单实机与五路混合源集成验收
|
||||
phase: 1
|
||||
deps: [T-001, T-003]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
issue: 16
|
||||
context_ref: 94ae2f00488bde184a9db4e5437232dfceb5f1bb
|
||||
claim_branch: claims/T-006
|
||||
work_branch: agent/codex/T-006
|
||||
write_paths:
|
||||
- docs/tasks/T-006.md
|
||||
- Sense/
|
||||
- docs/research/sense-5-stream-integration.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-003 只用 fake ONVIF 和 MediaMTX 假服务建立无实机骨架,不能证明指定海康实机基线可用或真实媒体路径能自动收敛。受当前硬件预算限制,M1 实验室出口改为使用 T-001 已批准的 1 台 Hikvision IP Camera 作为真实 ONVIF/RTSP 上游,再用至少 4 条可独立启停的合成 RTSP 上游补足 5 路,验证控制面、多路调和、探活和恢复。该结果是开发集成 smoke,不是五台真实设备或生产 SLA 证据。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-001、US-002、US-007。
|
||||
- 交互清单:无正式 UI;使用 Sense API、日志、指标和版本化实验记录验收。
|
||||
- 前置产物:T-001 的 `docs/research/camera-compatibility.md` 与 T-003 的 Sense/MediaMTX 骨架、版本和启动命令。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 使用 T-001 指定准入基线的 1 台真实海康摄像头,记录型号、硬件版本、固件、网络拓扑和主/子码流,但不记录密码或含凭据的完整 URI。
|
||||
2. 使用 FFmpeg、GStreamer 或等价离线工具建立至少 4 条合成 RTSP 上游;每条必须有不同 source/path,并可单独启动、停止和注入故障。同一真实 RTSP URI 的重复连接、同一编码上游的 fan-out 或同一合成 publisher 的重复映射不增加独立源计数。
|
||||
3. 通过 Sense 设备台账写入期望态,验证真实设备的 ONVIF profile/stream URI 映射,以及全部 5 路的 MediaMTX path 自动创建和健康状态;不允许人工修改 MediaMTX 配置冒充自动化成功。
|
||||
4. 验证四类恢复:真实摄像头/网络短时离线、单条合成 publisher 停止/恢复、Sense 进程重启、MediaMTX 进程重启。每类都记录探活/退避期限、实际恢复时间、对账结果和失败证据。
|
||||
5. 验证 5 路并行运行期间 path、reader/upstream、探活和数据库期望态一致;连续观察窗口至少 30 分钟,只作为 M1 实验室集成 smoke,不外推真实多设备故障隔离、16/64/128 路容量或生产 SLA。
|
||||
6. 将可重复步骤、版本、合成源生成命令、脱敏结果、限制和结论写入 `docs/research/sense-5-stream-integration.md`;失败通道保留记录,不通过删除样本美化结果。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:至少 5 条同时在线的开发上游,其中 1 条来自 T-001 真实准入样机、至少 4 条为可独立故障注入的合成 RTSP 源,连续观察至少 30 分钟;重复上游不增加计数。默认 16、最大 128 的配额语义不得改变。本任务不验收真实 5 机或 16/64/128 路容量。
|
||||
- 判定式 / 状态转换:数据库仍是期望态真相源;四类恢复均须在 T-003 冻结的探活/退避配置内自动重新收敛,`unconverged = 0`,不得靠人工改配置或重建数据库通过。
|
||||
- 安全边界:只使用自购样机、无人物测试图案和隔离网络;证据中不得出现密码、完整 RTSP/ONVIF 凭据、可复用 token、真实客户地址或人物画面。
|
||||
- 既有契约:MediaMTX 保持独立二进制,MiBeeNvr 不进入生产依赖;不得为通过某一厂商测试把 5、16 或厂商名称写死在业务逻辑中。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:执行 T-003 冻结的全部 Sense 单测、vet、migration/契约检查;完成 1 实机 + 4 合成源的 5 路自动 path、健康、30 分钟观察和四类恢复矩阵;运行三条 harness 治理命令。
|
||||
- 完整门禁:若为实机差异修改 ONVIF、MediaMTX、store、reconcile、probe 或公共 API,必须重跑全部 Sense 测试和完整五路混合源矩阵。
|
||||
- 人工 / 设备验收:必需。实施/硬件负责人核对真实设备身份/固件、4 条合成源的独立启停命令、网络条件、原始时间线与脱敏证据;任何一条未通过则任务不得标记 `DONE`。
|
||||
- 构建产物:记录实际 Sense 与 MediaMTX 二进制版本/哈希、配置模板、启动命令和证据文档;不提交摄像头配置秘密或媒体样本。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不修改 Brain、Bell、规则/告警业务、正式管理 UI,不做真实 5 机或 16/64/128 路容量验收,不重新裁决 T-001 指定型号准入基线,不把失败源从证据中删除。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:由 dispatcher 分配。
|
||||
- 唯一写入者:同责任 Agent。
|
||||
- 委派:默认不启用;现场人员可执行设备操作,但任务所有者必须核验并版本化脱敏证据。
|
||||
- Gitea:主 Issue 为 #16;T-001、T-003 均为 `DONE` 前不得添加 `status/todo` 或领取。
|
||||
|
||||
任何新增写路径先由 dispatcher 与活跃任务做前缀冲突检查。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-07 完成五路混合源验收
|
||||
|
||||
- 实现标准 ONVIF SOAP 1.2 / WS-Security adapter、`env://` 凭据解析、HTTP/RTSP router、NAT RTSP 重写,以及默认关闭的 RTSP query 兼容开关;凭据只进入进程环境和内存 URI,不写 SQLite 或证据。
|
||||
- 增加 `sense-lab` 播种/脱敏状态工具、真实 RTSP 故障代理、4 路独立 FFmpeg publisher fixture 和一键集成脚本;MediaMTX runtime path 丢失会使观察代失效并自动触发重新对账。
|
||||
- 正式使用 1 台 T-001 准入实机和 4 个独立合成 publisher:5 路初始收敛 `8.2 s`;真实网络恢复 `5.1 s`、单 publisher 恢复 `5.1 s`、Sense 重启恢复 `0.0 s`、MediaMTX 重启恢复 `2.1 s`,各阶段 `unconverged = 0`。
|
||||
- 连续观察 `1806.6 s`、采样 180 次,`maximum_unconverged = 0`、`final_unconverged = 0`;自动配置 path 数为 5。结果、版本、失败记录和限制见 `docs/research/sense-5-stream-integration.md`。
|
||||
- 该结论仅关闭 M1 单实机混合源 smoke;五条独立真实上游、真实多设备故障隔离与生产 SLA 仍由 T-007 验收,16/64/128 路容量不在本任务结论内。
|
||||
|
||||
### 2026-08-07 领取任务
|
||||
|
||||
- dispatcher `ila` 将任务分配给 `codex`;`context_ref` 为 `94ae2f00488bde184a9db4e5437232dfceb5f1bb`,claim 为 `claims/T-006`,工作分支为 `agent/codex/T-006`。
|
||||
- 接受既有写路径:`docs/tasks/T-006.md`、`Sense/`、`docs/research/sense-5-stream-integration.md`、`docs/current-state.md`。
|
||||
|
||||
### 2026-08-04 任务定义
|
||||
|
||||
- 项目负责人批准将 T-003 拆成无实机软件骨架,并由本任务保留真实摄像头和 5 路 M1 集成门禁。
|
||||
- 当前 T-001 暂缓、T-003 尚未领取,因此 T-006 仅建立规格,不进入可领取队列。
|
||||
|
||||
### 2026-08-04 上游准入范围更新(已由下一节替代)
|
||||
|
||||
- T-001 已按项目负责人决定收敛为一个指定型号/固件基线的一台样机准入;T-006 可以使用同一准入型号的设备,不再要求多个品牌。
|
||||
- T-006 的 5 路门禁不随之降低:必须是 5 条独立真实上游,通常需要 5 台设备;同一摄像头或同一 RTSP URI 的复制连接不能计数。
|
||||
|
||||
### 2026-08-04 单实机开发策略
|
||||
|
||||
- 项目负责人确认无预算购买更多摄像头,后续本地开发统一使用现有 1 台 Hikvision IP Camera;精确型号/硬件/固件仍由 T-001 冻结,代码不得写死品牌或私有地址。
|
||||
- T-006 改为 1 路真实准入样机 + 至少 4 路可独立启停的合成 RTSP 源,验证五路软件闭环;原“五条独立真实上游”现场证据移交 T-007,不再阻塞日常开发或 M1 实验室出口。
|
||||
- 该变更替代上一节“需要五台真实设备”的约束,但不允许把同一摄像头/同一 RTSP URI 重复拉流冒充多个独立源。
|
||||
|
||||
### 2026-08-05 前置依赖解除
|
||||
|
||||
- T-001 已完成指定海康型号准入,T-003 已完成 Sense 无实机骨架;本任务的代码和设备依赖均已满足,可由 dispatcher 将 Gitea Issue #16 从 `status/waiting` 切换为 `status/todo`。
|
||||
- T-001 的真实网络断开恢复由项目负责人豁免,没有产生三次原始恢复时间线;本任务不得继承该豁免,仍须按本任务方案独立完成真实摄像头/网络、合成 publisher、Sense 和 MediaMTX 四类恢复验证。
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: T-007
|
||||
title: 完成客户试点真实多路摄像头现场验收
|
||||
phase: 3
|
||||
deps: [T-001, T-006]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
issue: 22
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
write_paths:
|
||||
- docs/tasks/T-007.md
|
||||
- docs/research/pilot-real-camera-integration.md
|
||||
- Sense/
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
受硬件预算限制,T-006 只用 1 台真实海康摄像头和至少 4 条合成 RTSP 源完成实验室软件闭环,不能证明多台真实设备的并发接入、网络故障隔离、批次一致性或现场恢复。项目无需为日常开发提前采购设备,但在客户试点、借用或租赁条件具备后,生产上线和真实多路 SLA 声明仍需要现场证据。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-001、US-002、US-007。
|
||||
- 交互清单:无正式 UI;使用 Sense API、日志、指标和版本化现场记录验收。
|
||||
- 前置产物:T-001 指定准入基线、T-006 五路混合源实验室证据。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 使用客户授权、借用或租赁设备,在隔离的试点测试窗口建立至少 5 条来自不同真实编码上游的视频通道;优先使用 T-001 准入型号。若型号/硬件/固件不在准入基线,先按 T-001 全矩阵完成独立准入变更,不能在本任务中默许。
|
||||
2. 通过 Sense 自动完成设备发现/台账期望态、MediaMTX path、探活和对账;不得人工修改 MediaMTX 配置或复制同一 RTSP URI 凑数。
|
||||
3. 对至少一条上游执行摄像头/网络断开恢复,并验证 Sense 与 MediaMTX 分别重启后的全量自动收敛;记录配置期限、实际恢复时间和 `unconverged`。
|
||||
4. 全部真实上游同时在线连续观察至少 2 小时;记录码率、分辨率、FPS、连接/重连、path/reader/upstream 一致性和失败时间线。
|
||||
5. 将步骤、授权来源、设备身份/固件的脱敏摘要、网络拓扑、命令、结果、限制和签字结论写入 `docs/research/pilot-real-camera-integration.md`,不提交视频样本或秘密。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:至少 5 条独立真实编码上游,同时在线至少 2 小时;同一摄像头/编码会话/RTSP URI 的重复连接和 MediaMTX fan-out 只算 1 路。本任务不替代 16/64/128 路容量验收。
|
||||
- 判定式 / 状态转换:数据库仍是期望态真相源;摄像头/网络、Sense、MediaMTX 三类恢复后必须自动达到 `unconverged = 0`,不得人工改配置或重建数据库通过。
|
||||
- 安全边界:设备必须自购、借用、租赁或由客户书面授权;在测试窗口使用无敏感人物画面或遮挡画面,证据不含密码、完整 URI、token、客户地址或媒体样本。
|
||||
- 上线边界:本任务不阻塞本地开发和 M1 实验室出口,但阻塞客户生产试点启用、真实多路稳定性结论和相关 SLA。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:执行全部 Sense 单测、vet、migration/契约检查;完成至少 5 路真实上游、2 小时观察和三类恢复;运行三条 harness 治理命令。
|
||||
- 完整门禁:若修改 ONVIF、MediaMTX、store、reconcile、probe 或公共 API,重跑全部 Sense 测试和全部现场矩阵。
|
||||
- 人工 / 设备验收:必需。客户/实施/硬件负责人核对设备来源、身份、固件、网络、原始时间线、脱敏证据与限制,并签署结论。
|
||||
- 构建产物:记录 Sense/MediaMTX 版本与哈希、配置模板、启动/恢复命令和证据文档;不提交摄像头秘密或媒体。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不开发 Brain/Bell 功能,不评估 AI 检出率,不宣称多品牌兼容,不做 16/64/128 路容量验收,不要求项目在客户/借用/租赁条件出现前自行购买额外摄像头。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:由 dispatcher 分配。
|
||||
- 唯一写入者:同责任 Agent。
|
||||
- 委派:默认不启用;现场人员可执行设备操作,但任务所有者必须核验并版本化脱敏证据。
|
||||
- Gitea:主 Issue 为 #22;T-001、T-006 均为 `DONE` 且外部设备条件具备前保持 `status/waiting`。
|
||||
|
||||
任何新增写路径先由 dispatcher 与活跃任务做前缀冲突检查。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04 任务定义
|
||||
|
||||
- 项目负责人批准后续本地开发只使用现有一台海康摄像头,多路由合成 RTSP 补足;真实五路现场证据从 T-006 移入本任务。
|
||||
- 设备不要求项目当前采购,可使用客户授权、借用或租赁设备;本任务只阻塞生产试点,不阻塞软件开发。
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: T-008
|
||||
title: 冻结 Sense 设备管理 API 与 Bell 配额只读投影契约
|
||||
phase: 2
|
||||
deps: [T-006]
|
||||
status: DONE
|
||||
created: 2026-08-07
|
||||
issue: 27
|
||||
context_ref: cc47e1463849151bee3c349cc53971181e262043
|
||||
claim_branch: claims/T-008
|
||||
work_branch: agent/codex/T-008
|
||||
write_paths:
|
||||
- docs/tasks/T-008.md
|
||||
- docs/contracts/
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
- docs/current-state.md
|
||||
- tests/test_sense_control_contract.py
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-006 已证明 Sense 能用 SQLite 期望态驱动 1 路真实摄像头与 4 路合成 RTSP 上游自动收敛,但设备只能通过实验室 `sense-lab` 直接播种数据库,尚无可供 Bell 管理面或受控集成方依赖的设备管理 HTTP 契约。站点配额仍只停留在“版本化内部 API 或只读投影”的二选一表述,也没有冻结跨 schema 的列签名、权限和失败语义。若直接实现 CRUD,会在 tenant 边界、批量结果、幂等、并发控制和配额所有权上形成临时接口并导致返工。
|
||||
|
||||
当前架构已确定首期使用一个 PostgreSQL 实例、`sense`/`bell` schema 分离。T-008 据此冻结 Sense v1 设备管理 OpenAPI,并把 Bell→Sense 配额同步裁决为版本化只读视图,而不是新增网络调用;未来分库必须发布新版本契约,不能静默改变 v1 语义。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-001、US-002、US-008、US-009、US-010。
|
||||
- 交互清单:IX-001~IX-004、IX-013~IX-016、IX-019、IX-020。
|
||||
- 相关页面 / 路由:`/sites/:siteId/devices`、`/operations`;本任务只冻结后端契约,不修改已确认 HTML 原型。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 `docs/contracts/` 新建契约说明、OpenAPI 3.1 JSON 和 `bell.site_quota_v1` PostgreSQL 只读视图签名;跨系统契约不放入某一系统的内部包。
|
||||
2. Sense HTTP v1 只管理设备期望态与收敛查询:单项创建/读取/修改/启停、强制分页列表、批量期望态变更和批量操作状态。Site/Area/RBAC/配额仍由 Bell 持有,Sense 不提供这些资源的 CRUD。
|
||||
3. tenant 从认证上下文确定,不能由请求 body/query 自报;资源不存在与越权使用一致响应。所有写操作定义幂等或 `If-Match` 并发保护,批量操作返回逐项结果且最多 128 项。
|
||||
4. `endpoint_ref`、`credential_ref` 仅允许作为写入字段且不得包含 userinfo;普通响应不回显两者、密码、完整流 URI、token 或 MediaMTX 管理细节。
|
||||
5. 配额视图由 Bell migration 创建并拥有,Sense 数据库角色只有 `SELECT`;列签名至少包含 tenant/site、`max_video_channels`、单调 `source_version` 与源更新时间。默认 16、范围 1~128;不可读取时拒绝新增/启用视频设备,已有链路保持不变。
|
||||
6. 增加标准库契约测试,校验 OpenAPI 结构、operationId 唯一、认证、分页、写入并发/幂等、批量上限、敏感字段 write-only、稳定错误码和配额 SQL 签名,并用负例证明关键约束会被拦截。
|
||||
7. 同步 `docs/api.md`、正式架构、路线图和当前状态,明确 v1 的兼容、废弃、迁移与分库退出路线。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:站点视频配额默认 16、有效范围 1~128;批量请求最多 128 项;列表必须分页且服务端上限不超过 100;16/128 均不是单机性能保证。
|
||||
- 判定式 / 状态转换:只统计 `desired_state=enabled` 且含 `video_capture` capability 的设备;新增/启用必须在同一写路径校验配额和 Area 投影;配额/策略不可读时只拒绝相关新写入,不停已有流;期望态写入成功不等于实际态已收敛。
|
||||
- 安全边界:tenant 只来自认证上下文;越权不泄露资源存在性;密码、完整连接串、credential ref、token 和内部堆栈不出现在普通响应/错误/示例;Sense 不写 Bell schema。
|
||||
- 既有契约:保留 `modality + capabilities`、`desired_state`/`actual_state`、generation/observed_generation、默认 16/最大 128、SQLite→PostgreSQL 迁移方向和 MediaMTX 独立数据面;`/healthz`、`/readyz` 继续是运维探针,不改成业务健康接口。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:OpenAPI/SQL/说明互相引用且无悬空路径;运行 `python -m unittest discover -s tests -p "test_sense_control_contract.py"`,正例全通过,关键约束负例均产生预期错误;JSON 可由标准库解析。
|
||||
- 完整门禁:运行 `./init.ps1`、`python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`、`python scripts/validate_harness_governance.py` 和 `git diff --check`,全部通过。
|
||||
- 人工 / 设备验收:不需要摄像头或 UI 人工验收;由架构/后端负责人核对资源所有权、失败语义、幂等/并发、隐私和未来分库退出路线。
|
||||
- 构建产物:`docs/contracts/sense-control-v1.openapi.json`、`docs/contracts/site-quota-v1.sql`、`docs/contracts/README.md`;本任务不生成部署二进制或数据库 migration。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不实现 Sense HTTP handler、认证中间件、Bell 服务、PostgreSQL migration、配额 repository、Area 投影、审计 outbox/relay、管理 UI、16 路压测或 T-007 现场验收;不修改 Brain、事件 v0.1 契约、MediaMTX API 或已确认原型。实现工作由后续任务使用本契约完成。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:由 dispatcher 分配。
|
||||
- 唯一写入者:同责任 Agent。
|
||||
- 委派:默认不启用。
|
||||
- Gitea:任务文件先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim 与工作分支。
|
||||
|
||||
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-07 完成契约冻结
|
||||
|
||||
- 冻结 OpenAPI 3.1 `1.0.0`:7 个站点作用域/批量操作 endpoint,tenant 来自认证上下文,列表默认 50/最大 100,批量最大 128,并明确 Bearer、幂等键、ETag、逐项结果和稳定错误码。
|
||||
- 设备创建使用 `modality + capabilities` 且 ID 由服务端生成;连接和凭据引用只写不读,v1 不暴露删除或 Site/Area/RBAC/配额 CRUD,也不把期望态受理误报为实际态收敛。
|
||||
- 冻结 `bell.site_quota_v1` 的 5 列签名、`bell_app` 所有权及 `sense_app` 的最小 `USAGE + SELECT` 权限;配额默认 16、范围 1~128,投影失败只阻断相关新增/启用,已有链路保持不变。
|
||||
- 增加 8 个标准库契约测试,正例覆盖 OpenAPI/SQL/说明一致性,负例证明缺失认证、客户端自报 tenant、101 页上限、129 项批量、敏感字段回显和 Bell 写权限都会被拒绝。
|
||||
- `./init.ps1` 通过;`python -m unittest discover -s tests -p "test_*.py"` 共 26 项通过;JSON 标准库解析、上下文校验、治理校验和 `git diff --check` 均通过。本任务不需要摄像头或 UI 人工验收。
|
||||
|
||||
### 2026-08-07 领取任务
|
||||
|
||||
- dispatcher `ila` 将任务分配给 `codex`;`context_ref` 为 `cc47e1463849151bee3c349cc53971181e262043`,claim 为 `claims/T-008`,工作分支为 `agent/codex/T-008`。
|
||||
- 接受既有写路径:`docs/tasks/T-008.md`、`docs/contracts/`、`docs/api.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/current-state.md`、`tests/test_sense_control_contract.py`。
|
||||
|
||||
### 2026-08-07 任务定义
|
||||
|
||||
- 项目负责人要求落成并立即实施 T-008;任务按 M2 第一项契约门禁建立,依赖已完成的 T-006。
|
||||
- 配额跨系统读取基于“一个 PostgreSQL 实例、schema 分离”的既有前提,选择 `bell.site_quota_v1` 版本化只读视图;未来分库通过新版本替代,v1 不原地改成网络 API。
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: T-009
|
||||
title: 建立 PostgreSQL 双 Schema 与 Bell 配额投影基础
|
||||
phase: 2
|
||||
deps: [T-008]
|
||||
status: TODO
|
||||
created: 2026-08-07
|
||||
issue: 31
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
write_paths:
|
||||
- docs/tasks/T-009.md
|
||||
- deploy/postgres/
|
||||
- scripts/test_postgres.ps1
|
||||
- Sense/internal/store/
|
||||
- Sense/internal/config/
|
||||
- Sense/cmd/sense-api/main.go
|
||||
- Sense/go.mod
|
||||
- Sense/go.sum
|
||||
- Sense/README.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
- tests/test_postgres_contract.py
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-008 已冻结 Sense Control API v1 和 `bell.site_quota_v1` 只读投影,但当前 Sense 仍只使用 M1 SQLite,Bell 也没有 PostgreSQL 源表、schema、角色或 migration。若直接实现 HTTP handler,tenant、配额读取、并发准入和事务语义仍会绑定 SQLite,后续迁移必然返工。
|
||||
|
||||
项目负责人指定使用本机现有 `D:\pgsql17`,已核实二进制与服务版本为 PostgreSQL `17.10`、默认端口 `5432`。现有实例要求 SCRAM 且当前执行环境没有管理员密码,因此自动验收必须使用同一套本机二进制启动隔离临时集群;不得修改现有实例的认证、角色、数据库或数据。生产/共享实例安装只有在操作者显式提供管理员连接信息时才执行。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-001、US-002、US-008、US-009、US-010。
|
||||
- 交互清单:IX-001~IX-004、IX-013~IX-016、IX-019、IX-020;本任务是数据基础,不修改已确认原型或实现 HTTP 页面交互。
|
||||
- 前置契约:`docs/contracts/sense-control-v1.openapi.json`、`docs/contracts/site-quota-v1.sql` 和 `docs/contracts/README.md`。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 冻结 PostgreSQL `17.10` 与 Go PostgreSQL driver;增加可审计、顺序执行的初始化 SQL,创建 NOLOGIN 权限角色 `bell_app`/`sense_app`、`bell`/`sense` schema、Bell Site 配额源表和 Sense 设备/能力/调和表。
|
||||
2. migration 创建与 T-008 完全一致的 `bell.site_quota_v1(tenant_id, site_id, max_video_channels, source_version, source_updated_at)`;Bell 拥有源表和视图,Sense 只有 Bell schema `USAGE` 与视图 `SELECT`,没有 Bell 源表或写权限。
|
||||
3. Bell Site 在数据库层执行默认 16、范围 1~128、逻辑删除和单调版本;配额更新通过 trigger 增加版本,不能由 Sense 修改。
|
||||
4. 新增 Sense PostgreSQL repository,覆盖现有 SQLite 被对账/探活使用的全部 port;设备新增/启用在同一事务内按 tenant/site 读取配额投影、串行化同站点准入、统计 `enabled + video_capture`,并拒绝投影缺失、越界或版本回退。
|
||||
5. Sense 进程增加显式 `SENSE_DB_DRIVER=sqlite|postgres`;默认继续使用 SQLite 保持单摄像头开发路径,生产选择 PostgreSQL 时必须显式提供 DSN。不得把 DSN、密码或完整连接串写入日志、错误、任务证据或仓库。
|
||||
6. 使用 `D:\pgsql17\bin` 的 `initdb/pg_ctl/psql` 启动仅绑定回环地址的隔离临时集群,执行 migration、权限断言和 PostgreSQL repository 集成测试,结束后验证目标路径再清理临时数据目录;不停止或重启现有 Windows PostgreSQL 服务。
|
||||
7. 增加无 PostgreSQL 也能运行的静态契约测试,校验 migration 顺序、角色权限、配额视图签名、范围、版本 trigger 和禁止的宽权限;同步技术栈、架构、API、路线图、Sense 启动说明与当前状态。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:`max_video_channels` 默认 16、有效范围 1~128;16/128 不是单机承载保证。只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;同站点并发创建/启用不能突破配额。
|
||||
- 判定式 / 状态转换:配额行缺失、读取失败、值越界或 `source_version` 回退时,PostgreSQL repository 只拒绝相关视频新增/启用,不停已有设备、不修改期望态;降低配额不会自动停用已有流。相同期望态重复提交不增加 generation。
|
||||
- 数据所有权:Bell 是 Tenant/Site/配额真相源;Sense 不建立可写 Site 真相副本,不写 Bell schema、不读取 `bell.sites` 源表。Sense 设备和调和状态只写 `sense` schema;两个 schema 只以 tenant/site 逻辑 ID 关联。
|
||||
- 安全边界:仓库、测试输出、Issue/PR 和日志不得出现 PostgreSQL 密码、DSN 凭据或私有数据。自动测试只使用临时 trust 集群且只绑定 `127.0.0.1`;现有 `D:\pgsql17\data`、Windows 服务和其他数据库严格只读。
|
||||
- 兼容与恢复:M1 SQLite 默认路径和现有五路集成工具继续可用;T-009 不搬迁或删除现有 SQLite 数据。生产切换失败时可回到 SQLite 配置;初始 PostgreSQL schema 不提供自动破坏性 down migration,清理由管理员在备份/确认后限定到 YoVision 专用数据库执行。
|
||||
- 既有契约:不修改 T-008 OpenAPI 路径、字段、错误语义、配额视图列签名或 Brain 事件 v0.1;PostgreSQL repository 的出现不表示 Sense HTTP handler、认证或 Bell 管理服务已经实现。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:在本机执行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17`,必须完成隔离集群启动、migration 重放、权限断言、tenant/配额/并发/版本回退 repository 集成测试和自动清理;运行 `python -m unittest tests/test_postgres_contract.py` 或对应 discovery 命令,静态负例能够拦截 Bell 写权限与契约漂移。
|
||||
- 完整门禁:运行 `./init.ps1`、`python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`、`python scripts/validate_harness_governance.py`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` 和 `git diff --check`,全部通过。
|
||||
- 人工 / 设备验收:不需要摄像头、UI 或客户现场;任务所有者必须核对隔离集群确实没有使用 `D:\pgsql17\data`,现有服务 PID/端口在测试前后保持运行,且共享实例未新增 YoVision 对象。
|
||||
- 构建产物:PostgreSQL migration、权限/迁移断言、本机隔离测试入口、Sense PostgreSQL repository 和配置/运维文档;不提交临时数据目录、数据库 dump 或秘密。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不实现 Sense Control API HTTP handler、Bearer 认证、RBAC、幂等收据、ETag/cursor/batch operation、Area/capture policy 投影、Bell Go 服务、SQLite→PostgreSQL 数据搬迁、WireGuard、孤儿删除、16 路容量压测或 T-007 现场验收;不修改 Brain、MediaMTX API、摄像头适配器、事件契约或已确认 UI 原型。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:由 dispatcher 分配。
|
||||
- 唯一写入者:同责任 Agent。
|
||||
- 委派:默认不启用。
|
||||
- Gitea:任务文件先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim 与工作分支。
|
||||
|
||||
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-07 任务定义
|
||||
|
||||
- 项目负责人批准创建并实施 T-009,并指定复用本机 `D:\pgsql17`;核实版本为 PostgreSQL `17.10`。
|
||||
- 当前实例监听 `5432` 且 SCRAM 认证有效,执行环境未持有管理员密码;任务据此选择“本机同版二进制 + 隔离临时集群”作为自动验收,不触碰现有数据目录和服务。
|
||||
@@ -10,11 +10,10 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
# 当前仍是文档/契约基线:没有业务依赖和可启动服务,标准入口先收敛治理验证。
|
||||
# M1 创建 Sense 脚手架时必须把真实安装、验证、启动命令同步到本文档链路。
|
||||
$InstallCmd = "Write-Host '当前无业务依赖需要安装'"
|
||||
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
|
||||
$StartCmd = "Write-Host '当前无生产服务;请从 Gitea 工单开始 M0/M1 工作'"
|
||||
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
||||
$InstallCmd = "go -C Sense mod download"
|
||||
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
|
||||
$StartCmd = "go -C Sense run ./cmd/sense-api"
|
||||
|
||||
function Assert-Configured {
|
||||
param(
|
||||
|
||||
@@ -12,11 +12,10 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# 当前仍是文档/契约基线:没有业务依赖和可启动服务,标准入口先收敛治理验证。
|
||||
# M1 创建 Sense 脚手架时必须把真实安装、验证、启动命令同步到本文档链路。
|
||||
INSTALL_CMD=(true)
|
||||
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py")
|
||||
START_CMD=(echo "当前无生产服务;请从 Gitea 工单开始 M0/M1 工作")
|
||||
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
||||
INSTALL_CMD=(go -C Sense mod download)
|
||||
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...")
|
||||
START_CMD=(go -C Sense run ./cmd/sense-api)
|
||||
|
||||
ensure_configured() {
|
||||
local name="$1"
|
||||
|
||||
@@ -26,6 +26,7 @@ from validate_harness_governance import (
|
||||
|
||||
STATUS_LABELS = {
|
||||
"status/todo",
|
||||
"status/waiting",
|
||||
"status/doing",
|
||||
"status/blocked",
|
||||
"status/review",
|
||||
@@ -242,13 +243,25 @@ def validate_pull_request(
|
||||
pr: dict[str, Any], task: RemoteTask
|
||||
) -> list[AuditFinding]:
|
||||
findings: list[AuditFinding] = []
|
||||
if not task.work_branch or pull_request_head(pr) != task.work_branch:
|
||||
body = pr.get("body")
|
||||
body = body if isinstance(body, str) else ""
|
||||
head = pull_request_head(pr)
|
||||
head_matches = bool(task.work_branch) and head == task.work_branch
|
||||
if (
|
||||
not head_matches
|
||||
and bool(pr.get("merged"))
|
||||
and re.fullmatch(r"refs/pull/\d+/head", head)
|
||||
and bool(task.work_branch)
|
||||
and task.work_branch in body
|
||||
):
|
||||
# Gitea rewrites head.ref after a merged source branch is deleted. The
|
||||
# immutable PR body still records the claimed work branch.
|
||||
head_matches = True
|
||||
if not head_matches:
|
||||
findings.append(
|
||||
AuditFinding(task.number, "pull-request", "PR head 与 claim 工作分支不一致。")
|
||||
)
|
||||
body = pr.get("body")
|
||||
body = body if isinstance(body, str) else ""
|
||||
required_values = [f"docs/tasks/{task.task_id}.md"]
|
||||
required_values = [f"docs/tasks/{task.task_id}.md", task.work_branch or ""]
|
||||
if task.context_ref:
|
||||
required_values.append(task.context_ref)
|
||||
required_values.extend(task.write_paths or [])
|
||||
@@ -558,8 +571,8 @@ def audit_repository(
|
||||
findings.append(AuditFinding(number, "stale", "claim 租期不得超过 24 小时。"))
|
||||
if task.lease_until <= now:
|
||||
findings.append(AuditFinding(number, "stale", "claim 已过期,需人工审查回收。"))
|
||||
elif status == "status/todo" and claim_branch in branches:
|
||||
findings.append(AuditFinding(number, "claim", "TODO 仍存在 claim 分支。"))
|
||||
elif status in {"status/todo", "status/waiting"} and claim_branch in branches:
|
||||
findings.append(AuditFinding(number, "claim", "未领取状态仍存在 claim 分支。"))
|
||||
|
||||
matching_prs = [
|
||||
pr
|
||||
|
||||
@@ -38,6 +38,12 @@ LABELS: tuple[dict[str, Any], ...] = (
|
||||
"description": "Ready to claim",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/waiting",
|
||||
"color": "D4C5F9",
|
||||
"description": "Not claimable; waiting on dependency or external condition",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/doing",
|
||||
"color": "FBCA04",
|
||||
|
||||
@@ -14,12 +14,17 @@ if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from audit_gitea_coordination import (
|
||||
ACTIVE_LABELS,
|
||||
READY_OR_ACTIVE_LABELS,
|
||||
STATUS_LABELS,
|
||||
audit_repository,
|
||||
latest_claim,
|
||||
paged,
|
||||
parse_datetime,
|
||||
parse_write_paths,
|
||||
select_latest_claim,
|
||||
RemoteTask,
|
||||
validate_pull_request,
|
||||
)
|
||||
from setup_gitea_labels import LABELS, NoRedirect, build_plan
|
||||
from test_gitea_claim_race import PROBE_PREFIX, new_probe_branch
|
||||
@@ -177,6 +182,36 @@ class OfflineRuleTests(unittest.TestCase):
|
||||
|
||||
|
||||
class GiteaHelperTests(unittest.TestCase):
|
||||
def test_merged_pr_accepts_gitea_deleted_branch_ref(self) -> None:
|
||||
sha = "a" * 40
|
||||
task = RemoteTask(
|
||||
number=1,
|
||||
task_id="T-123",
|
||||
state="closed",
|
||||
status="status/done",
|
||||
labels={"status/done"},
|
||||
body="",
|
||||
work_branch="agent/worker-1/T-123",
|
||||
context_ref=sha,
|
||||
write_paths=["docs/tasks/T-123.md"],
|
||||
)
|
||||
pr = {
|
||||
"merged": True,
|
||||
"head": {"ref": "refs/pull/1/head"},
|
||||
"body": (
|
||||
"Closes #1\n"
|
||||
"task_file: docs/tasks/T-123.md\n"
|
||||
f"context_ref: {sha}\n"
|
||||
"work_branch: agent/worker-1/T-123\n"
|
||||
),
|
||||
}
|
||||
self.assertEqual([], validate_pull_request(pr, task))
|
||||
|
||||
def test_waiting_status_is_not_claimable_or_active(self) -> None:
|
||||
self.assertIn("status/waiting", STATUS_LABELS)
|
||||
self.assertNotIn("status/waiting", READY_OR_ACTIVE_LABELS)
|
||||
self.assertNotIn("status/waiting", ACTIVE_LABELS)
|
||||
|
||||
def test_label_plan_detects_exclusive_change(self) -> None:
|
||||
desired = next(label for label in LABELS if label["name"] == "status/todo")
|
||||
existing = {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Repository-local invariants for the frozen Sense control-plane contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OPENAPI_PATH = ROOT / "docs" / "contracts" / "sense-control-v1.openapi.json"
|
||||
QUOTA_SQL_PATH = ROOT / "docs" / "contracts" / "site-quota-v1.sql"
|
||||
CONTRACT_README_PATH = ROOT / "docs" / "contracts" / "README.md"
|
||||
|
||||
HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"}
|
||||
EXPECTED_OPERATIONS = {
|
||||
("get", "/api/v1/sites/{site_id}/devices"),
|
||||
("post", "/api/v1/sites/{site_id}/devices"),
|
||||
("get", "/api/v1/sites/{site_id}/devices/{device_id}"),
|
||||
("patch", "/api/v1/sites/{site_id}/devices/{device_id}"),
|
||||
("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"),
|
||||
("post", "/api/v1/sites/{site_id}/devices:batchDesiredState"),
|
||||
("get", "/api/v1/operations/{operation_id}"),
|
||||
}
|
||||
|
||||
EXPECTED_ENUMS = {
|
||||
"Modality": ["video", "radar", "contact", "button", "wearable", "other"],
|
||||
"Capability": ["video_capture", "audio_capture", "spatial_rule", "telemetry"],
|
||||
"DesiredState": ["disabled", "enabled"],
|
||||
"ActualState": ["pending", "online", "offline", "failed"],
|
||||
}
|
||||
|
||||
REQUIRED_ERROR_CODES = {
|
||||
"invalid_request",
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"precondition_required",
|
||||
"etag_mismatch",
|
||||
"idempotency_conflict",
|
||||
"duplicate_serial_number",
|
||||
"quota_exceeded",
|
||||
"quota_projection_unavailable",
|
||||
"quota_projection_invalid",
|
||||
"area_policy_denied",
|
||||
"area_policy_unavailable",
|
||||
"adapter_not_ready",
|
||||
"endpoint_credentials_forbidden",
|
||||
"batch_too_large",
|
||||
}
|
||||
|
||||
|
||||
def load_openapi() -> dict[str, Any]:
|
||||
return json.loads(OPENAPI_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def resolve_local_ref(document: dict[str, Any], value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve one local JSON pointer; the contract only needs direct component refs."""
|
||||
ref = value.get("$ref")
|
||||
if not ref:
|
||||
return value
|
||||
if not ref.startswith("#/"):
|
||||
raise ValueError(f"external ref is not allowed here: {ref}")
|
||||
current: Any = document
|
||||
for token in ref[2:].split("/"):
|
||||
current = current[token.replace("~1", "/").replace("~0", "~")]
|
||||
if not isinstance(current, dict):
|
||||
raise ValueError(f"ref does not resolve to an object: {ref}")
|
||||
return current
|
||||
|
||||
|
||||
def operation_parameters(
|
||||
document: dict[str, Any], operation: dict[str, Any]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for value in operation.get("parameters", []):
|
||||
parameter = resolve_local_ref(document, value)
|
||||
result[parameter.get("name", "")] = parameter
|
||||
return result
|
||||
|
||||
|
||||
def validate_openapi(document: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if document.get("openapi") != "3.1.0":
|
||||
errors.append("OpenAPI version must be 3.1.0")
|
||||
if document.get("info", {}).get("version") != "1.0.0":
|
||||
errors.append("contract version must be 1.0.0")
|
||||
|
||||
top_security = document.get("security")
|
||||
if not top_security or not any("bearerAuth" in item for item in top_security):
|
||||
errors.append("top-level bearerAuth is required")
|
||||
|
||||
operations: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
operation_ids: list[str] = []
|
||||
for path, path_item in document.get("paths", {}).items():
|
||||
if not path.startswith("/api/v1/"):
|
||||
errors.append(f"unversioned path: {path}")
|
||||
for method, operation in path_item.items():
|
||||
if method not in HTTP_METHODS:
|
||||
continue
|
||||
key = (method, path)
|
||||
operations[key] = operation
|
||||
operation_id = operation.get("operationId")
|
||||
if not operation_id:
|
||||
errors.append(f"missing operationId: {method.upper()} {path}")
|
||||
else:
|
||||
operation_ids.append(operation_id)
|
||||
if not operation.get("responses"):
|
||||
errors.append(f"missing responses: {method.upper()} {path}")
|
||||
effective_security = operation.get("security", top_security)
|
||||
if not effective_security or not any(
|
||||
"bearerAuth" in item for item in effective_security
|
||||
):
|
||||
errors.append(f"bearerAuth required: {method.upper()} {path}")
|
||||
|
||||
missing_operations = EXPECTED_OPERATIONS - set(operations)
|
||||
unexpected_operations = set(operations) - EXPECTED_OPERATIONS
|
||||
if missing_operations:
|
||||
errors.append(f"missing operations: {sorted(missing_operations)}")
|
||||
if unexpected_operations:
|
||||
errors.append(f"unexpected operations: {sorted(unexpected_operations)}")
|
||||
if len(operation_ids) != len(set(operation_ids)):
|
||||
errors.append("operationId values must be unique")
|
||||
|
||||
list_operation = operations.get(("get", "/api/v1/sites/{site_id}/devices"), {})
|
||||
list_parameters = operation_parameters(document, list_operation)
|
||||
if "cursor" not in list_parameters:
|
||||
errors.append("device list requires cursor pagination")
|
||||
limit = list_parameters.get("limit", {}).get("schema", {})
|
||||
if limit.get("default") != 50 or limit.get("maximum") != 100:
|
||||
errors.append("device list limit must default to 50 and have maximum 100")
|
||||
|
||||
header_requirements = {
|
||||
("post", "/api/v1/sites/{site_id}/devices"): "Idempotency-Key",
|
||||
("post", "/api/v1/sites/{site_id}/devices:batchDesiredState"): "Idempotency-Key",
|
||||
("patch", "/api/v1/sites/{site_id}/devices/{device_id}"): "If-Match",
|
||||
("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"): "If-Match",
|
||||
}
|
||||
for key, header in header_requirements.items():
|
||||
parameters = operation_parameters(document, operations.get(key, {}))
|
||||
parameter = parameters.get(header, {})
|
||||
if parameter.get("in") != "header" or parameter.get("required") is not True:
|
||||
errors.append(f"{key[0].upper()} {key[1]} requires header {header}")
|
||||
|
||||
for key in (
|
||||
("patch", "/api/v1/sites/{site_id}/devices/{device_id}"),
|
||||
("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"),
|
||||
):
|
||||
responses = operations.get(key, {}).get("responses", {})
|
||||
if "412" not in responses or "428" not in responses:
|
||||
errors.append(f"{key[0].upper()} {key[1]} must define 412 and 428")
|
||||
|
||||
schemas = document.get("components", {}).get("schemas", {})
|
||||
batch_items = (
|
||||
schemas.get("BatchDesiredStateRequest", {})
|
||||
.get("properties", {})
|
||||
.get("items", {})
|
||||
)
|
||||
if batch_items.get("maxItems") != 128:
|
||||
errors.append("batch desired-state request must have maximum 128 items")
|
||||
|
||||
client_schemas = ("DeviceCreate", "DevicePatch", "DesiredStateChange", "BatchDesiredStateRequest")
|
||||
for schema_name in client_schemas:
|
||||
if "tenant_id" in schemas.get(schema_name, {}).get("properties", {}):
|
||||
errors.append(f"tenant_id must not be client-controlled in {schema_name}")
|
||||
|
||||
create_properties = schemas.get("DeviceCreate", {}).get("properties", {})
|
||||
if "id" in create_properties:
|
||||
errors.append("device id must be generated by the service")
|
||||
required_create = set(schemas.get("DeviceCreate", {}).get("required", []))
|
||||
if not {"modality", "capabilities"}.issubset(required_create):
|
||||
errors.append("device create requires modality and capabilities")
|
||||
|
||||
for schema_name in ("DeviceCreate", "DevicePatch"):
|
||||
properties = schemas.get(schema_name, {}).get("properties", {})
|
||||
for field in ("endpoint_ref", "credential_ref", "profile_token"):
|
||||
if properties.get(field, {}).get("writeOnly") is not True:
|
||||
errors.append(f"{schema_name}.{field} must be writeOnly")
|
||||
|
||||
output_properties = schemas.get("Device", {}).get("properties", {})
|
||||
tenant = output_properties.get("tenant_id", {})
|
||||
if tenant.get("readOnly") is not True:
|
||||
errors.append("Device.tenant_id must be readOnly")
|
||||
forbidden_output_fields = {
|
||||
"endpoint_ref",
|
||||
"credential_ref",
|
||||
"profile_token",
|
||||
"active_profile",
|
||||
"stream_uri",
|
||||
"password",
|
||||
"token",
|
||||
}
|
||||
leaked_fields = forbidden_output_fields & set(output_properties)
|
||||
if leaked_fields:
|
||||
errors.append(f"Device leaks sensitive fields: {sorted(leaked_fields)}")
|
||||
|
||||
for schema_name, expected in EXPECTED_ENUMS.items():
|
||||
actual = schemas.get(schema_name, {}).get("enum")
|
||||
if actual != expected:
|
||||
errors.append(f"{schema_name} enum drift: {actual!r}")
|
||||
|
||||
error_codes = set(schemas.get("ErrorCode", {}).get("enum", []))
|
||||
missing_error_codes = REQUIRED_ERROR_CODES - error_codes
|
||||
if missing_error_codes:
|
||||
errors.append(f"missing stable error codes: {sorted(missing_error_codes)}")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_quota_sql(sql: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
normalized = re.sub(r"\s+", " ", sql.lower()).strip()
|
||||
expected_signature = (
|
||||
"create view bell.site_quota_v1 ( tenant_id, site_id, "
|
||||
"max_video_channels, source_version, source_updated_at ) as"
|
||||
)
|
||||
if expected_signature not in normalized:
|
||||
errors.append("bell.site_quota_v1 column signature is not frozen as expected")
|
||||
if "alter view bell.site_quota_v1 owner to bell_app" not in normalized:
|
||||
errors.append("Bell role must own the quota view")
|
||||
if "grant usage on schema bell to sense_app" not in normalized:
|
||||
errors.append("Sense role needs schema USAGE")
|
||||
if "grant select on table bell.site_quota_v1 to sense_app" not in normalized:
|
||||
errors.append("Sense role needs SELECT on only the quota view")
|
||||
if "revoke all privileges on table bell.site_quota_v1 from public" not in normalized:
|
||||
errors.append("PUBLIC privileges must be revoked")
|
||||
if re.search(
|
||||
r"grant\s+(?:all(?:\s+privileges)?|insert|update|delete|truncate|references|trigger)\b",
|
||||
normalized,
|
||||
):
|
||||
errors.append("quota view grants a forbidden write or broad privilege")
|
||||
if re.search(r"grant\s+select\s+on\s+table\s+bell\.sites\b", normalized):
|
||||
errors.append("Sense must not receive SELECT on the Bell source table")
|
||||
return errors
|
||||
|
||||
|
||||
class SenseControlContractTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.document = load_openapi()
|
||||
self.quota_sql = QUOTA_SQL_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_frozen_openapi_invariants(self) -> None:
|
||||
self.assertEqual([], validate_openapi(self.document))
|
||||
|
||||
def test_frozen_quota_projection_invariants(self) -> None:
|
||||
self.assertEqual([], validate_quota_sql(self.quota_sql))
|
||||
|
||||
def test_contract_narrative_records_failure_and_compatibility_semantics(self) -> None:
|
||||
text = CONTRACT_README_PATH.read_text(encoding="utf-8")
|
||||
for marker in (
|
||||
"默认 16",
|
||||
"1~128",
|
||||
"至少保存 24 小时",
|
||||
"404 not_found",
|
||||
"已有流保持运行",
|
||||
"必须发布新版本",
|
||||
):
|
||||
self.assertIn(marker, text)
|
||||
|
||||
def test_validator_rejects_missing_authentication(self) -> None:
|
||||
changed = copy.deepcopy(self.document)
|
||||
changed.pop("security", None)
|
||||
self.assertTrue(any("bearerAuth" in item for item in validate_openapi(changed)))
|
||||
|
||||
def test_validator_rejects_client_controlled_tenant(self) -> None:
|
||||
changed = copy.deepcopy(self.document)
|
||||
changed["components"]["schemas"]["DeviceCreate"]["properties"]["tenant_id"] = {
|
||||
"type": "string"
|
||||
}
|
||||
self.assertTrue(any("tenant_id" in item for item in validate_openapi(changed)))
|
||||
|
||||
def test_validator_rejects_unbounded_list_or_batch(self) -> None:
|
||||
changed = copy.deepcopy(self.document)
|
||||
changed["components"]["parameters"]["Limit"]["schema"]["maximum"] = 101
|
||||
changed["components"]["schemas"]["BatchDesiredStateRequest"]["properties"][
|
||||
"items"
|
||||
]["maxItems"] = 129
|
||||
errors = validate_openapi(changed)
|
||||
self.assertTrue(any("maximum 100" in item for item in errors))
|
||||
self.assertTrue(any("maximum 128" in item for item in errors))
|
||||
|
||||
def test_validator_rejects_sensitive_output_or_writable_credential_ref(self) -> None:
|
||||
changed = copy.deepcopy(self.document)
|
||||
changed["components"]["schemas"]["DevicePatch"]["properties"]["credential_ref"][
|
||||
"writeOnly"
|
||||
] = False
|
||||
changed["components"]["schemas"]["Device"]["properties"]["stream_uri"] = {
|
||||
"type": "string"
|
||||
}
|
||||
errors = validate_openapi(changed)
|
||||
self.assertTrue(any("credential_ref" in item for item in errors))
|
||||
self.assertTrue(any("sensitive fields" in item for item in errors))
|
||||
|
||||
def test_validator_rejects_write_privilege(self) -> None:
|
||||
changed = self.quota_sql + "\nGRANT ALL PRIVILEGES ON bell.site_quota_v1 TO sense_app;\n"
|
||||
self.assertTrue(any("forbidden" in item for item in validate_quota_sql(changed)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user