Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c202b7ac8 | ||
|
|
7db707e596 | ||
|
|
a4427a6d2b | ||
|
|
e3b166c5af | ||
|
|
e46f175551 | ||
|
|
2ca51aa2bc | ||
|
|
19a3233f11 | ||
|
|
a2790c1f5e | ||
|
|
2e04737922 | ||
|
|
2768cfeacb | ||
|
|
5c9318ceef | ||
|
|
5de6f0c2bb | ||
|
|
2b9a59fe8a | ||
|
|
4be2386421 | ||
|
|
aca22f4667 | ||
|
|
88b92d8811 | ||
|
|
325e6ec255 | ||
|
|
e3cb1c5bc5 | ||
|
|
7e66fab042 | ||
|
|
236964d242 |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。
|
YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。
|
||||||
|
|
||||||
当前为 **M0:摄像头兼容性验证与需求定稿**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。
|
当前已完成 M0、M1 和 M2 的本地基线,进入 **M3:Bell/Brain 最小事件闭环**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。
|
||||||
|
|
||||||
## 固定阅读顺序
|
## 固定阅读顺序
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ Gitea 不可用时,只能继续已经确认属于自己的任务;不得领
|
|||||||
python scripts/validate_agent_context.py
|
python scripts/validate_agent_context.py
|
||||||
python -m unittest discover -s tests -p "test_*.py"
|
python -m unittest discover -s tests -p "test_*.py"
|
||||||
python scripts/validate_harness_governance.py
|
python scripts/validate_harness_governance.py
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。
|
代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Bell 事件存储与内部审计入口
|
||||||
|
|
||||||
|
Bell 当前实现 M3 的事件域基础及 Sense 审计 relay 的最小内部 HTTP 服务:
|
||||||
|
|
||||||
|
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
|
||||||
|
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
|
||||||
|
- PostgreSQL `bell.events` 保存不可变事实;后续 outcome 追加到 `bell.event_outcomes`。
|
||||||
|
- `bell_runtime` 对三张不可变事实表只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;仅可在短期 `audit_relay_receipts` 表查询、插入和清理过期收据。
|
||||||
|
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
|
||||||
|
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
|
||||||
|
|
||||||
|
Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。审计 relay 只服务 Sense,不得把 `internal/event` 的 Go 类型或该 HMAC 适配器当成公共协议。
|
||||||
|
|
||||||
|
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Bell run ./cmd/bell-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Bell mod download
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
|
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
|
||||||
|
```
|
||||||
|
|
||||||
|
隔离 PostgreSQL harness 会创建临时 `bell_runtime` 登录成员,运行真实 repository、幂等冲突和不可变权限测试,然后停止并删除临时集群;不会读取或修改现有 `D:\pgsql17\data` 或 5432 服务。
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"yovision/bell/internal/audit"
|
||||||
|
"yovision/bell/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
address string
|
||||||
|
dsn string
|
||||||
|
keyFile string
|
||||||
|
tlsCert string
|
||||||
|
tlsKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
if err := run(logger); err != nil {
|
||||||
|
logger.Error("Bell stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfiguration() (configuration, error) {
|
||||||
|
value := configuration{
|
||||||
|
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
||||||
|
dsn: os.Getenv("BELL_DB_DSN"),
|
||||||
|
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
||||||
|
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
||||||
|
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
||||||
|
}
|
||||||
|
if value.dsn == "" {
|
||||||
|
return configuration{}, errors.New("BELL_DB_DSN is required")
|
||||||
|
}
|
||||||
|
if value.keyFile == "" || !filepath.IsAbs(value.keyFile) {
|
||||||
|
return configuration{}, errors.New("BELL_AUDIT_KEYS_FILE must be an absolute external path")
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(value.address)
|
||||||
|
if err != nil {
|
||||||
|
return configuration{}, errors.New("invalid BELL_HTTP_ADDR")
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||||
|
if !loopback && (value.tlsCert == "" || value.tlsKey == "" || !filepath.IsAbs(value.tlsCert) || !filepath.IsAbs(value.tlsKey)) {
|
||||||
|
return configuration{}, errors.New("non-loopback Bell bind requires absolute TLS certificate and key paths")
|
||||||
|
}
|
||||||
|
if (value.tlsCert == "") != (value.tlsKey == "") {
|
||||||
|
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(logger *slog.Logger) error {
|
||||||
|
cfg, err := loadConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pgConfig, err := pgx.ParseConfig(cfg.dsn)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid Bell postgres DSN")
|
||||||
|
}
|
||||||
|
if pgConfig.RuntimeParams == nil {
|
||||||
|
pgConfig.RuntimeParams = make(map[string]string)
|
||||||
|
}
|
||||||
|
pgConfig.RuntimeParams["application_name"] = "yovision-bell"
|
||||||
|
db := stdlib.OpenDB(*pgConfig)
|
||||||
|
db.SetMaxOpenConns(16)
|
||||||
|
db.SetMaxIdleConns(4)
|
||||||
|
db.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
repository, err := store.OpenPostgres(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := repository.AuditRelayReady(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := audit.LoadKeys(cfg.keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
handler, err := audit.NewHandler(repository, keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle(audit.RelayPath, handler)
|
||||||
|
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeStatus(writer, http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := repository.AuditRelayReady(request.Context()); err != nil {
|
||||||
|
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeStatus(writer, http.StatusOK, "ready")
|
||||||
|
})
|
||||||
|
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("Bell listening", "address", cfg.address, "version", version, "tls_enabled", cfg.tlsCert != "")
|
||||||
|
if cfg.tlsCert != "" {
|
||||||
|
serverErrors <- server.ListenAndServeTLS(cfg.tlsCert, cfg.tlsKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverErrors <- server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case serverErr := <-serverErrors:
|
||||||
|
if !errors.Is(serverErr, http.ErrServerClosed) {
|
||||||
|
return fmt.Errorf("serve Bell HTTP: %w", serverErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(shutdownContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStatus(writer http.ResponseWriter, status int, value string) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"status":%q}`, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(name, fallback string) string {
|
||||||
|
if value := os.Getenv(name); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
|
||||||
|
t.Setenv("BELL_DB_DSN", "")
|
||||||
|
t.Setenv("BELL_AUDIT_KEYS_FILE", "")
|
||||||
|
if _, err := loadConfiguration(); err == nil {
|
||||||
|
t.Fatal("missing Bell database was accepted")
|
||||||
|
}
|
||||||
|
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||||
|
t.Setenv("BELL_AUDIT_KEYS_FILE", "relative.json")
|
||||||
|
if _, err := loadConfiguration(); err == nil {
|
||||||
|
t.Fatal("relative Bell key file was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
|
||||||
|
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||||
|
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
|
||||||
|
t.Setenv("BELL_HTTP_ADDR", "0.0.0.0:8081")
|
||||||
|
if _, err := loadConfiguration(); err == nil {
|
||||||
|
t.Fatal("remote plaintext Bell bind was accepted")
|
||||||
|
}
|
||||||
|
t.Setenv("BELL_TLS_CERT_FILE", filepath.Join(t.TempDir(), "server.crt"))
|
||||||
|
t.Setenv("BELL_TLS_KEY_FILE", filepath.Join(t.TempDir(), "server.key"))
|
||||||
|
if _, err := loadConfiguration(); err != nil {
|
||||||
|
t.Fatalf("remote TLS Bell bind rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Package contracts embeds the frozen event contract used by Bell.
|
||||||
|
package contracts
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// EventV01Schema is byte-identical to docs/raw/contracts/event-v0.1.schema.json.
|
||||||
|
//
|
||||||
|
//go:embed event-v0.1.schema.json
|
||||||
|
var EventV01Schema []byte
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://yovision.local/contracts/event-v0.1.schema.json",
|
||||||
|
"title": "YoVision Event Instance v0.1",
|
||||||
|
"description": "推理侧 → 平台侧的唯一契约。冻结于 2026-08-03。所有顶层键必须存在(可为 null),不允许省略——省略与显式 null 无法区分,是这类系统最常见的排查陷阱。",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"id",
|
||||||
|
"source_event_id",
|
||||||
|
"tenant_id",
|
||||||
|
"site_id",
|
||||||
|
"device_id",
|
||||||
|
"sensors",
|
||||||
|
"kind",
|
||||||
|
"severity",
|
||||||
|
"confidence",
|
||||||
|
"occurred_at",
|
||||||
|
"detected_at",
|
||||||
|
"latency_seconds",
|
||||||
|
"config_version",
|
||||||
|
"rule",
|
||||||
|
"subject",
|
||||||
|
"observation",
|
||||||
|
"evidence",
|
||||||
|
"dedup_key",
|
||||||
|
"aggregated_into",
|
||||||
|
"outcome",
|
||||||
|
"outcome_source",
|
||||||
|
"outcome_reason",
|
||||||
|
"diagnostics",
|
||||||
|
"ext"
|
||||||
|
],
|
||||||
|
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"description": "契约版本。破坏性变更必须递增主版本。",
|
||||||
|
"const": "0.1"
|
||||||
|
},
|
||||||
|
|
||||||
|
"id": {
|
||||||
|
"description": "平台侧生成的全局唯一事件 ID(ULID)。推理侧不得自行生成。",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"source_event_id": {
|
||||||
|
"description": "推理侧原始事件 ID,如 silver_pose 的 FALL-<session>-000001。用于回溯本地截图文件名(截图即按它命名)。会话内唯一,全局不保证唯一——不得用作主键。",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9_-]{1,128}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"tenant_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"site_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"device_id": {
|
||||||
|
"description": "主传感器的平台设备实体主键。由推理侧的 source_id 经平台映射表解析得到。事件中不得冗余 RTSP 地址或任何凭据。多传感器融合事件的完整来源见 sensors。",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"sensors": {
|
||||||
|
"description": "参与本次判定的全部传感器。单摄像头事件为单元素数组。恰好一个元素的 role 为 primary,且其 device_id 必须等于顶层 device_id。",
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["device_id", "modality", "role"],
|
||||||
|
"properties": {
|
||||||
|
"device_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"modality": {
|
||||||
|
"description": "设备模态。决定隐私区域准入:privacy_flag 为真的区域只允许非成像模态。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["video", "radar", "contact", "button", "wearable", "other"]
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"description": "primary=判定主依据;corroborating=佐证(如雷达判跌倒、门磁佐证无人离开)。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["primary", "corroborating"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"kind": {
|
||||||
|
"description": "事件类型。取值登记在 contracts/README.md 的类型注册表中,新增类型不需要升 schema 版本。v0.1 已登记:fall。",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{2,63}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high", "critical"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"confidence": {
|
||||||
|
"description": "模型置信度。几何+状态机判定链路没有天然来源,必须填 null——不得用任意常量或阈值余量伪造。",
|
||||||
|
"type": ["number", "null"],
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"occurred_at": {
|
||||||
|
"description": "事发时刻(墙钟 UTC)。决定证据回捞窗口。推理侧若只有单调时钟,按 detected_at - latency_seconds 换算。",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
|
||||||
|
"detected_at": {
|
||||||
|
"description": "判定成立时刻(墙钟 UTC)。决定 SLA 计算。必须 >= occurred_at。",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
|
||||||
|
"latency_seconds": {
|
||||||
|
"description": "从可疑到确认的耗时。可由两时间戳相减,但显式存储:它是判定质量的直接指标——贴近确认窗口下限说明证据干脆,贴近上限是误报高发区,为误报排查的首选排序键。",
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
|
||||||
|
"config_version": {
|
||||||
|
"description": "产出本事件时整套判定配置的版本。粒度高于 rule.version(阈值往往是全局的),用于调参后的回归对比。不得为空串。",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
|
||||||
|
"rule": {
|
||||||
|
"description": "命中的规则实体。推理侧无规则引擎时为 null,由平台侧按 kind 反查补全。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "version", "code"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"version": { "type": "integer", "minimum": 1 },
|
||||||
|
"code": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"subject": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["class", "track_id", "attributes", "anon_id", "identity", "identity_status"],
|
||||||
|
"properties": {
|
||||||
|
"class": { "type": "string", "enum": ["person", "vehicle", "object"] },
|
||||||
|
"track_id": {
|
||||||
|
"description": "跟踪器内的短期标识,跨会话不保证稳定。",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"description": "A 类属性(年龄段、着装等)。未启用时为空对象,不是 null。",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"anon_id": {
|
||||||
|
"description": "B+ 类 ReID 匿名标识,站点内会话级有效(≤30min),不做跨日长期关联。未启用为 null。",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"description": "C 类人脸身份。仅在租户已授权且比对命中时非 null。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["person_id", "library_id", "score"],
|
||||||
|
"properties": {
|
||||||
|
"person_id": { "type": "string" },
|
||||||
|
"library_id": { "type": "string" },
|
||||||
|
"score": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"identity_status": {
|
||||||
|
"description": "必须显式。只写 null 无法区分「没开这功能」与「比对失败」,后者是需要排查的故障。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["not_enabled", "pending", "matched", "below_threshold", "no_candidate", "timeout"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"observation": {
|
||||||
|
"description": "结构化观测。bbox/keypoint 序列是数据闭环的唯一原料——只有视频与截图无法用于训练。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["zone", "dwell_sec", "bbox_seq_uri", "keypoint_seq_uri", "signal_seq_uri"],
|
||||||
|
"properties": {
|
||||||
|
"zone": { "type": ["string", "null"] },
|
||||||
|
"dwell_sec": { "type": ["number", "null"], "minimum": 0 },
|
||||||
|
"bbox_seq_uri": {
|
||||||
|
"description": "视觉模态专用。非视觉事件为 null。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"keypoint_seq_uri": {
|
||||||
|
"description": "COCO-17 关键点逐帧序列(JSONL)。视觉模态专用,P1 必补项。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"signal_seq_uri": {
|
||||||
|
"description": "非视觉模态的结构化序列(雷达点云轨迹与多普勒、门磁状态变迁等,JSONL)。与 keypoint_seq_uri 平级——两者是各自模态的数据闭环原料,缺任一模态的序列,该模态就无法参与模型迭代。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"evidence": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["snapshot_uris", "clip_uri", "clip_range"],
|
||||||
|
"properties": {
|
||||||
|
"snapshot_uris": {
|
||||||
|
"description": "证据截图。**允许为空数组**:非成像模态(雷达、门磁)产出的事件本就没有画面,隐私区域更是禁止成像。不得据此假设每个事件都有图可看——值班台 UI 必须能渲染无画面事件。文件命名只允许包含事件 ID 与日期目录,绝不得含 RTSP 地址、凭据或客户名称,文件名会出现在日志、URL 与工单中。",
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
|
"items": { "type": "string", "format": "uri" }
|
||||||
|
},
|
||||||
|
"clip_uri": {
|
||||||
|
"description": "含 pre-roll 的证据片段。仅有截图不足以让值班员判断真假,是误报反馈闭环的前置条件。P1 必补项。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"clip_range": {
|
||||||
|
"type": ["array", "null"],
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 2,
|
||||||
|
"items": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"dedup_key": {
|
||||||
|
"description": "跨机位/跨时间去重键,由平台侧构造。推理侧进程内按 source_event_id 的去重仍保留——它防的是同帧重复写盘,属不同层次。",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"aggregated_into": {
|
||||||
|
"description": "被合并入的事件 ID。非 null 时本事件不独立触发告警。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"outcome": {
|
||||||
|
"description": "处置结果。事件不可变,误判只能通过本字段标记,不得删改。subject_recovered 由推理侧状态机自动回传(确认后自行起身),无需等人工。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"unknown",
|
||||||
|
"true_positive",
|
||||||
|
"false_positive",
|
||||||
|
"subject_recovered",
|
||||||
|
"duplicate",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"outcome_source": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["auto", "manual", null]
|
||||||
|
},
|
||||||
|
|
||||||
|
"outcome_reason": { "type": ["string", "null"] },
|
||||||
|
|
||||||
|
"diagnostics": {
|
||||||
|
"description": "推理侧内部诊断量,仅用于排查,平台不得依赖其语义。单调时钟跨进程无意义,不得用于任何时间计算。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"fsm_state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["NORMAL", "SUSPECT", "CONFIRMED", "RECOVERING"]
|
||||||
|
},
|
||||||
|
"suspected_at_monotonic": { "type": "number" },
|
||||||
|
"confirmed_at_monotonic": { "type": "number" },
|
||||||
|
"horizontal_angle_degrees": { "type": ["number", "null"] },
|
||||||
|
"visible_joint_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 17 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"ext": {
|
||||||
|
"description": "厂商/场景扩展位。根对象 additionalProperties=false,任何未登记字段一律放这里,避免为实验性字段升版本。",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
module yovision/bell
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
toolchain go1.26.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// Package audit authenticates and validates Sense audit relay batches.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RelayPath = "/internal/v1/audit-events:batch"
|
||||||
|
MaxBatchSize = 100
|
||||||
|
MaxBodyBytes = 1 << 20
|
||||||
|
HeaderKeyID = "X-YoVision-Key-Id"
|
||||||
|
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||||
|
HeaderNonce = "X-YoVision-Nonce"
|
||||||
|
HeaderSignature = "X-YoVision-Signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrReplayConflict = errors.New("audit relay replay conflict")
|
||||||
|
|
||||||
|
type Actor struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectionVersions struct {
|
||||||
|
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||||
|
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Actor Actor `json:"actor"`
|
||||||
|
Reason *string `json:"reason"`
|
||||||
|
TraceID *string `json:"trace_id"`
|
||||||
|
AggregateGeneration int64 `json:"aggregate_generation"`
|
||||||
|
ProjectionVersions ProjectionVersions `json:"projection_versions"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Event Event `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ErrorCode *string `json:"error_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchResponse struct {
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Candidate struct {
|
||||||
|
Envelope Envelope
|
||||||
|
RecordHash [sha256.Size]byte
|
||||||
|
ErrorCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
ProcessAuditBatch(context.Context, string, string, [sha256.Size]byte, []Candidate) ([]Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
repository Repository
|
||||||
|
keys map[string][]byte
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(repository Repository, keys map[string][]byte) (*Handler, error) {
|
||||||
|
if repository == nil || len(keys) == 0 {
|
||||||
|
return nil, errors.New("audit handler dependencies are required")
|
||||||
|
}
|
||||||
|
copyKeys := make(map[string][]byte, len(keys))
|
||||||
|
for id, secret := range keys {
|
||||||
|
if !keyIDPattern.MatchString(id) || len(secret) < 32 {
|
||||||
|
return nil, errors.New("invalid audit handler key")
|
||||||
|
}
|
||||||
|
copyKeys[id] = append([]byte(nil), secret...)
|
||||||
|
}
|
||||||
|
return &Handler{repository: repository, keys: copyKeys, now: time.Now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != RelayPath {
|
||||||
|
writeError(writer, http.StatusNotFound, "not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
|
||||||
|
if err != nil || len(body) > MaxBodyBytes {
|
||||||
|
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyID := request.Header.Get(HeaderKeyID)
|
||||||
|
timestamp := request.Header.Get(HeaderTimestamp)
|
||||||
|
nonce := request.Header.Get(HeaderNonce)
|
||||||
|
provided := request.Header.Get(HeaderSignature)
|
||||||
|
secret, ok := h.keys[keyID]
|
||||||
|
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
|
||||||
|
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
|
||||||
|
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
|
||||||
|
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
|
||||||
|
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
|
||||||
|
writeError(writer, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := signature(secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
|
||||||
|
if !hmac.Equal(signatureBytes, expected) {
|
||||||
|
writeError(writer, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates, err := decodeCandidates(body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(writer, http.StatusBadRequest, "invalid_batch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestHash := sha256.Sum256(body)
|
||||||
|
results, err := h.repository.ProcessAuditBatch(request.Context(), keyID, nonce, requestHash, candidates)
|
||||||
|
if errors.Is(err, ErrReplayConflict) {
|
||||||
|
writeError(writer, http.StatusConflict, "replay_conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(writer, http.StatusOK, BatchResponse{Results: results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func signature(secret []byte, canonical string) []byte {
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
_, _ = mac.Write([]byte(canonical))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func absDuration(value time.Duration) time.Duration {
|
||||||
|
if value < 0 {
|
||||||
|
return -value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawBatch struct {
|
||||||
|
Events []json.RawMessage `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCandidates(body []byte) ([]Candidate, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var batch rawBatch
|
||||||
|
if err := decoder.Decode(&batch); err != nil || len(batch.Events) < 1 || len(batch.Events) > MaxBatchSize {
|
||||||
|
return nil, errors.New("invalid audit batch")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New("invalid audit batch trailing data")
|
||||||
|
}
|
||||||
|
values := make([]Candidate, len(batch.Events))
|
||||||
|
for index, raw := range batch.Events {
|
||||||
|
values[index].RecordHash = sha256.Sum256(raw)
|
||||||
|
if !hasExactEnvelopeShape(raw) {
|
||||||
|
values[index].ErrorCode = "schema_invalid"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemDecoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
itemDecoder.DisallowUnknownFields()
|
||||||
|
if err := itemDecoder.Decode(&values[index].Envelope); err != nil {
|
||||||
|
values[index].ErrorCode = "schema_invalid"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values[index].ErrorCode = validateEnvelope(values[index].Envelope)
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
eventIDPattern = regexp.MustCompile(`^audit_[0-9a-f]{32}$`)
|
||||||
|
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||||
|
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateEnvelope(value Envelope) string {
|
||||||
|
event := value.Event
|
||||||
|
if (value.SchemaVersion != 1 && value.SchemaVersion != 2) || !eventIDPattern.MatchString(event.EventID) ||
|
||||||
|
!logicalIDPattern.MatchString(event.TenantID) || !logicalIDPattern.MatchString(event.SiteID) || !logicalIDPattern.MatchString(event.DeviceID) ||
|
||||||
|
event.AggregateGeneration < 1 || event.OccurredAt.IsZero() || strings.TrimSpace(event.Actor.ID) == "" || utf8.RuneCountInString(event.Actor.ID) > 200 ||
|
||||||
|
(event.Actor.Type != "user" && event.Actor.Type != "service" && event.Actor.Type != "system") ||
|
||||||
|
(event.Reason != nil && utf8.RuneCountInString(*event.Reason) > 500) || (event.TraceID != nil && utf8.RuneCountInString(*event.TraceID) > 128) ||
|
||||||
|
(event.ProjectionVersions.QuotaSourceVersion != nil && *event.ProjectionVersions.QuotaSourceVersion < 1) ||
|
||||||
|
(event.ProjectionVersions.AreaPolicySourceVersion != nil && *event.ProjectionVersions.AreaPolicySourceVersion < 1) {
|
||||||
|
return "schema_invalid"
|
||||||
|
}
|
||||||
|
if event.EventType != "device.created" && event.EventType != "device.desired_state.accepted" &&
|
||||||
|
(event.EventType != "device.configuration.accepted" || value.SchemaVersion != 2) {
|
||||||
|
return "schema_invalid"
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(event.Data, &data); err != nil || data == nil {
|
||||||
|
return "schema_invalid"
|
||||||
|
}
|
||||||
|
expected := map[string]string{
|
||||||
|
"device.created": "device_created",
|
||||||
|
"device.desired_state.accepted": "desired_state_accepted",
|
||||||
|
"device.configuration.accepted": "configuration_accepted",
|
||||||
|
}[event.EventType]
|
||||||
|
if data["kind"] != expected || !validateData(event.EventType, data) || containsSensitiveKey(data) {
|
||||||
|
return "payload_invalid"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExactEnvelopeShape(raw []byte) bool {
|
||||||
|
var envelope map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &envelope); err != nil || !exactRawKeys(envelope, "schema_version", "event") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var event map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(envelope["event"], &event); err != nil || !exactRawKeys(event,
|
||||||
|
"event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id",
|
||||||
|
"aggregate_generation", "projection_versions", "data", "occurred_at",
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var actor, projections map[string]json.RawMessage
|
||||||
|
return json.Unmarshal(event["actor"], &actor) == nil && exactRawKeys(actor, "type", "id") &&
|
||||||
|
json.Unmarshal(event["projection_versions"], &projections) == nil && exactRawKeys(projections, "quota_source_version", "area_policy_source_version")
|
||||||
|
}
|
||||||
|
|
||||||
|
func exactRawKeys(value map[string]json.RawMessage, expected ...string) bool {
|
||||||
|
if len(value) != len(expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, key := range expected {
|
||||||
|
if _, exists := value[key]; !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateData(eventType string, data map[string]any) bool {
|
||||||
|
switch eventType {
|
||||||
|
case "device.created":
|
||||||
|
if !exactAnyKeys(data, "kind", "area_id", "modality", "capabilities", "desired_state") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !member(stringValue(data["modality"]), "video", "radar", "contact", "button", "wearable", "other") || !member(stringValue(data["desired_state"]), "disabled", "enabled") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return validStringSet(data["capabilities"], 16, "video_capture", "audio_capture", "spatial_rule", "telemetry")
|
||||||
|
case "device.desired_state.accepted":
|
||||||
|
if !exactAnyKeys(data, "kind", "previous_desired_state", "desired_state", "changed") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, changed := data["changed"].(bool)
|
||||||
|
return changed && member(stringValue(data["previous_desired_state"]), "disabled", "enabled") && member(stringValue(data["desired_state"]), "disabled", "enabled")
|
||||||
|
case "device.configuration.accepted":
|
||||||
|
if !exactAnyKeys(data, "kind", "changed", "changed_fields", "area_id") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, changed := data["changed"].(bool)
|
||||||
|
return changed && validStringSet(data["changed_fields"], 5, "name", "area_id", "endpoint_ref", "credential_ref", "profile_token")
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exactAnyKeys(value map[string]any, expected ...string) bool {
|
||||||
|
if len(value) != len(expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, key := range expected {
|
||||||
|
if _, exists := value[key]; !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value any) string {
|
||||||
|
result, _ := value.(string)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func member(value string, allowed ...string) bool {
|
||||||
|
for _, candidate := range allowed {
|
||||||
|
if value == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validStringSet(value any, maximum int, allowed ...string) bool {
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok || len(items) > maximum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
text, ok := item.(string)
|
||||||
|
if !ok || !member(text, allowed...) || seen[text] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[text] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSensitiveKey(value any) bool {
|
||||||
|
forbidden := map[string]bool{"password": true, "stream_uri": true, "mediamtx_config": true}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range typed {
|
||||||
|
if forbidden[strings.ToLower(key)] || containsSensitiveKey(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if containsSensitiveKey(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(writer http.ResponseWriter, status int, code string) {
|
||||||
|
writeJSON(writer, status, map[string]string{"error": code})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(writer).Encode(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingRepository struct {
|
||||||
|
candidates []Candidate
|
||||||
|
results []Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRepository) ProcessAuditBatch(_ context.Context, _, _ string, _ [sha256.Size]byte, values []Candidate) ([]Result, error) {
|
||||||
|
r.candidates = values
|
||||||
|
return r.results, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validBody(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
value := map[string]any{"events": []any{map[string]any{
|
||||||
|
"schema_version": 1,
|
||||||
|
"event": map[string]any{
|
||||||
|
"event_id": "audit_00000000000000000000000000000001", "event_type": "device.created",
|
||||||
|
"tenant_id": "tenant", "site_id": "site", "device_id": "camera-1",
|
||||||
|
"actor": map[string]any{"type": "system", "id": "sense"}, "reason": nil, "trace_id": nil,
|
||||||
|
"aggregate_generation": 1,
|
||||||
|
"projection_versions": map[string]any{"quota_source_version": 1, "area_policy_source_version": 1},
|
||||||
|
"data": map[string]any{"kind": "device_created", "area_id": "area", "modality": "video", "capabilities": []any{"video_capture"}, "desired_state": "enabled"},
|
||||||
|
"occurred_at": "2026-08-11T00:00:00Z",
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedRequest(t *testing.T, body, secret []byte, timestamp time.Time, nonce string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, RelayPath, bytes.NewReader(body))
|
||||||
|
stamp := strconv.FormatInt(timestamp.Unix(), 10)
|
||||||
|
request.Header.Set(HeaderKeyID, "sense-a")
|
||||||
|
request.Header.Set(HeaderTimestamp, stamp)
|
||||||
|
request.Header.Set(HeaderNonce, nonce)
|
||||||
|
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, RelayPath, stamp, nonce, body))))
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerAuthenticatesAndReturnsPerItemResults(t *testing.T) {
|
||||||
|
secret := bytes.Repeat([]byte{3}, 32)
|
||||||
|
repository := &recordingRepository{results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}}
|
||||||
|
handler, err := NewHandler(repository, map[string][]byte{"sense-a": secret})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 11, 0, 1, 0, 0, time.UTC)
|
||||||
|
handler.now = func() time.Time { return now }
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, signedRequest(t, validBody(t), secret, now, "AAAAAAAAAAAAAAAAAAAAAA"))
|
||||||
|
if response.Code != http.StatusOK || len(repository.candidates) != 1 || repository.candidates[0].ErrorCode != "" {
|
||||||
|
t.Fatalf("valid batch rejected: status=%d candidates=%+v", response.Code, repository.candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRejectsStaleOrTamperedRequests(t *testing.T) {
|
||||||
|
secret := bytes.Repeat([]byte{4}, 32)
|
||||||
|
repository := &recordingRepository{}
|
||||||
|
handler, _ := NewHandler(repository, map[string][]byte{"sense-a": secret})
|
||||||
|
now := time.Date(2026, 8, 11, 0, 10, 0, 0, time.UTC)
|
||||||
|
handler.now = func() time.Time { return now }
|
||||||
|
for _, request := range []*http.Request{
|
||||||
|
signedRequest(t, validBody(t), secret, now.Add(-301*time.Second), "BBBBBBBBBBBBBBBBBBBBBB"),
|
||||||
|
signedRequest(t, append(validBody(t), ' '), bytes.Repeat([]byte{5}, 32), now, "CCCCCCCCCCCCCCCCCCCCCC"),
|
||||||
|
} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unsafe request returned %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCandidatesRejectsSensitiveItemWithoutRejectingBatch(t *testing.T) {
|
||||||
|
body := validBody(t)
|
||||||
|
var value map[string]any
|
||||||
|
_ = json.Unmarshal(body, &value)
|
||||||
|
events := value["events"].([]any)
|
||||||
|
event := events[0].(map[string]any)["event"].(map[string]any)
|
||||||
|
event["data"].(map[string]any)["password"] = "must-not-persist"
|
||||||
|
body, _ = json.Marshal(value)
|
||||||
|
candidates, err := decodeCandidates(body)
|
||||||
|
if err != nil || len(candidates) != 1 || candidates[0].ErrorCode != "payload_invalid" {
|
||||||
|
t.Fatalf("unexpected per-item validation: %+v %v", candidates, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyDocument struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Keys []struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
Secret string `json:"secret_base64url"`
|
||||||
|
} `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadKeys(path string) (map[string][]byte, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("read Bell audit key file")
|
||||||
|
}
|
||||||
|
var document keyDocument
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
|
||||||
|
return nil, errors.New("invalid Bell audit key file")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New("invalid Bell audit key file")
|
||||||
|
}
|
||||||
|
values := make(map[string][]byte, len(document.Keys))
|
||||||
|
for _, item := range document.Keys {
|
||||||
|
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
|
||||||
|
if err != nil || !keyIDPattern.MatchString(item.KeyID) || len(secret) < 32 {
|
||||||
|
return nil, errors.New("invalid Bell audit key")
|
||||||
|
}
|
||||||
|
if _, exists := values[item.KeyID]; exists {
|
||||||
|
return nil, errors.New("duplicate Bell audit key ID")
|
||||||
|
}
|
||||||
|
values[item.KeyID] = secret
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// Package event assembles and validates immutable Bell event facts.
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxPayloadBytes = 1 << 20
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeInvalidJSON ErrorCode = "invalid_json"
|
||||||
|
CodePayloadTooLarge ErrorCode = "payload_too_large"
|
||||||
|
CodeUpstreamID ErrorCode = "upstream_id_forbidden"
|
||||||
|
CodeSchema ErrorCode = "schema_invalid"
|
||||||
|
CodeTimeOrder ErrorCode = "time_order_invalid"
|
||||||
|
CodeLatency ErrorCode = "latency_inconsistent"
|
||||||
|
CodeConfidence ErrorCode = "confidence_forbidden"
|
||||||
|
CodeEvidence ErrorCode = "evidence_unsafe"
|
||||||
|
CodePrimarySensor ErrorCode = "primary_sensor_invalid"
|
||||||
|
CodePrivacyDenied ErrorCode = "privacy_denied"
|
||||||
|
CodePrivacyUnavailable ErrorCode = "privacy_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError exposes a stable code without returning sensitive payloads.
|
||||||
|
type ValidationError struct {
|
||||||
|
Code ErrorCode
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string { return string(e.Code) }
|
||||||
|
func (e *ValidationError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func validationError(code ErrorCode, err error) error {
|
||||||
|
return &ValidationError{Code: code, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDGenerator is owned by Bell. Upstream candidates are not allowed to carry id.
|
||||||
|
type IDGenerator interface {
|
||||||
|
NewEventID() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ULIDGenerator struct{}
|
||||||
|
|
||||||
|
func (ULIDGenerator) NewEventID() (string, error) {
|
||||||
|
return "evt_" + ulid.Make().String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivacyPolicy resolves the authoritative device/Area policy. Implementations
|
||||||
|
// must fail closed when the mapping is missing or stale.
|
||||||
|
type PrivacyPolicy interface {
|
||||||
|
VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvidencePolicy checks every evidence/observation URI before persistence.
|
||||||
|
type EvidencePolicy interface {
|
||||||
|
ValidateURI(rawURI string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvidenceGuard rejects reusable credentials, network endpoints and configured
|
||||||
|
// customer/tenant names from persisted evidence URIs.
|
||||||
|
type EvidenceGuard struct {
|
||||||
|
forbidden []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEvidenceGuard(forbiddenNames ...string) (*EvidenceGuard, error) {
|
||||||
|
guard := &EvidenceGuard{}
|
||||||
|
for _, name := range forbiddenNames {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("forbidden evidence name cannot be blank")
|
||||||
|
}
|
||||||
|
guard.forbidden = append(guard.forbidden, name)
|
||||||
|
}
|
||||||
|
return guard, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipv4Like = regexp.MustCompile(`(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)`)
|
||||||
|
|
||||||
|
func (g *EvidenceGuard) ValidateURI(rawURI string) error {
|
||||||
|
parsed, err := url.Parse(rawURI)
|
||||||
|
if err != nil || parsed.Scheme == "" {
|
||||||
|
return errors.New("evidence URI is not absolute")
|
||||||
|
}
|
||||||
|
if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return errors.New("evidence URI contains reusable connection material")
|
||||||
|
}
|
||||||
|
if host := parsed.Hostname(); host != "" && net.ParseIP(host) != nil {
|
||||||
|
return errors.New("evidence URI contains an IP address")
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(rawURI)
|
||||||
|
for _, marker := range []string{"password", "passwd", "credential", "secret", "token=", "rtsp://"} {
|
||||||
|
if strings.Contains(lower, marker) {
|
||||||
|
return errors.New("evidence URI contains a forbidden marker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ipv4Like.MatchString(lower) {
|
||||||
|
return errors.New("evidence URI contains an IPv4-like value")
|
||||||
|
}
|
||||||
|
for _, name := range g.forbidden {
|
||||||
|
if strings.Contains(lower, name) {
|
||||||
|
return errors.New("evidence URI contains a configured sensitive name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sensor struct {
|
||||||
|
DeviceID int64 `json:"device_id"`
|
||||||
|
Modality string `json:"modality"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedShape struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
SiteID int64 `json:"site_id"`
|
||||||
|
DeviceID int64 `json:"device_id"`
|
||||||
|
SourceEventID string `json:"source_event_id"`
|
||||||
|
Sensors []Sensor `json:"sensors"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Confidence *float64 `json:"confidence"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
DetectedAt time.Time `json:"detected_at"`
|
||||||
|
LatencySeconds float64 `json:"latency_seconds"`
|
||||||
|
Observation *struct {
|
||||||
|
BBoxSeqURI *string `json:"bbox_seq_uri"`
|
||||||
|
KeypointSeqURI *string `json:"keypoint_seq_uri"`
|
||||||
|
SignalSeqURI *string `json:"signal_seq_uri"`
|
||||||
|
} `json:"observation"`
|
||||||
|
Evidence struct {
|
||||||
|
SnapshotURIs []string `json:"snapshot_uris"`
|
||||||
|
ClipURI *string `json:"clip_uri"`
|
||||||
|
} `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is a final, schema-valid immutable fact. JSON returns a defensive copy.
|
||||||
|
type Event struct {
|
||||||
|
shape storedShape
|
||||||
|
payload []byte
|
||||||
|
digest [sha256.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Event) ID() string { return e.shape.ID }
|
||||||
|
func (e Event) TenantID() int64 { return e.shape.TenantID }
|
||||||
|
func (e Event) SiteID() int64 { return e.shape.SiteID }
|
||||||
|
func (e Event) DeviceID() int64 { return e.shape.DeviceID }
|
||||||
|
func (e Event) SourceEventID() string { return e.shape.SourceEventID }
|
||||||
|
func (e Event) Kind() string { return e.shape.Kind }
|
||||||
|
func (e Event) Severity() string { return e.shape.Severity }
|
||||||
|
func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt }
|
||||||
|
func (e Event) DetectedAt() time.Time { return e.shape.DetectedAt }
|
||||||
|
func (e Event) Digest() [sha256.Size]byte { return e.digest }
|
||||||
|
func (e Event) JSON() []byte { return bytes.Clone(e.payload) }
|
||||||
|
|
||||||
|
type Factory struct {
|
||||||
|
schema *jsonschema.Schema
|
||||||
|
ids IDGenerator
|
||||||
|
privacy PrivacyPolicy
|
||||||
|
evidence EvidencePolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFactory(schemaJSON []byte, ids IDGenerator, privacy PrivacyPolicy, evidence EvidencePolicy) (*Factory, error) {
|
||||||
|
if ids == nil || privacy == nil || evidence == nil {
|
||||||
|
return nil, errors.New("event factory dependencies are required")
|
||||||
|
}
|
||||||
|
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse event schema: %w", err)
|
||||||
|
}
|
||||||
|
compiler := jsonschema.NewCompiler()
|
||||||
|
compiler.AssertFormat()
|
||||||
|
if err := compiler.AddResource("event-v0.1.schema.json", schemaDoc); err != nil {
|
||||||
|
return nil, fmt.Errorf("register event schema: %w", err)
|
||||||
|
}
|
||||||
|
compiled, err := compiler.Compile("event-v0.1.schema.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compile event schema: %w", err)
|
||||||
|
}
|
||||||
|
return &Factory{schema: compiled, ids: ids, privacy: privacy, evidence: evidence}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create turns a producer candidate into the final stored v0.1 event. The
|
||||||
|
// candidate must contain every v0.1 field except the Bell-owned id.
|
||||||
|
func (f *Factory) Create(ctx context.Context, candidate []byte) (Event, error) {
|
||||||
|
if len(candidate) > MaxPayloadBytes {
|
||||||
|
return Event{}, validationError(CodePayloadTooLarge, nil)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(candidate))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var object map[string]any
|
||||||
|
if err := decoder.Decode(&object); err != nil || object == nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, errors.New("multiple JSON values"))
|
||||||
|
}
|
||||||
|
if _, exists := object["id"]; exists {
|
||||||
|
return Event{}, validationError(CodeUpstreamID, nil)
|
||||||
|
}
|
||||||
|
id, err := f.ids.NewEventID()
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, fmt.Errorf("generate Bell event id: %w", err)
|
||||||
|
}
|
||||||
|
object["id"] = id
|
||||||
|
payload, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
if len(payload) > MaxPayloadBytes {
|
||||||
|
return Event{}, validationError(CodePayloadTooLarge, nil)
|
||||||
|
}
|
||||||
|
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
if err := f.schema.Validate(instance); err != nil {
|
||||||
|
return Event{}, validationError(CodeSchema, nil)
|
||||||
|
}
|
||||||
|
var shape storedShape
|
||||||
|
if err := json.Unmarshal(payload, &shape); err != nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
if err := f.assertSemantics(ctx, shape); err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
return Event{shape: shape, payload: payload, digest: sha256.Sum256(payload)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Factory) assertSemantics(ctx context.Context, shape storedShape) error {
|
||||||
|
if shape.DetectedAt.Before(shape.OccurredAt) {
|
||||||
|
return validationError(CodeTimeOrder, nil)
|
||||||
|
}
|
||||||
|
actual := shape.DetectedAt.Sub(shape.OccurredAt).Seconds()
|
||||||
|
if math.Abs(actual-shape.LatencySeconds) >= 0.1 {
|
||||||
|
return validationError(CodeLatency, nil)
|
||||||
|
}
|
||||||
|
if shape.Confidence != nil {
|
||||||
|
return validationError(CodeConfidence, nil)
|
||||||
|
}
|
||||||
|
primary := 0
|
||||||
|
for _, sensor := range shape.Sensors {
|
||||||
|
if sensor.Role == "primary" {
|
||||||
|
primary++
|
||||||
|
if sensor.DeviceID != shape.DeviceID {
|
||||||
|
return validationError(CodePrimarySensor, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sensor.Modality == "video" {
|
||||||
|
allowed, err := f.privacy.VideoAllowed(ctx, shape.TenantID, shape.SiteID, sensor.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return validationError(CodePrivacyUnavailable, nil)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return validationError(CodePrivacyDenied, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if primary != 1 {
|
||||||
|
return validationError(CodePrimarySensor, nil)
|
||||||
|
}
|
||||||
|
var uris []string
|
||||||
|
uris = append(uris, shape.Evidence.SnapshotURIs...)
|
||||||
|
if shape.Evidence.ClipURI != nil {
|
||||||
|
uris = append(uris, *shape.Evidence.ClipURI)
|
||||||
|
}
|
||||||
|
if shape.Observation != nil {
|
||||||
|
for _, value := range []*string{
|
||||||
|
shape.Observation.BBoxSeqURI,
|
||||||
|
shape.Observation.KeypointSeqURI,
|
||||||
|
shape.Observation.SignalSeqURI,
|
||||||
|
} {
|
||||||
|
if value != nil {
|
||||||
|
uris = append(uris, *value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rawURI := range uris {
|
||||||
|
if err := f.evidence.ValidateURI(rawURI); err != nil {
|
||||||
|
return validationError(CodeEvidence, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yovision/bell/contracts"
|
||||||
|
"yovision/bell/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixedEventID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
|
||||||
|
|
||||||
|
type fixedIDs struct{ id string }
|
||||||
|
|
||||||
|
func (f fixedIDs) NewEventID() (string, error) { return f.id, nil }
|
||||||
|
|
||||||
|
type privacy struct {
|
||||||
|
allowed bool
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *privacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||||
|
p.calls++
|
||||||
|
return p.allowed, p.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func contractPath(name string) string {
|
||||||
|
return filepath.Join("..", "..", "..", "docs", "raw", "contracts", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidate(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(contractPath(name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
delete(object, "id")
|
||||||
|
encoded, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutate(t *testing.T, raw []byte, fn func(map[string]any)) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fn(object)
|
||||||
|
encoded, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func factory(t *testing.T, policy *privacy) *event.Factory {
|
||||||
|
t.Helper()
|
||||||
|
guard, err := event.NewEvidenceGuard("private-customer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, policy, guard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCode(t *testing.T, err error, code event.ErrorCode) {
|
||||||
|
t.Helper()
|
||||||
|
var validation *event.ValidationError
|
||||||
|
if !errors.As(err, &validation) || validation.Code != code {
|
||||||
|
t.Fatalf("expected %s, got %v", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrozenContractCopyIsExact(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(contractPath("event-v0.1.schema.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(raw) != string(contracts.EventV01Schema) {
|
||||||
|
t.Fatal("Bell contract copy drifted from the frozen source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryAcceptsAllFrozenExamples(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"event-v0.1.example-current.json",
|
||||||
|
"event-v0.1.example-target.json",
|
||||||
|
"event-v0.1.example-radar.json",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
policy := &privacy{allowed: true}
|
||||||
|
created, err := factory(t, policy).Create(context.Background(), candidate(t, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created.ID() != fixedEventID || len(created.JSON()) == 0 {
|
||||||
|
t.Fatal("Bell did not assemble the final event")
|
||||||
|
}
|
||||||
|
if name == "event-v0.1.example-radar.json" && policy.calls != 0 {
|
||||||
|
t.Fatal("non-video event unexpectedly consulted video policy")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryRejectsUpstreamIDAndUnknownField(t *testing.T) {
|
||||||
|
policy := &privacy{allowed: true}
|
||||||
|
base := candidate(t, "event-v0.1.example-current.json")
|
||||||
|
withID := mutate(t, base, func(object map[string]any) { object["id"] = fixedEventID })
|
||||||
|
_, err := factory(t, policy).Create(context.Background(), withID)
|
||||||
|
assertCode(t, err, event.CodeUpstreamID)
|
||||||
|
|
||||||
|
unknown := mutate(t, base, func(object map[string]any) { object["surprise"] = true })
|
||||||
|
_, err = factory(t, policy).Create(context.Background(), unknown)
|
||||||
|
assertCode(t, err, event.CodeSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryEnforcesCrossFieldAssertions(t *testing.T) {
|
||||||
|
base := candidate(t, "event-v0.1.example-current.json")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
code event.ErrorCode
|
||||||
|
edit func(map[string]any)
|
||||||
|
}{
|
||||||
|
{"time-order", event.CodeTimeOrder, func(v map[string]any) { v["occurred_at"] = "2026-08-03T10:31:23.000Z" }},
|
||||||
|
{"latency", event.CodeLatency, func(v map[string]any) { v["latency_seconds"] = 9.0 }},
|
||||||
|
{"confidence", event.CodeConfidence, func(v map[string]any) { v["confidence"] = 0.9 }},
|
||||||
|
{"primary", event.CodePrimarySensor, func(v map[string]any) {
|
||||||
|
v["sensors"] = []any{
|
||||||
|
map[string]any{"device_id": float64(5012), "modality": "video", "role": "primary"},
|
||||||
|
map[string]any{"device_id": float64(5013), "modality": "radar", "role": "primary"},
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := factory(t, &privacy{allowed: true}).Create(context.Background(), mutate(t, base, test.edit))
|
||||||
|
assertCode(t, err, test.code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryFailsClosedForPrivacyAndEvidence(t *testing.T) {
|
||||||
|
base := candidate(t, "event-v0.1.example-current.json")
|
||||||
|
_, err := factory(t, &privacy{err: errors.New("mapping unavailable")}).Create(context.Background(), base)
|
||||||
|
assertCode(t, err, event.CodePrivacyUnavailable)
|
||||||
|
|
||||||
|
_, err = factory(t, &privacy{allowed: false}).Create(context.Background(), base)
|
||||||
|
assertCode(t, err, event.CodePrivacyDenied)
|
||||||
|
|
||||||
|
unsafe := mutate(t, base, func(v map[string]any) {
|
||||||
|
evidence := v["evidence"].(map[string]any)
|
||||||
|
evidence["snapshot_uris"] = []any{"rtsp://user:password@10.0.0.1:554/private-customer.png"}
|
||||||
|
})
|
||||||
|
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), unsafe)
|
||||||
|
assertCode(t, err, event.CodeEvidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryRequiresFailClosedPoliciesAndPayloadLimit(t *testing.T) {
|
||||||
|
guard, err := event.NewEvidenceGuard()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, nil, guard); err == nil {
|
||||||
|
t.Fatal("nil privacy policy unexpectedly accepted")
|
||||||
|
}
|
||||||
|
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), make([]byte, event.MaxPayloadBytes+1))
|
||||||
|
assertCode(t, err, event.CodePayloadTooLarge)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"yovision/bell/internal/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Postgres) AuditRelayReady(ctx context.Context) error {
|
||||||
|
var version int64
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 4 {
|
||||||
|
return errors.New("postgres Bell schema migration v4 is required for audit relay")
|
||||||
|
}
|
||||||
|
var auditSelect, auditInsert, auditUpdate, auditDelete, auditTruncate bool
|
||||||
|
var receiptUse bool
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'INSERT'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'UPDATE'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'TRUNCATE'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')`).Scan(
|
||||||
|
&auditSelect, &auditInsert, &auditUpdate, &auditDelete, &auditTruncate, &receiptUse,
|
||||||
|
); err != nil {
|
||||||
|
return errors.New("verify Bell audit relay privileges")
|
||||||
|
}
|
||||||
|
if !auditSelect || !auditInsert || auditUpdate || auditDelete || auditTruncate || !receiptUse {
|
||||||
|
return errors.New("Bell audit relay privileges violate append-only boundary")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) ProcessAuditBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
keyID, nonce string,
|
||||||
|
requestHash [sha256.Size]byte,
|
||||||
|
candidates []audit.Candidate,
|
||||||
|
) ([]audit.Result, error) {
|
||||||
|
if len(candidates) < 1 || len(candidates) > audit.MaxBatchSize {
|
||||||
|
return nil, errors.New("invalid audit candidate batch")
|
||||||
|
}
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("begin Bell audit batch")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, keyID, nonce); err != nil {
|
||||||
|
return nil, errors.New("lock Bell audit receipt")
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.audit_relay_receipts WHERE expires_at <= clock_timestamp()`); err != nil {
|
||||||
|
return nil, errors.New("expire Bell audit receipts")
|
||||||
|
}
|
||||||
|
var existingHash, existingBody []byte
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT request_hash, response_body::text
|
||||||
|
FROM bell.audit_relay_receipts WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&existingHash, &existingBody)
|
||||||
|
if err == nil {
|
||||||
|
if !bytes.Equal(existingHash, requestHash[:]) {
|
||||||
|
return nil, audit.ErrReplayConflict
|
||||||
|
}
|
||||||
|
var response audit.BatchResponse
|
||||||
|
if err := json.Unmarshal(existingBody, &response); err != nil {
|
||||||
|
return nil, errors.New("decode stored Bell audit receipt")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, errors.New("commit Bell audit replay")
|
||||||
|
}
|
||||||
|
return response.Results, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, errors.New("read Bell audit receipt")
|
||||||
|
}
|
||||||
|
results := make([]audit.Result, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.ErrorCode != "" {
|
||||||
|
code := candidate.ErrorCode
|
||||||
|
results = append(results, audit.Result{EventID: candidate.Envelope.Event.EventID, Status: "rejected", ErrorCode: &code})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
event := candidate.Envelope.Event
|
||||||
|
payload, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("encode Bell audit fact")
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_events(
|
||||||
|
source_system, event_id, schema_version, event_type, tenant_id, site_id,
|
||||||
|
device_id, actor_type, actor_id, reason, trace_id, aggregate_generation,
|
||||||
|
quota_source_version, area_policy_source_version, payload, occurred_at, record_hash
|
||||||
|
) VALUES ('sense',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)
|
||||||
|
ON CONFLICT (source_system,event_id) DO NOTHING`,
|
||||||
|
event.EventID, candidate.Envelope.SchemaVersion, event.EventType, event.TenantID,
|
||||||
|
event.SiteID, event.DeviceID, event.Actor.Type, event.Actor.ID, event.Reason,
|
||||||
|
event.TraceID, event.AggregateGeneration,
|
||||||
|
event.ProjectionVersions.QuotaSourceVersion,
|
||||||
|
event.ProjectionVersions.AreaPolicySourceVersion,
|
||||||
|
payload, event.OccurredAt, candidate.RecordHash[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert Bell audit fact: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("read Bell audit insert result")
|
||||||
|
}
|
||||||
|
if affected == 1 {
|
||||||
|
results = append(results, audit.Result{EventID: event.EventID, Status: "accepted"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var storedHash []byte
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT record_hash FROM bell.audit_events
|
||||||
|
WHERE source_system='sense' AND event_id=$1`, event.EventID).Scan(&storedHash); err != nil {
|
||||||
|
return nil, errors.New("read existing Bell audit fact")
|
||||||
|
}
|
||||||
|
if bytes.Equal(storedHash, candidate.RecordHash[:]) {
|
||||||
|
results = append(results, audit.Result{EventID: event.EventID, Status: "duplicate"})
|
||||||
|
} else {
|
||||||
|
code := "id_conflict"
|
||||||
|
results = append(results, audit.Result{EventID: event.EventID, Status: "rejected", ErrorCode: &code})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(audit.BatchResponse{Results: results})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("encode Bell audit response")
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_relay_receipts(
|
||||||
|
key_id, nonce, request_hash, response_status, response_body, expires_at
|
||||||
|
) VALUES ($1,$2,$3,200,$4::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], encoded); err != nil {
|
||||||
|
return nil, errors.New("insert Bell audit receipt")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, errors.New("commit Bell audit batch")
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"yovision/bell/internal/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func auditCandidate(t *testing.T, eventID, actorID string) audit.Candidate {
|
||||||
|
t.Helper()
|
||||||
|
data := json.RawMessage(`{"kind":"device_created","area_id":"area","modality":"video","capabilities":["video_capture"],"desired_state":"enabled"}`)
|
||||||
|
value := audit.Envelope{SchemaVersion: 1, Event: audit.Event{
|
||||||
|
EventID: eventID, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
|
||||||
|
Actor: audit.Actor{Type: "system", ID: actorID}, AggregateGeneration: 1,
|
||||||
|
ProjectionVersions: audit.ProjectionVersions{}, Data: data, OccurredAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC),
|
||||||
|
}}
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return audit.Candidate{Envelope: value, RecordHash: sha256.Sum256(raw)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresAuditBatchReceiptAndImmutableFact(t *testing.T) {
|
||||||
|
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
repository, err := OpenPostgres(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repository.AuditRelayReady(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestHash := sha256.Sum256([]byte("request-one"))
|
||||||
|
eventID := "audit_10000000000000000000000000000001"
|
||||||
|
results, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "sense")})
|
||||||
|
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
|
||||||
|
t.Fatalf("first batch: %+v %v", results, err)
|
||||||
|
}
|
||||||
|
replayed, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "ignored-by-receipt")})
|
||||||
|
if err != nil || replayed[0].Status != "accepted" {
|
||||||
|
t.Fatalf("receipt replay: %+v %v", replayed, err)
|
||||||
|
}
|
||||||
|
different := sha256.Sum256([]byte("request-two"))
|
||||||
|
if _, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", different, []audit.Candidate{auditCandidate(t, eventID, "sense")}); !errors.Is(err, audit.ErrReplayConflict) {
|
||||||
|
t.Fatalf("expected replay conflict, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate, err := repository.ProcessAuditBatch(ctx, "sense-a", "BBBBBBBBBBBBBBBBBBBBBB", different, []audit.Candidate{auditCandidate(t, eventID, "sense")})
|
||||||
|
if err != nil || duplicate[0].Status != "duplicate" {
|
||||||
|
t.Fatalf("event duplicate: %+v %v", duplicate, err)
|
||||||
|
}
|
||||||
|
conflictHash := sha256.Sum256([]byte("request-three"))
|
||||||
|
conflict, err := repository.ProcessAuditBatch(ctx, "sense-a", "CCCCCCCCCCCCCCCCCCCCCC", conflictHash, []audit.Candidate{auditCandidate(t, eventID, "other")})
|
||||||
|
if err != nil || conflict[0].Status != "rejected" || conflict[0].ErrorCode == nil || *conflict[0].ErrorCode != "id_conflict" {
|
||||||
|
t.Fatalf("event conflict: %+v %v", conflict, err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE bell.audit_events SET actor_id='mutated' WHERE event_id=$1`, eventID); err == nil {
|
||||||
|
t.Fatal("runtime updated immutable audit fact")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `DELETE FROM bell.audit_events WHERE event_id=$1`, eventID); err == nil {
|
||||||
|
t.Fatal("runtime deleted immutable audit fact")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"yovision/bell/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrConflict = errors.New("immutable record id conflict")
|
||||||
|
|
||||||
|
type Postgres struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPostgres(ctx context.Context, db *sql.DB) (*Postgres, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("postgres database is required")
|
||||||
|
}
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping Bell postgres: %w", err)
|
||||||
|
}
|
||||||
|
var version int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 3 {
|
||||||
|
return nil, errors.New("postgres Bell schema migration v3 is required")
|
||||||
|
}
|
||||||
|
var canInsert, canSelect, canUpdate, canDelete, canTruncate bool
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'INSERT'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'UPDATE'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'TRUNCATE')`).Scan(
|
||||||
|
&canInsert, &canSelect, &canUpdate, &canDelete, &canTruncate,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect Bell event privileges: %w", err)
|
||||||
|
}
|
||||||
|
if !canInsert || !canSelect || canUpdate || canDelete || canTruncate {
|
||||||
|
return nil, errors.New("Bell runtime event privileges violate append-only boundary")
|
||||||
|
}
|
||||||
|
return &Postgres{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertEvent is idempotent only for the same platform ID and exact payload.
|
||||||
|
func (p *Postgres) InsertEvent(ctx context.Context, value event.Event) (bool, error) {
|
||||||
|
digest := value.Digest()
|
||||||
|
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.events(
|
||||||
|
id, tenant_id, site_id, device_id, source_event_id, kind, severity,
|
||||||
|
occurred_at, detected_at, payload_hash, payload
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING`,
|
||||||
|
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(),
|
||||||
|
value.SourceEventID(), value.Kind(), value.Severity(), value.OccurredAt(),
|
||||||
|
value.DetectedAt(), digest[:], value.JSON(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("insert immutable Bell event: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read Bell event insert result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var existing []byte
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT payload_hash FROM bell.events WHERE id=$1`, value.ID()).Scan(&existing); err != nil {
|
||||||
|
return false, fmt.Errorf("read existing Bell event digest: %w", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(existing, digest[:]) {
|
||||||
|
return false, ErrConflict
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Outcome struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Value string `json:"outcome"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Reason *string `json:"reason"`
|
||||||
|
ActorType string `json:"actor_type"`
|
||||||
|
ActorID string `json:"actor_id"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcomeID = regexp.MustCompile(`^out_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||||
|
var eventID = regexp.MustCompile(`^evt_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||||
|
|
||||||
|
func (o Outcome) validate() error {
|
||||||
|
if !outcomeID.MatchString(o.ID) || !eventID.MatchString(o.EventID) || o.ActorID == "" || o.OccurredAt.IsZero() {
|
||||||
|
return errors.New("invalid outcome identity")
|
||||||
|
}
|
||||||
|
validOutcome := map[string]bool{"unknown": true, "true_positive": true, "false_positive": true, "subject_recovered": true, "duplicate": true, "test": true}
|
||||||
|
if !validOutcome[o.Value] || (o.Source != "auto" && o.Source != "manual") {
|
||||||
|
return errors.New("invalid outcome value or source")
|
||||||
|
}
|
||||||
|
if o.ActorType != "user" && o.ActorType != "service" && o.ActorType != "system" {
|
||||||
|
return errors.New("invalid outcome actor type")
|
||||||
|
}
|
||||||
|
if o.Reason != nil && utf8.RuneCountInString(*o.Reason) > 500 {
|
||||||
|
return errors.New("outcome reason is too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendOutcome never mutates the event or an earlier outcome record.
|
||||||
|
func (p *Postgres) AppendOutcome(ctx context.Context, value Outcome) (bool, error) {
|
||||||
|
if err := value.validate(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("encode outcome: %w", err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(encoded)
|
||||||
|
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.event_outcomes(
|
||||||
|
id, event_id, outcome, outcome_source, reason, actor_type, actor_id,
|
||||||
|
occurred_at, record_hash
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
ON CONFLICT (id) DO NOTHING`, value.ID, value.EventID, value.Value, value.Source,
|
||||||
|
value.Reason, value.ActorType, value.ActorID, value.OccurredAt, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("append Bell event outcome: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read Bell outcome insert result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var existing []byte
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT record_hash FROM bell.event_outcomes WHERE id=$1`, value.ID).Scan(&existing); err != nil {
|
||||||
|
return false, fmt.Errorf("read existing Bell outcome digest: %w", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(existing, digest[:]) {
|
||||||
|
return false, ErrConflict
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"yovision/bell/contracts"
|
||||||
|
"yovision/bell/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storeIDs struct{ id string }
|
||||||
|
|
||||||
|
func (f storeIDs) NewEventID() (string, error) { return f.id, nil }
|
||||||
|
|
||||||
|
type allowVideo struct{}
|
||||||
|
|
||||||
|
func (allowVideo) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCandidate(t *testing.T, configVersion string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
delete(object, "id")
|
||||||
|
object["config_version"] = configVersion
|
||||||
|
encoded, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEvent(t *testing.T, configVersion string) event.Event {
|
||||||
|
t.Helper()
|
||||||
|
guard, err := event.NewEvidenceGuard()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
factory, err := event.NewFactory(
|
||||||
|
contracts.EventV01Schema,
|
||||||
|
storeIDs{id: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"},
|
||||||
|
allowVideo{}, guard,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := factory.Create(context.Background(), testCandidate(t, configVersion))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresImmutableEventAndOutcome(t *testing.T) {
|
||||||
|
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
repo, err := OpenPostgres(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value := newEvent(t, "sp-v1-2026.07.20")
|
||||||
|
created, err := repo.InsertEvent(ctx, value)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("first insert: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
created, err = repo.InsertEvent(ctx, value)
|
||||||
|
if err != nil || created {
|
||||||
|
t.Fatalf("idempotent replay: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if _, err := repo.InsertEvent(ctx, newEvent(t, "sp-v1-conflict")); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("expected immutable conflict, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "confirmed by operator"
|
||||||
|
outcome := Outcome{
|
||||||
|
ID: "out_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", EventID: value.ID(),
|
||||||
|
Value: "true_positive", Source: "manual", Reason: &reason,
|
||||||
|
ActorType: "user", ActorID: "operator-1", OccurredAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
created, err = repo.AppendOutcome(ctx, outcome)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("append outcome: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
created, err = repo.AppendOutcome(ctx, outcome)
|
||||||
|
if err != nil || created {
|
||||||
|
t.Fatalf("idempotent outcome replay: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE bell.events SET kind='changed' WHERE id=$1`, value.ID()); err == nil {
|
||||||
|
t.Fatal("runtime unexpectedly updated immutable event")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `DELETE FROM bell.event_outcomes WHERE id=$1`, outcome.ID); err == nil {
|
||||||
|
t.Fatal("runtime unexpectedly deleted immutable outcome")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
artifacts/
|
||||||
|
*.local.json
|
||||||
|
*.url
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# YoVision Brain 单路工程原型
|
||||||
|
|
||||||
|
T-017 提供一条可见、可重复的工程链路:单路 frame source → 匿名人员检测/fixture → track → 归一化多边形区域 → `zone_entry` 候选事实。它不是生产模型、不是 NVR,也没有 Brain→Bell transport。
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
- Python `3.10.11`
|
||||||
|
- NumPy `1.26.4`
|
||||||
|
- OpenCV `4.9.0.80`
|
||||||
|
|
||||||
|
本任务复用本机已验证版本;新环境显式安装:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -r Brain/requirements-demo.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
本机冻结的是 `opencv-python`;同一环境只能安装一种提供 `cv2` 命名空间的 OpenCV wheel。不要同时安装标准、headless 和 contrib 变体。后续生产容器若改用 headless,必须在独立任务核对 wheel、许可证与完整回归,不能在本版本号下静默替换包名。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
无需摄像头的确定性合成回放:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m Brain.yovision_brain --source synthetic
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://127.0.0.1:8090/brain-demo`。合成人员框由 fixture 提供,页面和事件均显式标识,不得拿它证明模型效果。
|
||||||
|
|
||||||
|
使用真实流时,把完整 RTSP URL 写入仓库外绝对路径文件。推荐指向 Sense 管理的 MediaMTX path,而不是绕过 Sense 固化摄像头地址:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m Brain.yovision_brain --source stream --stream-url-file D:\private\brain-stream.url
|
||||||
|
```
|
||||||
|
|
||||||
|
URL 文件只允许一行、最多 4096 字节,服务不会在状态、页面和错误中返回其内容。演示服务只接受 `127.0.0.1`、`localhost` 或 `::1`;没有暴露到局域网的开关。
|
||||||
|
|
||||||
|
真实流使用 OpenCV 内置 HOG/SVM 人员检测和轻量 centroid tracker,只验证可替换端口与区域链路。它不是 M3 生产模型,不能据此承诺召回率、误报率、GPU 容量或 16/128 路能力。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
||||||
|
python -m compileall -q Brain
|
||||||
|
```
|
||||||
|
|
||||||
|
单文件 UI 原型位于 `docs/design/brain/index.html`,可直接打开;此时使用明确标识的离线原型数据。由本地服务打开时,同一文件消费回环 API,并用临时页面 token 保护区域写入。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- `source_event_id` 由 Brain 产生,平台 `evt_` ULID 由 Bell 产生。
|
||||||
|
- 事件只保留在最多 100 项的内存环中;重启即丢失是刻意边界。
|
||||||
|
- T-018 冻结并实现身份映射、候选契约、持久 Outbox、HMAC、幂等和 Bell ingress。
|
||||||
|
- `D:\OPC\silver_pose` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""YoVision Brain package root."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# T-017 engineering prototype only. Do not infer the production Brain stack.
|
||||||
|
numpy==1.26.4
|
||||||
|
opencv-python==4.9.0.80
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from Brain.yovision_brain.domain import (
|
||||||
|
Box,
|
||||||
|
Detection,
|
||||||
|
Point,
|
||||||
|
Zone,
|
||||||
|
ZoneEntryEvaluator,
|
||||||
|
point_in_polygon,
|
||||||
|
zone_from_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.zone = Zone(
|
||||||
|
"zone-1",
|
||||||
|
"危险区域",
|
||||||
|
1,
|
||||||
|
(Point(0.4, 0.2), Point(0.8, 0.2), Point(0.8, 0.8), Point(0.4, 0.8)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_point_in_polygon_includes_boundary(self) -> None:
|
||||||
|
self.assertTrue(point_in_polygon(Point(0.6, 0.5), self.zone.points))
|
||||||
|
self.assertTrue(point_in_polygon(Point(0.4, 0.5), self.zone.points))
|
||||||
|
self.assertFalse(point_in_polygon(Point(0.2, 0.5), self.zone.points))
|
||||||
|
|
||||||
|
def test_zone_requires_normalized_three_to_thirty_two_points(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "3 to 32"):
|
||||||
|
Zone("zone-1", "bad", 1, (Point(0, 0), Point(1, 1)))
|
||||||
|
with self.assertRaisesRegex(ValueError, "within"):
|
||||||
|
Point(1.1, 0.5)
|
||||||
|
with self.assertRaisesRegex(ValueError, "within"):
|
||||||
|
Point(float("nan"), 0.5)
|
||||||
|
with self.assertRaisesRegex(ValueError, "within"):
|
||||||
|
Box(0.1, 0.1, float("inf"), 0.9)
|
||||||
|
|
||||||
|
def test_zone_payload_rejects_unknown_fields_and_increments_version(self) -> None:
|
||||||
|
updated = zone_from_payload(
|
||||||
|
{"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]},
|
||||||
|
self.zone,
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.version, 2)
|
||||||
|
self.assertEqual(updated.name, "新区域")
|
||||||
|
with self.assertRaisesRegex(ValueError, "unknown"):
|
||||||
|
zone_from_payload({"points": [], "tenant_id": "must-not-be-here"}, self.zone)
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneEntryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.zone = Zone(
|
||||||
|
"zone-1",
|
||||||
|
"危险区域",
|
||||||
|
1,
|
||||||
|
(Point(0.5, 0.2), Point(0.9, 0.2), Point(0.9, 0.9), Point(0.5, 0.9)),
|
||||||
|
)
|
||||||
|
ids = iter(("BRN-0001", "BRN-0002", "BRN-0003"))
|
||||||
|
self.evaluator = ZoneEntryEvaluator("device-ref", True, track_ttl_frames=2, event_id_factory=lambda: next(ids))
|
||||||
|
self.now = datetime(2026, 8, 11, 1, 2, 3, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detection(track: str, center_x: float) -> Detection:
|
||||||
|
return Detection(track, "person", Box(center_x - 0.05, 0.3, center_x + 0.05, 0.8))
|
||||||
|
|
||||||
|
def test_first_seen_inside_does_not_fake_an_entry(self) -> None:
|
||||||
|
events, states = self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||||
|
self.assertEqual(events, [])
|
||||||
|
self.assertTrue(states["P-1"])
|
||||||
|
|
||||||
|
def test_entry_fires_once_until_track_exits_and_reenters(self) -> None:
|
||||||
|
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||||
|
events, _ = self.evaluator.evaluate(2, self.now, self.zone, [self.detection("P-1", 0.6)])
|
||||||
|
repeated, _ = self.evaluator.evaluate(3, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||||
|
self.evaluator.evaluate(4, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||||
|
reentered, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.6)])
|
||||||
|
|
||||||
|
self.assertEqual([item.source_event_id for item in events], ["BRN-0001"])
|
||||||
|
self.assertEqual(repeated, [])
|
||||||
|
self.assertEqual([item.source_event_id for item in reentered], ["BRN-0002"])
|
||||||
|
payload = events[0].as_dict()
|
||||||
|
self.assertNotIn("id", payload)
|
||||||
|
self.assertEqual(payload["confidence"], None)
|
||||||
|
self.assertTrue(payload["fixture"])
|
||||||
|
|
||||||
|
def test_expired_track_reappearing_inside_is_not_an_entry(self) -> None:
|
||||||
|
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||||
|
self.evaluator.evaluate(4, self.now, self.zone, [])
|
||||||
|
events, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||||
|
self.assertEqual(events, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from Brain.yovision_brain.runtime import DemoEngine
|
||||||
|
from Brain.yovision_brain.source import SyntheticSource, cv2
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(cv2 is None, "pinned OpenCV package is not installed")
|
||||||
|
class RuntimeTests(unittest.TestCase):
|
||||||
|
def test_rejects_non_finite_fps(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "fps"):
|
||||||
|
DemoEngine(SyntheticSource(width=320, height=180), fps=float("nan"))
|
||||||
|
|
||||||
|
def test_synthetic_step_produces_safe_state_and_jpeg(self) -> None:
|
||||||
|
engine = DemoEngine(SyntheticSource(width=320, height=180), fps=2.0)
|
||||||
|
engine.step()
|
||||||
|
state = engine.state()
|
||||||
|
self.assertTrue(state["source"]["fixture"])
|
||||||
|
self.assertEqual(state["detector"]["name"], "scripted_fixture")
|
||||||
|
self.assertEqual(state["frame"]["width"], 320)
|
||||||
|
self.assertGreater(len(engine.frame_jpeg() or b""), 100)
|
||||||
|
self.assertNotIn("url", state["source"])
|
||||||
|
|
||||||
|
def test_zone_update_increments_version(self) -> None:
|
||||||
|
engine = DemoEngine(SyntheticSource(width=320, height=180))
|
||||||
|
updated = engine.update_zone({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
|
||||||
|
self.assertEqual(updated.version, 2)
|
||||||
|
self.assertEqual(engine.state()["zone"]["name"], "新区域")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from Brain.yovision_brain.domain import Point, Zone, zone_from_payload
|
||||||
|
from Brain.yovision_brain.server import DemoHTTPServer, DemoHandler, index_path, parse_bind
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEngine:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.zone = Zone("zone-demo-01", "测试区域", 1, (Point(0.1, 0.1), Point(0.9, 0.1), Point(0.5, 0.9)))
|
||||||
|
|
||||||
|
def state(self) -> dict[str, object]:
|
||||||
|
return {"source": {"label": "safe", "connected": True}, "events": [], "zone": {"version": self.zone.version}}
|
||||||
|
|
||||||
|
def frame_jpeg(self) -> bytes:
|
||||||
|
return b"\xff\xd8safe-jpeg\xff\xd9"
|
||||||
|
|
||||||
|
def update_zone(self, payload: object) -> Zone:
|
||||||
|
self.zone = zone_from_payload(payload, self.zone)
|
||||||
|
return self.zone
|
||||||
|
|
||||||
|
|
||||||
|
class BindTests(unittest.TestCase):
|
||||||
|
def test_only_explicit_loopback_is_allowed(self) -> None:
|
||||||
|
self.assertEqual(parse_bind("127.0.0.1:8090"), ("127.0.0.1", 8090))
|
||||||
|
self.assertEqual(parse_bind("localhost:8090"), ("localhost", 8090))
|
||||||
|
with self.assertRaisesRegex(ValueError, "loopback"):
|
||||||
|
parse_bind("0.0.0.0:8090")
|
||||||
|
with self.assertRaisesRegex(ValueError, "loopback"):
|
||||||
|
parse_bind("192.168.1.10:8090")
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
html = index_path().read_text(encoding="utf-8")
|
||||||
|
self.server = DemoHTTPServer(("127.0.0.1", 0), FakeEngine(), "test-token", html)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.connection.close()
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(timeout=2)
|
||||||
|
|
||||||
|
def test_index_substitutes_token_and_sets_security_headers(self) -> None:
|
||||||
|
self.connection.request("GET", "/brain-demo")
|
||||||
|
response = self.connection.getresponse()
|
||||||
|
body = response.read().decode("utf-8")
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertIn('content="test-token"', body)
|
||||||
|
self.assertNotIn("__BRAIN_DEMO_TOKEN__", body)
|
||||||
|
self.assertEqual(response.getheader("X-Frame-Options"), "DENY")
|
||||||
|
self.assertIn("default-src 'self'", response.getheader("Content-Security-Policy"))
|
||||||
|
|
||||||
|
def test_zone_write_requires_token_and_rejects_unknown_fields(self) -> None:
|
||||||
|
body = json.dumps({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
|
||||||
|
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json"})
|
||||||
|
forbidden = self.connection.getresponse()
|
||||||
|
forbidden.read()
|
||||||
|
self.assertEqual(forbidden.status, 403)
|
||||||
|
|
||||||
|
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
|
||||||
|
accepted = self.connection.getresponse()
|
||||||
|
payload = json.loads(accepted.read())
|
||||||
|
self.assertEqual(accepted.status, 200)
|
||||||
|
self.assertEqual(payload["version"], 2)
|
||||||
|
|
||||||
|
invalid = json.dumps({"name": "bad", "points": [], "tenant_id": "leak"})
|
||||||
|
self.connection.request("PUT", "/api/v1/zones/active", body=invalid, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
|
||||||
|
rejected = self.connection.getresponse()
|
||||||
|
self.assertEqual(rejected.status, 400)
|
||||||
|
self.assertEqual(json.loads(rejected.read())["code"], "invalid_zone")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from Brain.yovision_brain.domain import Box
|
||||||
|
from Brain.yovision_brain.source import CentroidTracker, read_stream_url
|
||||||
|
|
||||||
|
|
||||||
|
class StreamURLTests(unittest.TestCase):
|
||||||
|
def test_requires_absolute_single_rtsp_url_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "stream.url"
|
||||||
|
path.write_text("rtsp://user:secret@127.0.0.1:8554/camera\n", encoding="utf-8")
|
||||||
|
self.assertEqual(read_stream_url(str(path)), "rtsp://user:secret@127.0.0.1:8554/camera")
|
||||||
|
path.write_text("rtsp://127.0.0.1/a\nrtsp://127.0.0.1/b\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "exactly one") as caught:
|
||||||
|
read_stream_url(str(path))
|
||||||
|
self.assertNotIn("127.0.0.1", str(caught.exception))
|
||||||
|
|
||||||
|
def test_rejects_relative_path_without_echoing_input(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "absolute"):
|
||||||
|
read_stream_url("camera-secret.url")
|
||||||
|
|
||||||
|
def test_rejects_url_files_inside_repository(self) -> None:
|
||||||
|
repository_file = Path(__file__).resolve()
|
||||||
|
with self.assertRaisesRegex(ValueError, "outside the repository"):
|
||||||
|
read_stream_url(str(repository_file))
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerTests(unittest.TestCase):
|
||||||
|
def test_nearby_boxes_retain_track_and_distant_box_gets_new_track(self) -> None:
|
||||||
|
tracker = CentroidTracker(max_distance=0.2)
|
||||||
|
first = tracker.update([(Box(0.1, 0.1, 0.2, 0.4), 0.8)], 1)
|
||||||
|
nearby = tracker.update([(Box(0.12, 0.1, 0.22, 0.4), 0.7)], 2)
|
||||||
|
distant = tracker.update([(Box(0.7, 0.1, 0.8, 0.4), 0.9)], 3)
|
||||||
|
self.assertEqual(first[0].track_id, nearby[0].track_id)
|
||||||
|
self.assertNotEqual(nearby[0].track_id, distant[0].track_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HTML_PATH = Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
|
||||||
|
|
||||||
|
|
||||||
|
class UIContractTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.html = HTML_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_prototype_is_self_contained_and_labels_fixture_truthfully(self) -> None:
|
||||||
|
self.assertIn("合成回放不等于模型效果", self.html)
|
||||||
|
self.assertIn("离线 HTML 原型数据", self.html)
|
||||||
|
self.assertNotRegex(self.html, r'(?:src|href)=["\']https?://')
|
||||||
|
self.assertNotIn("@import url", self.html)
|
||||||
|
|
||||||
|
def test_accessibility_and_responsive_guards_are_present(self) -> None:
|
||||||
|
required = (
|
||||||
|
'name="viewport"',
|
||||||
|
'class="skip-link"',
|
||||||
|
'aria-live="polite"',
|
||||||
|
':focus-visible',
|
||||||
|
'prefers-reduced-motion',
|
||||||
|
'min-height: 44px',
|
||||||
|
'@media (max-width: 420px)',
|
||||||
|
'.toolbar { display: grid; grid-template-columns: 1fr; }',
|
||||||
|
'键盘用户可使用右侧坐标表单',
|
||||||
|
)
|
||||||
|
for marker in required:
|
||||||
|
with self.subTest(marker=marker):
|
||||||
|
self.assertIn(marker, self.html)
|
||||||
|
|
||||||
|
def test_no_structural_emoji_or_unescaped_secret_placeholder_in_text(self) -> None:
|
||||||
|
visible_without_script = re.sub(r"<script[\s\S]*?</script>", "", self.html)
|
||||||
|
self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]")
|
||||||
|
self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Single-stream Brain engineering prototype.
|
||||||
|
|
||||||
|
This package intentionally exposes no Brain-to-Bell transport. T-018 owns
|
||||||
|
that contract and its delivery semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .runtime import DemoEngine
|
||||||
|
from .server import parse_bind, serve
|
||||||
|
from .source import StreamSource, SyntheticSource, read_stream_url, require_opencv
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="YoVision Brain single-stream engineering prototype")
|
||||||
|
parser.add_argument("--source", choices=("synthetic", "stream"), default="synthetic")
|
||||||
|
parser.add_argument("--stream-url-file", help="absolute external file containing one RTSP URL")
|
||||||
|
parser.add_argument("--bind", default="127.0.0.1:8090", help="loopback bind address")
|
||||||
|
parser.add_argument("--fps", type=float, default=2.0, help="prototype processing FPS (0, 30]")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
require_opencv()
|
||||||
|
parse_bind(args.bind)
|
||||||
|
if args.source == "stream":
|
||||||
|
if not args.stream_url_file:
|
||||||
|
raise ValueError("--stream-url-file is required for stream mode")
|
||||||
|
source = StreamSource(read_stream_url(args.stream_url_file))
|
||||||
|
else:
|
||||||
|
if args.stream_url_file:
|
||||||
|
raise ValueError("--stream-url-file is only valid for stream mode")
|
||||||
|
source = SyntheticSource()
|
||||||
|
engine = DemoEngine(source, fps=args.fps)
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
print(f"Brain demo configuration error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(f"Brain demo listening on http://{args.bind}/brain-demo")
|
||||||
|
print("Engineering prototype only; synthetic replay and HOG output are not production model evidence.")
|
||||||
|
try:
|
||||||
|
serve(engine, args.bind)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable, Iterable, Sequence
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Point:
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(math.isfinite(value) for value in (self.x, self.y)) or not (
|
||||||
|
0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0
|
||||||
|
):
|
||||||
|
raise ValueError("point coordinates must be within [0, 1]")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Box:
|
||||||
|
x1: float
|
||||||
|
y1: float
|
||||||
|
x2: float
|
||||||
|
y2: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
values = (self.x1, self.y1, self.x2, self.y2)
|
||||||
|
if not all(math.isfinite(value) for value in values) or any(value < 0.0 or value > 1.0 for value in values):
|
||||||
|
raise ValueError("box coordinates must be within [0, 1]")
|
||||||
|
if self.x1 >= self.x2 or self.y1 >= self.y2:
|
||||||
|
raise ValueError("box must have positive area")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center(self) -> Point:
|
||||||
|
return Point((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Detection:
|
||||||
|
track_id: str
|
||||||
|
class_name: str
|
||||||
|
box: Box
|
||||||
|
detector_score: float | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.track_id or len(self.track_id) > 128:
|
||||||
|
raise ValueError("track_id must contain 1 to 128 characters")
|
||||||
|
if self.class_name != "person":
|
||||||
|
raise ValueError("T-017 only supports anonymous person detections")
|
||||||
|
if self.detector_score is not None and not math.isfinite(self.detector_score):
|
||||||
|
raise ValueError("detector_score must be finite")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Zone:
|
||||||
|
zone_id: str
|
||||||
|
name: str
|
||||||
|
version: int
|
||||||
|
points: tuple[Point, ...]
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.zone_id or len(self.zone_id) > 128:
|
||||||
|
raise ValueError("zone_id must contain 1 to 128 characters")
|
||||||
|
if not self.name.strip() or len(self.name) > 80:
|
||||||
|
raise ValueError("zone name must contain 1 to 80 characters")
|
||||||
|
if self.version < 1:
|
||||||
|
raise ValueError("zone version must be positive")
|
||||||
|
if not 3 <= len(self.points) <= 32:
|
||||||
|
raise ValueError("zone must contain 3 to 32 points")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EventCandidate:
|
||||||
|
source_event_id: str
|
||||||
|
kind: str
|
||||||
|
source_ref: str
|
||||||
|
track_id: str
|
||||||
|
zone_id: str
|
||||||
|
zone_version: int
|
||||||
|
occurred_at: str
|
||||||
|
confidence: None
|
||||||
|
fixture: bool
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
# Bell owns the platform `id`; it is deliberately absent here.
|
||||||
|
return {
|
||||||
|
"candidate_version": "brain-demo-v1",
|
||||||
|
"source_event_id": self.source_event_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"source_ref": self.source_ref,
|
||||||
|
"track_id": self.track_id,
|
||||||
|
"zone_id": self.zone_id,
|
||||||
|
"zone_version": self.zone_version,
|
||||||
|
"occurred_at": self.occurred_at,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"fixture": self.fixture,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _on_segment(point: Point, start: Point, end: Point, epsilon: float = 1e-9) -> bool:
|
||||||
|
cross = (point.y - start.y) * (end.x - start.x) - (point.x - start.x) * (end.y - start.y)
|
||||||
|
if abs(cross) > epsilon:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
min(start.x, end.x) - epsilon <= point.x <= max(start.x, end.x) + epsilon
|
||||||
|
and min(start.y, end.y) - epsilon <= point.y <= max(start.y, end.y) + epsilon
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_polygon(point: Point, polygon: Sequence[Point]) -> bool:
|
||||||
|
if len(polygon) < 3:
|
||||||
|
return False
|
||||||
|
inside = False
|
||||||
|
previous = polygon[-1]
|
||||||
|
for current in polygon:
|
||||||
|
if _on_segment(point, previous, current):
|
||||||
|
return True
|
||||||
|
crosses = (current.y > point.y) != (previous.y > point.y)
|
||||||
|
if crosses:
|
||||||
|
boundary_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
|
||||||
|
if point.x < boundary_x:
|
||||||
|
inside = not inside
|
||||||
|
previous = current
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneEntryEvaluator:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source_ref: str,
|
||||||
|
fixture: bool,
|
||||||
|
track_ttl_frames: int = 8,
|
||||||
|
event_id_factory: Callable[[], str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if track_ttl_frames < 1:
|
||||||
|
raise ValueError("track_ttl_frames must be positive")
|
||||||
|
self._source_ref = source_ref
|
||||||
|
self._fixture = fixture
|
||||||
|
self._track_ttl_frames = track_ttl_frames
|
||||||
|
self._event_id_factory = event_id_factory or (lambda: f"BRN-{uuid4().hex}")
|
||||||
|
self._inside: dict[str, bool] = {}
|
||||||
|
self._last_seen: dict[str, int] = {}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._inside.clear()
|
||||||
|
self._last_seen.clear()
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
sequence: int,
|
||||||
|
occurred_at: datetime,
|
||||||
|
zone: Zone,
|
||||||
|
detections: Iterable[Detection],
|
||||||
|
) -> tuple[list[EventCandidate], dict[str, bool]]:
|
||||||
|
if sequence < 0:
|
||||||
|
raise ValueError("sequence cannot be negative")
|
||||||
|
if occurred_at.tzinfo is None:
|
||||||
|
raise ValueError("occurred_at must be timezone-aware")
|
||||||
|
|
||||||
|
expired = [
|
||||||
|
track_id
|
||||||
|
for track_id, last_seen in self._last_seen.items()
|
||||||
|
if sequence - last_seen > self._track_ttl_frames
|
||||||
|
]
|
||||||
|
for track_id in expired:
|
||||||
|
self._inside.pop(track_id, None)
|
||||||
|
self._last_seen.pop(track_id, None)
|
||||||
|
|
||||||
|
events: list[EventCandidate] = []
|
||||||
|
states: dict[str, bool] = {}
|
||||||
|
for detection in detections:
|
||||||
|
inside = point_in_polygon(detection.box.center, zone.points)
|
||||||
|
previous = self._inside.get(detection.track_id)
|
||||||
|
if previous is False and inside:
|
||||||
|
events.append(
|
||||||
|
EventCandidate(
|
||||||
|
source_event_id=self._event_id_factory(),
|
||||||
|
kind="zone_entry",
|
||||||
|
source_ref=self._source_ref,
|
||||||
|
track_id=detection.track_id,
|
||||||
|
zone_id=zone.zone_id,
|
||||||
|
zone_version=zone.version,
|
||||||
|
occurred_at=occurred_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
confidence=None,
|
||||||
|
fixture=self._fixture,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._inside[detection.track_id] = inside
|
||||||
|
self._last_seen[detection.track_id] = sequence
|
||||||
|
states[detection.track_id] = inside
|
||||||
|
return events, states
|
||||||
|
|
||||||
|
|
||||||
|
def zone_from_payload(payload: object, current: Zone) -> Zone:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("request body must be an object")
|
||||||
|
if set(payload) - {"name", "points"}:
|
||||||
|
raise ValueError("unknown zone fields are not allowed")
|
||||||
|
name = payload.get("name", current.name)
|
||||||
|
raw_points = payload.get("points")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ValueError("zone name must be a string")
|
||||||
|
if not isinstance(raw_points, list):
|
||||||
|
raise ValueError("zone points must be an array")
|
||||||
|
points: list[Point] = []
|
||||||
|
for raw_point in raw_points:
|
||||||
|
if not isinstance(raw_point, dict) or set(raw_point) != {"x", "y"}:
|
||||||
|
raise ValueError("each point must contain only x and y")
|
||||||
|
x = raw_point["x"]
|
||||||
|
y = raw_point["y"]
|
||||||
|
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
|
||||||
|
raise ValueError("point coordinates must be numbers")
|
||||||
|
points.append(Point(float(x), float(y)))
|
||||||
|
return Zone(current.zone_id, name.strip(), current.version + 1, tuple(points))
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .domain import Detection, Point, Zone, ZoneEntryEvaluator, zone_from_payload
|
||||||
|
from .source import CentroidTracker, HOGPersonDetector, require_opencv
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2 # type: ignore
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
cv2 = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ZONE = Zone(
|
||||||
|
"zone-demo-01",
|
||||||
|
"楼梯口危险区",
|
||||||
|
1,
|
||||||
|
(Point(0.55, 0.30), Point(0.90, 0.30), Point(0.90, 0.88), Point(0.55, 0.88)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DemoEngine:
|
||||||
|
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100) -> None:
|
||||||
|
if not math.isfinite(fps) or fps <= 0.0 or fps > 30.0:
|
||||||
|
raise ValueError("fps must be within (0, 30]")
|
||||||
|
if not 1 <= event_limit <= 100:
|
||||||
|
raise ValueError("event_limit must be within [1, 100]")
|
||||||
|
require_opencv()
|
||||||
|
self._source = source
|
||||||
|
self._fps = fps
|
||||||
|
self._detector = None if source.fixture else HOGPersonDetector()
|
||||||
|
self._tracker = CentroidTracker()
|
||||||
|
self._evaluator = ZoneEntryEvaluator(source.source_ref, source.fixture)
|
||||||
|
self._zone = DEFAULT_ZONE
|
||||||
|
self._events: deque[dict[str, object]] = deque(maxlen=event_limit)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._sequence = 0
|
||||||
|
self._frame_jpeg: bytes | None = None
|
||||||
|
self._frame_width = 0
|
||||||
|
self._frame_height = 0
|
||||||
|
self._captured_at: str | None = None
|
||||||
|
self._detections: list[dict[str, object]] = []
|
||||||
|
self._latency_ms: float | None = None
|
||||||
|
self._connected = False
|
||||||
|
self._last_error_code: str | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread is not None:
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._run, name="brain-demo", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=3.0)
|
||||||
|
self._thread = None
|
||||||
|
self._source.close()
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
interval = 1.0 / self._fps
|
||||||
|
while not self._stop.is_set():
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
self.step()
|
||||||
|
except RuntimeError:
|
||||||
|
with self._lock:
|
||||||
|
self._connected = False
|
||||||
|
self._last_error_code = "source_unavailable"
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
self._stop.wait(max(0.0, interval - elapsed))
|
||||||
|
|
||||||
|
def step(self) -> None:
|
||||||
|
started = time.perf_counter()
|
||||||
|
packet = self._source.read()
|
||||||
|
self._sequence += 1
|
||||||
|
if packet.scripted_detections is not None:
|
||||||
|
detections = list(packet.scripted_detections)
|
||||||
|
else:
|
||||||
|
raw_boxes = self._detector.detect(packet.frame) if self._detector is not None else []
|
||||||
|
detections = self._tracker.update(raw_boxes, self._sequence)
|
||||||
|
with self._lock:
|
||||||
|
zone = self._zone
|
||||||
|
new_events, inside_by_track = self._evaluator.evaluate(self._sequence, packet.captured_at, zone, detections)
|
||||||
|
ok, encoded = cv2.imencode(".jpg", packet.frame, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("frame encoding failed")
|
||||||
|
height, width = packet.frame.shape[:2]
|
||||||
|
serialized_detections = [self._serialize_detection(item, inside_by_track.get(item.track_id, False)) for item in detections]
|
||||||
|
with self._lock:
|
||||||
|
for event in new_events:
|
||||||
|
self._events.appendleft(event.as_dict())
|
||||||
|
self._frame_jpeg = encoded.tobytes()
|
||||||
|
self._frame_width = int(width)
|
||||||
|
self._frame_height = int(height)
|
||||||
|
self._captured_at = packet.captured_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
self._detections = serialized_detections
|
||||||
|
self._latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
|
||||||
|
self._connected = True
|
||||||
|
self._last_error_code = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_detection(detection: Detection, inside: bool) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"track_id": detection.track_id,
|
||||||
|
"class": detection.class_name,
|
||||||
|
"bbox": [detection.box.x1, detection.box.y1, detection.box.x2, detection.box.y2],
|
||||||
|
"detector_score": detection.detector_score,
|
||||||
|
"inside_zone": inside,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_zone(self, payload: object) -> Zone:
|
||||||
|
with self._lock:
|
||||||
|
updated = zone_from_payload(payload, self._zone)
|
||||||
|
self._zone = updated
|
||||||
|
self._evaluator.reset()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def frame_jpeg(self) -> bytes | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._frame_jpeg
|
||||||
|
|
||||||
|
def state(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
zone = self._zone
|
||||||
|
return {
|
||||||
|
"prototype": True,
|
||||||
|
"notice": "工程原型;合成回放不是模型输出,HOG 适配器不是生产检测模型。",
|
||||||
|
"source": {
|
||||||
|
"mode": self._source.mode,
|
||||||
|
"label": self._source.label,
|
||||||
|
"fixture": self._source.fixture,
|
||||||
|
"connected": self._connected,
|
||||||
|
"ref": self._source.source_ref,
|
||||||
|
},
|
||||||
|
"detector": {
|
||||||
|
"name": "scripted_fixture" if self._source.fixture else self._detector.name,
|
||||||
|
"production_ready": False,
|
||||||
|
},
|
||||||
|
"frame": {
|
||||||
|
"sequence": self._sequence,
|
||||||
|
"width": self._frame_width,
|
||||||
|
"height": self._frame_height,
|
||||||
|
"captured_at": self._captured_at,
|
||||||
|
},
|
||||||
|
"inference": {"target_fps": self._fps, "latency_ms": self._latency_ms},
|
||||||
|
"zone": {
|
||||||
|
"id": zone.zone_id,
|
||||||
|
"name": zone.name,
|
||||||
|
"version": zone.version,
|
||||||
|
"points": [{"x": point.x, "y": point.y} for point in zone.points],
|
||||||
|
},
|
||||||
|
"detections": list(self._detections),
|
||||||
|
"events": list(self._events),
|
||||||
|
"last_error_code": self._last_error_code,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from .domain import Zone
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BODY_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bind(value: str) -> tuple[str, int]:
|
||||||
|
parsed = urlsplit(f"//{value}")
|
||||||
|
host = parsed.hostname
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("invalid demo bind address") from exc
|
||||||
|
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
|
||||||
|
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
|
||||||
|
return host, port
|
||||||
|
|
||||||
|
|
||||||
|
def index_path() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
|
||||||
|
|
||||||
|
|
||||||
|
class DemoHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
|
||||||
|
self.engine = engine
|
||||||
|
self.demo_token = token
|
||||||
|
self.html = html
|
||||||
|
super().__init__(address, DemoHandler)
|
||||||
|
|
||||||
|
|
||||||
|
class DemoHandler(BaseHTTPRequestHandler):
|
||||||
|
server: DemoHTTPServer
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
|
||||||
|
return
|
||||||
|
|
||||||
|
def _security_headers(self) -> None:
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_json(self, status: int, payload: object) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
self._send_bytes(status, "application/json; charset=utf-8", body)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if path in {"/", "/brain-demo"}:
|
||||||
|
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
|
||||||
|
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||||
|
return
|
||||||
|
if path == "/healthz":
|
||||||
|
self._send_json(HTTPStatus.OK, {"status": "ok"})
|
||||||
|
return
|
||||||
|
if path == "/api/v1/state":
|
||||||
|
self._send_json(HTTPStatus.OK, self.server.engine.state())
|
||||||
|
return
|
||||||
|
if path == "/api/v1/frame.jpg":
|
||||||
|
frame = self.server.engine.frame_jpeg()
|
||||||
|
if frame is None:
|
||||||
|
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
|
||||||
|
return
|
||||||
|
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
|
||||||
|
return
|
||||||
|
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
|
||||||
|
|
||||||
|
def do_PUT(self) -> None: # noqa: N802
|
||||||
|
if urlsplit(self.path).path != "/api/v1/zones/active":
|
||||||
|
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
|
||||||
|
return
|
||||||
|
raw_length = self.headers.get("Content-Length")
|
||||||
|
try:
|
||||||
|
length = int(raw_length or "-1")
|
||||||
|
except ValueError:
|
||||||
|
length = -1
|
||||||
|
if length < 0 or length > MAX_BODY_BYTES:
|
||||||
|
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
|
||||||
|
return
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
supplied = self.headers.get("X-Brain-Demo-Token", "")
|
||||||
|
if not hmac.compare_digest(supplied, self.server.demo_token):
|
||||||
|
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8"))
|
||||||
|
zone: Zone = self.server.engine.update_zone(payload)
|
||||||
|
except (UnicodeError, json.JSONDecodeError, ValueError):
|
||||||
|
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
|
||||||
|
return
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"id": zone.zone_id,
|
||||||
|
"name": zone.name,
|
||||||
|
"version": zone.version,
|
||||||
|
"points": [{"x": point.x, "y": point.y} for point in zone.points],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
|
||||||
|
html = index_path().read_text(encoding="utf-8")
|
||||||
|
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(engine: Any, bind: str) -> None:
|
||||||
|
server = build_server(engine, bind)
|
||||||
|
engine.start()
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.25)
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
engine.stop()
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from .domain import Box, Detection
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2 # type: ignore
|
||||||
|
import numpy as np # type: ignore
|
||||||
|
except ImportError: # pragma: no cover - exercised by the startup failure path
|
||||||
|
cv2 = None
|
||||||
|
np = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FramePacket:
|
||||||
|
frame: Any
|
||||||
|
captured_at: datetime
|
||||||
|
scripted_detections: tuple[Detection, ...] | None
|
||||||
|
|
||||||
|
|
||||||
|
def require_opencv() -> None:
|
||||||
|
if cv2 is None or np is None:
|
||||||
|
raise RuntimeError("Brain demo requires the pinned NumPy and OpenCV packages")
|
||||||
|
|
||||||
|
|
||||||
|
def read_stream_url(path_value: str) -> str:
|
||||||
|
path = Path(path_value)
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ValueError("stream URL file must be an absolute external path")
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError("stream URL file does not exist") from exc
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise ValueError("stream URL file does not exist")
|
||||||
|
repository_root = Path(__file__).resolve().parents[2]
|
||||||
|
try:
|
||||||
|
resolved.relative_to(repository_root)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValueError("stream URL file must be outside the repository")
|
||||||
|
try:
|
||||||
|
with resolved.open("rb") as stream:
|
||||||
|
raw = stream.read(4097)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError("stream URL file cannot be read") from exc
|
||||||
|
if len(raw) > 4096:
|
||||||
|
raise ValueError("stream URL file exceeds 4096 bytes")
|
||||||
|
try:
|
||||||
|
lines = raw.decode("utf-8").splitlines()
|
||||||
|
except UnicodeError as exc:
|
||||||
|
raise ValueError("stream URL file must be UTF-8") from exc
|
||||||
|
values = [line.strip() for line in lines if line.strip()]
|
||||||
|
if len(values) != 1:
|
||||||
|
raise ValueError("stream URL file must contain exactly one non-empty line")
|
||||||
|
parsed = urlsplit(values[0])
|
||||||
|
if parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname:
|
||||||
|
raise ValueError("stream URL must be an RTSP URL")
|
||||||
|
return values[0]
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticSource:
|
||||||
|
mode = "synthetic"
|
||||||
|
label = "合成回放"
|
||||||
|
fixture = True
|
||||||
|
source_ref = "demo-camera-01"
|
||||||
|
|
||||||
|
def __init__(self, width: int = 960, height: int = 540) -> None:
|
||||||
|
require_opencv()
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._sequence = 0
|
||||||
|
|
||||||
|
def read(self) -> FramePacket:
|
||||||
|
self._sequence += 1
|
||||||
|
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
||||||
|
frame[:] = (20, 28, 42)
|
||||||
|
cv2.rectangle(frame, (0, int(self.height * 0.72)), (self.width, self.height), (31, 42, 58), -1)
|
||||||
|
for x in range(0, self.width, 80):
|
||||||
|
cv2.line(frame, (x, int(self.height * 0.72)), (x + 80, self.height), (43, 57, 75), 1)
|
||||||
|
cv2.putText(frame, "SYNTHETIC FIXTURE - NOT MODEL OUTPUT", (24, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.72, (82, 190, 245), 2)
|
||||||
|
|
||||||
|
phase = ((self._sequence - 1) % 160) / 159.0
|
||||||
|
center_x = -0.04 + phase * 1.08
|
||||||
|
x1 = max(0.0, center_x - 0.04)
|
||||||
|
x2 = min(1.0, center_x + 0.04)
|
||||||
|
detections: tuple[Detection, ...] = ()
|
||||||
|
if x2 - x1 > 0.01:
|
||||||
|
box = Box(x1, 0.34, x2, 0.82)
|
||||||
|
detections = (Detection("P-DEMO-001", "person", box, None),)
|
||||||
|
px = int(center_x * self.width)
|
||||||
|
head_y = int(self.height * 0.40)
|
||||||
|
cv2.circle(frame, (px, head_y), 17, (195, 210, 225), -1)
|
||||||
|
cv2.line(frame, (px, head_y + 18), (px, int(self.height * 0.65)), (195, 210, 225), 12)
|
||||||
|
cv2.line(frame, (px, int(self.height * 0.52)), (px - 30, int(self.height * 0.60)), (195, 210, 225), 8)
|
||||||
|
cv2.line(frame, (px, int(self.height * 0.52)), (px + 30, int(self.height * 0.60)), (195, 210, 225), 8)
|
||||||
|
cv2.line(frame, (px, int(self.height * 0.65)), (px - 24, int(self.height * 0.79)), (195, 210, 225), 9)
|
||||||
|
cv2.line(frame, (px, int(self.height * 0.65)), (px + 24, int(self.height * 0.79)), (195, 210, 225), 9)
|
||||||
|
return FramePacket(frame, datetime.now(timezone.utc), detections)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class StreamSource:
|
||||||
|
mode = "stream"
|
||||||
|
label = "外部 MediaMTX / RTSP"
|
||||||
|
fixture = False
|
||||||
|
source_ref = "configured-video-source"
|
||||||
|
|
||||||
|
def __init__(self, stream_url: str) -> None:
|
||||||
|
require_opencv()
|
||||||
|
self._stream_url = stream_url
|
||||||
|
self._capture: Any = None
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if self._capture is not None:
|
||||||
|
self._capture.release()
|
||||||
|
self._capture = cv2.VideoCapture(self._stream_url)
|
||||||
|
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
|
||||||
|
def read(self) -> FramePacket:
|
||||||
|
if self._capture is None or not self._capture.isOpened():
|
||||||
|
self._open()
|
||||||
|
ok, frame = self._capture.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
self._open()
|
||||||
|
raise RuntimeError("stream frame unavailable")
|
||||||
|
return FramePacket(frame, datetime.now(timezone.utc), None)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._capture is not None:
|
||||||
|
self._capture.release()
|
||||||
|
self._capture = None
|
||||||
|
|
||||||
|
|
||||||
|
class HOGPersonDetector:
|
||||||
|
name = "opencv_hog_person_demo"
|
||||||
|
production_ready = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
require_opencv()
|
||||||
|
self._hog = cv2.HOGDescriptor()
|
||||||
|
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
|
||||||
|
|
||||||
|
def detect(self, frame: Any) -> list[tuple[Box, float]]:
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
scale = min(1.0, 960.0 / max(width, 1))
|
||||||
|
working = frame if scale == 1.0 else cv2.resize(frame, (int(width * scale), int(height * scale)))
|
||||||
|
boxes, weights = self._hog.detectMultiScale(working, winStride=(8, 8), padding=(8, 8), scale=1.05)
|
||||||
|
result: list[tuple[Box, float]] = []
|
||||||
|
work_height, work_width = working.shape[:2]
|
||||||
|
for raw_box, weight in zip(boxes, weights):
|
||||||
|
x, y, box_width, box_height = (int(value) for value in raw_box)
|
||||||
|
x1 = max(0.0, min(1.0, x / work_width))
|
||||||
|
y1 = max(0.0, min(1.0, y / work_height))
|
||||||
|
x2 = max(0.0, min(1.0, (x + box_width) / work_width))
|
||||||
|
y2 = max(0.0, min(1.0, (y + box_height) / work_height))
|
||||||
|
if x2 > x1 and y2 > y1:
|
||||||
|
result.append((Box(x1, y1, x2, y2), float(weight)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CentroidTracker:
|
||||||
|
def __init__(self, max_distance: float = 0.18, ttl_frames: int = 8) -> None:
|
||||||
|
self._max_distance = max_distance
|
||||||
|
self._ttl_frames = ttl_frames
|
||||||
|
self._next_id = 1
|
||||||
|
self._tracks: dict[str, tuple[Box, int]] = {}
|
||||||
|
|
||||||
|
def update(self, boxes: Iterable[tuple[Box, float]], sequence: int) -> list[Detection]:
|
||||||
|
incoming = list(boxes)
|
||||||
|
available = set(self._tracks)
|
||||||
|
detections: list[Detection] = []
|
||||||
|
for box, score in incoming:
|
||||||
|
center = box.center
|
||||||
|
selected: str | None = None
|
||||||
|
selected_distance = self._max_distance
|
||||||
|
for track_id in available:
|
||||||
|
old_center = self._tracks[track_id][0].center
|
||||||
|
distance = ((center.x - old_center.x) ** 2 + (center.y - old_center.y) ** 2) ** 0.5
|
||||||
|
if distance < selected_distance:
|
||||||
|
selected = track_id
|
||||||
|
selected_distance = distance
|
||||||
|
if selected is None:
|
||||||
|
selected = f"P-{self._next_id:04d}"
|
||||||
|
self._next_id += 1
|
||||||
|
else:
|
||||||
|
available.remove(selected)
|
||||||
|
self._tracks[selected] = (box, sequence)
|
||||||
|
detections.append(Detection(selected, "person", box, score))
|
||||||
|
for track_id, (_, last_seen) in list(self._tracks.items()):
|
||||||
|
if sequence - last_seen > self._ttl_frames:
|
||||||
|
del self._tracks[track_id]
|
||||||
|
return detections
|
||||||
+25
-4
@@ -1,6 +1,6 @@
|
|||||||
# Sense M1/M2 接入骨架
|
# Sense M1/M2 接入骨架
|
||||||
|
|
||||||
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-012 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing 和孤儿受控处置。默认关闭真实 ONVIF 与公共业务路由;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
|
本目录是 YoVision Sense 的接入与 NVR 管理面。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。T-018 增加默认关闭的回环工程控制台,用于展示设备、配额、收敛状态和最多 4 路按需 MediaMTX WebRTC 预览;它不包含录像/回放,也不是生产公网入口。默认关闭真实 ONVIF、公共业务路由、控制台和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ go build -o bin/sense-api.exe ./cmd/sense-api
|
|||||||
go run ./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` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control 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` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由,`/sense-console/` 还需独立显式开启。
|
||||||
|
|
||||||
常用环境变量:
|
常用环境变量:
|
||||||
|
|
||||||
@@ -42,6 +42,13 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`
|
|||||||
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
|
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
|
||||||
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
|
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
|
||||||
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
|
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
|
||||||
|
| `SENSE_CONSOLE_ENABLED` | `false` | 显式开启 `/sense-console/`;T-018 只允许与回环 Control API 一起使用 |
|
||||||
|
| `SENSE_CONSOLE_WEBRTC_BASE_URL` | `http://127.0.0.1:8889` | MediaMTX WebRTC 浏览器入口基地址;必须为无 userinfo/path/query/fragment 的显式回环 HTTP(S) URL |
|
||||||
|
| `SENSE_AUDIT_RELAY_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 |
|
||||||
|
| `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS |
|
||||||
|
| `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 |
|
||||||
|
| `SENSE_AUDIT_RELAY_KEY_ID` | 空 | 本实例用于签名的 key ID |
|
||||||
|
| `SENSE_AUDIT_RELAY_INTERVAL` | `1s` | 队列轮询间隔,最短 1 秒 |
|
||||||
|
|
||||||
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
|
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API
|
|||||||
|
|
||||||
初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取两个视图,不能读取或写入 Bell 源表。T-011 的 v4 schema 增加资源版本、24 小时幂等收据和 batch operation;T-012 的 v5 schema 增加数据库时钟租约、Path 历史归属及脱敏孤儿报告/处置结果。表中不保存 MediaMTX source URI。应用登录角色和密码由部署环境创建,不进入仓库。
|
初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取两个视图,不能读取或写入 Bell 源表。T-011 的 v4 schema 增加资源版本、24 小时幂等收据和 batch operation;T-012 的 v5 schema 增加数据库时钟租约、Path 历史归属及脱敏孤儿报告/处置结果。表中不保存 MediaMTX source URI。应用登录角色和密码由部署环境创建,不进入仓库。
|
||||||
|
|
||||||
PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建、移动 Area 和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。创建、配置修改和期望态受理都与对应脱敏 Outbox 事实同事务;停用后调和器只删除该设备的精确 MediaMTX path 并收敛为 offline,不枚举未知 path。Bell relay 尚未实现。
|
PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建、移动 Area 和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。创建、配置修改和期望态受理都与对应脱敏 Outbox 事实同事务;停用后调和器只删除该设备的精确 MediaMTX path 并收敛为 offline,不枚举未知 path。开启 relay 后,Sense 用数据库 lease/fencing 批量投递,成功、重试和 dead letter 均保留本地事实;Sense 不访问 Bell schema。
|
||||||
|
|
||||||
Windows 本机集成测试从仓库根目录执行:
|
Windows 本机集成测试从仓库根目录执行:
|
||||||
|
|
||||||
@@ -78,7 +85,7 @@ $env:SENSE_DB_DSN = '由部署环境私下设置'
|
|||||||
go run ./cmd/sense-api
|
go run ./cmd/sense-api
|
||||||
```
|
```
|
||||||
|
|
||||||
PostgreSQL 启动会检查 Sense v5 migration、当前角色对两个 Bell 投影视图和本地控制/对账表的最小权限;权限过宽、视图不可读或 schema 未安装时初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox、多实例租约或孤儿处置语义,Control API 与孤儿扫描在 SQLite 下不会启动。
|
PostgreSQL 基础启动会检查 Sense v5 migration、当前角色对两个 Bell 投影视图和本地控制/对账表的最小权限;启用 relay 时额外要求 v6 和本地 Outbox 权限,并确认当前 Sense 登录不能访问 Bell 全局审计表。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox、多实例租约、孤儿处置或 relay 语义,Control API、孤儿扫描与 relay 在 SQLite 下不会启动。
|
||||||
|
|
||||||
### 孤儿报告与受控处置
|
### 孤儿报告与受控处置
|
||||||
|
|
||||||
@@ -117,6 +124,20 @@ go run ./cmd/sense-api
|
|||||||
|
|
||||||
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
|
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
|
||||||
|
|
||||||
|
### 开启 T-018 回环控制台
|
||||||
|
|
||||||
|
先按上一节完成 PostgreSQL、Control API、仓库外 auth/cursor 文件和 MediaMTX 启动,再增加:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SENSE_CONSOLE_ENABLED = 'true'
|
||||||
|
$env:SENSE_CONSOLE_WEBRTC_BASE_URL = 'http://127.0.0.1:8889'
|
||||||
|
go run ./cmd/sense-api
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 `http://127.0.0.1:8080/sense-console/`,输入已授权的 Site ID 和原始 Bearer token。token 只保留在当前页面 JavaScript 内存,输入框随即清空,刷新页面后必须重新输入;不得把 token 放进 URL、截图、命令历史或文档。设备列表默认每页 16 项,只有 `video_capture + enabled + online + converged` 的设备可选择,最多同时启动 4 路预览。
|
||||||
|
|
||||||
|
播放使用 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,Sense 不代理媒体字节,也不复制播放器源码。浏览器是否能解码取决于摄像头编码;首选已验收的低码率 H.264 子码流,H.265 或带 B-frame 的 H.264 不能因“Path 在线”就宣称浏览器可播放。本版 Sense 和播放端都必须显式回环;非回环 HTTPS、正式会话认证和 MediaMTX 外部鉴权需另立任务。控制台不会实现或暗示常态录像、录像计划和回放。
|
||||||
|
|
||||||
Windows 本地准备 MediaMTX(从仓库根目录执行):
|
Windows 本地准备 MediaMTX(从仓库根目录执行):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/auditrelay"
|
||||||
"yovision/sense/internal/auth"
|
"yovision/sense/internal/auth"
|
||||||
"yovision/sense/internal/config"
|
"yovision/sense/internal/config"
|
||||||
|
"yovision/sense/internal/console"
|
||||||
"yovision/sense/internal/controlapi"
|
"yovision/sense/internal/controlapi"
|
||||||
"yovision/sense/internal/metrics"
|
"yovision/sense/internal/metrics"
|
||||||
"yovision/sense/internal/mtx"
|
"yovision/sense/internal/mtx"
|
||||||
@@ -55,6 +57,28 @@ func run(logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer repository.Close()
|
defer repository.Close()
|
||||||
|
var auditWorker *auditrelay.Worker
|
||||||
|
if cfg.AuditRelayEnabled {
|
||||||
|
relayStore, ok := repository.(auditrelay.Repository)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("selected repository does not support audit relay")
|
||||||
|
}
|
||||||
|
if err := relayStore.AuditRelayReady(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secret, err := auditrelay.LoadKey(cfg.AuditRelayKeyFile, cfg.AuditRelayKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := auditrelay.NewClient(cfg.AuditRelayURL, cfg.AuditRelayKeyID, secret, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auditWorker, err = auditrelay.NewWorker(relayStore, client, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
var controlHandler http.Handler
|
var controlHandler http.Handler
|
||||||
if cfg.ControlAPIEnabled {
|
if cfg.ControlAPIEnabled {
|
||||||
controlStore, ok := repository.(store.ControlRepository)
|
controlStore, ok := repository.(store.ControlRepository)
|
||||||
@@ -116,6 +140,9 @@ func run(logger *slog.Logger) error {
|
|||||||
if orphanScanner != nil {
|
if orphanScanner != nil {
|
||||||
startBackground(func() { orphanScanner.Run(ctx, cfg.OrphanScanInterval, report) })
|
startBackground(func() { orphanScanner.Run(ctx, cfg.OrphanScanInterval, report) })
|
||||||
}
|
}
|
||||||
|
if auditWorker != nil {
|
||||||
|
startBackground(func() { auditWorker.Run(ctx, cfg.AuditRelayInterval, report) })
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -134,6 +161,17 @@ func run(logger *slog.Logger) error {
|
|||||||
if cfg.ControlAPIEnabled {
|
if cfg.ControlAPIEnabled {
|
||||||
mux.Handle("/api/v1/", controlHandler)
|
mux.Handle("/api/v1/", controlHandler)
|
||||||
}
|
}
|
||||||
|
if cfg.ConsoleEnabled {
|
||||||
|
consoleHandler, err := console.NewHandler(cfg.ConsoleWebRTCBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mux.Handle("/sense-console/", consoleHandler)
|
||||||
|
mux.HandleFunc("GET /sense-console", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.Redirect(writer, request, "/sense-console/", http.StatusTemporaryRedirect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.HTTPAddress, Handler: mux,
|
Addr: cfg.HTTPAddress, Handler: mux,
|
||||||
@@ -146,7 +184,9 @@ func run(logger *slog.Logger) error {
|
|||||||
go func() {
|
go func() {
|
||||||
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
|
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
|
||||||
"instance_id", instanceID,
|
"instance_id", instanceID,
|
||||||
"control_api_enabled", cfg.ControlAPIEnabled)
|
"control_api_enabled", cfg.ControlAPIEnabled,
|
||||||
|
"console_enabled", cfg.ConsoleEnabled,
|
||||||
|
"audit_relay_enabled", cfg.AuditRelayEnabled)
|
||||||
serverErrors <- server.ListenAndServe()
|
serverErrors <- server.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package auditrelay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testEnvelope(id string) Envelope {
|
||||||
|
return Envelope{SchemaVersion: 1, Event: Event{
|
||||||
|
EventID: id, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
|
||||||
|
Actor: Actor{Type: "system", ID: "sense"}, AggregateGeneration: 1,
|
||||||
|
ProjectionVersions: ProjectionVersions{}, Data: json.RawMessage(`{"kind":"device_created"}`), OccurredAt: time.Unix(1, 0).UTC(),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientSignsCanonicalRequest(t *testing.T) {
|
||||||
|
secret := bytesOf(32, 7)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
body := make([]byte, request.ContentLength)
|
||||||
|
_, _ = request.Body.Read(body)
|
||||||
|
canonical := CanonicalString(request.Method, request.URL.Path, request.Header.Get(HeaderTimestamp), request.Header.Get(HeaderNonce), body)
|
||||||
|
if request.Header.Get(HeaderSignature) != Signature(secret, canonical) {
|
||||||
|
t.Error("request signature did not match canonical vector")
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode(BatchResponse{Results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL+RelayPath, "sense-a", secret, server.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
|
||||||
|
client.nonce = func() (string, error) { return "AAAAAAAAAAAAAAAAAAAAAA", nil }
|
||||||
|
results, err := client.Send(context.Background(), []Envelope{testEnvelope("audit_00000000000000000000000000000001")})
|
||||||
|
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
|
||||||
|
t.Fatalf("unexpected relay result: %+v, %v", results, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpointAndKeySecurity(t *testing.T) {
|
||||||
|
if _, err := ValidateEndpoint("http://example.com" + RelayPath); err == nil {
|
||||||
|
t.Fatal("remote plaintext relay URL was accepted")
|
||||||
|
}
|
||||||
|
if _, err := ValidateEndpoint("https://example.com" + RelayPath + "?secret=x"); err == nil {
|
||||||
|
t.Fatal("relay URL query was accepted")
|
||||||
|
}
|
||||||
|
secret := bytesOf(32, 9)
|
||||||
|
path := filepath.Join(t.TempDir(), "keys.json")
|
||||||
|
document := map[string]any{"version": 1, "keys": []any{map[string]any{"key_id": "sense-a", "secret_base64url": base64.RawURLEncoding.EncodeToString(secret)}}}
|
||||||
|
raw, _ := json.Marshal(document)
|
||||||
|
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadKey(path, "sense-a")
|
||||||
|
if err != nil || sha256.Sum256(loaded) != sha256.Sum256(secret) {
|
||||||
|
t.Fatalf("external key was not loaded: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRepository struct {
|
||||||
|
queued []QueuedEvent
|
||||||
|
completed []Completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*fakeRepository) AuditRelayReady(context.Context) error { return nil }
|
||||||
|
func (f *fakeRepository) ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error) {
|
||||||
|
return f.queued, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepository) CompleteAuditRelayBatch(_ context.Context, _ string, values []Completion) error {
|
||||||
|
f.completed = append([]Completion(nil), values...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSender struct {
|
||||||
|
results []Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeSender) Send(context.Context, []Envelope) ([]Result, error) { return f.results, f.err }
|
||||||
|
|
||||||
|
func TestWorkerDispositionAndBackoff(t *testing.T) {
|
||||||
|
first := "audit_00000000000000000000000000000001"
|
||||||
|
second := "audit_00000000000000000000000000000002"
|
||||||
|
repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 10}}}
|
||||||
|
code := "schema_invalid"
|
||||||
|
worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}, {EventID: second, Status: "rejected", ErrorCode: &code}}}, "worker")
|
||||||
|
if err := worker.RelayOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if repository.completed[0].Disposition != Delivered || repository.completed[1].Disposition != DeadLetter {
|
||||||
|
t.Fatalf("unexpected dispositions: %+v", repository.completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.completed = nil
|
||||||
|
worker.sender = fakeSender{err: errors.New("network")}
|
||||||
|
if err := worker.RelayOnce(context.Background()); err == nil {
|
||||||
|
t.Fatal("network failure was hidden")
|
||||||
|
}
|
||||||
|
if repository.completed[0].RetryAfter != time.Second || repository.completed[1].RetryAfter != 300*time.Second {
|
||||||
|
t.Fatalf("retry bounds drifted: %+v", repository.completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerRetriesWholeBatchForIncompleteResponse(t *testing.T) {
|
||||||
|
first := "audit_00000000000000000000000000000001"
|
||||||
|
second := "audit_00000000000000000000000000000002"
|
||||||
|
repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 2}}}
|
||||||
|
worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}}}, "worker")
|
||||||
|
if err := worker.RelayOnce(context.Background()); err == nil {
|
||||||
|
t.Fatal("incomplete response was accepted")
|
||||||
|
}
|
||||||
|
if len(repository.completed) != 2 || repository.completed[0].Disposition != Retry || repository.completed[1].Disposition != Retry {
|
||||||
|
t.Fatalf("incomplete response partially completed the batch: %+v", repository.completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesOf(size int, value byte) []byte {
|
||||||
|
result := make([]byte, size)
|
||||||
|
for index := range result {
|
||||||
|
result[index] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package auditrelay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HeaderKeyID = "X-YoVision-Key-Id"
|
||||||
|
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||||
|
HeaderNonce = "X-YoVision-Nonce"
|
||||||
|
HeaderSignature = "X-YoVision-Signature"
|
||||||
|
RelayPath = "/internal/v1/audit-events:batch"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
endpoint *url.URL
|
||||||
|
keyID string
|
||||||
|
secret []byte
|
||||||
|
httpClient *http.Client
|
||||||
|
now func() time.Time
|
||||||
|
nonce func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(rawURL, keyID string, secret []byte, client *http.Client) (*Client, error) {
|
||||||
|
endpoint, err := ValidateEndpoint(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keyID == "" || len(secret) < 32 {
|
||||||
|
return nil, errors.New("audit relay key ID and 32-byte secret are required")
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
return &Client{endpoint: endpoint, keyID: keyID, secret: append([]byte(nil), secret...), httpClient: client, now: time.Now, nonce: randomNonce}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateEndpoint(rawURL string) (*url.URL, error) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" || parsed.Path != RelayPath || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||||
|
return nil, errors.New("invalid Bell audit relay URL")
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())
|
||||||
|
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
|
||||||
|
return nil, errors.New("Bell audit relay URL requires HTTPS outside loopback")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanonicalString(method, path, timestamp, nonce string, body []byte) string {
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Signature(secret []byte, canonical string) string {
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
_, _ = mac.Write([]byte(canonical))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomNonce() (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Send(ctx context.Context, events []Envelope) ([]Result, error) {
|
||||||
|
if len(events) < 1 || len(events) > MaxBatchSize {
|
||||||
|
return nil, errors.New("audit relay batch must contain 1 to 100 events")
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(BatchRequest{Events: events})
|
||||||
|
if err != nil || len(body) > MaxBodyBytes {
|
||||||
|
return nil, errors.New("encode audit relay batch")
|
||||||
|
}
|
||||||
|
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
timestamp := strconv.FormatInt(c.now().UTC().Unix(), 10)
|
||||||
|
nonce, err := c.nonce()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("generate audit relay nonce")
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("create audit relay request")
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set(HeaderKeyID, c.keyID)
|
||||||
|
request.Header.Set(HeaderTimestamp, timestamp)
|
||||||
|
request.Header.Set(HeaderNonce, nonce)
|
||||||
|
request.Header.Set(HeaderSignature, Signature(c.secret, CanonicalString(http.MethodPost, RelayPath, timestamp, nonce, body)))
|
||||||
|
response, err := c.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("send audit relay request")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("Bell audit relay returned HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(response.Body, MaxBodyBytes+1))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var decoded BatchResponse
|
||||||
|
if err := decoder.Decode(&decoded); err != nil {
|
||||||
|
return nil, errors.New("decode audit relay response")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New("audit relay response contains trailing data")
|
||||||
|
}
|
||||||
|
return decoded.Results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package auditrelay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||||
|
|
||||||
|
type keyFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Keys []struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
Secret string `json:"secret_base64url"`
|
||||||
|
} `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadKey(path, keyID string) ([]byte, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("read audit relay key file")
|
||||||
|
}
|
||||||
|
var document keyFile
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
|
||||||
|
return nil, errors.New("invalid audit relay key file")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New("invalid audit relay key file")
|
||||||
|
}
|
||||||
|
values := make(map[string][]byte, len(document.Keys))
|
||||||
|
for _, candidate := range document.Keys {
|
||||||
|
secret, err := base64.RawURLEncoding.DecodeString(candidate.Secret)
|
||||||
|
if err != nil || !keyIDPattern.MatchString(candidate.KeyID) || len(secret) < 32 {
|
||||||
|
return nil, errors.New("invalid audit relay secret")
|
||||||
|
}
|
||||||
|
if _, duplicate := values[candidate.KeyID]; duplicate {
|
||||||
|
return nil, errors.New("duplicate audit relay key ID")
|
||||||
|
}
|
||||||
|
values[candidate.KeyID] = secret
|
||||||
|
}
|
||||||
|
if secret, exists := values[keyID]; exists {
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("audit relay key ID not found")
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package auditrelay delivers Sense-owned audit facts to Bell without sharing databases.
|
||||||
|
package auditrelay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxBatchSize = 100
|
||||||
|
MaxBodyBytes = 1 << 20
|
||||||
|
LeaseDuration = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Actor struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectionVersions struct {
|
||||||
|
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||||
|
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Actor Actor `json:"actor"`
|
||||||
|
Reason *string `json:"reason"`
|
||||||
|
TraceID *string `json:"trace_id"`
|
||||||
|
AggregateGeneration int64 `json:"aggregate_generation"`
|
||||||
|
ProjectionVersions ProjectionVersions `json:"projection_versions"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Event Event `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchRequest struct {
|
||||||
|
Events []Envelope `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ErrorCode *string `json:"error_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchResponse struct {
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueuedEvent struct {
|
||||||
|
Envelope
|
||||||
|
LeaseToken int64
|
||||||
|
AttemptCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Disposition string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Delivered Disposition = "delivered"
|
||||||
|
DeadLetter Disposition = "dead_letter"
|
||||||
|
Retry Disposition = "retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Completion struct {
|
||||||
|
EventID string
|
||||||
|
LeaseToken int64
|
||||||
|
Disposition Disposition
|
||||||
|
ErrorCode string
|
||||||
|
RetryAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrLeaseLost = errors.New("audit relay lease lost")
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
AuditRelayReady(context.Context) error
|
||||||
|
ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error)
|
||||||
|
CompleteAuditRelayBatch(context.Context, string, []Completion) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package auditrelay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stableErrorCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||||
|
|
||||||
|
type Sender interface {
|
||||||
|
Send(context.Context, []Envelope) ([]Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Worker struct {
|
||||||
|
repository Repository
|
||||||
|
sender Sender
|
||||||
|
owner string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorker(repository Repository, sender Sender, owner string) (*Worker, error) {
|
||||||
|
if repository == nil || sender == nil || owner == "" {
|
||||||
|
return nil, errors.New("audit relay worker dependencies are required")
|
||||||
|
}
|
||||||
|
return &Worker{repository: repository, sender: sender, owner: owner}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RetryDelay(attempt int) time.Duration {
|
||||||
|
if attempt < 1 {
|
||||||
|
attempt = 1
|
||||||
|
}
|
||||||
|
if attempt > 9 {
|
||||||
|
return 300 * time.Second
|
||||||
|
}
|
||||||
|
delay := time.Second << (attempt - 1)
|
||||||
|
if delay > 300*time.Second {
|
||||||
|
return 300 * time.Second
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) RelayOnce(ctx context.Context) error {
|
||||||
|
queued, err := w.repository.ClaimAuditRelayBatch(ctx, w.owner, MaxBatchSize, LeaseDuration)
|
||||||
|
if err != nil || len(queued) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
events := make([]Envelope, len(queued))
|
||||||
|
for index := range queued {
|
||||||
|
events[index] = queued[index].Envelope
|
||||||
|
}
|
||||||
|
results, sendErr := w.sender.Send(ctx, events)
|
||||||
|
if sendErr != nil {
|
||||||
|
completions := make([]Completion, len(queued))
|
||||||
|
for index, value := range queued {
|
||||||
|
completions[index] = Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Retry, ErrorCode: "delivery_failed", RetryAfter: RetryDelay(value.AttemptCount)}
|
||||||
|
}
|
||||||
|
if err := w.repository.CompleteAuditRelayBatch(ctx, w.owner, completions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
|
if len(results) != len(queued) {
|
||||||
|
return w.retryAll(ctx, queued, "invalid_response")
|
||||||
|
}
|
||||||
|
expected := make(map[string]bool, len(queued))
|
||||||
|
for _, value := range queued {
|
||||||
|
expected[value.Event.EventID] = true
|
||||||
|
}
|
||||||
|
byID := make(map[string]Result, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
validStatus := ((result.Status == "accepted" || result.Status == "duplicate") && result.ErrorCode == nil) ||
|
||||||
|
(result.Status == "rejected" && result.ErrorCode != nil && stableErrorCode.MatchString(*result.ErrorCode))
|
||||||
|
if !expected[result.EventID] || !validStatus {
|
||||||
|
return w.retryAll(ctx, queued, "invalid_response")
|
||||||
|
}
|
||||||
|
if _, duplicate := byID[result.EventID]; duplicate {
|
||||||
|
return w.retryAll(ctx, queued, "invalid_response")
|
||||||
|
}
|
||||||
|
byID[result.EventID] = result
|
||||||
|
}
|
||||||
|
completions := make([]Completion, 0, len(queued))
|
||||||
|
for _, value := range queued {
|
||||||
|
result, ok := byID[value.Event.EventID]
|
||||||
|
if !ok {
|
||||||
|
return w.retryAll(ctx, queued, "invalid_response")
|
||||||
|
}
|
||||||
|
switch result.Status {
|
||||||
|
case "accepted", "duplicate":
|
||||||
|
completions = append(completions, Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Delivered})
|
||||||
|
case "rejected":
|
||||||
|
completions = append(completions, Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: DeadLetter, ErrorCode: *result.ErrorCode})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.repository.CompleteAuditRelayBatch(ctx, w.owner, completions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) retryAll(ctx context.Context, queued []QueuedEvent, code string) error {
|
||||||
|
values := make([]Completion, len(queued))
|
||||||
|
for index, value := range queued {
|
||||||
|
values[index] = Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Retry, ErrorCode: code, RetryAfter: RetryDelay(value.AttemptCount)}
|
||||||
|
}
|
||||||
|
if err := w.repository.CompleteAuditRelayBatch(ctx, w.owner, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("audit relay %s", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Second
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if err := w.RelayOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ const (
|
|||||||
defaultOrphanScanPeriod = time.Minute
|
defaultOrphanScanPeriod = time.Minute
|
||||||
defaultONVIFMode = "disabled"
|
defaultONVIFMode = "disabled"
|
||||||
defaultControlAuthMode = "static-sha256"
|
defaultControlAuthMode = "static-sha256"
|
||||||
|
defaultAuditRelayPeriod = time.Second
|
||||||
|
defaultConsoleWebRTCURL = "http://127.0.0.1:8889"
|
||||||
)
|
)
|
||||||
|
|
||||||
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||||
@@ -53,6 +55,13 @@ type Config struct {
|
|||||||
ControlAuthFile string
|
ControlAuthFile string
|
||||||
ControlCursorKeyFile string
|
ControlCursorKeyFile string
|
||||||
ControlAllowInsecureHTTP bool
|
ControlAllowInsecureHTTP bool
|
||||||
|
ConsoleEnabled bool
|
||||||
|
ConsoleWebRTCBaseURL string
|
||||||
|
AuditRelayEnabled bool
|
||||||
|
AuditRelayURL string
|
||||||
|
AuditRelayKeyFile string
|
||||||
|
AuditRelayKeyID string
|
||||||
|
AuditRelayInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -92,6 +101,18 @@ func Load() (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
auditRelayInterval, err := durationEnv("SENSE_AUDIT_RELAY_INTERVAL", defaultAuditRelayPeriod)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
metricsEnabled, err := boolEnv("SENSE_METRICS_ENABLED", true)
|
metricsEnabled, err := boolEnv("SENSE_METRICS_ENABLED", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -130,6 +151,13 @@ func Load() (Config, error) {
|
|||||||
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
|
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
|
||||||
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
|
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
|
||||||
ControlAllowInsecureHTTP: controlAllowInsecure,
|
ControlAllowInsecureHTTP: controlAllowInsecure,
|
||||||
|
ConsoleEnabled: consoleEnabled,
|
||||||
|
ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL),
|
||||||
|
AuditRelayEnabled: auditRelayEnabled,
|
||||||
|
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
|
||||||
|
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""),
|
||||||
|
AuditRelayKeyID: stringEnv("SENSE_AUDIT_RELAY_KEY_ID", ""),
|
||||||
|
AuditRelayInterval: auditRelayInterval,
|
||||||
}
|
}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -228,6 +256,51 @@ func (c Config) Validate() error {
|
|||||||
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
|
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.ConsoleEnabled {
|
||||||
|
if !c.ControlAPIEnabled {
|
||||||
|
return fmt.Errorf("Sense console requires SENSE_CONTROL_API_ENABLED=true")
|
||||||
|
}
|
||||||
|
if !isLoopback {
|
||||||
|
return fmt.Errorf("Sense console requires an explicit loopback SENSE_HTTP_ADDR")
|
||||||
|
}
|
||||||
|
previewURL, err := url.Parse(c.ConsoleWebRTCBaseURL)
|
||||||
|
if err != nil || previewURL.Host == "" ||
|
||||||
|
(previewURL.Scheme != "http" && previewURL.Scheme != "https") ||
|
||||||
|
previewURL.User != nil || previewURL.RawQuery != "" || previewURL.Fragment != "" ||
|
||||||
|
(previewURL.Path != "" && previewURL.Path != "/") {
|
||||||
|
return fmt.Errorf("invalid SENSE_CONSOLE_WEBRTC_BASE_URL")
|
||||||
|
}
|
||||||
|
previewHost := previewURL.Hostname()
|
||||||
|
previewIP := net.ParseIP(previewHost)
|
||||||
|
if previewHost != "localhost" && (previewIP == nil || !previewIP.IsLoopback()) {
|
||||||
|
return fmt.Errorf("SENSE_CONSOLE_WEBRTC_BASE_URL must use an explicit loopback host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.AuditRelayEnabled {
|
||||||
|
if databaseDriver != postgresDatabaseDriver {
|
||||||
|
return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres")
|
||||||
|
}
|
||||||
|
if c.AuditRelayKeyFile == "" || !filepath.IsAbs(c.AuditRelayKeyFile) {
|
||||||
|
return fmt.Errorf("SENSE_AUDIT_RELAY_KEY_FILE must be an absolute external path")
|
||||||
|
}
|
||||||
|
if !instanceIDPattern.MatchString(c.AuditRelayKeyID) {
|
||||||
|
return fmt.Errorf("invalid SENSE_AUDIT_RELAY_KEY_ID")
|
||||||
|
}
|
||||||
|
if c.AuditRelayInterval < time.Second {
|
||||||
|
return fmt.Errorf("SENSE_AUDIT_RELAY_INTERVAL must be at least 1s")
|
||||||
|
}
|
||||||
|
relayURL, err := url.Parse(c.AuditRelayURL)
|
||||||
|
if err != nil || relayURL.Host == "" || relayURL.Path != "/internal/v1/audit-events:batch" ||
|
||||||
|
relayURL.RawQuery != "" || relayURL.Fragment != "" || relayURL.User != nil {
|
||||||
|
return fmt.Errorf("invalid SENSE_AUDIT_RELAY_URL")
|
||||||
|
}
|
||||||
|
relayHost := relayURL.Hostname()
|
||||||
|
relayIP := net.ParseIP(relayHost)
|
||||||
|
relayLoopback := relayHost == "localhost" || (relayIP != nil && relayIP.IsLoopback())
|
||||||
|
if relayURL.Scheme != "https" && !(relayURL.Scheme == "http" && relayLoopback) {
|
||||||
|
return fmt.Errorf("SENSE_AUDIT_RELAY_URL requires HTTPS outside loopback")
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,3 +166,67 @@ func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T)
|
|||||||
t.Fatalf("explicit non-loopback Control API risk acceptance failed: %v", err)
|
t.Fatalf("explicit non-loopback Control API risk acceptance failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateSenseConsoleSecurityBoundary(t *testing.T) {
|
||||||
|
base := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
|
||||||
|
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second,
|
||||||
|
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
|
||||||
|
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
|
||||||
|
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
|
||||||
|
ConsoleEnabled: true, ConsoleWebRTCBaseURL: "http://127.0.0.1:8889",
|
||||||
|
}
|
||||||
|
if err := base.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid loopback console rejected: %v", err)
|
||||||
|
}
|
||||||
|
withoutControl := base
|
||||||
|
withoutControl.ControlAPIEnabled = false
|
||||||
|
if err := withoutControl.Validate(); err == nil {
|
||||||
|
t.Fatal("console without Control API was accepted")
|
||||||
|
}
|
||||||
|
remoteBind := base
|
||||||
|
remoteBind.HTTPAddress, remoteBind.AllowNonLoopback = "0.0.0.0:8080", true
|
||||||
|
remoteBind.ControlAllowInsecureHTTP = true
|
||||||
|
if err := remoteBind.Validate(); err == nil {
|
||||||
|
t.Fatal("console on non-loopback Sense listener was accepted")
|
||||||
|
}
|
||||||
|
for _, invalidURL := range []string{
|
||||||
|
"http://media.example:8889", "ftp://127.0.0.1:8889", "http://127.0.0.1:8889/path",
|
||||||
|
"http://127.0.0.1:8889?token=hidden", "http://user@127.0.0.1:8889",
|
||||||
|
} {
|
||||||
|
candidate := base
|
||||||
|
candidate.ConsoleWebRTCBaseURL = invalidURL
|
||||||
|
if err := candidate.Validate(); err == nil {
|
||||||
|
t.Fatalf("invalid console WebRTC URL was accepted: %s", invalidURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAuditRelaySecurityBoundary(t *testing.T) {
|
||||||
|
base := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
|
||||||
|
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second,
|
||||||
|
AuditRelayEnabled: true, AuditRelayURL: "http://127.0.0.1:8081/internal/v1/audit-events:batch",
|
||||||
|
AuditRelayKeyFile: filepath.Join(t.TempDir(), "relay-keys.json"), AuditRelayKeyID: "sense-a", AuditRelayInterval: time.Second,
|
||||||
|
}
|
||||||
|
if err := base.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid loopback relay rejected: %v", err)
|
||||||
|
}
|
||||||
|
remoteHTTP := base
|
||||||
|
remoteHTTP.AuditRelayURL = "http://bell.example/internal/v1/audit-events:batch"
|
||||||
|
if err := remoteHTTP.Validate(); err == nil {
|
||||||
|
t.Fatal("remote plaintext relay was accepted")
|
||||||
|
}
|
||||||
|
sqlite := base
|
||||||
|
sqlite.DatabaseDriver, sqlite.DatabaseDSN = "sqlite", "file:test.db"
|
||||||
|
if err := sqlite.Validate(); err == nil {
|
||||||
|
t.Fatal("SQLite audit relay was accepted")
|
||||||
|
}
|
||||||
|
relativeKey := base
|
||||||
|
relativeKey.AuditRelayKeyFile = "relay-keys.json"
|
||||||
|
if err := relativeKey.Validate(); err == nil {
|
||||||
|
t.Fatal("repository-relative relay key was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #06111f;
|
||||||
|
--sidebar: #071522;
|
||||||
|
--surface: #0d2033;
|
||||||
|
--surface-2: #112a40;
|
||||||
|
--surface-3: #16344d;
|
||||||
|
--border: #294762;
|
||||||
|
--text: #f5f9fc;
|
||||||
|
--muted: #a8bbcb;
|
||||||
|
--accent: #55dcc7;
|
||||||
|
--accent-strong: #2bbca9;
|
||||||
|
--accent-ink: #03231f;
|
||||||
|
--info: #6fc6ff;
|
||||||
|
--success: #71e19c;
|
||||||
|
--warning: #ffd16a;
|
||||||
|
--danger: #ff8a95;
|
||||||
|
--focus: #8bdcff;
|
||||||
|
--shadow: 0 18px 48px rgba(0, 0, 0, .26);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { background: var(--bg); }
|
||||||
|
body { margin: 0; min-width: 320px; min-height: 100dvh; background: radial-gradient(circle at 75% -10%, #103150 0, transparent 32rem), var(--bg); color: var(--text); }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button, a, input, select { touch-action: manipulation; }
|
||||||
|
button, a { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button { color: inherit; }
|
||||||
|
a { color: var(--info); }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.skip-link { position: fixed; left: 12px; top: -80px; z-index: 1000; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--bg); font-weight: 700; }
|
||||||
|
.skip-link:focus { top: 8px; }
|
||||||
|
.engineering-banner { min-height: 30px; display: grid; place-items: center; padding: 4px 16px; background: #f5c95b; color: #1e1705; font-size: 12px; font-weight: 800; letter-spacing: .04em; text-align: center; }
|
||||||
|
.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: calc(100dvh - 30px); }
|
||||||
|
|
||||||
|
.sidebar { position: sticky; top: 0; height: calc(100dvh - 30px); display: flex; flex-direction: column; padding: 28px 14px 18px; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--sidebar) 95%, transparent); }
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; padding: 8px 8px 28px; }
|
||||||
|
.brand strong, .brand small { display: block; }
|
||||||
|
.brand strong { font-size: 17px; }
|
||||||
|
.brand small { color: var(--muted); font-size: 12px; }
|
||||||
|
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), #58a9ff); color: #06222a; font-weight: 900; box-shadow: 0 9px 24px rgba(85, 220, 199, .2); }
|
||||||
|
.primary-nav { display: grid; gap: 6px; }
|
||||||
|
.primary-nav button, .mobile-nav button { border: 0; cursor: pointer; }
|
||||||
|
.primary-nav button { min-height: 48px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; padding: 0 12px; border-radius: 12px; background: transparent; color: var(--muted); text-align: left; transition: background-color .18s ease, color .18s ease; }
|
||||||
|
.primary-nav button:hover { background: var(--surface); color: var(--text); }
|
||||||
|
.primary-nav button[aria-current="page"] { background: #123d3a; color: var(--text); box-shadow: inset 3px 0 var(--accent); }
|
||||||
|
.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: #d6e5ef; font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||||
|
.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 9px; padding: 13px; border: 1px solid var(--border); border-radius: 12px; color: var(--muted); font-size: 12px; }
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #73879a; box-shadow: 0 0 0 4px rgba(115, 135, 154, .13); }
|
||||||
|
.sidebar-foot.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px rgba(113, 225, 156, .13); }
|
||||||
|
|
||||||
|
.workspace { min-width: 0; }
|
||||||
|
.topbar { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 28px; border-bottom: 1px solid var(--border); background: rgba(8, 24, 39, .78); backdrop-filter: blur(18px); }
|
||||||
|
.topbar strong, .topbar .eyebrow { display: block; }
|
||||||
|
.topbar strong { margin-top: 2px; }
|
||||||
|
.topbar-actions, .heading-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.eyebrow { color: var(--info); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.connection-pill { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 13px; }
|
||||||
|
.connection-pill > .connection-dot { width: 7px; height: 7px; border-radius: 50%; background: #718497; }
|
||||||
|
.connection-pill.connected { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
|
||||||
|
.connection-pill.connected > .connection-dot { background: var(--success); }
|
||||||
|
|
||||||
|
.degraded-banner { display: flex; align-items: center; gap: 12px; padding: 12px 28px; border-bottom: 1px solid rgba(255, 138, 149, .38); background: rgba(96, 26, 39, .82); color: #ffe7ea; }
|
||||||
|
.degraded-banner span { flex: 1; color: #ffc4ca; }
|
||||||
|
main { padding: 32px clamp(18px, 3vw, 42px) 80px; }
|
||||||
|
.view { max-width: 1440px; margin: 0 auto; }
|
||||||
|
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; }
|
||||||
|
h1, h2, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: 4px; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -.025em; }
|
||||||
|
h2 { margin-bottom: 4px; font-size: 17px; }
|
||||||
|
.page-heading p, .panel-heading p { margin-bottom: 0; color: var(--muted); }
|
||||||
|
|
||||||
|
.button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; text-decoration: none; font-weight: 700; font-size: 13px; transition: background-color .18s ease, border-color .18s ease, opacity .18s ease; }
|
||||||
|
.button.primary { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
.button.primary:hover { background: #7ae7d5; }
|
||||||
|
.button.secondary { border-color: var(--border); background: var(--surface); color: var(--text); }
|
||||||
|
.button.secondary:hover, .button.ghost:hover { border-color: #48708f; background: var(--surface-2); }
|
||||||
|
.button.ghost { border-color: transparent; background: transparent; color: var(--info); }
|
||||||
|
.button:disabled { cursor: not-allowed; opacity: .42; }
|
||||||
|
|
||||||
|
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
|
||||||
|
.metric-card, .panel { border: 1px solid var(--border); border-radius: 15px; background: linear-gradient(145deg, rgba(17, 42, 64, .82), rgba(10, 29, 47, .9)); box-shadow: var(--shadow); }
|
||||||
|
.metric-card { min-height: 140px; display: flex; flex-direction: column; justify-content: center; padding: 20px; }
|
||||||
|
.metric-card > span { color: var(--muted); font-size: 13px; }
|
||||||
|
.metric-card strong { margin: 7px 0 2px; font-size: 30px; font-variant-numeric: tabular-nums; }
|
||||||
|
.metric-card small { color: var(--muted); }
|
||||||
|
.metric-card.warning strong { color: var(--warning); }
|
||||||
|
.content-grid, .operations-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); gap: 18px; }
|
||||||
|
.panel { padding: 18px; }
|
||||||
|
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.health-list { display: grid; gap: 8px; }
|
||||||
|
.health-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 13px; border: 1px solid rgba(74, 111, 140, .56); border-radius: 11px; background: rgba(4, 17, 29, .35); }
|
||||||
|
.health-item strong, .health-item small { display: block; }
|
||||||
|
.health-item small { margin-top: 2px; color: var(--muted); }
|
||||||
|
.health-item .value { color: var(--warning); font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.empty-compact { min-height: 100px; display: grid; place-items: center; padding: 18px; border: 1px dashed var(--border); border-radius: 11px; color: var(--muted); text-align: center; }
|
||||||
|
.boundary-card { position: relative; overflow: hidden; }
|
||||||
|
.boundary-card::after { content: ""; position: absolute; right: -45px; top: -45px; width: 130px; height: 130px; border-radius: 50%; background: rgba(85, 220, 199, .09); }
|
||||||
|
.boundary-label, .feature-state { display: inline-flex; padding: 4px 8px; border-radius: 999px; background: rgba(85, 220, 199, .12); color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .05em; }
|
||||||
|
.boundary-card h2 { margin-top: 14px; }
|
||||||
|
.boundary-card p, .boundary-card li { color: var(--muted); }
|
||||||
|
.boundary-card ul { padding-left: 18px; margin-bottom: 0; }
|
||||||
|
|
||||||
|
.monitor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.monitor-toolbar strong, .monitor-toolbar span { display: block; }
|
||||||
|
.monitor-toolbar span { color: var(--muted); font-size: 13px; }
|
||||||
|
.preview-grid { min-height: 500px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.preview-empty { grid-column: 1 / -1; min-height: 500px; display: grid; align-content: center; justify-items: center; padding: 30px; border: 1px dashed var(--border); border-radius: 15px; background: rgba(7, 20, 33, .55); color: var(--muted); text-align: center; }
|
||||||
|
.preview-empty > span { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--accent); font-size: 24px; }
|
||||||
|
.preview-empty strong { color: var(--text); font-size: 18px; }
|
||||||
|
.preview-empty p { margin: 4px 0 0; }
|
||||||
|
.preview-card { overflow: hidden; border: 1px solid var(--border); border-radius: 15px; background: #02070b; box-shadow: var(--shadow); }
|
||||||
|
.preview-frame { position: relative; aspect-ratio: 16 / 9; background: #02070b; }
|
||||||
|
.preview-frame iframe { width: 100%; height: 100%; display: block; border: 0; }
|
||||||
|
.preview-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--surface); }
|
||||||
|
.preview-meta strong, .preview-meta small { display: block; }
|
||||||
|
.preview-meta small { color: var(--muted); }
|
||||||
|
|
||||||
|
.filter-panel { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(140px, .7fr)) auto; align-items: end; gap: 12px; margin-bottom: 14px; }
|
||||||
|
label > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||||
|
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: #081927; color: var(--text); }
|
||||||
|
input::placeholder { color: #72899c; }
|
||||||
|
select { cursor: pointer; }
|
||||||
|
.table-panel { padding: 0; overflow: hidden; }
|
||||||
|
.table-status { padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; }
|
||||||
|
.device-table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; min-width: 900px; }
|
||||||
|
th, td { padding: 13px 14px; border-bottom: 1px solid rgba(41, 71, 98, .75); text-align: left; vertical-align: middle; }
|
||||||
|
th { color: var(--muted); font-size: 11px; letter-spacing: .04em; }
|
||||||
|
td { font-size: 13px; }
|
||||||
|
td strong, td small { display: block; }
|
||||||
|
td small { margin-top: 2px; color: var(--muted); }
|
||||||
|
tbody tr:hover { background: rgba(34, 66, 92, .34); }
|
||||||
|
.select-preview { width: 44px; height: 44px; display: grid; place-items: center; border: 0; }
|
||||||
|
.select-preview input { width: 18px; min-height: auto; height: 18px; accent-color: var(--accent); }
|
||||||
|
.status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; white-space: nowrap; }
|
||||||
|
.status-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.status-badge.success { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
|
||||||
|
.status-badge.warning { color: var(--warning); border-color: rgba(255, 209, 106, .35); background: rgba(255, 209, 106, .08); }
|
||||||
|
.status-badge.danger { color: var(--danger); border-color: rgba(255, 138, 149, .35); background: rgba(255, 138, 149, .08); }
|
||||||
|
.device-cards { display: none; }
|
||||||
|
.pagination { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 14px; }
|
||||||
|
.pagination > span { color: var(--muted); font-size: 13px; }
|
||||||
|
.pagination > div { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.honest-empty { max-width: 820px; min-height: 340px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(28px, 5vw, 60px); }
|
||||||
|
.honest-empty h2 { margin: 18px 0 8px; font-size: 24px; }
|
||||||
|
.honest-empty > p { max-width: 70ch; color: var(--muted); }
|
||||||
|
.next-list { width: 100%; display: grid; gap: 8px; margin-top: 14px; }
|
||||||
|
.next-list span { padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); }
|
||||||
|
.next-list strong { margin-right: 10px; color: var(--text); }
|
||||||
|
|
||||||
|
dialog { width: min(520px, calc(100% - 28px)); padding: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 30px 90px rgba(0, 0, 0, .6); }
|
||||||
|
dialog::backdrop { background: rgba(0, 7, 13, .72); backdrop-filter: blur(4px); }
|
||||||
|
dialog form { padding: 22px; }
|
||||||
|
.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||||
|
.dialog-heading h2 { margin-top: 5px; font-size: 22px; }
|
||||||
|
.icon-button { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: 10px; background: transparent; cursor: pointer; font-size: 20px; }
|
||||||
|
dialog p { color: var(--muted); }
|
||||||
|
dialog label { display: block; margin-top: 14px; }
|
||||||
|
.form-error { margin-top: 12px; padding: 10px 12px; border: 1px solid rgba(255, 138, 149, .5); border-radius: 9px; background: rgba(120, 28, 43, .28); color: #ffdce0; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||||
|
.toast { position: fixed; right: 20px; bottom: 24px; z-index: 200; max-width: min(420px, calc(100% - 40px)); padding: 13px 16px; border: 1px solid var(--border); border-radius: 11px; background: #173148; color: var(--text); box-shadow: var(--shadow); }
|
||||||
|
.mobile-nav { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.content-grid, .operations-grid { grid-template-columns: 1fr; }
|
||||||
|
.filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.engineering-banner { min-height: 38px; }
|
||||||
|
.shell { display: block; min-height: calc(100dvh - 38px); }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.topbar { min-height: 72px; padding: 12px 16px; }
|
||||||
|
.connection-pill { display: none; }
|
||||||
|
.topbar-actions .button { min-height: 44px; }
|
||||||
|
.degraded-banner { align-items: flex-start; flex-wrap: wrap; padding: 12px 16px; }
|
||||||
|
.degraded-banner span { flex-basis: calc(100% - 10px); }
|
||||||
|
main { padding: 24px 14px 92px; }
|
||||||
|
.page-heading { align-items: stretch; flex-direction: column; }
|
||||||
|
.page-heading .button, .heading-actions { width: 100%; }
|
||||||
|
.heading-actions .button { flex: 1; }
|
||||||
|
.metric-grid { grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||||
|
.metric-card { min-height: 126px; padding: 15px; }
|
||||||
|
.metric-card strong { font-size: 25px; }
|
||||||
|
.panel { padding: 15px; }
|
||||||
|
.panel-heading, .monitor-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
.preview-grid { grid-template-columns: 1fr; min-height: 420px; }
|
||||||
|
.preview-empty { min-height: 420px; }
|
||||||
|
.filter-panel { grid-template-columns: 1fr; }
|
||||||
|
input, select { min-height: 46px; font-size: 16px; }
|
||||||
|
.device-table-wrap { display: none; }
|
||||||
|
.device-cards { display: grid; gap: 10px; padding: 12px; }
|
||||||
|
.device-card { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 18, 30, .45); }
|
||||||
|
.device-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||||
|
.device-card strong, .device-card small { display: block; }
|
||||||
|
.device-card small { color: var(--muted); }
|
||||||
|
.device-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0 0; }
|
||||||
|
.device-card dt { color: var(--muted); font-size: 11px; }
|
||||||
|
.device-card dd { margin: 2px 0 0; font-size: 13px; }
|
||||||
|
.pagination { align-items: stretch; flex-direction: column; }
|
||||||
|
.pagination > div { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
|
.pagination .button { min-height: 44px; }
|
||||||
|
.mobile-nav { position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 100; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; border: 1px solid var(--border); border-radius: 15px; background: rgba(8, 24, 39, .96); box-shadow: 0 15px 40px rgba(0, 0, 0, .5); backdrop-filter: blur(18px); }
|
||||||
|
.mobile-nav button { min-height: 48px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 12px; }
|
||||||
|
.mobile-nav button[aria-current="page"] { background: #153c3a; color: var(--accent); font-weight: 800; }
|
||||||
|
dialog form { padding: 18px; }
|
||||||
|
.dialog-actions { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
|
.dialog-actions .button { min-height: 46px; }
|
||||||
|
.toast { left: 14px; right: 14px; bottom: 78px; max-width: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.metric-grid { grid-template-columns: 1fr; }
|
||||||
|
.topbar { gap: 8px; }
|
||||||
|
.topbar strong { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
token: "",
|
||||||
|
siteId: "",
|
||||||
|
config: { page_size: 16, max_active_previews: 4, webrtc_base_url: "" },
|
||||||
|
items: [],
|
||||||
|
quota: null,
|
||||||
|
cursors: [null],
|
||||||
|
pageIndex: 0,
|
||||||
|
nextCursor: null,
|
||||||
|
selected: new Map(),
|
||||||
|
previewing: new Set(),
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
modality: { video: "视频", radar: "雷达", contact: "门磁", button: "按钮", wearable: "可穿戴", other: "其他" },
|
||||||
|
desired: { enabled: "启用", disabled: "停用" },
|
||||||
|
actual: { online: "在线", offline: "离线", failed: "失败", pending: "收敛中" },
|
||||||
|
adapter: { ready: "适配器就绪", pending: "适配器处理中", adapter_not_ready: "适配器未交付", authentication_failed: "认证失败", unavailable: "适配器不可用" },
|
||||||
|
capability: { video_capture: "成像", audio_capture: "音频", spatial_rule: "空间配置", telemetry: "遥测" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = $("#toast");
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.hidden = false;
|
||||||
|
window.clearTimeout(showToast.timer);
|
||||||
|
showToast.timer = window.setTimeout(() => { toast.hidden = true; }, 4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(selector, value) {
|
||||||
|
const target = $(selector);
|
||||||
|
if (target) target.textContent = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewEligible(device) {
|
||||||
|
return Array.isArray(device.capabilities) && device.capabilities.includes("video_capture") &&
|
||||||
|
device.desired_state === "enabled" && device.actual_state === "online" && device.converged === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attentionNeeded(device) {
|
||||||
|
return device.converged !== true || device.actual_state === "offline" || device.actual_state === "failed" ||
|
||||||
|
device.adapter_status === "authentication_failed" || device.adapter_status === "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(device) {
|
||||||
|
if (device.actual_state === "online" && device.converged) return { text: "在线 · 已收敛", tone: "success" };
|
||||||
|
if (device.actual_state === "failed" || device.adapter_status === "authentication_failed") return { text: labels.adapter[device.adapter_status] || "失败", tone: "danger" };
|
||||||
|
if (device.actual_state === "offline") return { text: "离线", tone: "danger" };
|
||||||
|
return { text: device.converged ? (labels.actual[device.actual_state] || "未知") : "等待收敛", tone: "warning" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusBadge(device) {
|
||||||
|
const value = statusFor(device);
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = `status-badge ${value.tone}`;
|
||||||
|
badge.textContent = value.text;
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnected(connected) {
|
||||||
|
const pill = $("#connectionPill");
|
||||||
|
const foot = $(".sidebar-foot");
|
||||||
|
pill.classList.toggle("connected", connected);
|
||||||
|
foot.classList.toggle("connected", connected);
|
||||||
|
setText("#connectionText", connected ? "已连接" : "未连接");
|
||||||
|
setText("#sessionState", connected ? "会话仅驻留当前页面" : "尚未建立会话");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
state.loading = loading;
|
||||||
|
$("#refreshDevices").disabled = loading || !state.token;
|
||||||
|
$("#refreshOperations").disabled = loading || !state.token;
|
||||||
|
$("#retryLoad").disabled = loading || !state.token;
|
||||||
|
$("#prevPage").disabled = loading || state.pageIndex === 0;
|
||||||
|
$("#nextPage").disabled = loading || !state.nextCursor;
|
||||||
|
setText("#deviceTableStatus", loading ? "正在读取最新状态…" : state.token ? "已读取当前页真实设备状态。" : "请先建立会话。");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDegraded(title, message) {
|
||||||
|
setText("#degradedTitle", title);
|
||||||
|
setText("#degradedMessage", message);
|
||||||
|
$("#degradedBanner").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDegraded() {
|
||||||
|
$("#degradedBanner").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: { Authorization: `Bearer ${state.token}`, Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
let payload = null;
|
||||||
|
try { payload = await response.json(); } catch (_) { payload = null; }
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(payload && payload.code ? payload.code : `http_${response.status}`);
|
||||||
|
error.status = response.status;
|
||||||
|
error.payload = payload;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevicePath(cursor) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", String(state.config.page_size));
|
||||||
|
const modality = $("#modalityFilter").value;
|
||||||
|
const desired = $("#desiredFilter").value;
|
||||||
|
const actual = $("#actualFilter").value;
|
||||||
|
if (modality) params.set("modality", modality);
|
||||||
|
if (desired) params.set("desired_state", desired);
|
||||||
|
if (actual) params.set("actual_state", actual);
|
||||||
|
if (cursor) params.set("cursor", cursor);
|
||||||
|
return `/api/v1/sites/${encodeURIComponent(state.siteId)}/devices?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices(options = {}) {
|
||||||
|
if (!state.token || state.loading) return;
|
||||||
|
const cursor = state.cursors[state.pageIndex] || null;
|
||||||
|
setLoading(true);
|
||||||
|
clearDegraded();
|
||||||
|
try {
|
||||||
|
const payload = await apiFetch(buildDevicePath(cursor));
|
||||||
|
state.items = Array.isArray(payload.items) ? payload.items : [];
|
||||||
|
state.quota = payload.quota || null;
|
||||||
|
state.nextCursor = payload.page && payload.page.has_more ? payload.page.next_cursor : null;
|
||||||
|
for (const device of state.items) {
|
||||||
|
if (state.selected.has(device.id)) {
|
||||||
|
if (isPreviewEligible(device)) state.selected.set(device.id, device);
|
||||||
|
else state.selected.delete(device.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConnected(true);
|
||||||
|
renderAll();
|
||||||
|
if (options.announce !== false) showToast(`已刷新第 ${state.pageIndex + 1} 页,共 ${state.items.length} 台设备`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleLoadError(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoadError(error) {
|
||||||
|
let title = "无法读取最新状态";
|
||||||
|
let message = "未知不会显示为在线。请检查 Sense 进程、网络和依赖后重试。";
|
||||||
|
if (error.status === 401) {
|
||||||
|
state.token = "";
|
||||||
|
setConnected(false);
|
||||||
|
title = "会话无效或已过期";
|
||||||
|
message = "Bearer token 已从页面内存清除,请重新建立会话。";
|
||||||
|
} else if (error.status === 403) {
|
||||||
|
title = "当前角色没有设备读取权限";
|
||||||
|
message = "页面不会尝试绕过权限;请使用具备 devices:read 的会话。";
|
||||||
|
} else if (error.status === 404) {
|
||||||
|
title = "无法访问该站点";
|
||||||
|
message = "目标不存在或当前会话无权访问,页面不会区分这两种情况。";
|
||||||
|
} else if (error.payload && ["quota_projection_unavailable", "area_policy_unavailable"].includes(error.payload.code)) {
|
||||||
|
title = "策略投影暂时不可用";
|
||||||
|
message = "已有链路不会因此被静默停用;相关新写入应保持禁用。";
|
||||||
|
}
|
||||||
|
showDegraded(title, message);
|
||||||
|
state.items = [];
|
||||||
|
state.quota = null;
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleItems() {
|
||||||
|
const query = $("#deviceSearch").value.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
if (!query) return state.items;
|
||||||
|
return state.items.filter((device) => `${device.name} ${device.serial_number}`.toLocaleLowerCase("zh-CN").includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderDevices();
|
||||||
|
renderMetrics();
|
||||||
|
renderHealth();
|
||||||
|
renderOperations();
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics() {
|
||||||
|
const used = state.quota && Number.isInteger(state.quota.used_video_channels) ? state.quota.used_video_channels : null;
|
||||||
|
const max = state.quota && Number.isInteger(state.quota.max_video_channels) ? state.quota.max_video_channels : null;
|
||||||
|
setText("#quotaMetric", used === null || max === null ? "—" : `${used} / ${max}`);
|
||||||
|
setText("#quotaHint", state.quota ? `投影状态:${state.quota.status || "未知"}` : "连接后读取 Bell 投影");
|
||||||
|
setText("#pageMetric", state.items.length);
|
||||||
|
setText("#convergedMetric", state.items.filter((device) => device.converged === true).length);
|
||||||
|
const attention = state.items.filter(attentionNeeded).length;
|
||||||
|
setText("#attentionMetric", attention);
|
||||||
|
setText("#deviceCount", state.items.length);
|
||||||
|
setText("#issueCount", attention);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTextCell(row, primary, secondary) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = primary || "—";
|
||||||
|
cell.appendChild(strong);
|
||||||
|
if (secondary) {
|
||||||
|
const small = document.createElement("small");
|
||||||
|
small.textContent = secondary;
|
||||||
|
cell.appendChild(small);
|
||||||
|
}
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreviewCheckbox(device) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "select-preview";
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.checked = state.selected.has(device.id);
|
||||||
|
checkbox.disabled = !isPreviewEligible(device);
|
||||||
|
checkbox.setAttribute("aria-label", checkbox.disabled ? `${device.name} 当前不可预览` : `选择 ${device.name} 进行预览`);
|
||||||
|
checkbox.addEventListener("change", () => toggleSelection(device, checkbox));
|
||||||
|
label.appendChild(checkbox);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevices() {
|
||||||
|
const rows = $("#deviceRows");
|
||||||
|
const cards = $("#deviceCards");
|
||||||
|
rows.replaceChildren();
|
||||||
|
cards.replaceChildren();
|
||||||
|
const items = visibleItems();
|
||||||
|
for (const device of items) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
const selectCell = document.createElement("td");
|
||||||
|
selectCell.appendChild(createPreviewCheckbox(device));
|
||||||
|
row.appendChild(selectCell);
|
||||||
|
addTextCell(row, device.name, device.serial_number);
|
||||||
|
addTextCell(row, labels.modality[device.modality] || device.modality, (device.capabilities || []).map((value) => labels.capability[value] || value).join(" / "));
|
||||||
|
const statusCell = document.createElement("td");
|
||||||
|
statusCell.appendChild(createStatusBadge(device));
|
||||||
|
row.appendChild(statusCell);
|
||||||
|
addTextCell(row, labels.desired[device.desired_state] || device.desired_state, labels.actual[device.actual_state] || device.actual_state);
|
||||||
|
addTextCell(row, device.area_id || "—", device.projection_versions && device.projection_versions.area_policy_source_version ? `策略 v${device.projection_versions.area_policy_source_version}` : "策略版本未知");
|
||||||
|
addTextCell(row, formatTime(device.updated_at), device.next_attempt_at ? `重试 ${formatTime(device.next_attempt_at)}` : "");
|
||||||
|
rows.appendChild(row);
|
||||||
|
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "device-card";
|
||||||
|
const cardHead = document.createElement("div");
|
||||||
|
cardHead.className = "device-card-head";
|
||||||
|
const identity = document.createElement("div");
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
const serial = document.createElement("small");
|
||||||
|
name.textContent = device.name;
|
||||||
|
serial.textContent = device.serial_number;
|
||||||
|
identity.append(name, serial);
|
||||||
|
cardHead.append(identity, createPreviewCheckbox(device));
|
||||||
|
const status = createStatusBadge(device);
|
||||||
|
const details = document.createElement("dl");
|
||||||
|
const pairs = [
|
||||||
|
["状态", "", status],
|
||||||
|
["模态", labels.modality[device.modality] || device.modality],
|
||||||
|
["期望 / 实际", `${labels.desired[device.desired_state] || device.desired_state} / ${labels.actual[device.actual_state] || device.actual_state}`],
|
||||||
|
["Area", device.area_id || "—"],
|
||||||
|
];
|
||||||
|
for (const [term, value, node] of pairs) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
const dt = document.createElement("dt");
|
||||||
|
const dd = document.createElement("dd");
|
||||||
|
dt.textContent = term;
|
||||||
|
if (node) dd.appendChild(node); else dd.textContent = value;
|
||||||
|
wrapper.append(dt, dd);
|
||||||
|
details.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
card.append(cardHead, details);
|
||||||
|
cards.appendChild(card);
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty-compact";
|
||||||
|
empty.textContent = state.token ? "当前页没有符合条件的设备。" : "请先建立会话。";
|
||||||
|
cards.appendChild(empty.cloneNode(true));
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.colSpan = 7;
|
||||||
|
cell.appendChild(empty);
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.appendChild(cell);
|
||||||
|
rows.appendChild(row);
|
||||||
|
}
|
||||||
|
setText("#pageLabel", `第 ${state.pageIndex + 1} 页 · 每页 ${state.config.page_size} · 当前显示 ${items.length} 项`);
|
||||||
|
$("#prevPage").disabled = state.loading || state.pageIndex === 0;
|
||||||
|
$("#nextPage").disabled = state.loading || !state.nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(device, checkbox) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!isPreviewEligible(device)) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
showToast("只有在线、已启用且已收敛的视频设备可以预览");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.selected.has(device.id) && state.selected.size >= state.config.max_active_previews) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
showToast(`最多同时选择 ${state.config.max_active_previews} 路预览`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.selected.set(device.id, device);
|
||||||
|
} else {
|
||||||
|
state.selected.delete(device.id);
|
||||||
|
if (state.previewing.has(device.id)) stopPreview(device.id);
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection() {
|
||||||
|
setText("#selectionCount", state.selected.size);
|
||||||
|
setText("#previewCount", `${state.previewing.size}/${state.config.max_active_previews}`);
|
||||||
|
$("#startPreview").disabled = state.selected.size === 0;
|
||||||
|
$("#stopAll").disabled = state.previewing.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewURL(deviceID) {
|
||||||
|
const url = new URL(`${state.config.webrtc_base_url}/devices/${encodeURIComponent(deviceID)}`);
|
||||||
|
url.searchParams.set("controls", "true");
|
||||||
|
url.searchParams.set("muted", "true");
|
||||||
|
url.searchParams.set("autoplay", "true");
|
||||||
|
url.searchParams.set("playsInline", "true");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSelectedPreviews() {
|
||||||
|
if (state.selected.size === 0) return;
|
||||||
|
state.previewing = new Set(state.selected.keys());
|
||||||
|
const grid = $("#previewGrid");
|
||||||
|
grid.replaceChildren();
|
||||||
|
for (const device of state.selected.values()) {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "preview-card";
|
||||||
|
card.dataset.deviceId = device.id;
|
||||||
|
const frame = document.createElement("div");
|
||||||
|
frame.className = "preview-frame";
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.title = `${device.name} 实时预览`;
|
||||||
|
iframe.loading = "eager";
|
||||||
|
iframe.allow = "autoplay; fullscreen";
|
||||||
|
iframe.sandbox = "allow-scripts allow-same-origin";
|
||||||
|
iframe.referrerPolicy = "no-referrer";
|
||||||
|
iframe.src = previewURL(device.id);
|
||||||
|
frame.appendChild(iframe);
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "preview-meta";
|
||||||
|
const identity = document.createElement("div");
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
const note = document.createElement("small");
|
||||||
|
name.textContent = device.name;
|
||||||
|
note.textContent = "MediaMTX WebRTC · 按需读取";
|
||||||
|
identity.append(name, note);
|
||||||
|
const stop = document.createElement("button");
|
||||||
|
stop.type = "button";
|
||||||
|
stop.className = "button secondary";
|
||||||
|
stop.textContent = "停止";
|
||||||
|
stop.addEventListener("click", () => stopPreview(device.id));
|
||||||
|
meta.append(identity, stop);
|
||||||
|
card.append(frame, meta);
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
switchView("monitor");
|
||||||
|
renderSelection();
|
||||||
|
showToast(`已按需启动 ${state.previewing.size} 路预览`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPreview(deviceID) {
|
||||||
|
state.previewing.delete(deviceID);
|
||||||
|
const card = $(`[data-device-id="${CSS.escape(deviceID)}"]`);
|
||||||
|
if (card) {
|
||||||
|
const iframe = card.querySelector("iframe");
|
||||||
|
if (iframe) iframe.removeAttribute("src");
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
if (state.previewing.size === 0) renderPreviewEmpty();
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAllPreviews() {
|
||||||
|
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
|
||||||
|
state.previewing.clear();
|
||||||
|
renderPreviewEmpty();
|
||||||
|
renderSelection();
|
||||||
|
showToast("全部预览已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreviewEmpty() {
|
||||||
|
const grid = $("#previewGrid");
|
||||||
|
grid.replaceChildren();
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "preview-empty";
|
||||||
|
const mark = document.createElement("span");
|
||||||
|
mark.setAttribute("aria-hidden", "true");
|
||||||
|
mark.textContent = "▷";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = "尚未启动预览";
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.textContent = "前往设备页选择最多 4 路运行中的视频设备。";
|
||||||
|
empty.append(mark, title, note);
|
||||||
|
grid.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHealth() {
|
||||||
|
const container = $("#overviewHealth");
|
||||||
|
container.replaceChildren();
|
||||||
|
if (!state.token || state.items.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty-compact";
|
||||||
|
empty.textContent = state.token ? "当前页没有设备事实。" : "建立会话后显示真实状态。";
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groups = [
|
||||||
|
["在线且已收敛", state.items.filter((device) => device.actual_state === "online" && device.converged).length, "设备可用于按需预览"],
|
||||||
|
["等待收敛", state.items.filter((device) => !device.converged).length, "期望 generation 尚未被完整观察"],
|
||||||
|
["离线或失败", state.items.filter((device) => ["offline", "failed"].includes(device.actual_state)).length, "查看重试时间和稳定错误码"],
|
||||||
|
];
|
||||||
|
for (const [title, count, note] of groups) container.appendChild(createHealthItem(title, count, note));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHealthItem(title, count, note) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "health-item";
|
||||||
|
const text = document.createElement("div");
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
const small = document.createElement("small");
|
||||||
|
strong.textContent = title;
|
||||||
|
small.textContent = note;
|
||||||
|
text.append(strong, small);
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.className = "value";
|
||||||
|
value.textContent = String(count);
|
||||||
|
item.append(text, value);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOperations() {
|
||||||
|
const container = $("#operationIssues");
|
||||||
|
container.replaceChildren();
|
||||||
|
const issues = state.items.filter(attentionNeeded);
|
||||||
|
if (issues.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty-compact";
|
||||||
|
empty.textContent = state.token ? "当前页没有已知对账差异;这不代表未加载设备或其他基础设施正常。" : "建立会话后显示。";
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const device of issues) {
|
||||||
|
const message = device.last_error_code ? `错误 ${device.last_error_code}` : `generation ${device.observed_generation || 0} / ${device.generation || 0}`;
|
||||||
|
container.appendChild(createHealthItem(device.name, device.failure_count || 0, device.next_attempt_at ? `${message} · 下次 ${formatTime(device.next_attempt_at)}` : message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(name) {
|
||||||
|
$$(".view").forEach((view) => {
|
||||||
|
const active = view.dataset.page === name;
|
||||||
|
view.hidden = !active;
|
||||||
|
view.classList.toggle("active", active);
|
||||||
|
});
|
||||||
|
$$('[data-view]').forEach((button) => {
|
||||||
|
const active = button.dataset.view === name;
|
||||||
|
button.toggleAttribute("aria-current", active);
|
||||||
|
});
|
||||||
|
$("#main-content").focus({ preventScroll: true });
|
||||||
|
window.scrollTo({ top: 0, behavior: "auto" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPaging() {
|
||||||
|
state.cursors = [null];
|
||||||
|
state.pageIndex = 0;
|
||||||
|
state.nextCursor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const siteInput = $("#siteInput");
|
||||||
|
const tokenInput = $("#tokenInput");
|
||||||
|
const site = siteInput.value.trim();
|
||||||
|
const token = tokenInput.value;
|
||||||
|
const errorBox = $("#contextError");
|
||||||
|
errorBox.hidden = true;
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(site)) {
|
||||||
|
errorBox.textContent = "Site ID 格式无效:只能使用 1~64 位字母、数字、点、下划线、冒号或连字符。";
|
||||||
|
errorBox.hidden = false;
|
||||||
|
siteInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token || token.length > 4096) {
|
||||||
|
errorBox.textContent = "请输入有效的 Bearer token。";
|
||||||
|
errorBox.hidden = false;
|
||||||
|
tokenInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.siteId = site;
|
||||||
|
state.token = token;
|
||||||
|
tokenInput.value = "";
|
||||||
|
setText("#siteContext", site);
|
||||||
|
resetPaging();
|
||||||
|
try {
|
||||||
|
await loadDevices({ announce: false });
|
||||||
|
$("#contextDialog").close();
|
||||||
|
showToast("会话已建立;token 仅驻留当前页面内存");
|
||||||
|
} catch (error) {
|
||||||
|
errorBox.textContent = error.status === 401 ? "认证失败,请检查 token 后重试。" : "无法读取该站点,请按页面提示检查权限或服务状态。";
|
||||||
|
errorBox.hidden = false;
|
||||||
|
$("#contextDialog").showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/sense-console/config", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("config unavailable");
|
||||||
|
state.config = await response.json();
|
||||||
|
if (state.config.page_size !== 16 || state.config.max_active_previews !== 4 || state.config.recording_and_playback !== false) {
|
||||||
|
throw new Error("unsafe console bounds");
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
showDegraded("控制台配置不可用", "页面保持只读且不会尝试连接视频,请检查 Sense 启动配置。");
|
||||||
|
$$("button, input, select").forEach((control) => { control.disabled = true; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnected(false);
|
||||||
|
renderAll();
|
||||||
|
$("#contextDialog").showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
$$('[data-view]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)));
|
||||||
|
$$('[data-view-target]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewTarget)));
|
||||||
|
$("#changeContext").addEventListener("click", () => $("#contextDialog").showModal());
|
||||||
|
$("#closeContext").addEventListener("click", () => $("#contextDialog").close());
|
||||||
|
$("#cancelContext").addEventListener("click", () => $("#contextDialog").close());
|
||||||
|
$("#contextForm").addEventListener("submit", connect);
|
||||||
|
$("#refreshDevices").addEventListener("click", () => loadDevices());
|
||||||
|
$("#refreshOperations").addEventListener("click", () => loadDevices());
|
||||||
|
$("#retryLoad").addEventListener("click", () => state.token ? loadDevices() : $("#contextDialog").showModal());
|
||||||
|
$("#startPreview").addEventListener("click", startSelectedPreviews);
|
||||||
|
$("#stopAll").addEventListener("click", stopAllPreviews);
|
||||||
|
$("#deviceSearch").addEventListener("input", renderDevices);
|
||||||
|
|
||||||
|
for (const selector of ["#modalityFilter", "#desiredFilter", "#actualFilter"]) {
|
||||||
|
$(selector).addEventListener("change", () => {
|
||||||
|
resetPaging();
|
||||||
|
if (state.token) loadDevices();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#clearFilters").addEventListener("click", () => {
|
||||||
|
$("#deviceSearch").value = "";
|
||||||
|
$("#modalityFilter").value = "";
|
||||||
|
$("#desiredFilter").value = "";
|
||||||
|
$("#actualFilter").value = "";
|
||||||
|
resetPaging();
|
||||||
|
if (state.token) loadDevices(); else renderDevices();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#nextPage").addEventListener("click", async () => {
|
||||||
|
if (!state.nextCursor) return;
|
||||||
|
state.cursors[state.pageIndex + 1] = state.nextCursor;
|
||||||
|
state.pageIndex += 1;
|
||||||
|
try { await loadDevices(); } catch (_) { state.pageIndex -= 1; }
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#prevPage").addEventListener("click", async () => {
|
||||||
|
if (state.pageIndex === 0) return;
|
||||||
|
state.pageIndex -= 1;
|
||||||
|
try { await loadDevices(); } catch (_) { state.pageIndex += 1; }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
state.token = "";
|
||||||
|
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
|
||||||
|
});
|
||||||
|
|
||||||
|
initialize();
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>YoVision Sense · 接入运维工作台</title>
|
||||||
|
<link rel="stylesheet" href="/sense-console/app.css">
|
||||||
|
<script defer src="/sense-console/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||||
|
<div class="engineering-banner" role="note">
|
||||||
|
回环工程控制台 · 非录像/回放 · 不代表生产公网入口
|
||||||
|
</div>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar" aria-label="Sense 主导航">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark" aria-hidden="true">S</span>
|
||||||
|
<span><strong>YoVision Sense</strong><small>接入运维工作台</small></span>
|
||||||
|
</div>
|
||||||
|
<nav class="primary-nav">
|
||||||
|
<button type="button" data-view="overview" aria-current="page"><span aria-hidden="true">◫</span>运行总览</button>
|
||||||
|
<button type="button" data-view="monitor"><span aria-hidden="true">▷</span>实时监控 <span class="nav-count" id="previewCount">0/4</span></button>
|
||||||
|
<button type="button" data-view="devices"><span aria-hidden="true">▣</span>设备 <span class="nav-count" id="deviceCount">0</span></button>
|
||||||
|
<button type="button" data-view="onboarding"><span aria-hidden="true">⇧</span>接入任务</button>
|
||||||
|
<button type="button" data-view="operations"><span aria-hidden="true">⌁</span>运维中心 <span class="nav-count" id="issueCount">0</span></button>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span id="sessionState">尚未建立会话</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">当前站点</span>
|
||||||
|
<strong id="siteContext">未选择</strong>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="connection-pill" id="connectionPill"><span class="connection-dot" aria-hidden="true"></span><span id="connectionText">未连接</span></span>
|
||||||
|
<button type="button" class="button secondary" id="changeContext">连接设置</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="degraded-banner" id="degradedBanner" role="status" hidden>
|
||||||
|
<strong id="degradedTitle">无法读取最新状态</strong>
|
||||||
|
<span id="degradedMessage">未知不会显示为在线,请检查会话后重试。</span>
|
||||||
|
<button type="button" class="button ghost" id="retryLoad">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="main-content" tabindex="-1">
|
||||||
|
<section class="view active" data-page="overview" aria-labelledby="overviewTitle">
|
||||||
|
<div class="page-heading">
|
||||||
|
<div><span class="eyebrow">SENSE / OVERVIEW</span><h1 id="overviewTitle">运行总览</h1><p>先看已加载事实,再进入设备或运维中心处理。</p></div>
|
||||||
|
<button type="button" class="button primary" data-view-target="devices">查看设备</button>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid" aria-label="当前页运行指标">
|
||||||
|
<article class="metric-card"><span>视频配额</span><strong id="quotaMetric">—</strong><small id="quotaHint">连接后读取 Bell 投影</small></article>
|
||||||
|
<article class="metric-card"><span>当前页设备</span><strong id="pageMetric">0</strong><small>默认每页 16,不代表站点总数</small></article>
|
||||||
|
<article class="metric-card"><span>当前页已收敛</span><strong id="convergedMetric">0</strong><small>期望态与实际态分开计算</small></article>
|
||||||
|
<article class="metric-card warning"><span>当前页需关注</span><strong id="attentionMetric">0</strong><small>失败、离线或尚未收敛</small></article>
|
||||||
|
</div>
|
||||||
|
<div class="content-grid">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-heading"><div><h2>接入健康</h2><p>只汇总当前设备页,不推断未加载设备。</p></div><button type="button" class="button ghost" data-view-target="operations">进入运维中心</button></div>
|
||||||
|
<div class="health-list" id="overviewHealth"><div class="empty-compact">建立会话后显示真实状态。</div></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel boundary-card">
|
||||||
|
<span class="boundary-label">本纵切边界</span>
|
||||||
|
<h2>实时监看,不替代客户 NVR</h2>
|
||||||
|
<p>常态录像仍留在客户现有 NVR;当前仅验证 Sense 设备管理面和 MediaMTX 按需播放。录像计划、录像索引与回放未实现。</p>
|
||||||
|
<ul><li>同时最多 4 路预览</li><li>刷新后 Bearer token 自动丢失</li><li>Sense 与播放端均限回环地址</li></ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-page="monitor" aria-labelledby="monitorTitle" hidden>
|
||||||
|
<div class="page-heading">
|
||||||
|
<div><span class="eyebrow">SENSE / LIVE</span><h1 id="monitorTitle">实时监控</h1><p>从当前页设备中选择,只有明确在线且已收敛的视频设备可预览。</p></div>
|
||||||
|
<div class="heading-actions"><button type="button" class="button secondary" id="stopAll">停止全部</button><button type="button" class="button primary" id="startPreview">开始预览</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-toolbar panel">
|
||||||
|
<div><strong>已选择 <span id="selectionCount">0</span> / 4</strong><span>默认不自动播放,不加载未选择设备。</span></div>
|
||||||
|
<button type="button" class="button ghost" data-view-target="devices">选择设备</button>
|
||||||
|
</div>
|
||||||
|
<div class="preview-grid" id="previewGrid" aria-live="polite">
|
||||||
|
<div class="preview-empty"><span aria-hidden="true">▷</span><strong>尚未启动预览</strong><p>前往设备页选择最多 4 路运行中的视频设备。</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-page="devices" aria-labelledby="devicesTitle" hidden>
|
||||||
|
<div class="page-heading">
|
||||||
|
<div><span class="eyebrow">SENSE / DEVICES</span><h1 id="devicesTitle">设备</h1><p>统一台账按模态与能力展示;列表只读取脱敏 Control API 字段。</p></div>
|
||||||
|
<button type="button" class="button primary" id="refreshDevices">刷新状态</button>
|
||||||
|
</div>
|
||||||
|
<section class="panel filter-panel" aria-label="设备筛选">
|
||||||
|
<label><span>当前页搜索</span><input type="search" id="deviceSearch" placeholder="名称 / 序列号" autocomplete="off"></label>
|
||||||
|
<label><span>模态</span><select id="modalityFilter"><option value="">全部模态</option><option value="video">视频</option><option value="radar">雷达</option><option value="contact">门磁</option><option value="button">按钮</option><option value="wearable">可穿戴</option><option value="other">其他</option></select></label>
|
||||||
|
<label><span>期望态</span><select id="desiredFilter"><option value="">全部期望态</option><option value="enabled">启用</option><option value="disabled">停用</option></select></label>
|
||||||
|
<label><span>实际态</span><select id="actualFilter"><option value="">全部实际态</option><option value="online">在线</option><option value="offline">离线</option><option value="failed">失败</option><option value="pending">收敛中</option></select></label>
|
||||||
|
<button type="button" class="button secondary" id="clearFilters">清除筛选</button>
|
||||||
|
</section>
|
||||||
|
<div class="table-panel panel">
|
||||||
|
<div class="table-status" id="deviceTableStatus" role="status">请先建立会话。</div>
|
||||||
|
<div class="device-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th scope="col">预览</th><th scope="col">设备</th><th scope="col">模态 / 能力</th><th scope="col">状态</th><th scope="col">期望 / 实际</th><th scope="col">Area</th><th scope="col">最后变化</th></tr></thead>
|
||||||
|
<tbody id="deviceRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="device-cards" id="deviceCards"></div>
|
||||||
|
<div class="pagination"><span id="pageLabel">第 1 页 · 每页 16</span><div><button type="button" class="button secondary" id="prevPage" disabled>上一页</button><button type="button" class="button secondary" id="nextPage" disabled>下一页</button></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-page="onboarding" aria-labelledby="onboardingTitle" hidden>
|
||||||
|
<div class="page-heading"><div><span class="eyebrow">SENSE / ONBOARDING</span><h1 id="onboardingTitle">接入任务</h1><p>批量导入仍是已确认需求,但不属于本次设备与实时监看纵切。</p></div></div>
|
||||||
|
<article class="panel honest-empty">
|
||||||
|
<span class="feature-state">尚未实现</span><h2>批量导入与逐项激活将在独立任务完成</h2>
|
||||||
|
<p>当前 Control API 支持单设备创建和最多 128 项的批量启停,但没有冻结批量 CSV 创建契约。本页不会用假上传或假成功伪装交付。</p>
|
||||||
|
<div class="next-list"><span><strong>已具备</strong>设备查询、期望态修改、批量启停与结果查询</span><span><strong>后续补齐</strong>模板下载、逐行校验、只重试失败项和任务历史</span></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-page="operations" aria-labelledby="operationsTitle" hidden>
|
||||||
|
<div class="page-heading">
|
||||||
|
<div><span class="eyebrow">SENSE / OPERATIONS</span><h1 id="operationsTitle">运维中心</h1><p>仅展示接入控制面的事实,不混入 Bell 业务预警。</p></div>
|
||||||
|
<a class="button secondary" href="/metrics" target="_blank" rel="noreferrer">打开低基数指标</a>
|
||||||
|
</div>
|
||||||
|
<div class="operations-grid">
|
||||||
|
<article class="panel"><div class="panel-heading"><div><h2>当前页对账差异</h2><p>失败、退避与尚未观察到当前 generation。</p></div><button type="button" class="button ghost" id="refreshOperations">刷新</button></div><div id="operationIssues" class="health-list"><div class="empty-compact">建立会话后显示。</div></div></article>
|
||||||
|
<article class="panel boundary-card"><span class="boundary-label">数据边界</span><h2>完整运维 API 尚未冻结</h2><p>分片、边缘隧道、补传、孤儿扫描和运维告警已经有后端能力或设计,但本纵切只消费设备 Control API;未取到的数据不会显示为正常。</p></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="mobile-nav" aria-label="移动端主导航">
|
||||||
|
<button type="button" data-view="overview" aria-current="page">总览</button><button type="button" data-view="monitor">监控</button><button type="button" data-view="devices">设备</button><button type="button" data-view="onboarding">接入</button><button type="button" data-view="operations">运维</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<dialog id="contextDialog" aria-labelledby="contextTitle">
|
||||||
|
<form method="dialog" id="contextForm">
|
||||||
|
<div class="dialog-heading"><div><span class="eyebrow">LOOPBACK SESSION</span><h2 id="contextTitle">连接 Sense Control API</h2></div><button type="button" class="icon-button" id="closeContext" aria-label="关闭连接设置">×</button></div>
|
||||||
|
<p>Site ID 只用于当前页面上下文;Bearer token 仅保存在内存,刷新页面后自动丢失。</p>
|
||||||
|
<label><span>Site ID</span><input id="siteInput" name="site" required maxlength="128" autocomplete="off" placeholder="例如 school-main"></label>
|
||||||
|
<label><span>Bearer token</span><input id="tokenInput" name="token" type="password" required autocomplete="off" placeholder="不会保存或回显"></label>
|
||||||
|
<div class="form-error" id="contextError" role="alert" hidden></div>
|
||||||
|
<div class="dialog-actions"><button type="button" class="button secondary" id="cancelContext">取消</button><button type="submit" class="button primary" id="connectButton">连接并读取</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Package console serves the loopback-only Sense engineering console.
|
||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/index.html assets/app.css assets/app.js
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
previewBaseURL string
|
||||||
|
previewOrigin string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler builds the self-contained console handler. Config validation also
|
||||||
|
// enforces this boundary; validating here keeps the package safe in isolation.
|
||||||
|
func NewHandler(previewBaseURL string) (http.Handler, error) {
|
||||||
|
parsed, err := url.Parse(previewBaseURL)
|
||||||
|
if err != nil || parsed.Host == "" ||
|
||||||
|
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
|
||||||
|
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
|
||||||
|
(parsed.Path != "" && parsed.Path != "/") {
|
||||||
|
return nil, errors.New("invalid Sense console WebRTC base URL")
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||||
|
return nil, errors.New("Sense console WebRTC base URL is not loopback")
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(parsed.String(), "/")
|
||||||
|
return &handler{
|
||||||
|
previewBaseURL: base,
|
||||||
|
previewOrigin: parsed.Scheme + "://" + parsed.Host,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
h.securityHeaders(writer)
|
||||||
|
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||||
|
writer.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/sense-console/":
|
||||||
|
h.serveAsset(writer, request, "assets/index.html", "text/html; charset=utf-8")
|
||||||
|
case "/sense-console/app.css":
|
||||||
|
h.serveAsset(writer, request, "assets/app.css", "text/css; charset=utf-8")
|
||||||
|
case "/sense-console/app.js":
|
||||||
|
h.serveAsset(writer, request, "assets/app.js", "text/javascript; charset=utf-8")
|
||||||
|
case "/sense-console/config":
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
if request.Method == http.MethodHead {
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode(map[string]any{
|
||||||
|
"webrtc_base_url": h.previewBaseURL,
|
||||||
|
"page_size": 16,
|
||||||
|
"max_active_previews": 4,
|
||||||
|
"recording_and_playback": false,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) securityHeaders(writer http.ResponseWriter) {
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
|
writer.Header().Set("Content-Security-Policy", fmt.Sprintf(
|
||||||
|
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src %s; img-src 'self' data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||||
|
h.previewOrigin,
|
||||||
|
))
|
||||||
|
writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
|
||||||
|
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
writer.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) serveAsset(writer http.ResponseWriter, request *http.Request, path, contentType string) {
|
||||||
|
contents, err := assets.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(writer, "asset unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", contentType)
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(contents)))
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
if request.Method == http.MethodGet {
|
||||||
|
_, _ = writer.Write(contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerServesSelfContainedConsoleWithSecurityHeaders(t *testing.T) {
|
||||||
|
handler, err := NewHandler("http://127.0.0.1:8889/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/sense-console/", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status: %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, required := range []string{"YoVision Sense", "/sense-console/app.css", "/sense-console/app.js"} {
|
||||||
|
if !strings.Contains(body, required) {
|
||||||
|
t.Fatalf("console HTML lacks %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"http://", "https://", "<script>", "<style>"} {
|
||||||
|
if strings.Contains(body, forbidden) {
|
||||||
|
t.Fatalf("console HTML contains forbidden inline/external marker %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
csp := response.Header().Get("Content-Security-Policy")
|
||||||
|
if !strings.Contains(csp, "frame-src http://127.0.0.1:8889") ||
|
||||||
|
!strings.Contains(csp, "frame-ancestors 'none'") {
|
||||||
|
t.Fatalf("unexpected CSP: %s", csp)
|
||||||
|
}
|
||||||
|
if response.Header().Get("Cache-Control") != "no-store" ||
|
||||||
|
response.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||||
|
t.Fatal("security headers are incomplete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerReturnsBoundedRuntimeConfig(t *testing.T) {
|
||||||
|
handler, err := NewHandler("http://localhost:8889")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/sense-console/config", nil))
|
||||||
|
var payload struct {
|
||||||
|
WebRTCBaseURL string `json:"webrtc_base_url"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
MaxActivePreviews int `json:"max_active_previews"`
|
||||||
|
RecordingAndPlayback bool `json:"recording_and_playback"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.WebRTCBaseURL != "http://localhost:8889" || payload.PageSize != 16 ||
|
||||||
|
payload.MaxActivePreviews != 4 || payload.RecordingAndPlayback {
|
||||||
|
t.Fatalf("unexpected runtime config: %+v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRejectsRemoteOrCredentialedPreviewBase(t *testing.T) {
|
||||||
|
for _, value := range []string{
|
||||||
|
"https://media.example", "http://user@127.0.0.1:8889", "http://127.0.0.1:8889/path",
|
||||||
|
} {
|
||||||
|
if _, err := NewHandler(value); err == nil {
|
||||||
|
t.Fatalf("invalid preview base was accepted: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRejectsWritesAndUnknownAssets(t *testing.T) {
|
||||||
|
handler, err := NewHandler("http://127.0.0.1:8889")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{http.MethodPost, "/sense-console/", http.StatusMethodNotAllowed},
|
||||||
|
{http.MethodGet, "/sense-console/missing", http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
|
||||||
|
if response.Code != test.want {
|
||||||
|
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/auditrelay"
|
||||||
|
)
|
||||||
|
|
||||||
|
var relayErrorCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||||
|
|
||||||
|
func (s *Postgres) AuditRelayReady(ctx context.Context) error {
|
||||||
|
var version int64
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM sense.schema_migrations`).Scan(&version); err != nil || version < 6 {
|
||||||
|
return errors.New("postgres Sense schema migration v6 is required for audit relay")
|
||||||
|
}
|
||||||
|
var canUseOutbox, canReadBell, canWriteBell bool
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'sense.device_operation_outbox', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.audit_events', 'INSERT,UPDATE,DELETE')`).Scan(
|
||||||
|
&canUseOutbox, &canReadBell, &canWriteBell,
|
||||||
|
); err != nil {
|
||||||
|
return errors.New("verify audit relay privileges")
|
||||||
|
}
|
||||||
|
if !canUseOutbox || canReadBell || canWriteBell {
|
||||||
|
return errors.New("Sense audit relay violates schema ownership boundary")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ClaimAuditRelayBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
owner string,
|
||||||
|
limit int,
|
||||||
|
lease time.Duration,
|
||||||
|
) ([]auditrelay.QueuedEvent, error) {
|
||||||
|
if strings.TrimSpace(owner) == "" || limit < 1 || limit > auditrelay.MaxBatchSize || lease <= 0 {
|
||||||
|
return nil, errors.New("invalid audit relay claim")
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `WITH due AS (
|
||||||
|
SELECT event_id FROM sense.device_operation_outbox
|
||||||
|
WHERE delivered_at IS NULL AND dead_lettered_at IS NULL
|
||||||
|
AND COALESCE(next_attempt_at, available_at) <= clock_timestamp()
|
||||||
|
AND (relay_lease_until IS NULL OR relay_lease_until <= clock_timestamp())
|
||||||
|
ORDER BY COALESCE(next_attempt_at, available_at), event_id
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT $1
|
||||||
|
)
|
||||||
|
UPDATE sense.device_operation_outbox AS outbox SET
|
||||||
|
relay_lease_owner = $2,
|
||||||
|
relay_lease_token = outbox.relay_lease_token + 1,
|
||||||
|
relay_lease_until = clock_timestamp() + ($3 * interval '1 second'),
|
||||||
|
attempt_count = outbox.attempt_count + 1,
|
||||||
|
last_error_code = NULL
|
||||||
|
FROM due WHERE outbox.event_id = due.event_id
|
||||||
|
RETURNING outbox.event_id, outbox.event_type, outbox.tenant_id,
|
||||||
|
outbox.site_id, outbox.device_id, outbox.actor_type, outbox.actor_id,
|
||||||
|
outbox.reason, outbox.trace_id, outbox.aggregate_generation,
|
||||||
|
outbox.quota_source_version, outbox.area_policy_source_version,
|
||||||
|
outbox.payload, outbox.occurred_at, outbox.relay_lease_token,
|
||||||
|
outbox.attempt_count`, limit, owner, lease.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("claim audit relay batch")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]auditrelay.QueuedEvent, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var value auditrelay.QueuedEvent
|
||||||
|
var reason, trace sql.NullString
|
||||||
|
var quota, area sql.NullInt64
|
||||||
|
var payload []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&value.Event.EventID, &value.Event.EventType, &value.Event.TenantID,
|
||||||
|
&value.Event.SiteID, &value.Event.DeviceID, &value.Event.Actor.Type,
|
||||||
|
&value.Event.Actor.ID, &reason, &trace, &value.Event.AggregateGeneration,
|
||||||
|
"a, &area, &payload, &value.Event.OccurredAt, &value.LeaseToken,
|
||||||
|
&value.AttemptCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, errors.New("scan audit relay claim")
|
||||||
|
}
|
||||||
|
if reason.Valid {
|
||||||
|
value.Event.Reason = &reason.String
|
||||||
|
}
|
||||||
|
if trace.Valid {
|
||||||
|
value.Event.TraceID = &trace.String
|
||||||
|
}
|
||||||
|
if quota.Valid {
|
||||||
|
value.Event.ProjectionVersions.QuotaSourceVersion = "a.Int64
|
||||||
|
}
|
||||||
|
if area.Valid {
|
||||||
|
value.Event.ProjectionVersions.AreaPolicySourceVersion = &area.Int64
|
||||||
|
}
|
||||||
|
if !json.Valid(payload) {
|
||||||
|
return nil, errors.New("invalid audit payload in outbox")
|
||||||
|
}
|
||||||
|
value.Event.Data = append(json.RawMessage(nil), payload...)
|
||||||
|
value.SchemaVersion = 1
|
||||||
|
if value.Event.EventType == "device.configuration.accepted" {
|
||||||
|
value.SchemaVersion = 2
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errors.New("iterate audit relay claims")
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) CompleteAuditRelayBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
owner string,
|
||||||
|
values []auditrelay.Completion,
|
||||||
|
) error {
|
||||||
|
if strings.TrimSpace(owner) == "" || len(values) == 0 || len(values) > auditrelay.MaxBatchSize {
|
||||||
|
return errors.New("invalid audit relay completion")
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin audit relay completion")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, value := range values {
|
||||||
|
code := value.ErrorCode
|
||||||
|
if code != "" && !relayErrorCode.MatchString(code) {
|
||||||
|
code = "invalid_response"
|
||||||
|
}
|
||||||
|
var result sql.Result
|
||||||
|
switch value.Disposition {
|
||||||
|
case auditrelay.Delivered:
|
||||||
|
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
|
||||||
|
delivered_at = clock_timestamp(), last_error_code = NULL,
|
||||||
|
relay_lease_owner = NULL, relay_lease_until = NULL
|
||||||
|
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
|
||||||
|
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken)
|
||||||
|
case auditrelay.DeadLetter:
|
||||||
|
if code == "" {
|
||||||
|
return errors.New("dead-letter completion requires an error code")
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
|
||||||
|
dead_lettered_at = clock_timestamp(), last_error_code = $4,
|
||||||
|
relay_lease_owner = NULL, relay_lease_until = NULL
|
||||||
|
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
|
||||||
|
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken, code)
|
||||||
|
case auditrelay.Retry:
|
||||||
|
if code == "" || value.RetryAfter < time.Second || value.RetryAfter > 300*time.Second {
|
||||||
|
return errors.New("invalid audit relay retry completion")
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
|
||||||
|
next_attempt_at = clock_timestamp() + ($4 * interval '1 second'),
|
||||||
|
last_error_code = $5, relay_lease_owner = NULL, relay_lease_until = NULL
|
||||||
|
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
|
||||||
|
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken, value.RetryAfter.Seconds(), code)
|
||||||
|
default:
|
||||||
|
return errors.New("invalid audit relay disposition")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("persist audit relay completion")
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("read audit relay completion")
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return auditrelay.ErrLeaseLost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit audit relay completion")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/auditrelay"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresAuditRelayClaimUsesFencing(t *testing.T) {
|
||||||
|
repository, admin := openPostgresTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := repository.AuditRelayReady(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
insertBellSite(t, admin, "relay-tenant", "relay-site", 1)
|
||||||
|
device := videoDevice(9001, "relay-tenant", "relay-site")
|
||||||
|
if err := repository.CreateDevice(ctx, device); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := repository.ClaimAuditRelayBatch(ctx, "worker-a", 100, 30*time.Second)
|
||||||
|
if err != nil || len(first) != 1 || first[0].SchemaVersion != 1 || first[0].AttemptCount != 1 {
|
||||||
|
t.Fatalf("first claim: %+v %v", first, err)
|
||||||
|
}
|
||||||
|
if _, err := admin.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET relay_lease_until=clock_timestamp()-interval '1 second' WHERE event_id=$1`, first[0].Event.EventID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := repository.ClaimAuditRelayBatch(ctx, "worker-b", 100, 30*time.Second)
|
||||||
|
if err != nil || len(second) != 1 || second[0].LeaseToken <= first[0].LeaseToken {
|
||||||
|
t.Fatalf("reclaim: %+v %v", second, err)
|
||||||
|
}
|
||||||
|
err = repository.CompleteAuditRelayBatch(ctx, "worker-a", []auditrelay.Completion{{EventID: first[0].Event.EventID, LeaseToken: first[0].LeaseToken, Disposition: auditrelay.Delivered}})
|
||||||
|
if !errors.Is(err, auditrelay.ErrLeaseLost) {
|
||||||
|
t.Fatalf("stale worker completion returned %v", err)
|
||||||
|
}
|
||||||
|
if err := repository.CompleteAuditRelayBatch(ctx, "worker-b", []auditrelay.Completion{{EventID: second[0].Event.EventID, LeaseToken: second[0].LeaseToken, Disposition: auditrelay.Delivered}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var delivered bool
|
||||||
|
if err := admin.QueryRowContext(ctx, `SELECT delivered_at IS NOT NULL FROM sense.device_operation_outbox WHERE event_id=$1`, first[0].Event.EventID).Scan(&delivered); err != nil || !delivered {
|
||||||
|
t.Fatalf("delivered=%v err=%v", delivered, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- Bell v3 immutable event facts and append-only outcomes.
|
||||||
|
-- bell_app remains the migration/object owner; bell_runtime is a NOLOGIN
|
||||||
|
-- least-privilege group for the Bell process.
|
||||||
|
|
||||||
|
DO $role$
|
||||||
|
DECLARE
|
||||||
|
role_record pg_roles%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_runtime';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
CREATE ROLE bell_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
|
||||||
|
ELSIF role_record.rolcanlogin OR role_record.rolsuper OR role_record.rolcreatedb
|
||||||
|
OR role_record.rolcreaterole OR role_record.rolreplication THEN
|
||||||
|
RAISE EXCEPTION 'existing bell_runtime role has unsafe attributes';
|
||||||
|
END IF;
|
||||||
|
IF pg_has_role('bell_runtime', 'bell_app', 'MEMBER')
|
||||||
|
OR pg_has_role('bell_runtime', 'sense_app', 'MEMBER') THEN
|
||||||
|
RAISE EXCEPTION 'bell_runtime must not inherit migration or Sense roles';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$role$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bell.reject_immutable_change()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = pg_catalog, bell
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'Bell immutable facts cannot be updated or deleted';
|
||||||
|
END
|
||||||
|
$function$;
|
||||||
|
ALTER FUNCTION bell.reject_immutable_change() OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.events (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
tenant_id bigint NOT NULL,
|
||||||
|
site_id bigint NOT NULL,
|
||||||
|
device_id bigint NOT NULL,
|
||||||
|
source_event_id text NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
severity text NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
detected_at timestamptz NOT NULL,
|
||||||
|
payload_hash bytea NOT NULL,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT bell_event_id_format CHECK (id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
CONSTRAINT bell_event_ids_positive CHECK (tenant_id >= 1 AND site_id >= 1 AND device_id >= 1),
|
||||||
|
CONSTRAINT bell_event_source_id CHECK (source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
||||||
|
CONSTRAINT bell_event_kind CHECK (kind ~ '^[a-z][a-z0-9_]{2,63}$'),
|
||||||
|
CONSTRAINT bell_event_severity CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||||
|
CONSTRAINT bell_event_time_order CHECK (detected_at >= occurred_at),
|
||||||
|
CONSTRAINT bell_event_hash_length CHECK (octet_length(payload_hash) = 32),
|
||||||
|
CONSTRAINT bell_event_payload_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||||
|
CONSTRAINT bell_event_payload_identity CHECK (
|
||||||
|
payload ->> 'id' = id
|
||||||
|
AND (payload ->> 'tenant_id')::bigint = tenant_id
|
||||||
|
AND (payload ->> 'site_id')::bigint = site_id
|
||||||
|
AND (payload ->> 'device_id')::bigint = device_id
|
||||||
|
AND payload ->> 'source_event_id' = source_event_id
|
||||||
|
AND payload ->> 'kind' = kind
|
||||||
|
AND payload ->> 'severity' = severity
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.events OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_events_immutable ON bell.events;
|
||||||
|
CREATE TRIGGER bell_events_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON bell.events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_scope_time_idx
|
||||||
|
ON bell.events(tenant_id, site_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_device_time_idx
|
||||||
|
ON bell.events(tenant_id, site_id, device_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_kind_time_idx
|
||||||
|
ON bell.events(tenant_id, site_id, kind, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_source_lookup_idx
|
||||||
|
ON bell.events(tenant_id, site_id, device_id, source_event_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.event_outcomes (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
event_id text NOT NULL REFERENCES bell.events(id),
|
||||||
|
outcome text NOT NULL,
|
||||||
|
outcome_source text NOT NULL,
|
||||||
|
reason text,
|
||||||
|
actor_type text NOT NULL,
|
||||||
|
actor_id text NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
record_hash bytea NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT bell_event_outcome_id_format CHECK (id ~ '^out_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
CONSTRAINT bell_event_outcome_value CHECK (
|
||||||
|
outcome IN ('unknown', 'true_positive', 'false_positive', 'subject_recovered', 'duplicate', 'test')
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_event_outcome_source CHECK (outcome_source IN ('auto', 'manual')),
|
||||||
|
CONSTRAINT bell_event_outcome_actor CHECK (
|
||||||
|
actor_type IN ('user', 'service', 'system') AND btrim(actor_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_event_outcome_reason CHECK (reason IS NULL OR char_length(reason) <= 500),
|
||||||
|
CONSTRAINT bell_event_outcome_hash_length CHECK (octet_length(record_hash) = 32)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.event_outcomes OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_event_outcomes_immutable ON bell.event_outcomes;
|
||||||
|
CREATE TRIGGER bell_event_outcomes_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON bell.event_outcomes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_event_outcomes_event_time_idx
|
||||||
|
ON bell.event_outcomes(event_id, occurred_at, id);
|
||||||
|
|
||||||
|
INSERT INTO bell.schema_migrations(version) VALUES (3)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Runtime Bell can append/read event facts but cannot rewrite, delete or
|
||||||
|
-- truncate them. Object ownership stays with the offline migration role.
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION bell.reject_immutable_change() FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM bell_runtime;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA bell TO bell_runtime;
|
||||||
|
GRANT SELECT ON TABLE bell.schema_migrations TO bell_runtime;
|
||||||
|
GRANT SELECT, INSERT ON TABLE bell.events, bell.event_outcomes TO bell_runtime;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell
|
||||||
|
REVOKE ALL ON TABLES FROM PUBLIC;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell
|
||||||
|
REVOKE ALL ON FUNCTIONS FROM PUBLIC;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
-- Sense Outbox relay fencing and Bell global audit facts.
|
||||||
|
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS relay_lease_owner text,
|
||||||
|
ADD COLUMN IF NOT EXISTS relay_lease_token bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS relay_lease_until timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_error_code text,
|
||||||
|
ADD COLUMN IF NOT EXISTS dead_lettered_at timestamptz;
|
||||||
|
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
DROP CONSTRAINT IF EXISTS sense_outbox_relay_lease_pair;
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
ADD CONSTRAINT sense_outbox_relay_lease_pair CHECK (
|
||||||
|
(relay_lease_owner IS NULL AND relay_lease_until IS NULL)
|
||||||
|
OR (relay_lease_owner IS NOT NULL AND btrim(relay_lease_owner) <> ''
|
||||||
|
AND relay_lease_until IS NOT NULL)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
DROP CONSTRAINT IF EXISTS sense_outbox_relay_token_nonnegative;
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
ADD CONSTRAINT sense_outbox_relay_token_nonnegative CHECK (relay_lease_token >= 0);
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
DROP CONSTRAINT IF EXISTS sense_outbox_relay_error_length;
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
ADD CONSTRAINT sense_outbox_relay_error_length CHECK (
|
||||||
|
last_error_code IS NULL OR (
|
||||||
|
char_length(last_error_code) BETWEEN 1 AND 64
|
||||||
|
AND last_error_code ~ '^[a-z][a-z0-9_]*$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
DROP CONSTRAINT IF EXISTS sense_outbox_relay_terminal_state;
|
||||||
|
ALTER TABLE sense.device_operation_outbox
|
||||||
|
ADD CONSTRAINT sense_outbox_relay_terminal_state CHECK (
|
||||||
|
delivered_at IS NULL OR dead_lettered_at IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_outbox_relay_due_idx
|
||||||
|
ON sense.device_operation_outbox(
|
||||||
|
COALESCE(next_attempt_at, available_at), event_id
|
||||||
|
)
|
||||||
|
WHERE delivered_at IS NULL AND dead_lettered_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.audit_events (
|
||||||
|
source_system text NOT NULL,
|
||||||
|
event_id text NOT NULL,
|
||||||
|
schema_version smallint NOT NULL,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
device_id text NOT NULL,
|
||||||
|
actor_type text NOT NULL,
|
||||||
|
actor_id text NOT NULL,
|
||||||
|
reason text,
|
||||||
|
trace_id text,
|
||||||
|
aggregate_generation bigint NOT NULL,
|
||||||
|
quota_source_version bigint,
|
||||||
|
area_policy_source_version bigint,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
record_hash bytea NOT NULL,
|
||||||
|
PRIMARY KEY (source_system, event_id),
|
||||||
|
CONSTRAINT bell_audit_source_system CHECK (source_system = 'sense'),
|
||||||
|
CONSTRAINT bell_audit_event_id CHECK (event_id ~ '^audit_[0-9a-f]{32}$'),
|
||||||
|
CONSTRAINT bell_audit_schema_version CHECK (schema_version IN (1, 2)),
|
||||||
|
CONSTRAINT bell_audit_event_type CHECK (event_type IN (
|
||||||
|
'device.created',
|
||||||
|
'device.desired_state.accepted',
|
||||||
|
'device.configuration.accepted'
|
||||||
|
)),
|
||||||
|
CONSTRAINT bell_audit_identity_not_blank CHECK (
|
||||||
|
btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(device_id) <> ''
|
||||||
|
AND btrim(actor_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_audit_actor_type CHECK (actor_type IN ('user', 'service', 'system')),
|
||||||
|
CONSTRAINT bell_audit_generation_positive CHECK (aggregate_generation >= 1),
|
||||||
|
CONSTRAINT bell_audit_projection_versions CHECK (
|
||||||
|
(quota_source_version IS NULL OR quota_source_version >= 1)
|
||||||
|
AND (area_policy_source_version IS NULL OR area_policy_source_version >= 1)
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_audit_reason_length CHECK (reason IS NULL OR char_length(reason) <= 500),
|
||||||
|
CONSTRAINT bell_audit_trace_length CHECK (trace_id IS NULL OR char_length(trace_id) <= 128),
|
||||||
|
CONSTRAINT bell_audit_payload_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||||
|
CONSTRAINT bell_audit_record_hash_length CHECK (octet_length(record_hash) = 32)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.audit_events OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_audit_events_immutable ON bell.audit_events;
|
||||||
|
CREATE TRIGGER bell_audit_events_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON bell.audit_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_audit_scope_time_idx
|
||||||
|
ON bell.audit_events(tenant_id, site_id, occurred_at DESC, event_id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_audit_device_time_idx
|
||||||
|
ON bell.audit_events(tenant_id, site_id, device_id, occurred_at DESC, event_id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.audit_relay_receipts (
|
||||||
|
key_id text NOT NULL,
|
||||||
|
nonce text NOT NULL,
|
||||||
|
request_hash bytea NOT NULL,
|
||||||
|
response_status integer NOT NULL,
|
||||||
|
response_body jsonb NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (key_id, nonce),
|
||||||
|
CONSTRAINT bell_audit_receipt_key_id CHECK (
|
||||||
|
char_length(key_id) BETWEEN 1 AND 64 AND key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_audit_receipt_nonce CHECK (
|
||||||
|
char_length(nonce) BETWEEN 22 AND 64 AND nonce ~ '^[A-Za-z0-9_-]+$'
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_audit_receipt_hash_length CHECK (octet_length(request_hash) = 32),
|
||||||
|
CONSTRAINT bell_audit_receipt_status CHECK (response_status = 200),
|
||||||
|
CONSTRAINT bell_audit_receipt_body CHECK (jsonb_typeof(response_body) = 'object'),
|
||||||
|
CONSTRAINT bell_audit_receipt_ttl CHECK (
|
||||||
|
expires_at >= received_at + interval '10 minutes'
|
||||||
|
AND expires_at <= received_at + interval '11 minutes'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.audit_relay_receipts OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_audit_receipt_expiry_idx
|
||||||
|
ON bell.audit_relay_receipts(expires_at, key_id, nonce);
|
||||||
|
|
||||||
|
INSERT INTO bell.schema_migrations(version) VALUES (4)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
INSERT INTO sense.schema_migrations(version) VALUES (6)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Bell runtime may append global audit facts and maintain only short-lived
|
||||||
|
-- idempotency receipts. Sense keeps ownership of its local Outbox.
|
||||||
|
|
||||||
|
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM bell_runtime;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON TABLE bell.audit_events TO bell_runtime;
|
||||||
|
GRANT SELECT, INSERT, DELETE ON TABLE bell.audit_relay_receipts TO bell_runtime;
|
||||||
|
|
||||||
|
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM sense_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE sense.device_operation_outbox TO sense_app;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# YoVision PostgreSQL 初始化
|
# YoVision PostgreSQL 初始化
|
||||||
|
|
||||||
本目录实现 T-009~T-012 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。
|
本目录实现 T-009~T-012、T-015~T-016 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与本地审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果;`012`~`013` 增加 Bell 不可变事件、append-only outcome 和独立 `bell_runtime` 最小权限;`014`~`015` 增加审计 Outbox relay fencing、Bell 全局审计事实、短期防重收据和双方最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
|
||||||
|
|
||||||
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||||
|
|
||||||
@@ -20,7 +20,9 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
|||||||
- `bell_app` 拥有 `bell.sites`/`bell.areas`、版本 trigger、`bell.site_quota_v1` 和 `bell.area_policy_v1`。
|
- `bell_app` 拥有 `bell.sites`/`bell.areas`、版本 trigger、`bell.site_quota_v1` 和 `bell.area_policy_v1`。
|
||||||
- `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。
|
- `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。
|
||||||
- `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。
|
- `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。
|
||||||
- `sense.device_operation_outbox` 是本地持久化审计事实,不是 Bell 全局审计真相;relay 的传输、签名、确认和留存尚未实现。
|
- `sense.device_operation_outbox` 是本地持久化队列,Bell 全局审计真相只写入 `bell.audit_events`。Sense 只领取/确认本地行,不获得 Bell 表权限;Bell 只通过签名 HTTP ingress 收取,不读取 Outbox。
|
||||||
|
- `bell.audit_events` 与事件事实一样不可更新/删除;`bell.audit_relay_receipts` 只为 10 分钟 nonce 幂等窗口保留,Bell runtime 仅可在这张限定表中查询、插入和清理过期记录。
|
||||||
|
- `bell.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。
|
||||||
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
|
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
|
||||||
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
|
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
|
||||||
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
||||||
|
|||||||
@@ -71,11 +71,33 @@ BEGIN
|
|||||||
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
|
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
|
||||||
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
|
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT max(version) FROM bell.schema_migrations) <> 2
|
IF (SELECT max(version) FROM bell.schema_migrations) <> 4
|
||||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN
|
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
|
||||||
RAISE EXCEPTION 'schema migration version drift';
|
RAISE EXCEPTION 'schema migration version drift';
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_roles
|
||||||
|
WHERE rolname = 'bell_runtime' AND NOT rolcanlogin AND NOT rolsuper
|
||||||
|
AND NOT rolcreatedb AND NOT rolcreaterole AND NOT rolreplication
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'bell_runtime role is missing or unsafe';
|
||||||
|
END IF;
|
||||||
|
IF NOT has_table_privilege('yovision_t015_bell', 'bell.events', 'SELECT,INSERT')
|
||||||
|
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'SELECT,INSERT')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'UPDATE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'DELETE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'TRUNCATE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'UPDATE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'DELETE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'TRUNCATE') THEN
|
||||||
|
RAISE EXCEPTION 'Bell runtime violates immutable event privileges';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('public', 'bell.events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||||
|
OR has_table_privilege('public', 'bell.event_outcomes', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
|
||||||
|
RAISE EXCEPTION 'Bell event facts leaked to PUBLIC';
|
||||||
|
END IF;
|
||||||
|
|
||||||
INSERT INTO bell.sites(tenant_id, id, name)
|
INSERT INTO bell.sites(tenant_id, id, name)
|
||||||
VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site')
|
VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site')
|
||||||
RETURNING max_video_channels, version INTO default_quota, initial_version;
|
RETURNING max_video_channels, version INTO default_quota, initial_version;
|
||||||
@@ -179,3 +201,30 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END
|
END
|
||||||
$reconcile_safety$;
|
$reconcile_safety$;
|
||||||
|
|
||||||
|
DO $audit_relay$
|
||||||
|
BEGIN
|
||||||
|
IF NOT has_table_privilege('yovision_t012_sense', 'sense.device_operation_outbox', 'SELECT,INSERT,UPDATE,DELETE')
|
||||||
|
OR has_table_privilege('yovision_t012_sense', 'bell.audit_events', 'SELECT,INSERT,UPDATE,DELETE')
|
||||||
|
OR has_table_privilege('yovision_t012_sense', 'bell.audit_relay_receipts', 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'Sense runtime violates audit relay schema ownership';
|
||||||
|
END IF;
|
||||||
|
IF NOT has_table_privilege('yovision_t015_bell', 'bell.audit_events', 'SELECT,INSERT')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.audit_events', 'UPDATE,DELETE,TRUNCATE')
|
||||||
|
OR NOT has_table_privilege('yovision_t015_bell', 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.audit_relay_receipts', 'UPDATE,TRUNCATE') THEN
|
||||||
|
RAISE EXCEPTION 'Bell runtime violates audit relay privileges';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('public', 'bell.audit_events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||||
|
OR has_table_privilege('public', 'bell.audit_relay_receipts', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
|
||||||
|
RAISE EXCEPTION 'Bell audit relay tables leaked to PUBLIC';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema='sense' AND table_name='device_operation_outbox'
|
||||||
|
AND column_name='relay_lease_token'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Sense audit relay fencing columns are missing';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$audit_relay$;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,正在转入 M3 的 Bell/Brain 最小闭环**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Bell 事件存储与审计 relay 边界设计。
|
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路可视化工程原型,并完成 T-018 Sense 回环 NVR 管理面纵切**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置;下一项建议独立创建 T-019,冻结 Brain→Bell 业务事件 ingress。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
@@ -89,8 +89,13 @@ go -C Sense generate ./internal/mtx ./internal/controlapi
|
|||||||
go -C Sense test ./...
|
go -C Sense test ./...
|
||||||
go -C Sense vet ./...
|
go -C Sense vet ./...
|
||||||
go -C Sense build ./...
|
go -C Sense build ./...
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
|
python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
||||||
|
python -m compileall -q Brain
|
||||||
```
|
```
|
||||||
|
|
||||||
日常优先运行根目录 `./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)。
|
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;T-018 控制台必须在完成 PostgreSQL/Control API 外部安全配置后显式设置 `SENSE_CONSOLE_ENABLED=true`,再访问回环 `/sense-console/`。Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。工程页面默认只允许回环,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。
|
||||||
|
|
||||||
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|||||||
+52
-4
@@ -56,6 +56,37 @@ T-012 同样未增加第三方依赖:PostgreSQL v5 使用数据库时钟租约
|
|||||||
|
|
||||||
T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模块 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3 与 FFmpeg 8.1.2;MediaMTX Windows amd64 ZIP 使用上表官方 SHA-256,解压后 `mediamtx.exe` 另固定为 `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05`。FFmpeg 只生成无人物夹具并以 16 个独立 `-c copy` 进程发布,不进入生产镜像或 Go module。正式本机结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模块 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3 与 FFmpeg 8.1.2;MediaMTX Windows amd64 ZIP 使用上表官方 SHA-256,解压后 `mediamtx.exe` 另固定为 `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05`。FFmpeg 只生成无人物夹具并以 16 个独立 `-c copy` 进程发布,不进入生产镜像或 Go module。正式本机结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|
||||||
|
### 1.3 Bell M3 事件存储冻结版本(T-015)
|
||||||
|
|
||||||
|
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Go | `1.26.5`(与 Sense 相同) | BSD-3-Clause;复用 §1.1 工具链与校验 | Bell 独立 Go module;升级时同时运行两个 module 的 test/vet/build |
|
||||||
|
| PostgreSQL / pgx | `17.10` / `github.com/jackc/pgx/v5 v5.10.0` | PostgreSQL License / MIT;module sum `h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=` | 复用 T-009 的 `database/sql` 边界;运行身份改用无 owner 权限的 `bell_runtime` 组 |
|
||||||
|
| JSON Schema | `github.com/santhosh-tekuri/jsonschema/v6 v6.0.2` | Apache-2.0;module sum `h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=` | 运行时校验 Draft 2020-12 与 format;退出时可替换 validator,但冻结 schema 和负向契约测试不变 |
|
||||||
|
| ULID | `github.com/oklog/ulid/v2 v2.1.2` | Apache-2.0;module sum `h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=` | 只封装在 Bell ID generator port 后;平台前缀仍为 `evt_`,替换实现不得改变格式或所有权 |
|
||||||
|
|
||||||
|
T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进制。内部 factory 接收不含 `id` 的候选事实,由 Bell 生成 ULID 后才形成最终 v0.1 事件;不得把该 Go 类型当成公共网络协议。
|
||||||
|
|
||||||
|
### 1.4 Sense 审计 relay(T-016)
|
||||||
|
|
||||||
|
T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、SHA-256、base64url 和 constant-time compare,数据库继续使用已冻结的 PostgreSQL 17.10/pgx。`cmd/bell-api` 只提供回环 health/ready 和 Sense 审计内部端点;非回环监听必须同时提供绝对路径 TLS 证书/私钥。HMAC key 使用仓库外 version 1 JSON 文件,secret 至少 32 字节;该适配器不替代未来 Bell 公共 JWT/OIDC。
|
||||||
|
|
||||||
|
### 1.5 Brain 单路工程原型(T-017)
|
||||||
|
|
||||||
|
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Python | `3.10.11` | PSF License;本机 `python --version` 已验证 | 仅冻结 T-017 工程原型和测试语法基线,不等于生产 Savant/DeepStream Python 版本;生产脚手架冻结时通过标准模块边界迁移 |
|
||||||
|
| NumPy | `1.26.4` | BSD-3-Clause;本机 `numpy.__version__` 已验证 | 只在 frame fixture/OpenCV 数组边界使用;不把 NumPy 类型写入事件候选或 HTTP JSON |
|
||||||
|
| OpenCV Python | `opencv-python 4.9.0.80` | wheel 构建脚本 MIT、OpenCV Apache-2.0;Windows amd64 wheel SHA-256 `3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0` | 内置 HOG/SVM 只作为可替换 `Detector` 的匿名人员检测演示,不是生产模型;生产退出时替换 detector port,区域判定、候选事实和 UI 状态不依赖 HOG 类型。不得在同一环境混装标准/headless/contrib wheel |
|
||||||
|
|
||||||
|
T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/ReID 或 `max_sources`。默认合成 fixture 可重复展示工程链路,并必须标识为“非模型输出”;真实流只从仓库外绝对 URL 文件读取,优先消费 Sense 管理的 MediaMTX path。`/brain-demo` 只监听显式回环地址,事件候选最多保留 100 项内存环,不形成 Brain 业务数据库。OpenCV wheel 是 CPU-only;本机存在 NVIDIA GPU 也不能据此宣称 GPU 推理已接入。
|
||||||
|
|
||||||
|
### 1.6 Sense 回环 NVR 管理面纵切(T-018)
|
||||||
|
|
||||||
|
T-018 不增加生产或前端依赖:Go 使用标准库 `embed`/`net/http` 提供自包含 HTML、CSS 和原生 JavaScript,页面直接消费已冻结的 Sense Control API v1;实时预览使用同版 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,不 vendor `reader.js`、不引入 CDN/播放器库,也不让 Sense 代理媒体字节。
|
||||||
|
|
||||||
|
该选择只适用于默认关闭的回环工程控制台,不冻结 Bell 最终前端框架。页面默认 16 项 cursor 分页、最多 4 路按需预览且不自动加载视频;Sense HTTP 与 WebRTC 基地址必须同时是显式回环地址。非回环 HTTPS/JWT/OIDC、MediaMTX 外部认证、录像/回放和正式客户会话均保持待冻结,不能从 T-018 的本地演示外推为生产安全或 NVR 存储能力。
|
||||||
|
|
||||||
## 2. 外部项目边界
|
## 2. 外部项目边界
|
||||||
|
|
||||||
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
||||||
@@ -67,7 +98,7 @@ T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模
|
|||||||
|
|
||||||
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
|
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
|
||||||
- Bell 前端框架和组件库。
|
- Bell 前端框架和组件库。
|
||||||
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。
|
- Brain→Bell 业务事件投递 transport;Sense→Bell 审计 relay 已独立冻结为内部 HTTP,不能据此默认 Brain transport。
|
||||||
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
||||||
- MinIO/S3 的精确版本、加密实现,以及客户/法务确认后的最终生命周期策略。
|
- MinIO/S3 的精确版本、加密实现,以及客户/法务确认后的最终生命周期策略。
|
||||||
- 短信/语音供应商及生产双路径组合;是否开发原生 App 最早在 M4 根据试点反馈决定。
|
- 短信/语音供应商及生产双路径组合;是否开发原生 App 最早在 M4 根据试点反馈决定。
|
||||||
@@ -76,7 +107,7 @@ T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模
|
|||||||
|
|
||||||
## 4. 当前标准入口
|
## 4. 当前标准入口
|
||||||
|
|
||||||
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
Sense 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
./init.ps1
|
./init.ps1
|
||||||
@@ -99,6 +130,23 @@ go -C Sense build ./...
|
|||||||
go -C Sense run ./cmd/sense-api
|
go -C Sense run ./cmd/sense-api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bell 事件域与内部审计 receiver 单独执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Bell mod download
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Brain 单路工程原型单独执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
||||||
|
python -m compileall -q Brain
|
||||||
|
python -m Brain.yovision_brain --source synthetic
|
||||||
|
```
|
||||||
|
|
||||||
直接验证:
|
直接验证:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -115,8 +163,8 @@ python scripts/validate_harness_governance.py
|
|||||||
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
|
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
|
||||||
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
|
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
|
||||||
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
|
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
|
||||||
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
|
| Brain Python | 当前工程原型:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain` | source、detector port、track、判定状态机、mapper 或模型接口变化 | T-017 合成 fixture 只验工程闭环;命中真实模型任务时另用冻结数据集和目标硬件,不能用 fixture/HOG 结果替代 |
|
||||||
| Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 |
|
| Bell Go/Web | 当前后端:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 |
|
||||||
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
|
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
|
||||||
|
|
||||||
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
|
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
|
||||||
|
|||||||
+13
-8
@@ -47,8 +47,8 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
|
|
||||||
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
|
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
|
||||||
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
|
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
|
||||||
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
|
3. Brain 消费视频与触发信号,产生符合冻结契约的事件候选。T-017 已先建立单路 frame source、可替换 detector、轻量 track、多边形进入判定和回环可视化工程原型;合成 fixture 与 OpenCV HOG 均明确不是生产模型,候选只含 `source_event_id`,不自报平台 `id`。T-018 的 Sense 回环控制台只读取设备事实并按需嵌入 MediaMTX WebRTC 页面,不代理媒体、不参与推理或事件生成。
|
||||||
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。
|
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。
|
||||||
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
|
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
|
||||||
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
|
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
|
||||||
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
||||||
@@ -68,7 +68,7 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
|
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
|
||||||
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
|
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
|
||||||
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
|
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
|
||||||
12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-010 已冻结脱敏本地事件并实现原子写入;relay 的 transport、签名、确认、重放窗口与留存仍须独立冻结。
|
12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-016 已实现 HMAC/nonce 内部 HTTP relay、数据库时钟 lease/fencing、逐项确认与 Bell 不可变全局事实;Sense 不获得 Bell schema 权限,Bell 不读取 Sense Outbox。
|
||||||
|
|
||||||
## 6. 容量架构
|
## 6. 容量架构
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
- 单分片故障不能扩散到其他分片。
|
- 单分片故障不能扩散到其他分片。
|
||||||
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
|
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
|
||||||
|
|
||||||
|
T-018 先实现其中的回环工程纵切:设备 cursor 默认每页 16 项,同时预览最多 4 路且默认不播放;只有 `video_capture + enabled + online + converged` 的设备允许打开预览。该上限是浏览器工程台保护值,不是站点视频配额或 MediaMTX 分片容量。常态录像仍由客户现有 NVR 承担,Sense 不因提供监看页而变成媒体代理或录像真相源。
|
||||||
|
|
||||||
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|
||||||
## 7. 一致性与失败处理
|
## 7. 一致性与失败处理
|
||||||
@@ -88,8 +90,11 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
|
|||||||
- MediaMTX Path 扫描把“Sense 历史拥有但当前失配”和“从未归属 Sense”分开;未知归属永不自动删除。历史拥有项也只允许在 15 分钟二次快照、1~128 项和 `候选 × 100 <= 当前 Path 总数 × 10` 全部通过时由本地运维命令逐项处置,不提供绕过。
|
- MediaMTX Path 扫描把“Sense 历史拥有但当前失配”和“从未归属 Sense”分开;未知归属永不自动删除。历史拥有项也只允许在 15 分钟二次快照、1~128 项和 `候选 × 100 <= 当前 Path 总数 × 10` 全部通过时由本地运维命令逐项处置,不提供绕过。
|
||||||
- `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。多 Sense 实例使用 PostgreSQL transaction-scoped advisory lock 串行化同 tenant/site 的计数与写入,不能用进程内锁替代。
|
- `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。多 Sense 实例使用 PostgreSQL transaction-scoped advisory lock 串行化同 tenant/site 的计数与写入,不能用进程内锁替代。
|
||||||
- `bell.area_policy_v1` 缺失、非法、版本回退或读取失败时,PostgreSQL repository 拒绝相关新增/启用;`non_imaging_only` 允许非成像设备但拒绝具有 `video_capture` 的设备。已有设备保持原状态,策略冲突由 Bell 管理端显式迁移或取消。同库实时视图不以源记录年龄误判 freshness。
|
- `bell.area_policy_v1` 缺失、非法、版本回退或读取失败时,PostgreSQL repository 拒绝相关新增/启用;`non_imaging_only` 允许非成像设备但拒绝具有 `video_capture` 的设备。已有设备保持原状态,策略冲突由 Bell 管理端显式迁移或取消。同库实时视图不以源记录年龄误判 freshness。
|
||||||
- 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。异步 relay 尚未实现。
|
- 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。relay 最多领取 100 行,以 30 秒数据库 lease 和单调 fencing token 防止过期 worker 确认;成功和 dead letter 都保留本地事实。
|
||||||
|
- Bell 先验证时间窗、nonce 和 constant-time HMAC,再逐项校验 v1/v2 事件;同 nonce/同摘要重放原结果,同 nonce/不同摘要拒绝。`bell.audit_events` 只追加且不自动清理,只有 10 分钟幂等收据允许 Bell runtime 删除过期行。
|
||||||
|
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
|
||||||
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
|
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
|
||||||
|
- T-017 的 100 项内存事件环只服务单路工程演示,重启可丢失且不等同于上述投递队列;Brain→Bell 后续建议 T-019 必须另行实现有界持久 Outbox、身份映射、认证和幂等确认,不能把 demo 内存状态升级为生产 transport。T-018 先实现 Sense 回环 NVR 管理面纵切,不改变该事件链边界。
|
||||||
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
||||||
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
||||||
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
||||||
@@ -97,7 +102,7 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
|
|||||||
## 8. 数据与契约
|
## 8. 数据与契约
|
||||||
|
|
||||||
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及只记录已观察版本的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 为五列,Area v1 固定为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at` 六列。
|
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及只记录已观察版本的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 为五列,Area v1 固定为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at` 六列。
|
||||||
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。正式签名和兼容规则以 [`contracts/`](contracts/) 为准,Bell 管理服务、JWT/OIDC 与 Outbox relay 仍未实现。
|
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。T-016 审计 relay 采用独立外部 HMAC key 文件和内部端点,不等同于 Bell 公共管理认证。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。
|
||||||
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
|
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
|
||||||
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
|
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
|
||||||
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
|
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
|
||||||
@@ -113,17 +118,17 @@ Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trig
|
|||||||
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
||||||
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
||||||
Bell/{web,packs,contracts}
|
Bell/{web,packs,contracts}
|
||||||
deploy/postgres/{001_roles.sql,...,011_privileges_reconcile_safety.sql,tests}
|
deploy/postgres/{001_roles.sql,...,015_privileges_audit_relay.sql,tests}
|
||||||
```
|
```
|
||||||
|
|
||||||
Sense 脚手架和 PostgreSQL `001`~`011` 已实现;Brain/Bell 应用目录仍是占位,真实服务必须由对应任务创建。
|
Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API。Brain 已有 T-017 单路工程原型,包括合成/RTSP source、HOG 演示 detector、track、zone entry、回环服务与自包含 UI;尚无生产模型、GPU pipeline、持久 Outbox 或 Bell ingress。
|
||||||
|
|
||||||
## 10. 开发顺序
|
## 10. 开发顺序
|
||||||
|
|
||||||
- M0 不写生产代码。
|
- M0 不写生产代码。
|
||||||
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
||||||
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
||||||
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
|
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用;按项目负责人调整,T-017 后先补 Sense 回环 NVR 管理面纵切,再继续 Brain→Bell 事件 ingress。
|
||||||
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||||
|
|
||||||
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
||||||
|
|||||||
@@ -36,6 +36,11 @@
|
|||||||
|
|
||||||
- Brain 模型接口、判定内核和 v0.1 mapper。
|
- Brain 模型接口、判定内核和 v0.1 mapper。
|
||||||
- Bell 事件校验、不可变存储和 ULID。
|
- Bell 事件校验、不可变存储和 ULID。
|
||||||
|
- T-015:建立 Bell Go 事件域基础,复制并校验冻结 v0.1 schema,由 Bell 生成平台 ULID,执行六项代码断言,并以 `bell_runtime` 最小权限保存不可变事件和 append-only outcome;不冻结 Brain transport 或公共 API。
|
||||||
|
- T-016:冻结并实现 Sense Outbox → Bell 内部审计 relay;使用 HMAC、nonce 收据、数据库时钟 lease/fencing、逐项确认和 dead letter,在不共享 schema 权限的前提下写入 Bell 不可变全局审计事实。
|
||||||
|
- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。
|
||||||
|
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
|
||||||
|
- 后续建议 T-019:独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress;不得把 T-017 内存事件环当作生产投递。
|
||||||
- 规则引擎、场景包加载、预警状态机与双路径投递。
|
- 规则引擎、场景包加载、预警状态机与双路径投递。
|
||||||
- 最小 Web/App 处置流程、RBAC 与审计。
|
- 最小 Web/App 处置流程、RBAC 与审计。
|
||||||
- 现场误报基线和反馈队列。
|
- 现场误报基线和反馈队列。
|
||||||
|
|||||||
@@ -106,6 +106,14 @@
|
|||||||
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
|
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
|
||||||
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
|
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
|
||||||
|
|
||||||
|
## US-014 演示单路匿名区域事件链
|
||||||
|
|
||||||
|
- 角色:售前/实施工程师、客户项目负责人。
|
||||||
|
- 目标:在没有完整 Bell 业务闭环前,直接看到一条视频源如何形成匿名人员框、track、区域命中和事件候选,并能当场调整多边形区域。
|
||||||
|
- 价值:尽早验证客户能理解的主链路,同时把“工程已连通”和“生产模型效果已验收”严格分开。
|
||||||
|
- 验收:默认合成回放无外部条件即可重复运行且始终标识为 fixture;真实流只通过仓库外配置接入且页面不显示 URL/凭据;HOG 适配器明确不是生产模型,无检测时不伪造人员框;区域支持画布加点和键盘坐标等效路径;候选事实只有 `source_event_id`,不伪造 Bell 平台 ID、Alert 或处置状态。
|
||||||
|
- 关联:RQ-C-11~RQ-C-15,IX-017、IX-024;T-017。
|
||||||
|
|
||||||
## 追溯规则
|
## 追溯规则
|
||||||
|
|
||||||
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
|
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
|
||||||
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
|
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
|
||||||
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
|
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
|
||||||
|
| IX-024 | Brain 单路工程演示 | 固定展示来源类型、fixture/真实检测边界、连接状态、detector 名称、帧序号、处理耗时、匿名 track、区域内外和最近候选;合成/离线数据不得伪装为模型输出。区域支持画布加点、撤销、清空、保存和坐标表单等效路径;保存失败保留草稿。页面不得展示流 URL、凭据、平台事件 ID、Alert 或虚构准确率 | US-014 | M3 |
|
||||||
|
|
||||||
## 全局状态
|
## 全局状态
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
# API 与契约
|
# API 与契约
|
||||||
|
|
||||||
> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1 与 Sense 本地设备审计事件 v1/v2 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1、Sense 本地设备审计事件 v1/v2 与 Sense→Bell 审计 relay v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
||||||
|
|
||||||
## 1. 已冻结:Brain → Bell 事件契约
|
## 1. 已冻结:Brain → Bell 事件契约
|
||||||
|
|
||||||
@@ -17,19 +17,21 @@
|
|||||||
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
|
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
|
||||||
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
|
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
|
||||||
|
|
||||||
|
T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress 先接收“不含平台 `id`”的候选事实,Bell 生成 `evt_` ULID 后形成最终 v0.1 对象,再执行 schema 与六项代码断言并不可变落库。该候选类型是 Bell 内部 port,不是 Brain 可依赖的 HTTP/消息总线协议;transport、认证和重放语义仍由后续任务冻结。
|
||||||
|
|
||||||
## 2. 跨系统接口状态
|
## 2. 跨系统接口状态
|
||||||
|
|
||||||
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
|
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 冻结,T-009 已实现数据路径 |
|
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 冻结,T-009 已实现数据路径 |
|
||||||
| Sense → Bell | 读取 Area 成像准入 | 只读 `bell.area_policy_v1`;`video_allowed | non_imaging_only`;缺失/非法/回退失败关闭但不影响已有设备 | T-010 已冻结并实现数据路径 |
|
| Sense → Bell | 读取 Area 成像准入 | 只读 `bell.area_policy_v1`;`video_allowed | non_imaging_only`;缺失/非法/回退失败关闭但不影响已有设备 | T-010 已冻结并实现数据路径 |
|
||||||
| Sense → Bell | 汇入设备操作审计 | 本地 Outbox 事件已冻结并原子落库;transport、签名、确认与留存未冻结 | T-010 本地基础已实现,relay 待设计 |
|
| Sense → Bell | 汇入设备操作审计 | `POST /internal/v1/audit-events:batch`;1~100 项、1 MiB、10 秒 deadline、HMAC/nonce、逐项确认;非回环必须 HTTPS | T-016 已冻结并实现 |
|
||||||
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
|
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
|
||||||
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
|
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
|
||||||
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
||||||
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
||||||
|
|
||||||
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)、[`contracts/area-policy-v1.sql`](contracts/area-policy-v1.sql) 与 [`contracts/sense-device-audit-v1.schema.json`](contracts/sense-device-audit-v1.schema.json)。Bell 拥有投影源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。
|
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/sense-audit-relay-v1.openapi.json`](contracts/sense-audit-relay-v1.openapi.json)、[`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)、[`contracts/area-policy-v1.sql`](contracts/area-policy-v1.sql) 与 [`contracts/sense-device-audit-v1.schema.json`](contracts/sense-device-audit-v1.schema.json)。Bell 拥有投影源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。
|
||||||
|
|
||||||
## 3. 已冻结:Sense Control API v1
|
## 3. 已冻结:Sense Control API v1
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。
|
- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。
|
||||||
- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。
|
- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。
|
||||||
|
|
||||||
T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入和本地审计基础;T-011 已实现 7 个 HTTP handler、外部静态摘要认证适配器、tenant/Site scope、幂等收据、ETag/HMAC cursor 和持久化 batch operation。业务路由默认关闭且仅可在 PostgreSQL 上开启;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。
|
T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入和本地审计基础;T-011 已实现 7 个 HTTP handler、外部静态摘要认证适配器、tenant/Site scope、幂等收据、ETag/HMAC cursor 和持久化 batch operation。业务路由默认关闭且仅可在 PostgreSQL 上开启;T-016 的审计 relay 是独立内部端点,不替代 Bell 管理服务或 JWT/OIDC。
|
||||||
|
|
||||||
## 4. 待冻结的 Bell 公共 API
|
## 4. 待冻结的 Bell 公共 API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Sense 控制面、准入投影与本地审计契约 v1
|
# Sense 控制面、准入投影与本地审计契约 v1
|
||||||
|
|
||||||
> 冻结日期:2026-08-07。Control API 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-011 已实现 Control API handler 与 PostgreSQL 一致性边界;Bell 管理服务、JWT/OIDC 和 Outbox relay 仍未实现。
|
> 冻结日期:2026-08-11。Control API 与审计 relay 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-016 已实现 Sense Outbox 到 Bell 的内部 relay;Bell 公共管理服务与 JWT/OIDC 仍未实现。
|
||||||
|
|
||||||
## 契约文件
|
## 契约文件
|
||||||
|
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
|
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
|
||||||
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 |
|
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 |
|
||||||
| [`area-policy-v1.sql`](area-policy-v1.sql) | Bell | Sense | Area 归属与 `capture_policy` 只读投影 |
|
| [`area-policy-v1.sql`](area-policy-v1.sql) | Bell | Sense | Area 归属与 `capture_policy` 只读投影 |
|
||||||
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;未来 Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
|
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
|
||||||
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;未来 Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
||||||
|
| [`sense-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
|
||||||
|
|
||||||
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||||
|
|
||||||
@@ -57,7 +58,19 @@ T-010 冻结 `bell.area_policy_v1` 的列顺序为 `tenant_id/site_id/area_id/ca
|
|||||||
|
|
||||||
`sense-device-audit-v1.schema.json` 继续冻结创建与期望态两类事实且不原地扩展严格枚举。T-011 新增 v2 后继,兼容 v1 两类事件并增加 `device.configuration.accepted`;该 payload 只保存是否变化、字段名和 Area 逻辑 ID,不保存字段值。主体类型为 `user | service | system`,投影版本与 generation 随事实保存;endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置始终禁止进入审计。
|
`sense-device-audit-v1.schema.json` 继续冻结创建与期望态两类事实且不原地扩展严格枚举。T-011 新增 v2 后继,兼容 v1 两类事件并增加 `device.configuration.accepted`;该 payload 只保存是否变化、字段名和 Area 逻辑 ID,不保存字段值。主体类型为 `user | service | system`,投影版本与 generation 随事实保存;endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置始终禁止进入审计。
|
||||||
|
|
||||||
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。Schema 不是 Bell relay 协议:传输端点、签名、批量确认、重放窗口和留存由后续任务冻结。
|
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。事件 schema 继续只定义事实;传输由 `sense-audit-relay-v1.openapi.json` 独立冻结。
|
||||||
|
|
||||||
|
## Sense → Bell 审计 relay
|
||||||
|
|
||||||
|
Sense 向 `/internal/v1/audit-events:batch` 每批发送 1~100 个事件,请求体不超过 1 MiB、deadline 10 秒。请求用外部文件中的至少 32 字节 secret 做 HMAC-SHA256,canonical string 为 method、path、Unix 秒、随机 nonce 与 body SHA-256 的换行拼接;非回环地址必须使用 HTTPS。Bell 允许 300 秒时钟偏差并将 `(key_id, nonce)` 收据保留 600 秒:相同请求摘要返回原结果,不同摘要返回 `409 replay_conflict`。
|
||||||
|
|
||||||
|
Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SKIP LOCKED` 协调实例。`accepted/duplicate` 才标记 delivered;逐项 `rejected` 进入 dead letter;网络、5xx、认证失败或缺失结果以 1 秒起步、最多 300 秒指数退避。Sense 不直接访问 Bell schema,Bell 不读取 Sense Outbox;全局 `bell.audit_events` 不自动删除,只有短期 relay receipt 自动过期。
|
||||||
|
|
||||||
|
两端读取同格式的仓库外 key 文件;Bell 可同时接受多个 key,Sense 用 `SENSE_AUDIT_RELAY_KEY_ID` 选择一个,便于先加新 key、切换发送端、再移除旧 key。占位结构如下,`secret_base64url` 必须替换为至少 32 个随机字节的无填充 base64url,不能提交真实值:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"version":1,"keys":[{"key_id":"sense-a","secret_base64url":"<external-secret>"}]}
|
||||||
|
```
|
||||||
|
|
||||||
## 兼容与废弃
|
## 兼容与废弃
|
||||||
|
|
||||||
@@ -73,8 +86,10 @@ PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_
|
|||||||
```powershell
|
```powershell
|
||||||
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
||||||
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
||||||
|
python -m json.tool docs/contracts/sense-audit-relay-v1.openapi.json | Out-Null
|
||||||
python -m unittest discover -s tests -p "test_sense_control_contract.py"
|
python -m unittest discover -s tests -p "test_sense_control_contract.py"
|
||||||
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
||||||
|
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
|
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "YoVision Sense Audit Relay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Internal, signed and idempotent delivery of redacted Sense device audit facts to Bell."
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/internal/v1/audit-events:batch": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "receiveSenseAuditBatch",
|
||||||
|
"description": "Accepts 1-100 events in a body no larger than 1048576 bytes. The request deadline is 10 seconds. HMAC clock skew is at most 300 seconds and nonce receipts live for 600 seconds.",
|
||||||
|
"parameters": [
|
||||||
|
{"name": "X-YoVision-Key-Id", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}},
|
||||||
|
{"name": "X-YoVision-Timestamp", "in": "header", "required": true, "description": "Unix seconds", "schema": {"type": "string", "pattern": "^[0-9]{10,}$"}},
|
||||||
|
{"name": "X-YoVision-Nonce", "in": "header", "required": true, "description": "16-48 random bytes encoded as unpadded base64url", "schema": {"type": "string", "minLength": 22, "maxLength": 64, "pattern": "^[A-Za-z0-9_-]+$"}},
|
||||||
|
{"name": "X-YoVision-Signature", "in": "header", "required": true, "description": "Unpadded base64url HMAC-SHA256 over POST, path, timestamp, nonce and lowercase SHA-256 body digest joined by newlines", "schema": {"type": "string", "minLength": 43, "maxLength": 43}}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BatchRequest"}}}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Stored, duplicate or permanently rejected per item. Same key ID, nonce and request digest returns the original response.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BatchResponse"}}}},
|
||||||
|
"400": {"description": "Malformed batch envelope"},
|
||||||
|
"401": {"description": "Invalid key, timestamp, nonce or signature"},
|
||||||
|
"409": {"description": "Same key ID and nonce used with a different request digest; error is replay_conflict"},
|
||||||
|
"413": {"description": "Body exceeds 1048576 bytes"},
|
||||||
|
"503": {"description": "Bell cannot atomically persist the batch and receipt"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"BatchRequest": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["events"],
|
||||||
|
"properties": {"events": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Envelope"}}}
|
||||||
|
},
|
||||||
|
"Envelope": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["schema_version", "event"],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {"type": "integer", "enum": [1, 2]},
|
||||||
|
"event": {"$ref": "#/components/schemas/AuditEvent"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AuditEvent": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id", "aggregate_generation", "projection_versions", "data", "occurred_at"],
|
||||||
|
"properties": {
|
||||||
|
"event_id": {"type": "string", "pattern": "^audit_[0-9a-f]{32}$"},
|
||||||
|
"event_type": {"type": "string", "enum": ["device.created", "device.desired_state.accepted", "device.configuration.accepted"]},
|
||||||
|
"tenant_id": {"$ref": "#/components/schemas/LogicalId"},
|
||||||
|
"site_id": {"$ref": "#/components/schemas/LogicalId"},
|
||||||
|
"device_id": {"$ref": "#/components/schemas/LogicalId"},
|
||||||
|
"actor": {"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"type": "string", "enum": ["user", "service", "system"]}, "id": {"type": "string", "minLength": 1, "maxLength": 200}}},
|
||||||
|
"reason": {"type": ["string", "null"], "maxLength": 500},
|
||||||
|
"trace_id": {"type": ["string", "null"], "maxLength": 128},
|
||||||
|
"aggregate_generation": {"type": "integer", "minimum": 1},
|
||||||
|
"projection_versions": {"type": "object", "additionalProperties": false, "required": ["quota_source_version", "area_policy_source_version"], "properties": {"quota_source_version": {"type": ["integer", "null"], "minimum": 1}, "area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}}},
|
||||||
|
"data": {"type": "object"},
|
||||||
|
"occurred_at": {"type": "string", "format": "date-time"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LogicalId": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},
|
||||||
|
"BatchResponse": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["results"],
|
||||||
|
"properties": {"results": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Result"}}}
|
||||||
|
},
|
||||||
|
"Result": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["event_id", "status"],
|
||||||
|
"properties": {
|
||||||
|
"event_id": {"type": "string"},
|
||||||
|
"status": {"type": "string", "enum": ["accepted", "duplicate", "rejected"]},
|
||||||
|
"error_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-yovision-signature": {
|
||||||
|
"canonical": "METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nLOWERCASE_SHA256_BODY",
|
||||||
|
"algorithm": "HMAC-SHA256",
|
||||||
|
"encoding": "base64url-no-padding",
|
||||||
|
"clock_skew_seconds": 300,
|
||||||
|
"receipt_ttl_seconds": 600
|
||||||
|
},
|
||||||
|
"x-yovision-delivery": {
|
||||||
|
"lease_seconds": 30,
|
||||||
|
"initial_retry_seconds": 1,
|
||||||
|
"maximum_retry_seconds": 300,
|
||||||
|
"successful_statuses": ["accepted", "duplicate"],
|
||||||
|
"permanent_status": "rejected"
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-9
@@ -1,27 +1,31 @@
|
|||||||
# 当前实现状态
|
# 当前实现状态
|
||||||
|
|
||||||
> 快照日期:2026-08-10。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
|
> 快照日期:2026-08-11。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
|
||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。下一开发重点转向 M3 的 Bell 事件存储与审计 relay 边界。
|
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay 及 Brain 单路匿名区域事件工程原型。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
|
||||||
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 管理服务/JWT、Outbox relay、WireGuard 和完整生产部署仍未实现。
|
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository。Brain 已包含单路 source/detector/track/zone-entry 流水线和回环可视化页,但仍没有生产模型/GPU pipeline、Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或正式 Web/H5。
|
||||||
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
|
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
|
||||||
|
|
||||||
## 仓库现实
|
## 仓库现实
|
||||||
|
|
||||||
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Brain/`、`Bell/` 仍只有目录占位,Bell 的首个数据库源表/视图 migration 位于根目录 `deploy/postgres/`。
|
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 已有 Python 3.10 单路工程原型、19 项单元/HTTP/UI 契约测试和 `docs/design/brain/index.html` 自包含页面。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
|
||||||
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
|
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
|
||||||
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
|
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
|
||||||
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
|
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
|
||||||
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`011` migration 可重放、权限断言和 29 个真实 PostgreSQL 测试通过后自动清理;现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
|
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`015` migration 可重放;Sense Outbox fencing、Bell event/global-audit repository、nonce 收据、权限、幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
|
||||||
|
- T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。
|
||||||
|
- T-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。
|
||||||
|
- T-017 冻结的只是工程原型:Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80;默认 2 FPS 合成 fixture,可选从仓库外文件读取 MediaMTX/RTSP。合成框不是模型输出,HOG/SVM 不是生产 detector,100 项内存事件环不是可靠投递;候选不含 Bell 平台 ID。
|
||||||
|
- T-018 Sense 回环工程控制台已通过项目负责人产品验收:默认关闭并强制 Sense/MediaMTX 播放端显式回环,直接读取 Control API v1 的设备、配额、期望态/实际态/收敛事实;设备 cursor 每页 16 项,只有成像、启用、在线且已收敛的设备可选,最多 4 路按需嵌入 MediaMTX WebRTC 页面。token 只驻留页面内存,刷新即丢失;该验收不包含录像/回放、非回环生产认证、真实 16 机或生产 SLA。
|
||||||
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
|
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
|
||||||
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
|
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
|
||||||
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
|
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
|
||||||
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
|
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
|
||||||
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
||||||
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
||||||
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现:外部静态 SHA-256 主体注册表、tenant/Site scope、HMAC cursor、ETag、PostgreSQL 24 小时幂等收据和最多 128 项 batch operation 均有代码与隔离集成测试。默认 SQLite 只暴露运维探针与低基数 `/metrics`,不注册业务路由;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。
|
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json` 已由 T-016 实现。默认 SQLite 只暴露运维探针与低基数 `/metrics`,不注册业务路由或 relay;Bell 管理服务与 JWT/OIDC 尚未实现。
|
||||||
- T-012 把 PostgreSQL schema 提升到 v5:due row 用数据库时钟、`FOR UPDATE SKIP LOCKED`、逐项续租和 fencing token 协调;MediaMTX Path 历史归属、15 分钟孤儿快照、最多 10%/128 项安全闸、无 bypass 的本地处置命令及 `/metrics` 已实现。SQLite 明确保留单实例开发语义。
|
- T-012 把 PostgreSQL schema 提升到 v5:due row 用数据库时钟、`FOR UPDATE SKIP LOCKED`、逐项续租和 fencing token 协调;MediaMTX Path 历史归属、15 分钟孤儿快照、最多 10%/128 项安全闸、无 bypass 的本地处置命令及 `/metrics` 已实现。SQLite 明确保留单实例开发语义。
|
||||||
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
|
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
|
||||||
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
||||||
@@ -43,6 +47,22 @@ Windows:
|
|||||||
go -C Sense run ./cmd/sense-api
|
go -C Sense run ./cmd/sense-api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bell 事件域和内部审计 receiver 验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Brain 单路工程原型验证与启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
||||||
|
python -m compileall -q Brain
|
||||||
|
python -m Brain.yovision_brain --source synthetic
|
||||||
|
```
|
||||||
|
|
||||||
跨平台直接验证:
|
跨平台直接验证:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -76,17 +96,18 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
|
|||||||
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
|
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
|
||||||
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
||||||
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
||||||
- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
|
- 生产 Brain 的 Python/Savant/DeepStream 精确版本、目标硬件和 Bell 前端栈尚未冻结;T-017 的 Python/OpenCV 只适用于工程原型,不能外推为生产选择。Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
|
||||||
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-014 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色、外部 Control API 安全文件与备份方案。
|
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-016 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/relay key 文件、TLS 证书与备份方案。
|
||||||
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
客户网络仍未提供,T-013 WireGuard 继续后置。下一项建议拆分并冻结 M3 的 Bell 不可变事件接收/存储,以及 Sense 设备操作 Outbox 到 Bell 全局审计的幂等 relay 协议与实现;两者不要在一个任务中混写。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014 只解除本地默认 16 路软件基线缺口,不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。
|
客户网络仍未提供,T-013 WireGuard 继续后置。T-018 Sense NVR 管理面已通过产品验收,下一项建议创建并实施 T-019,独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。
|
||||||
|
|
||||||
## 已知风险
|
## 已知风险
|
||||||
|
|
||||||
- M0 临时 NVR 容易被误当生产基线,必须持续隔离。
|
- M0 临时 NVR 容易被误当生产基线,必须持续隔离。
|
||||||
- 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。
|
- 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。
|
||||||
- 未经真实硬件压测不能给出 GPU 路数承诺。
|
- 未经真实硬件压测不能给出 GPU 路数承诺。
|
||||||
|
- 合成 fixture 和 HOG 演示框容易被误读为生产 AI 效果;页面、日志和交付说明必须持续显示“非模型输出/非生产模型”,效果验收必须使用冻结模型、数据集和目标硬件。
|
||||||
- Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。
|
- Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
<!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">
|
||||||
|
<meta name="brain-demo-token" content="__BRAIN_DEMO_TOKEN__">
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<title>YoVision Brain · 单路区域事件工程原型</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1120;
|
||||||
|
--surface: #111a2b;
|
||||||
|
--surface-2: #172236;
|
||||||
|
--surface-3: #1e2c43;
|
||||||
|
--text: #f5f7fb;
|
||||||
|
--muted: #aebbd0;
|
||||||
|
--subtle: #8291a9;
|
||||||
|
--line: #2b3a52;
|
||||||
|
--primary: #8b7cf6;
|
||||||
|
--primary-strong: #7567e8;
|
||||||
|
--cyan: #38bdf8;
|
||||||
|
--success: #34d399;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--danger: #fb7185;
|
||||||
|
--focus: #b8e5ff;
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-width: 320px; background: var(--bg); }
|
||||||
|
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.55 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||||
|
button, input, textarea { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.skip-link { position: fixed; z-index: 100; top: 8px; left: 8px; padding: 10px 14px; border-radius: 8px; background: white; color: #101827; transform: translateY(-150%); }
|
||||||
|
.skip-link:focus { transform: none; }
|
||||||
|
.prototype-banner { min-height: 30px; padding: 5px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f4c84a; color: #241b00; font-size: 12px; font-weight: 800; text-align: center; letter-spacing: .04em; }
|
||||||
|
.app-shell { width: min(1540px, 100%); margin: 0 auto; padding: 0 18px 24px; }
|
||||||
|
.topbar { min-height: 72px; display: flex; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; min-width: 230px; }
|
||||||
|
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--primary), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px rgba(56, 189, 248, .18); }
|
||||||
|
.brand strong, .brand span { display: block; }
|
||||||
|
.brand strong { font-size: 16px; }
|
||||||
|
.brand span { color: var(--muted); font-size: 12px; }
|
||||||
|
.top-context { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
|
.chip { min-height: 32px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; }
|
||||||
|
.chip strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
|
||||||
|
.status-dot.online { background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
|
||||||
|
.status-dot.warning { background: var(--warning); box-shadow: 0 0 0 4px rgba(251, 191, 36, .12); }
|
||||||
|
main { padding-top: 18px; }
|
||||||
|
.page-head { margin-bottom: 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||||
|
.page-head h1 { margin: 0 0 4px; font-size: clamp(22px, 2.4vw, 30px); line-height: 1.25; }
|
||||||
|
.page-head p { max-width: 800px; margin: 0; color: var(--muted); }
|
||||||
|
.mode-badge { min-height: 36px; padding: 7px 11px; border: 1px solid rgba(56, 189, 248, .35); border-radius: 9px; background: rgba(56, 189, 248, .09); color: #b8e5ff; font-weight: 750; white-space: nowrap; }
|
||||||
|
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .65fr); gap: 14px; }
|
||||||
|
.card { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
.card-head { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
.card-head h2 { margin: 0; font-size: 15px; }
|
||||||
|
.card-head p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
|
||||||
|
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.btn { min-height: 44px; padding: 8px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-weight: 750; transition: border-color .18s ease, background .18s ease, opacity .18s ease; }
|
||||||
|
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
|
||||||
|
.btn:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
.btn-primary { border-color: var(--primary-strong); background: var(--primary-strong); color: white; }
|
||||||
|
.btn-quiet { background: transparent; }
|
||||||
|
.icon { width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.video-stage { position: relative; aspect-ratio: 16/9; background: #050912; overflow: hidden; }
|
||||||
|
.video-stage img, .video-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
.video-stage img { object-fit: contain; background: #050912; }
|
||||||
|
.video-stage canvas { touch-action: manipulation; }
|
||||||
|
.video-label { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 8px; background: rgba(3, 8, 18, .78); color: #dce6f5; font-size: 12px; backdrop-filter: blur(8px); pointer-events: none; }
|
||||||
|
.video-footer { min-height: 48px; padding: 8px 13px; display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
||||||
|
.video-footer strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.legend i { width: 12px; height: 3px; display: inline-block; background: var(--cyan); }
|
||||||
|
.legend .zone-key { background: var(--warning); }
|
||||||
|
.metrics { padding: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); }
|
||||||
|
.metric { min-height: 82px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||||
|
.metric span { display: block; color: var(--muted); font-size: 12px; }
|
||||||
|
.metric strong { display: block; margin-top: 8px; font-size: 20px; font-variant-numeric: tabular-nums; }
|
||||||
|
.side-stack { display: grid; align-content: start; gap: 14px; }
|
||||||
|
.form-body { padding: 14px; display: grid; gap: 12px; }
|
||||||
|
label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 750; }
|
||||||
|
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
|
||||||
|
textarea { min-height: 124px; resize: vertical; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.helper { margin: 0; color: var(--subtle); font-size: 12px; }
|
||||||
|
.form-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.inline-state { min-height: 42px; padding: 9px 10px; border-left: 3px solid var(--cyan); background: rgba(56, 189, 248, .07); color: var(--muted); font-size: 12px; }
|
||||||
|
.event-list { max-height: 330px; padding: 8px; display: grid; gap: 7px; overflow-y: auto; }
|
||||||
|
.event { padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||||
|
.event-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.event strong { font-size: 13px; }
|
||||||
|
.event time, .event code { color: var(--muted); font-size: 12px; }
|
||||||
|
.event code { display: block; margin-top: 6px; overflow-wrap: anywhere; }
|
||||||
|
.event-tag { padding: 2px 7px; border: 1px solid rgba(251, 191, 36, .35); border-radius: 999px; background: rgba(251, 191, 36, .08); color: #ffe18a; font-size: 11px; }
|
||||||
|
.empty { min-height: 120px; padding: 26px 16px; display: grid; place-items: center; color: var(--muted); text-align: center; }
|
||||||
|
.architecture-note { margin-top: 14px; padding: 13px 15px; display: grid; grid-template-columns: auto 1fr; gap: 11px; border: 1px solid rgba(139, 124, 246, .30); border-radius: 12px; background: rgba(139, 124, 246, .07); color: var(--muted); }
|
||||||
|
.architecture-note strong { color: var(--text); }
|
||||||
|
.architecture-note p { margin: 2px 0 0; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.side-stack { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.app-shell { padding-inline: 12px; }
|
||||||
|
.topbar { align-items: flex-start; flex-direction: column; padding: 12px 0; }
|
||||||
|
.top-context { margin-left: 0; justify-content: flex-start; }
|
||||||
|
.page-head { flex-direction: column; }
|
||||||
|
.mode-badge { white-space: normal; }
|
||||||
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.side-stack { grid-template-columns: 1fr; }
|
||||||
|
.card-head { align-items: flex-start; flex-direction: column; }
|
||||||
|
.toolbar { width: 100%; }
|
||||||
|
.toolbar .btn { flex: 1; }
|
||||||
|
.video-footer { align-items: flex-start; flex-direction: column; }
|
||||||
|
.legend { margin-left: 0; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.prototype-banner { letter-spacing: 0; }
|
||||||
|
.chip { width: 100%; justify-content: flex-start; }
|
||||||
|
.toolbar { display: grid; grid-template-columns: 1fr; }
|
||||||
|
.toolbar .btn { width: 100%; min-width: 0; }
|
||||||
|
.metrics { grid-template-columns: 1fr 1fr; }
|
||||||
|
.metric strong { font-size: 17px; }
|
||||||
|
.form-actions { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
<div class="prototype-banner">
|
||||||
|
工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺
|
||||||
|
</div>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand" aria-label="YoVision Brain">
|
||||||
|
<div class="brand-mark" aria-hidden="true">Y</div>
|
||||||
|
<div><strong>YoVision Brain</strong><span>单路匿名区域事件原型</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="top-context" aria-label="运行上下文">
|
||||||
|
<span class="chip"><i id="connection-dot" class="status-dot warning" aria-hidden="true"></i><span id="connection-text">正在连接</span></span>
|
||||||
|
<span class="chip">来源 <strong id="source-label">合成回放</strong></span>
|
||||||
|
<span class="chip">区域版本 <strong id="zone-version">v1</strong></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" tabindex="-1">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>单路检测与区域判定</h1>
|
||||||
|
<p>验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。平台事件 ID、持久投递和 Alert 由后续 Bell 链路负责。</p>
|
||||||
|
</div>
|
||||||
|
<div id="mode-badge" class="mode-badge">加载运行状态</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card" aria-labelledby="preview-title">
|
||||||
|
<div class="card-head">
|
||||||
|
<div><h2 id="preview-title">实时预览</h2><p id="preview-description">检测框与区域均使用归一化坐标叠加</p></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="toggle-preview" class="btn btn-quiet" type="button" aria-pressed="false">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
<span>暂停刷新</span>
|
||||||
|
</button>
|
||||||
|
<button id="edit-zone" class="btn" type="button" aria-pressed="false">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 4 6 6L8 22H2v-6zM12 6l6 6"/></svg>
|
||||||
|
<span>画布加点</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-stage">
|
||||||
|
<img id="video-frame" alt="单路视频画面;检测框与区域由叠加层描述" width="960" height="540">
|
||||||
|
<canvas id="overlay" width="960" height="540" role="img" aria-label="匿名人员检测框与危险区域叠加;键盘用户可使用右侧坐标表单编辑区域"></canvas>
|
||||||
|
<div id="video-label" class="video-label">等待首帧</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-footer">
|
||||||
|
<span>帧序号 <strong id="sequence">—</strong></span>
|
||||||
|
<span>采集时间 <strong id="captured-at">—</strong></span>
|
||||||
|
<div class="legend" aria-label="画面图例">
|
||||||
|
<span><i aria-hidden="true"></i>匿名人员框</span>
|
||||||
|
<span><i class="zone-key" aria-hidden="true"></i>活动区域</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metrics" aria-label="推理摘要">
|
||||||
|
<article class="metric"><span>目标处理帧率</span><strong id="fps">2.0 FPS</strong></article>
|
||||||
|
<article class="metric"><span>单帧耗时</span><strong id="latency">—</strong></article>
|
||||||
|
<article class="metric"><span>当前人员</span><strong id="person-count">0</strong></article>
|
||||||
|
<article class="metric"><span>区域内人员</span><strong id="inside-count">0</strong></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side-stack" aria-label="区域配置与事件候选">
|
||||||
|
<section class="card" aria-labelledby="zone-title">
|
||||||
|
<div class="card-head">
|
||||||
|
<div><h2 id="zone-title">活动区域</h2><p>3–32 个归一化顶点;保存后重置 track 区域状态</p></div>
|
||||||
|
</div>
|
||||||
|
<form id="zone-form" class="form-body">
|
||||||
|
<label for="zone-name">区域名称
|
||||||
|
<input id="zone-name" name="zone-name" maxlength="80" autocomplete="off" value="楼梯口危险区">
|
||||||
|
</label>
|
||||||
|
<label for="zone-points">顶点坐标(每行 x,y)
|
||||||
|
<textarea id="zone-points" name="zone-points" spellcheck="false" aria-describedby="zone-helper">0.55,0.30 0.90,0.30 0.90,0.88 0.55,0.88</textarea>
|
||||||
|
</label>
|
||||||
|
<p id="zone-helper" class="helper">坐标范围 0.00–1.00。可直接输入,或启用“画布加点”后点击画面;无需精确鼠标操作。</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="undo-point" class="btn" type="button">撤销一点</button>
|
||||||
|
<button id="clear-points" class="btn" type="button">清空顶点</button>
|
||||||
|
</div>
|
||||||
|
<button id="save-zone" class="btn btn-primary" type="submit">保存区域版本</button>
|
||||||
|
<div id="form-state" class="inline-state" role="status" aria-live="polite">区域尚未修改。</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="event-title">
|
||||||
|
<div class="card-head">
|
||||||
|
<div><h2 id="event-title">最近候选事实</h2><p>只产生 source_event_id;Bell 才生成平台 ULID</p></div>
|
||||||
|
<span id="event-count" class="chip"><strong>0</strong> 项</span>
|
||||||
|
</div>
|
||||||
|
<div id="event-list" class="event-list" aria-live="polite">
|
||||||
|
<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="architecture-note" aria-label="系统边界说明">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||||
|
<div><strong>边界清楚比动画更重要</strong><p>Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。</p></div>
|
||||||
|
</section>
|
||||||
|
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const frame = $("video-frame");
|
||||||
|
const canvas = $("overlay");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const token = document.querySelector('meta[name="brain-demo-token"]').content;
|
||||||
|
let runtimeAvailable = location.protocol === "http:" || location.protocol === "https:";
|
||||||
|
let paused = false;
|
||||||
|
let editMode = false;
|
||||||
|
let state = null;
|
||||||
|
let offlineSequence = 0;
|
||||||
|
let offlineTriggered = false;
|
||||||
|
|
||||||
|
const offlineState = () => {
|
||||||
|
offlineSequence += 1;
|
||||||
|
const phase = ((offlineSequence - 1) % 160) / 159;
|
||||||
|
const center = -0.04 + phase * 1.08;
|
||||||
|
const bbox = [Math.max(0, center - .04), .34, Math.min(1, center + .04), .82];
|
||||||
|
const inside = center >= .55 && center <= .90;
|
||||||
|
const events = state && state.events ? state.events : [];
|
||||||
|
if (inside && !offlineTriggered) {
|
||||||
|
offlineTriggered = true;
|
||||||
|
events.unshift({source_event_id: `BRN-OFFLINE-${String(offlineSequence).padStart(6, "0")}`, kind: "zone_entry", track_id: "P-DEMO-001", zone_id: "zone-demo-01", occurred_at: new Date().toISOString(), fixture: true});
|
||||||
|
}
|
||||||
|
if (!inside && center < .50) offlineTriggered = false;
|
||||||
|
return {
|
||||||
|
prototype: true,
|
||||||
|
notice: "离线 HTML 原型数据;不是服务端或模型输出。",
|
||||||
|
source: {mode: "offline", label: "离线原型回放", fixture: true, connected: true, ref: "prototype-only"},
|
||||||
|
detector: {name: "scripted_prototype", production_ready: false},
|
||||||
|
frame: {sequence: offlineSequence, width: 960, height: 540, captured_at: new Date().toISOString()},
|
||||||
|
inference: {target_fps: 2, latency_ms: 0},
|
||||||
|
zone: state && state.zone ? state.zone : {id: "zone-demo-01", name: "楼梯口危险区", version: 1, points: parsePoints(false)},
|
||||||
|
detections: bbox[2] - bbox[0] > .01 ? [{track_id: "P-DEMO-001", class: "person", bbox, detector_score: null, inside_zone: inside}] : [],
|
||||||
|
events,
|
||||||
|
last_error_code: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePoints(showError = true) {
|
||||||
|
try {
|
||||||
|
const points = $("zone-points").value.split(/\r?\n/).filter(line => line.trim()).map(line => {
|
||||||
|
const parts = line.split(",").map(value => Number(value.trim()));
|
||||||
|
if (parts.length !== 2 || parts.some(value => !Number.isFinite(value) || value < 0 || value > 1)) throw new Error();
|
||||||
|
return {x: parts[0], y: parts[1]};
|
||||||
|
});
|
||||||
|
if (points.length < 3 || points.length > 32) throw new Error();
|
||||||
|
return points;
|
||||||
|
} catch (_) {
|
||||||
|
if (showError) setFormState("请输入 3–32 行有效坐标,每个值须在 0.00–1.00。", true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPoints(points) {
|
||||||
|
$("zone-points").value = points.map(point => `${Number(point.x).toFixed(3)},${Number(point.y).toFixed(3)}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormState(message, error = false) {
|
||||||
|
const target = $("form-state");
|
||||||
|
target.textContent = message;
|
||||||
|
target.style.borderLeftColor = error ? "var(--danger)" : "var(--cyan)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? "—" : new Intl.DateTimeFormat("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOfflineBackground() {
|
||||||
|
context.fillStyle = "#111a2b";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = "#1e2c43";
|
||||||
|
context.fillRect(0, canvas.height * .72, canvas.width, canvas.height * .28);
|
||||||
|
context.strokeStyle = "#2b3a52";
|
||||||
|
for (let x = 0; x < canvas.width; x += 80) {
|
||||||
|
context.beginPath(); context.moveTo(x, canvas.height * .72); context.lineTo(x + 80, canvas.height); context.stroke();
|
||||||
|
}
|
||||||
|
context.fillStyle = "#7dd3fc";
|
||||||
|
context.font = "700 18px Segoe UI";
|
||||||
|
context.fillText("OFFLINE PROTOTYPE DATA", 24, 38);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlay() {
|
||||||
|
if (!state) return;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (!runtimeAvailable) drawOfflineBackground();
|
||||||
|
const points = parsePoints(false).length ? parsePoints(false) : state.zone.points;
|
||||||
|
if (points.length >= 3) {
|
||||||
|
context.beginPath();
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
const x = point.x * canvas.width, y = point.y * canvas.height;
|
||||||
|
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
|
||||||
|
});
|
||||||
|
context.closePath();
|
||||||
|
context.fillStyle = "rgba(251, 191, 36, .10)";
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = "#fbbf24";
|
||||||
|
context.lineWidth = 3;
|
||||||
|
context.stroke();
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
context.beginPath(); context.arc(point.x * canvas.width, point.y * canvas.height, 6, 0, Math.PI * 2);
|
||||||
|
context.fillStyle = "#fbbf24"; context.fill();
|
||||||
|
context.fillStyle = "#111827"; context.font = "700 10px Segoe UI"; context.fillText(String(index + 1), point.x * canvas.width - 3, point.y * canvas.height + 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.detections.forEach(item => {
|
||||||
|
const [x1, y1, x2, y2] = item.bbox;
|
||||||
|
const x = x1 * canvas.width, y = y1 * canvas.height, width = (x2 - x1) * canvas.width, height = (y2 - y1) * canvas.height;
|
||||||
|
context.strokeStyle = item.inside_zone ? "#fb7185" : "#38bdf8";
|
||||||
|
context.lineWidth = 3;
|
||||||
|
context.strokeRect(x, y, width, height);
|
||||||
|
const label = `${item.track_id} · ${item.inside_zone ? "区域内" : "区域外"}`;
|
||||||
|
context.font = "700 14px Segoe UI";
|
||||||
|
const labelWidth = context.measureText(label).width + 14;
|
||||||
|
context.fillStyle = item.inside_zone ? "#be4058" : "#0878a4";
|
||||||
|
context.fillRect(x, Math.max(0, y - 25), labelWidth, 24);
|
||||||
|
context.fillStyle = "#fff";
|
||||||
|
context.fillText(label, x + 7, Math.max(17, y - 8));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvents(events) {
|
||||||
|
$("event-count").innerHTML = `<strong>${events.length}</strong> 项`;
|
||||||
|
if (!events.length) {
|
||||||
|
$("event-list").innerHTML = '<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("event-list").replaceChildren(...events.slice(0, 100).map(event => {
|
||||||
|
const article = document.createElement("article"); article.className = "event";
|
||||||
|
const head = document.createElement("div"); head.className = "event-head";
|
||||||
|
const title = document.createElement("strong"); title.textContent = `${event.track_id} 进入 ${state.zone.name}`;
|
||||||
|
const tag = document.createElement("span"); tag.className = "event-tag"; tag.textContent = event.fixture ? "夹具候选" : "检测候选";
|
||||||
|
head.append(title, tag);
|
||||||
|
const time = document.createElement("time"); time.dateTime = event.occurred_at; time.textContent = `${formatTime(event.occurred_at)} · ${event.kind}`;
|
||||||
|
const code = document.createElement("code"); code.textContent = event.source_event_id;
|
||||||
|
article.append(head, time, code); return article;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(next) {
|
||||||
|
state = next;
|
||||||
|
$("connection-dot").className = `status-dot ${next.source.connected ? "online" : "warning"}`;
|
||||||
|
$("connection-text").textContent = next.source.connected ? "画面在线" : "来源不可用";
|
||||||
|
$("source-label").textContent = next.source.label;
|
||||||
|
$("zone-version").textContent = `v${next.zone.version}`;
|
||||||
|
$("mode-badge").textContent = next.source.fixture ? "夹具模式 · 非模型输出" : "HOG 演示适配器 · 非生产模型";
|
||||||
|
$("sequence").textContent = String(next.frame.sequence);
|
||||||
|
$("captured-at").textContent = formatTime(next.frame.captured_at);
|
||||||
|
$("fps").textContent = `${Number(next.inference.target_fps).toFixed(1)} FPS`;
|
||||||
|
$("latency").textContent = next.inference.latency_ms == null ? "—" : `${Number(next.inference.latency_ms).toFixed(1)} ms`;
|
||||||
|
$("person-count").textContent = String(next.detections.length);
|
||||||
|
$("inside-count").textContent = String(next.detections.filter(item => item.inside_zone).length);
|
||||||
|
$("video-label").textContent = `${next.source.label} · ${next.detector.name} · ${next.notice}`;
|
||||||
|
if (document.activeElement !== $("zone-name") && document.activeElement !== $("zone-points")) {
|
||||||
|
$("zone-name").value = next.zone.name; setPoints(next.zone.points);
|
||||||
|
}
|
||||||
|
renderEvents(next.events);
|
||||||
|
drawOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (paused) return;
|
||||||
|
if (runtimeAvailable) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/state", {cache: "no-store"});
|
||||||
|
if (!response.ok) throw new Error();
|
||||||
|
const next = await response.json();
|
||||||
|
frame.hidden = false;
|
||||||
|
frame.src = `/api/v1/frame.jpg?t=${encodeURIComponent(next.frame.sequence)}`;
|
||||||
|
render(next);
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
runtimeAvailable = false;
|
||||||
|
frame.hidden = true;
|
||||||
|
setFormState("未连接本地 Brain 服务,已切换为离线原型数据。", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render(offlineState());
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.addEventListener("load", drawOverlay);
|
||||||
|
$("toggle-preview").addEventListener("click", event => {
|
||||||
|
paused = !paused;
|
||||||
|
event.currentTarget.setAttribute("aria-pressed", String(paused));
|
||||||
|
event.currentTarget.querySelector("span").textContent = paused ? "继续刷新" : "暂停刷新";
|
||||||
|
$("announcer").textContent = paused ? "预览刷新已暂停" : "预览刷新已继续";
|
||||||
|
if (!paused) poll();
|
||||||
|
});
|
||||||
|
$("edit-zone").addEventListener("click", event => {
|
||||||
|
editMode = !editMode;
|
||||||
|
event.currentTarget.setAttribute("aria-pressed", String(editMode));
|
||||||
|
event.currentTarget.querySelector("span").textContent = editMode ? "结束加点" : "画布加点";
|
||||||
|
setFormState(editMode ? "画布加点已启用;键盘用户可继续使用坐标表单。" : "画布加点已结束。", false);
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointerdown", event => {
|
||||||
|
if (!editMode) return;
|
||||||
|
const points = parsePoints(false);
|
||||||
|
if (points.length >= 32) { setFormState("区域最多 32 个顶点。", true); return; }
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
points.push({x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))});
|
||||||
|
setPoints(points); drawOverlay(); setFormState(`已添加第 ${points.length} 个顶点,尚未保存。`);
|
||||||
|
});
|
||||||
|
$("undo-point").addEventListener("click", () => {
|
||||||
|
const points = parsePoints(false); if (points.length) points.pop(); setPoints(points); drawOverlay(); setFormState("已撤销最后一个顶点,尚未保存。", false);
|
||||||
|
});
|
||||||
|
$("clear-points").addEventListener("click", () => { $("zone-points").value = ""; drawOverlay(); setFormState("顶点已清空;至少输入 3 个顶点后才能保存。", false); });
|
||||||
|
$("zone-points").addEventListener("input", drawOverlay);
|
||||||
|
$("zone-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const points = parsePoints(true); if (!points.length) return;
|
||||||
|
const name = $("zone-name").value.trim();
|
||||||
|
if (!name) { setFormState("请输入区域名称。", true); $("zone-name").focus(); return; }
|
||||||
|
const button = $("save-zone"); button.disabled = true; button.textContent = "正在保存";
|
||||||
|
if (runtimeAvailable) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/zones/active", {method: "PUT", headers: {"Content-Type": "application/json", "X-Brain-Demo-Token": token}, body: JSON.stringify({name, points})});
|
||||||
|
if (!response.ok) throw new Error();
|
||||||
|
const zone = await response.json();
|
||||||
|
state.zone = zone; render(state); setFormState(`区域 v${zone.version} 已保存;track 区域状态已重新建立。`);
|
||||||
|
} catch (_) { setFormState("区域保存失败,请检查本地服务后重试;草稿仍保留。", true); }
|
||||||
|
} else {
|
||||||
|
state.zone = {...state.zone, name, points, version: state.zone.version + 1}; render(state); setFormState(`离线原型区域 v${state.zone.version} 已在当前页面保存,刷新后不会保留。`);
|
||||||
|
}
|
||||||
|
button.disabled = false; button.textContent = "保存区域版本";
|
||||||
|
});
|
||||||
|
|
||||||
|
poll();
|
||||||
|
window.setInterval(poll, 500);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -85,7 +85,7 @@ mapper 的完整实现规格。左列出处见《07》§1。
|
|||||||
|
|
||||||
已实测(`REJECT` = schema 能拦,`ACCEPT` = 拦不住):
|
已实测(`REJECT` = schema 能拦,`ACCEPT` = 拦不住):
|
||||||
|
|
||||||
schema **能**拦:省略必填键 / 非法 ULID / 顶层多余字段 / outcome 越界 / `config_version` 空串 / `snapshot_uris` 空数组 / 负 latency / kind 大写。
|
schema **能**拦:省略必填键 / 非法 ULID / 顶层多余字段 / outcome 越界 / `config_version` 空串 / 负 latency / kind 大写。`snapshot_uris` 空数组对非成像事件是合法值,见修订记录、雷达示例和 schema 的 `minItems: 0`。
|
||||||
|
|
||||||
schema **拦不住**,需在 mapper 与平台入口各加一道断言:
|
schema **拦不住**,需在 mapper 与平台入口各加一道断言:
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -20,6 +20,15 @@
|
|||||||
|
|
||||||
这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。
|
这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。
|
||||||
|
|
||||||
|
## 工程演示路由
|
||||||
|
|
||||||
|
| 路由 | 所有者 | 关键约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 |
|
||||||
|
| `/sense-console/` | Sense T-018 回环工程服务 | 默认关闭;复用 Control API v1,设备分页默认 16 项,最多 4 路按需 WebRTC 预览。只允许 Sense 与 MediaMTX 播放端均为显式回环地址;不是录像/回放或 Bell 公共业务路由 |
|
||||||
|
|
||||||
|
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页或 T-018 Sense 回环控制台暴露到非可信网络或嵌入客户公共系统。
|
||||||
|
|
||||||
## App 候选导航
|
## App 候选导航
|
||||||
|
|
||||||
- 预警:待确认与升级中的事件。
|
- 预警:待确认与升级中的事件。
|
||||||
@@ -44,6 +53,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审
|
|||||||
|
|
||||||
## 组件归属
|
## 组件归属
|
||||||
|
|
||||||
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain。
|
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页,不改变该归属。
|
||||||
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
|
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
|
||||||
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
|
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
|
||||||
|
|||||||
+11
-4
@@ -3,12 +3,12 @@ id: T-015
|
|||||||
title: 建立 Bell 事件 v0.1 校验与不可变 PostgreSQL 存储
|
title: 建立 Bell 事件 v0.1 校验与不可变 PostgreSQL 存储
|
||||||
phase: 3
|
phase: 3
|
||||||
deps: [T-014]
|
deps: [T-014]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-08-10
|
created: 2026-08-10
|
||||||
issue: 51
|
issue: 51
|
||||||
context_ref: null
|
context_ref: 5b42f58f23255831067f44206849344909d74ee9
|
||||||
claim_branch: null
|
claim_branch: claims/T-015
|
||||||
work_branch: null
|
work_branch: agent/codex/T-015
|
||||||
write_paths:
|
write_paths:
|
||||||
- docs/tasks/T-015.md
|
- docs/tasks/T-015.md
|
||||||
- Bell/
|
- Bell/
|
||||||
@@ -18,6 +18,7 @@ write_paths:
|
|||||||
- deploy/postgres/README.md
|
- deploy/postgres/README.md
|
||||||
- scripts/test_postgres.ps1
|
- scripts/test_postgres.ps1
|
||||||
- tests/test_bell_event_contract.py
|
- tests/test_bell_event_contract.py
|
||||||
|
- tests/test_postgres_contract.py
|
||||||
- docs/raw/contracts/README.md
|
- docs/raw/contracts/README.md
|
||||||
- docs/00-ai-start-here.md
|
- docs/00-ai-start-here.md
|
||||||
- docs/03-tech-stack.md
|
- docs/03-tech-stack.md
|
||||||
@@ -83,3 +84,9 @@ M3 要求 Bell 首次真实消费冻结的事件契约 v0.1,生成平台 ULID
|
|||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-08-10:按 M3 建议拆出本任务;完成仓库/远端状态检查和 `./init.ps1` 基线,现有 58 个 Python 测试及 Sense generate/test/vet/build 全部通过。实现尚未开始。
|
- 2026-08-10:按 M3 建议拆出本任务;完成仓库/远端状态检查和 `./init.ps1` 基线,现有 58 个 Python 测试及 Sense generate/test/vet/build 全部通过。实现尚未开始。
|
||||||
|
- 2026-08-10:冻结 Bell Go 1.26.5、`jsonschema/v6 v6.0.2`、`ulid/v2 v2.1.2`,复用 PostgreSQL 17.10 / pgx v5.10.0;建立 candidate→Bell ULID→最终 v0.1 schema/语义校验。三个冻结示例均通过,负向测试覆盖上游自报 ID、未知字段、时间/延迟、confidence、primary、证据脱敏、隐私拒绝/不可用和 1 MiB 上限。
|
||||||
|
- 2026-08-10:新增 `bell.events` 与 `bell.event_outcomes`、不可变 trigger 和独立 NOLOGIN `bell_runtime`。repository 验证同 ID/同摘要幂等、同 ID/不同摘要冲突、outcome 仅追加,以及运行登录无 `UPDATE/DELETE/TRUNCATE`。校正事件 README 中与 schema/雷达示例冲突的“空 snapshot 数组会被拒绝”旧句,未修改冻结 schema。
|
||||||
|
- 2026-08-10:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...` 通过;`python -m unittest discover -s tests -p "test_bell_event_contract.py" -v` 5/5 通过,`test_postgres_contract.py` 8/8 通过。
|
||||||
|
- 2026-08-10:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过;PostgreSQL 17.10 临时集群将 `001`~`013` migration 重放两次,Sense/Bell 真实 repository 与权限断言通过,随机端口、临时目录均清理,现有 5432 listener 未改变。
|
||||||
|
- 2026-08-10:更新根 `init.ps1`/`init.sh` 后运行 `./init.ps1` 通过:64 个 Python 测试、Sense generate/test/vet/build 和 Bell test/vet/build 全部成功。人工/设备/UI 验收不适用。
|
||||||
|
- 边界:本任务没有可部署 Bell API;Brain transport、认证、整数事件 ID 与文本逻辑 ID 映射及生产隐私 resolver 仍待后续任务,内部 Go factory 不能被表述为已上线跨系统入口。
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
id: T-016
|
||||||
|
title: 冻结并实现 Sense Outbox 到 Bell 全局审计幂等 relay
|
||||||
|
phase: 3
|
||||||
|
deps: [T-015]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-10
|
||||||
|
issue: 55
|
||||||
|
context_ref: aca22f4667f0da77a102eb7ba15a93c0128df656
|
||||||
|
claim_branch: claims/T-016
|
||||||
|
work_branch: agent/codex/T-016
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-016.md
|
||||||
|
- docs/contracts/README.md
|
||||||
|
- docs/contracts/sense-audit-relay-v1.openapi.json
|
||||||
|
- Sense/cmd/sense-api/main.go
|
||||||
|
- Sense/internal/config/config.go
|
||||||
|
- Sense/internal/config/config_test.go
|
||||||
|
- Sense/internal/auditrelay/
|
||||||
|
- Sense/internal/store/audit_relay_postgres.go
|
||||||
|
- Sense/internal/store/audit_relay_postgres_test.go
|
||||||
|
- Sense/README.md
|
||||||
|
- Bell/cmd/bell-api/
|
||||||
|
- Bell/internal/audit/
|
||||||
|
- Bell/internal/store/audit_postgres.go
|
||||||
|
- Bell/internal/store/audit_postgres_test.go
|
||||||
|
- Bell/README.md
|
||||||
|
- deploy/postgres/014_audit_relay.sql
|
||||||
|
- deploy/postgres/015_privileges_audit_relay.sql
|
||||||
|
- deploy/postgres/tests/assertions.sql
|
||||||
|
- deploy/postgres/README.md
|
||||||
|
- scripts/test_postgres.ps1
|
||||||
|
- tests/test_sense_audit_relay_contract.py
|
||||||
|
- tests/test_postgres_contract.py
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-010 已保证 Sense 设备创建、配置和期望态变更与脱敏 `sense.device_operation_outbox` 同事务,但 Outbox 仍不会离开 Sense;Bell 因而没有完整的跨系统全局审计事实。直接让 Sense 写 `bell` schema 会破坏三系统所有权,简单“POST 后标记成功”又无法处理崩溃重试、重复投递、多实例竞争、消息篡改或重放攻击。
|
||||||
|
|
||||||
|
T-015 已提供 Bell 的独立运行角色和不可变事实模式。本任务只冻结并实现设备操作审计 relay,不顺带定义 Brain 事件 transport、业务规则、Alert 或公共管理 API。
|
||||||
|
|
||||||
|
## 关联需求与交互(如适用)
|
||||||
|
|
||||||
|
- 用户故事:为 US-009、US-010 的全局审计追溯提供后端事实基础。
|
||||||
|
- 交互清单:IX-020 的审计归属后端基础;本任务不实现 `/audit` UI、筛选或深链页面。
|
||||||
|
- 相关页面 / 路由:公共 UI 不适用;新增仅内部使用的 `/internal/v1/audit-events:batch`。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 冻结 `sense-audit-relay-v1.openapi.json`:Sense 通过 HTTP POST 批量发送 1~100 个 v1/v2 设备审计事实,Bell 返回与输入逐项对应的 `accepted | duplicate | rejected`;完整批次响应可按签名 nonce 幂等重放。
|
||||||
|
2. 请求使用 key ID、Unix 秒时间戳、随机 nonce 与 HMAC-SHA256。canonical string 固定为 `METHOD + path + timestamp + nonce + SHA256(body)` 的换行拼接;允许时钟偏差 5 分钟,nonce 收据保留 10 分钟。相同 key/nonce/请求摘要返回原响应,不同摘要返回 `409 replay_conflict`。密钥只从仓库外绝对路径文件读取,至少 32 个随机字节,不进数据库、日志或 Issue。
|
||||||
|
3. 非回环 transport 必须使用 HTTPS;回环 HTTP 只用于同机私有部署和测试,不提供跳过远端 TLS 的 flag。单次请求体最大 1 MiB、超时 10 秒,Bell 不回显原始 body、签名或敏感字段。
|
||||||
|
4. PostgreSQL v6 为 Sense Outbox 增加数据库时钟 lease、fencing token、last error 和 dead-letter 状态。最多 100 行用 `FOR UPDATE SKIP LOCKED` 领取;过期 worker 不能确认。`accepted/duplicate` 标记 delivered,逐项永久拒绝进入 dead letter,网络/5xx/整批认证失败按指数退避重试且不删除事实。
|
||||||
|
5. Bell 新增不可变 `bell.audit_events` 和可过期 `bell.audit_relay_receipts`。全局审计用 `(source_system,event_id)` 唯一;相同摘要为 duplicate,不同摘要逐项 `id_conflict`。审计事实没有 UPDATE/DELETE/TRUNCATE 权限并复用不可变 trigger;收据允许 Bell runtime 在限定表内维护过期记录。
|
||||||
|
6. 建立最小 `cmd/bell-api`,默认仅回环监听,暴露 health/ready 与内部 relay endpoint;数据库、监听地址、TLS 和 key 文件均来自私有环境。Sense relay 默认关闭,仅在 PostgreSQL v6、合法 Bell URL 和外部 key 文件齐备时启动后台 worker。
|
||||||
|
7. 单元测试覆盖签名向量、时间窗/nonce、body 限制、逐项确认、重复/冲突、配置失败关闭、退避和崩溃重领;隔离 PostgreSQL 17.10 测试覆盖 migration 重放、两端 repository、fencing、权限和安全清理。
|
||||||
|
|
||||||
|
## 不可变约束
|
||||||
|
|
||||||
|
- 阈值 / 数值边界:每批 1~100 项;body ≤1 MiB;HTTP deadline 10 秒;lease 30 秒;签名时钟偏差 ≤300 秒;nonce 收据 TTL 600 秒;退避从 1 秒指数增长并封顶 300 秒。16 不是审计批量或队列上限。
|
||||||
|
- 判定式 / 状态转换:只有 `accepted`/`duplicate` 可设置 `delivered_at`;`rejected` 必须带稳定错误码并 dead-letter;批次级网络/5xx/认证/响应缺项不得误标成功。所有完成/失败写入必须匹配 worker + fencing token + 未过期数据库 lease。
|
||||||
|
- 安全边界:非回环只允许 HTTPS;HMAC 使用至少 32 字节外部 secret 和 constant-time compare;时间戳、nonce、key ID、签名格式失败统一安全拒绝。Sense 不获得 Bell schema 权限,Bell 不读取 Sense Outbox;日志、响应、测试和指标不包含 secret、Authorization、原始 DSN、完整 payload 或租户/设备标签。
|
||||||
|
- 既有契约:本地事实继续符合 `sense-device-audit-v1/v2`,不原地修改两份 schema;relay v1 只增加传输 envelope/确认语义。Bell 全局审计是追加事实,不能改写 Sense 原事件或设备状态。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 任务相关验证:`go -C Sense test ./internal/auditrelay ./internal/store ./internal/config ./cmd/sense-api`、`go -C Bell test ./internal/audit ./internal/store ./cmd/bell-api`;`python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py" -v`;OpenAPI JSON 可解析且签名/状态/阈值与实现一致。
|
||||||
|
- 完整门禁:因命中 Sense/Bell Go、PostgreSQL schema/权限和内部 HTTP 安全边界,运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17`、`./init.ps1`、三条 Python 治理命令及 `git diff --check`。临时 PostgreSQL 必须重放 `001`~`015` 两次,现有 5432 listener 不变。
|
||||||
|
- 人工 / 设备验收:不适用;回环 `httptest` 和隔离 PostgreSQL 足以验收协议与持久化。跨主机证书、客户网络、防火墙和密钥轮换演练留给部署任务,不能据此宣称生产网络已验收。
|
||||||
|
- 构建产物:`go -C Sense build ./...` 与 `go -C Bell build ./...`;Bell 最小 receiver 可由 `go -C Bell build ./cmd/bell-api` 构建,未配置 DSN/key 时必须拒绝启动。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现 Brain→Bell 事件 transport、规则引擎、Alert/ack/升级、Bell 公共审计查询 API、Web/H5、JWT/OIDC、消息总线或客户网络部署。
|
||||||
|
- 不删除本地 delivered/dead-letter Outbox 行,不擅自冻结法务审计留存期;本版本只自动清理 10 分钟后 relay nonce 收据,全局审计事实不自动删除。
|
||||||
|
- 不提交 key 文件、DSN、客户标识、真实 payload 或私有实例配置;不为测试修改现有 PostgreSQL data 目录或 5432 服务。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
- 责任 Agent:codex
|
||||||
|
- 唯一写入者:codex
|
||||||
|
- 委派:不启用。
|
||||||
|
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
|
||||||
|
|
||||||
|
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-08-10:在 T-015 合并并关闭后拆出本任务;实现尚未开始。
|
||||||
|
- 2026-08-11:冻结 `sense-audit-relay-v1.openapi.json`,实现 Sense 外部 key/HMAC client、30 秒数据库 lease/fencing worker、逐项结果/dead letter/1~300 秒退避,以及 PostgreSQL v6 Outbox relay repository;默认关闭且远端 URL 强制 HTTPS。
|
||||||
|
- 2026-08-11:实现 Bell 最小 `cmd/bell-api`、300 秒时间窗与 constant-time HMAC 校验、600 秒 nonce 幂等收据、逐项 v1/v2 校验,以及 PostgreSQL v4 不可变 `bell.audit_events`;Sense/Bell 登录权限保持单向隔离,只有 Bell 可清理过期收据。
|
||||||
|
- 2026-08-11:`./init.ps1`、三条 Python 治理命令、Sense/Bell `test/vet/build` 与 `git diff --check` 全部通过;Python 共 68 项测试通过。
|
||||||
|
- 2026-08-11:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过;PostgreSQL 17.10 临时集群将 `001`~`015` 重放两次,Sense fencing、Bell receipt/重复/冲突/不可变性和双方权限断言通过,随机端口与临时目录已清理,现有 5432 listener 未改变。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
id: T-017
|
||||||
|
title: 建立 Brain 单路匿名区域事件可视化工程原型
|
||||||
|
phase: 3
|
||||||
|
deps: [T-016]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-11
|
||||||
|
issue: 59
|
||||||
|
context_ref: 2e047379228a30841da7ceed8cfb86275b3c330e
|
||||||
|
claim_branch: claims/T-017
|
||||||
|
work_branch: agent/codex/T-017
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-017.md
|
||||||
|
- Brain/
|
||||||
|
- docs/design/brain/
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
- docs/07-user-stories.md
|
||||||
|
- docs/08-interaction-checklist.md
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/current-state.md
|
||||||
|
- init.ps1
|
||||||
|
- init.sh
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
Sense 已能稳定提供 MediaMTX 路径,Bell 已有不可变事件存储,但 `Brain/` 仍是目录占位,客户无法看到“画面 → 匿名检测 → 区域判定 → 事件候选”的直接效果。直接把 `silver_pose` 合并进来会破坏独立交付边界;未经授权引入 Ultralytics 生产依赖又有商用许可风险。首步需要一个可重复、可替换检测器、不会伪装算法效果的单路工程原型,为后续 Brain→Bell ingress 提供真实生产者。
|
||||||
|
|
||||||
|
## 关联需求与交互(如适用)
|
||||||
|
|
||||||
|
- 用户故事:新增 US-014;同时关联 US-005 的区域规则配置目标。
|
||||||
|
- 交互清单:新增 IX-024;几何编辑遵守 IX-017 的绘制、撤销、清空、键盘等效和待校准语义。
|
||||||
|
- 相关页面 / 路由:`/brain-demo` 仅为回环工程演示入口,不属于 Bell 公共业务路由;原型为 `docs/design/brain/index.html`。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 冻结 T-017 工程原型栈:本机已验证的 Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80。OpenCV HOG 人员检测只作为可替换 `Detector` port 的演示适配器;不冻结 Savant/DeepStream、生产模型或 GPU 容量。
|
||||||
|
2. 建立单路 frame source、detector、轻量 track、归一化多边形 zone、进入/停留判定和内存事件候选流水线。默认合成回放携带明确的 fixture 标识,保证无摄像头/模型也能重复验收;真实流模式只从仓库外绝对路径文件读取 URL,并优先指向 Sense 管理的 MediaMTX path。
|
||||||
|
3. 提供仅监听回环地址的本地演示服务:画面、检测框、track、区域、命中状态、推理延迟和最近事件可观察;区域支持鼠标绘制、撤销、清空、保存,以及无需精确点击的归一化坐标表单。页面使用自包含 HTML/CSS/JS,不加载 CDN、字体或第三方脚本。
|
||||||
|
4. 本地演示 API 不是跨系统契约,不接受平台事件 `id`,不写数据库、不发送 Alert、不管理录像。T-018 再冻结 Brain→Bell transport、逻辑身份、Bell ULID、持久重试与幂等确认。
|
||||||
|
5. 单元测试覆盖几何边界、进入/停留只触发一次、track 过期、事件候选不含平台 ID、URL 文件校验、回环绑定、API 输入限制和敏感信息不回显;HTML 契约测试覆盖响应式、键盘、焦点、44px 操作目标、reduced-motion、状态文本和无外链资源。
|
||||||
|
|
||||||
|
## 不可变约束
|
||||||
|
|
||||||
|
- 阈值 / 数值边界:单路原型默认处理 2 FPS;区域点使用 `[0,1]` 归一化坐标,3~32 个顶点;最近事件仅保留 100 项内存环;HTTP body 不超过 64 KiB。任何值不得成为 16/128 路或生产 worker 的硬上限。
|
||||||
|
- 判定式 / 状态转换:仅 track 中心点从区域外进入区域内时产生一次 `zone_entry` 候选;持续位于区域内不重复产生;离开后再次进入才可重新产生。fixture 与真实检测结果必须显式区分,HOG 无检测不得伪造框。
|
||||||
|
- 安全边界:服务只允许回环监听;真实流 URL 只从仓库外绝对路径、单行、受限大小文件读取,响应、日志、页面和异常不得回显 URL/凭据。禁止提交真实视频、截图、模型权重、凭据或客户标识。
|
||||||
|
- 既有契约:Brain 只产生 `source_event_id` 和候选事实,平台 `evt_` ULID 仍由 Bell 生成;本任务不修改冻结 event v0.1,不把本地 demo API 宣称为 Brain→Bell transport。`silver_pose` 保持独立仓库且不进入依赖/构建上下文。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 任务相关验证:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain`;启动合成模式后用回环 HTTP smoke 验证状态、画面、区域保存和事件候选;HTML 静态契约测试通过。
|
||||||
|
- 完整门禁:因首次建立 Brain 脚手架并更新标准入口,运行 `./init.ps1`、三条 Python 治理命令以及 `git diff --check`;Sense/Bell 既有 test/vet/build 仍须由标准入口通过。
|
||||||
|
- 人工 / 设备验收:真实摄像头和目标 GPU 不作为本任务 DONE 门禁;合成回放验证工程闭环。真实流只做可选本机 smoke,结果不能外推为算法效果、GPU 容量或生产 SLA。UI 产品确认留给 Bell 业务闭环任务,工程演示页必须明确“非生产模型验收”。
|
||||||
|
- 构建产物:源码运行原型,无独立安装包;入口为 `python -m Brain.yovision_brain --source synthetic`,依赖清单和启动说明写入 `Brain/README.md`。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现 Brain→Bell 网络投递、持久 Outbox、Bell ingress、规则/Alert/ack、证据切片、对象存储、反馈回流或公共认证。
|
||||||
|
- 不承诺检测召回率/误报率,不购买或引入 Ultralytics 生产许可,不冻结 Savant/DeepStream、CUDA、跟踪/ReID 或每 worker 路数。
|
||||||
|
- 不修改 Sense、Bell、`_reference/` 或 `D:\OPC\silver_pose`;不直接连接摄像头管理端或绕过 MediaMTX 系统边界。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
- 责任 Agent:codex
|
||||||
|
- 唯一写入者:codex
|
||||||
|
- 委派:不启用。
|
||||||
|
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
|
||||||
|
|
||||||
|
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-08-11:按用户指定顺序建立 T-017;主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
|
||||||
|
- 2026-08-11:建立 `FrameSource → Detector → Tracker → ZoneEntryEvaluator → EventCandidate` 单路流水线。默认合成 fixture 可重复运行;可选真实流只从仓库外绝对路径文件读取 RTSP(S) URL,OpenCV HOG 仅作为非生产演示 detector port。区域进入只在 `outside → inside` 时触发,候选不含 Bell 平台 `id`。
|
||||||
|
- 2026-08-11:实现只允许显式回环地址的工程服务和自包含 `/brain-demo` 页面;HTTP body 上限 64 KiB,区域写入使用进程级临时 token,响应带 CSP、拒绝 framing、禁止缓存且不记录请求路径。桌面截图已目检;Edge CDP 在 375×900 设备视口实测 `innerWidth=clientWidth=scrollWidth=375`,可见按钮最小高度 44 px,移动端工具栏单列且无横向溢出。截图仅在系统临时目录用于 QA,未提交。
|
||||||
|
- 2026-08-11:独立 CLI 合成模式回环 smoke 通过:`health/state/frame/page/zone PUT` 均为 HTTP 200,source 为 `synthetic`、`fixture=true`、JPEG 可读、区域版本由 v1 递增至 v2。使用标准入口 `./init.ps1` 通过 68 项根测试、19 项 Brain 测试、Brain compileall,以及 Sense/Bell generate/test/vet/build;`git diff --check` 通过。
|
||||||
|
- 边界:未连接真实摄像头或目标 GPU,未引入生产模型、Brain→Bell transport、持久 Outbox、规则/Alert 或客户产品 UI;这些结果只关闭匿名单路工程原型,不构成算法效果、真实多路或生产 SLA 验收。
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
id: T-018
|
||||||
|
title: 建立 Sense NVR 管理面设备与实时监看纵切
|
||||||
|
phase: 3
|
||||||
|
deps: [T-004, T-014]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-11
|
||||||
|
issue: 63
|
||||||
|
context_ref: a4427a6d2b08e6e8a86d913b24abb61ebcfff906
|
||||||
|
claim_branch: claims/T-018
|
||||||
|
work_branch: agent/codex/T-018
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-018.md
|
||||||
|
- Sense/cmd/sense-api/
|
||||||
|
- Sense/internal/config/
|
||||||
|
- Sense/internal/console/
|
||||||
|
- Sense/README.md
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/current-state.md
|
||||||
|
- tests/test_sense_console_contract.py
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
Sense 已有 ONVIF/RTSP 接入、MediaMTX 控制、Control API v1、对账、探活和本地 16 路容量证据,但客户目前只能通过 API、脚本或静态原型理解这些能力。T-004 已确认 Sense 的五入口信息架构;本任务把其中最能证明 NVR 管理面价值的“设备 + 实时监看”落成可运行纵切,并保留总览、接入任务和运维中心的诚实入口。
|
||||||
|
|
||||||
|
本任务不是完整 NVR:常态录像仍由客户现有 NVR 承担,YoVision 暂不实现录像计划、录像索引或回放;MediaMTX 继续作为独立数据面,Sense 只提供管理面和受限播放入口。为避免在 JWT/OIDC、反向代理和 MediaMTX 外部认证尚未冻结时暴露视频,本版控制台只允许回环工程环境启用。
|
||||||
|
|
||||||
|
## 关联需求与交互(如适用)
|
||||||
|
|
||||||
|
- 用户故事:US-001、US-002、US-008、US-009;本任务只完成其中设备查询、状态理解和按需监看的首个纵切,不宣称批量导入、完整能力探测或全量运维 API 已完成。
|
||||||
|
- 交互清单:IX-002~IX-004、IX-013~IX-015、IX-019、IX-020,以及“桌面与大屏”中的按需预览和 128 路分页约束。
|
||||||
|
- 相关页面 / 路由:已确认原型 `docs/design/sense/index.html`;新增工程路由 `/sense-console/`,不冻结为 Bell 客户公共路由。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `Sense/internal/console/` 增加由 Go `embed` 提供的自包含 HTML/CSS/JavaScript 控制台,不引入前端框架、CDN、字体或第三方播放器依赖;外观沿用 T-004 已确认的深色接入运维台,不重新设计信息架构。
|
||||||
|
2. 控制台复用同源 Sense Control API v1。操作者输入 Site ID 和 Bearer token 后加载设备页;token 只保存在当前 JavaScript 内存中,刷新即丢失,不进入 URL、DOM 可见文本、`localStorage`、`sessionStorage`、cookie 或日志。
|
||||||
|
3. 设备列表默认每页 16 项,使用服务端 cursor,支持模态、期望态和实际态筛选;显示配额、期望态/实际态、收敛、失败码、重试时间和策略投影版本。页面只把当前页统计标为“当前页”,不伪造站点总量。
|
||||||
|
4. 实时监看只允许从当前已授权设备中选择具有 `video_capture` 能力的设备,最多同时打开 4 路;默认不自动播放。播放使用 MediaMTX v1.19.3 官方浏览器 WebRTC 页面,地址由仓库外环境配置提供,UI 不显示 RTSP/ONVIF、凭据或完整播放地址。
|
||||||
|
5. `/sense-console/` 默认关闭。启用时 Sense HTTP 监听与 MediaMTX WebRTC 基地址都必须是显式回环地址;配置含 userinfo、查询、fragment、非 HTTP(S) 或非回环主机时启动失败。页面添加 CSP、`frame-ancestors 'none'`、`nosniff`、`no-store` 和严格 Referrer Policy。
|
||||||
|
6. 总览、接入任务和运维中心继续保留为五入口结构:总览只汇总已加载事实;接入任务明确标识本纵切尚未实现批量导入;运维中心显示当前页未收敛项并提供受限 `/metrics` 入口,不伪造分片、隧道或告警数据。
|
||||||
|
7. 添加 Go handler/config/UI 契约测试;在 Edge 以 1440×900、375×812 和 812×375 检查焦点、无横向溢出、44px 触控目标、空态/鉴权失败/依赖失败和 `prefers-reduced-motion`。实流人工验收可继续使用一台已准入海康相机,不要求新增摄像头。
|
||||||
|
|
||||||
|
## 不可变约束
|
||||||
|
|
||||||
|
- 阈值 / 数值边界:站点默认 16 路、可配置 1~128;设备列表默认页长 16;本任务浏览器同时预览上限固定为 4,128 路不得一次加载全部详情或视频。
|
||||||
|
- 判定式 / 状态转换:Control API 写入受理不等于实际态收敛;未知或请求失败不得显示在线;只有 `video_capture + desired_state=enabled + actual_state=online + converged=true` 的设备可启动预览。
|
||||||
|
- 安全边界:控制台和 WebRTC 基地址仅回环;默认关闭;Bearer token 仅驻留页面内存;不得显示或持久化摄像头凭据、完整 RTSP/ONVIF URI、MediaMTX source、token、客户数据或真实视频证据。非回环/TLS/JWT/OIDC/MediaMTX 外部认证另立任务。
|
||||||
|
- 既有契约:不修改 Sense Control API v1、事件 v0.1、Bell schema 或 MediaMTX 生成客户端;MediaMTX 仍是独立二进制,Sense 不代理媒体字节;T-004 原型保持产品结构权威,本任务只实现其明确子集。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 任务相关验证:`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v`;覆盖默认关闭、回环限制、配置拒绝、路由/安全头、内存 token、16 项 cursor 页面、最多 4 路按需预览和状态降级。
|
||||||
|
- 完整门禁:运行 `./init.ps1`、`git diff --check`。本任务不改变 PostgreSQL migration、Control API OpenAPI 或 MediaMTX 生成源,因此不触发 `scripts/test_postgres.ps1` 和生成客户端漂移以外的新增 schema 门禁;根入口仍会执行既有 generate/test/vet/build。
|
||||||
|
- 人工 / 设备验收:必需。项目负责人打开实际 `/sense-console/`,确认与 T-004 五入口一致、设备/配额/状态可理解、最多 4 路按需监看、错误状态不伪装在线;可使用一台已准入海康设备或 T-014 合成流。未确认前任务文件保持 `DOING`,Issue 使用 `status/review`,不得标 `DONE`。
|
||||||
|
- 构建产物:`Sense` Go 二进制内嵌控制台静态资源;`go -C Sense build ./...` 可重现。无独立前端构建产物。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
不实现录像计划、常态录像、回放、证据切片、批量 CSV 导入、Site/Area/RBAC CRUD、Bell 业务预警、Brain 推理、非回环部署或生产会话认证;不修改 `_reference/`,不复制 MiBeeNvr 或 MediaMTX UI/播放器源码,不改已确认的 `docs/design/sense/index.html`。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
- 责任 Agent:codex。
|
||||||
|
- 唯一写入者:codex。
|
||||||
|
- 委派:不启用。
|
||||||
|
- Gitea:主 Issue 为 #63;领取时填写 `context_ref`、`claims/T-018` 与 `agent/codex/T-018`。
|
||||||
|
|
||||||
|
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-11 任务定义
|
||||||
|
|
||||||
|
- 项目负责人将开发优先级从 Brain/Bell 后续链调整为先做 Sense;T-018 因此定义为 Sense NVR 管理面首个可运行纵切,原 Brain→Bell ingress 顺延为后续建议任务。
|
||||||
|
- 复用 T-004 已确认的信息架构和现有 Control API/MediaMTX 基础,不新增前端或媒体依赖;录像/回放、非回环认证和完整运维 API 明确排除。
|
||||||
|
- 基线 `./init.ps1` 通过:68 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿。
|
||||||
|
- 任务定义已合入默认分支并创建唯一 Gitea Issue #63;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
|
||||||
|
|
||||||
|
### 2026-08-11 领取与基线
|
||||||
|
|
||||||
|
- dispatcher `ila` 从默认分支 `a4427a6d2b08e6e8a86d913b24abb61ebcfff906` 创建并读回 `claims/T-018`、`agent/codex/T-018`;Issue #63 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。
|
||||||
|
- T-007 仍为 `status/waiting`,不属于活跃写路径预留;T-017 的残留 PR 元数据异常不对应活跃 Issue,默认分支已包含其合并提交。
|
||||||
|
|
||||||
|
### 2026-08-11 实现与自动化验收
|
||||||
|
|
||||||
|
- `sense-api` 新增默认关闭的 `/sense-console/` 内嵌路由和运行时配置;配置层与 handler 双重拒绝非回环、userinfo、路径、query、fragment 和非 HTTP(S) 的播放基地址,控制台只能与已开启的回环 PostgreSQL Control API 一起运行。
|
||||||
|
- 自包含 HTML/CSS/原生 JavaScript 保留 T-004 已确认的运行总览、实时监控、设备、接入任务、运维中心五入口。设备页使用服务端 cursor 和固定 16 项页长;期望态、实际态、收敛与错误分别呈现,未知/请求失败不显示在线。接入任务明确标为未实现,不用假上传伪装交付。
|
||||||
|
- Bearer token 只保存于页面内存,输入后立即清空且刷新后会话清零;页面不使用 local/session storage、cookie、URL token 或 `innerHTML`。只有 `video_capture + enabled + online + converged` 可选,选择和已启动预览均硬限制为 4,iframe 停止时移除 `src`。
|
||||||
|
- Edge headless + 本地脱敏 Control API fixture 完成浏览器 QA:1440×900 显示 16 项和 `16 / 128` 配额,无横向溢出且 cursor 下一页可用;第五路选择被拒绝,实际 iframe 保持 4;375×812 切换为设备卡片和五入口底栏,无横向溢出、最小可见按钮 44 px;844×390 无横向溢出。刷新后连接对话框重新打开、token 输入为空、会话回到未建立。该 QA 只验证 UI/嵌入 URL,不替代真实 MediaMTX 解码验收。
|
||||||
|
- 浏览器 QA 首轮发现加载完成后分页按钮仍保留禁用态;已在 `setLoading(false)` 时重新计算前后页状态并复验通过。
|
||||||
|
- 任务验证通过:`node --check Sense/internal/console/assets/app.js`;`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v` 4 项;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`;`./init.ps1` 72 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿;`git diff --check` 在提交前复核。
|
||||||
|
- 人工门禁已满足:项目负责人于 2026-08-11 明确回复“验收通过”,接受实际 `/sense-console/` 的五入口、设备/配额/收敛语义和 MediaMTX WebRTC 播放纵切。该确认不扩大到录像/回放、非回环生产认证、真实 16 机或生产 SLA。
|
||||||
|
|
||||||
|
### 2026-08-11 产品验收
|
||||||
|
|
||||||
|
- 项目负责人在本轮明确确认 T-018 验收通过;任务状态更新为 `DONE`,允许合并 PR #65 并关闭 Issue #63。
|
||||||
|
- 交付边界保持不变:这是默认关闭的 Sense 回环 NVR 管理面纵切,常态录像仍由客户既有 NVR 承担,后续 Brain→Bell ingress 仍需独立 T-019。
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Set-Location -Path $PSScriptRoot
|
Set-Location -Path $PSScriptRoot
|
||||||
|
|
||||||
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
|
||||||
$InstallCmd = "go -C Sense mod download"
|
$InstallCmd = "go -C Sense mod download; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell 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 ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.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 }"
|
$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 }; python -c 'from importlib.metadata import version; assert version(`"numpy`") == `"1.26.4`"; assert version(`"opencv-python`") == `"4.9.0.80`"'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s Brain/tests -p 'test_*.py' -v; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m compileall -q Brain; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.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 }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
|
||||||
$StartCmd = "go -C Sense run ./cmd/sense-api"
|
$StartCmd = "go -C Sense run ./cmd/sense-api"
|
||||||
|
|
||||||
function Assert-Configured {
|
function Assert-Configured {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
|
||||||
INSTALL_CMD=(go -C Sense mod download)
|
INSTALL_CMD=(bash -lc "go -C Sense mod download && go -C Bell 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 ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...")
|
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 && python3 -c 'from importlib.metadata import version; assert version(\"numpy\") == \"1.26.4\"; assert version(\"opencv-python\") == \"4.9.0.80\"' && python3 -m unittest discover -s Brain/tests -p 'test_*.py' -v && python3 -m compileall -q Brain && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...")
|
||||||
START_CMD=(go -C Sense run ./cmd/sense-api)
|
START_CMD=(go -C Sense run ./cmd/sense-api)
|
||||||
|
|
||||||
ensure_configured() {
|
ensure_configured() {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ $hadSenseDSN = Test-Path Env:YOVISION_TEST_POSTGRES_DSN
|
|||||||
$previousSenseDSN = if ($hadSenseDSN) { (Get-Item Env:YOVISION_TEST_POSTGRES_DSN).Value } else { $null }
|
$previousSenseDSN = if ($hadSenseDSN) { (Get-Item Env:YOVISION_TEST_POSTGRES_DSN).Value } else { $null }
|
||||||
$hadAdminDSN = Test-Path Env:YOVISION_TEST_POSTGRES_ADMIN_DSN
|
$hadAdminDSN = Test-Path Env:YOVISION_TEST_POSTGRES_ADMIN_DSN
|
||||||
$previousAdminDSN = if ($hadAdminDSN) { (Get-Item Env:YOVISION_TEST_POSTGRES_ADMIN_DSN).Value } else { $null }
|
$previousAdminDSN = if ($hadAdminDSN) { (Get-Item Env:YOVISION_TEST_POSTGRES_ADMIN_DSN).Value } else { $null }
|
||||||
|
$hadBellDSN = Test-Path Env:YOVISION_TEST_BELL_POSTGRES_DSN
|
||||||
|
$previousBellDSN = if ($hadBellDSN) { (Get-Item Env:YOVISION_TEST_BELL_POSTGRES_DSN).Value } else { $null }
|
||||||
|
|
||||||
function Invoke-Checked {
|
function Invoke-Checked {
|
||||||
param(
|
param(
|
||||||
@@ -87,17 +89,25 @@ try {
|
|||||||
'008_control_api.sql',
|
'008_control_api.sql',
|
||||||
'009_privileges_control_api.sql',
|
'009_privileges_control_api.sql',
|
||||||
'010_reconcile_safety.sql',
|
'010_reconcile_safety.sql',
|
||||||
'011_privileges_reconcile_safety.sql'
|
'011_privileges_reconcile_safety.sql',
|
||||||
|
'012_bell_events.sql',
|
||||||
|
'013_privileges_bell_events.sql',
|
||||||
|
'014_audit_relay.sql',
|
||||||
|
'015_privileges_audit_relay.sql'
|
||||||
)) {
|
)) {
|
||||||
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot "deploy\postgres\$name")
|
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot "deploy\postgres\$name")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t012_sense LOGIN IN ROLE sense_app'
|
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t012_sense LOGIN IN ROLE sense_app'
|
||||||
|
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t015_bell LOGIN IN ROLE bell_runtime'
|
||||||
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot 'deploy\postgres\tests\assertions.sql')
|
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot 'deploy\postgres\tests\assertions.sql')
|
||||||
|
|
||||||
$env:YOVISION_TEST_POSTGRES_DSN = $senseDSN
|
$env:YOVISION_TEST_POSTGRES_DSN = $senseDSN
|
||||||
$env:YOVISION_TEST_POSTGRES_ADMIN_DSN = $adminDatabaseDSN
|
$env:YOVISION_TEST_POSTGRES_ADMIN_DSN = $adminDatabaseDSN
|
||||||
|
$bellDSN = "postgres://yovision_t015_bell@127.0.0.1:$port/${databaseName}?sslmode=disable"
|
||||||
|
$env:YOVISION_TEST_BELL_POSTGRES_DSN = $bellDSN
|
||||||
Invoke-Checked 'go' '-C' (Join-Path $repoRoot 'Sense') 'test' './internal/store' '-run' '^TestPostgres' '-count=1'
|
Invoke-Checked 'go' '-C' (Join-Path $repoRoot 'Sense') 'test' './internal/store' '-run' '^TestPostgres' '-count=1'
|
||||||
|
Invoke-Checked 'go' '-C' (Join-Path $repoRoot 'Bell') 'test' './internal/store' '-run' '^TestPostgres' '-count=1'
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if ($started) {
|
if ($started) {
|
||||||
@@ -132,6 +142,12 @@ finally {
|
|||||||
else {
|
else {
|
||||||
Remove-Item Env:YOVISION_TEST_POSTGRES_ADMIN_DSN -ErrorAction SilentlyContinue
|
Remove-Item Env:YOVISION_TEST_POSTGRES_ADMIN_DSN -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
if ($hadBellDSN) {
|
||||||
|
$env:YOVISION_TEST_BELL_POSTGRES_DSN = $previousBellDSN
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Remove-Item Env:YOVISION_TEST_BELL_POSTGRES_DSN -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
$after5432 = @(
|
$after5432 = @(
|
||||||
Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue |
|
Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue |
|
||||||
Select-Object -ExpandProperty OwningProcess -Unique |
|
Select-Object -ExpandProperty OwningProcess -Unique |
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Static contract checks for T-015 Bell immutable event storage."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def text(path: str) -> str:
|
||||||
|
return (ROOT / path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class BellEventContractTests(unittest.TestCase):
|
||||||
|
def test_runtime_schema_copy_matches_frozen_contract(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
(ROOT / "docs/raw/contracts/event-v0.1.schema.json").read_bytes(),
|
||||||
|
(ROOT / "Bell/contracts/event-v0.1.schema.json").read_bytes(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bell_module_freezes_reviewed_dependencies(self) -> None:
|
||||||
|
module = text("Bell/go.mod")
|
||||||
|
self.assertIn("github.com/jackc/pgx/v5 v5.10.0", module)
|
||||||
|
self.assertIn("github.com/oklog/ulid/v2 v2.1.2", module)
|
||||||
|
self.assertIn("github.com/santhosh-tekuri/jsonschema/v6 v6.0.2", module)
|
||||||
|
|
||||||
|
def test_event_migration_is_append_only_and_not_limited_to_sixteen(self) -> None:
|
||||||
|
migration = text("deploy/postgres/012_bell_events.sql").lower()
|
||||||
|
privileges = text("deploy/postgres/013_privileges_bell_events.sql").lower()
|
||||||
|
for marker in (
|
||||||
|
"create table if not exists bell.events",
|
||||||
|
"create table if not exists bell.event_outcomes",
|
||||||
|
"bell.reject_immutable_change",
|
||||||
|
"insert into bell.schema_migrations(version) values (3)",
|
||||||
|
):
|
||||||
|
self.assertIn(marker, migration)
|
||||||
|
self.assertNotIn("limit 16", migration)
|
||||||
|
self.assertIn("grant select, insert on table bell.events, bell.event_outcomes to bell_runtime", privileges)
|
||||||
|
self.assertNotIn("grant update", privileges)
|
||||||
|
self.assertNotIn("grant delete", privileges)
|
||||||
|
self.assertNotIn("grant truncate", privileges)
|
||||||
|
|
||||||
|
def test_factory_owns_id_and_checks_all_semantic_boundaries(self) -> None:
|
||||||
|
source = text("Bell/internal/event/event.go")
|
||||||
|
for marker in (
|
||||||
|
"upstream_id_forbidden",
|
||||||
|
"latency_inconsistent",
|
||||||
|
"confidence_forbidden",
|
||||||
|
"evidence_unsafe",
|
||||||
|
"primary_sensor_invalid",
|
||||||
|
"privacy_unavailable",
|
||||||
|
"privacy_denied",
|
||||||
|
):
|
||||||
|
self.assertIn(marker, source)
|
||||||
|
|
||||||
|
def test_contract_document_no_longer_rejects_non_imaging_empty_snapshot(self) -> None:
|
||||||
|
readme = text("docs/raw/contracts/README.md")
|
||||||
|
self.assertNotIn("`snapshot_uris` 空数组 / 负 latency", readme)
|
||||||
|
self.assertIn("`snapshot_uris` 空数组对非成像事件是合法值", readme)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -58,6 +58,10 @@ class PostgresContractTests(unittest.TestCase):
|
|||||||
"009_privileges_control_api.sql",
|
"009_privileges_control_api.sql",
|
||||||
"010_reconcile_safety.sql",
|
"010_reconcile_safety.sql",
|
||||||
"011_privileges_reconcile_safety.sql",
|
"011_privileges_reconcile_safety.sql",
|
||||||
|
"012_bell_events.sql",
|
||||||
|
"013_privileges_bell_events.sql",
|
||||||
|
"014_audit_relay.sql",
|
||||||
|
"015_privileges_audit_relay.sql",
|
||||||
],
|
],
|
||||||
names,
|
names,
|
||||||
)
|
)
|
||||||
@@ -71,6 +75,15 @@ class PostgresContractTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertNotRegex(text, r"\bpassword\b")
|
self.assertNotRegex(text, r"\bpassword\b")
|
||||||
|
|
||||||
|
def test_bell_runtime_role_is_separate_from_migration_owner(self) -> None:
|
||||||
|
text = normalized(migration_text("012_bell_events.sql"))
|
||||||
|
self.assertIn(
|
||||||
|
"create role bell_runtime nologin nosuperuser nocreatedb nocreaterole noreplication",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
self.assertNotIn("grant bell_app to bell_runtime", text)
|
||||||
|
self.assertIn("pg_has_role('bell_runtime', 'bell_app', 'member')", text)
|
||||||
|
|
||||||
def test_bell_quota_contract_and_version_are_enforced(self) -> None:
|
def test_bell_quota_contract_and_version_are_enforced(self) -> None:
|
||||||
text = normalized(migration_text("002_bell.sql"))
|
text = normalized(migration_text("002_bell.sql"))
|
||||||
expected_signature = (
|
expected_signature = (
|
||||||
@@ -118,6 +131,21 @@ class PostgresContractTests(unittest.TestCase):
|
|||||||
self.assertIn(marker, text)
|
self.assertIn(marker, text)
|
||||||
self.assertNotIn("D:\\pgsql17\\data", text)
|
self.assertNotIn("D:\\pgsql17\\data", text)
|
||||||
|
|
||||||
|
def test_audit_relay_uses_fencing_and_separate_schema_ownership(self) -> None:
|
||||||
|
migration = normalized(migration_text("014_audit_relay.sql"))
|
||||||
|
privileges = normalized(migration_text("015_privileges_audit_relay.sql"))
|
||||||
|
for marker in (
|
||||||
|
"relay_lease_owner",
|
||||||
|
"relay_lease_token",
|
||||||
|
"relay_lease_until",
|
||||||
|
"create table if not exists bell.audit_events",
|
||||||
|
"create table if not exists bell.audit_relay_receipts",
|
||||||
|
"expires_at >= received_at + interval '10 minutes'",
|
||||||
|
):
|
||||||
|
self.assertIn(marker, migration)
|
||||||
|
self.assertIn("grant select, insert on table bell.audit_events to bell_runtime", privileges)
|
||||||
|
self.assertIn("revoke all on table bell.audit_events, bell.audit_relay_receipts from sense_app", privileges)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Cross-language invariants for the Sense-to-Bell audit relay contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CONTRACT = ROOT / "docs" / "contracts" / "sense-audit-relay-v1.openapi.json"
|
||||||
|
|
||||||
|
|
||||||
|
class SenseAuditRelayContractTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.document = json.loads(CONTRACT.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_transport_and_response_are_frozen(self) -> None:
|
||||||
|
operation = self.document["paths"]["/internal/v1/audit-events:batch"]["post"]
|
||||||
|
self.assertEqual({"200", "400", "401", "409", "413", "503"}, set(operation["responses"]))
|
||||||
|
headers = {item["name"] for item in operation["parameters"]}
|
||||||
|
self.assertEqual(
|
||||||
|
{"X-YoVision-Key-Id", "X-YoVision-Timestamp", "X-YoVision-Nonce", "X-YoVision-Signature"},
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_numeric_safety_bounds_match_task(self) -> None:
|
||||||
|
events = self.document["components"]["schemas"]["BatchRequest"]["properties"]["events"]
|
||||||
|
self.assertEqual((1, 100), (events["minItems"], events["maxItems"]))
|
||||||
|
signature = self.document["x-yovision-signature"]
|
||||||
|
delivery = self.document["x-yovision-delivery"]
|
||||||
|
self.assertEqual(300, signature["clock_skew_seconds"])
|
||||||
|
self.assertEqual(600, signature["receipt_ttl_seconds"])
|
||||||
|
self.assertEqual((30, 1, 300), (delivery["lease_seconds"], delivery["initial_retry_seconds"], delivery["maximum_retry_seconds"]))
|
||||||
|
|
||||||
|
def test_contract_and_implementations_share_literals(self) -> None:
|
||||||
|
sense = (ROOT / "Sense" / "internal" / "auditrelay" / "client.go").read_text(encoding="utf-8")
|
||||||
|
bell = (ROOT / "Bell" / "internal" / "audit" / "audit.go").read_text(encoding="utf-8")
|
||||||
|
for marker in (
|
||||||
|
"/internal/v1/audit-events:batch",
|
||||||
|
"X-YoVision-Key-Id",
|
||||||
|
"X-YoVision-Timestamp",
|
||||||
|
"X-YoVision-Nonce",
|
||||||
|
"X-YoVision-Signature",
|
||||||
|
):
|
||||||
|
self.assertIn(marker, sense)
|
||||||
|
self.assertIn(marker, bell)
|
||||||
|
self.assertIn("10 * time.Second", sense)
|
||||||
|
self.assertIn("300*time.Second", (ROOT / "Sense" / "internal" / "auditrelay" / "worker.go").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ASSETS = ROOT / "Sense" / "internal" / "console" / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
class SenseConsoleContractTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.html = (ASSETS / "index.html").read_text(encoding="utf-8")
|
||||||
|
cls.css = (ASSETS / "app.css").read_text(encoding="utf-8")
|
||||||
|
cls.javascript = (ASSETS / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_console_is_self_contained_and_preserves_five_entry_ia(self):
|
||||||
|
self.assertIn('href="/sense-console/app.css"', self.html)
|
||||||
|
self.assertIn('src="/sense-console/app.js"', self.html)
|
||||||
|
self.assertNotRegex(self.html, r"https?://")
|
||||||
|
self.assertNotIn("<script>", self.html)
|
||||||
|
self.assertNotIn("<style>", self.html)
|
||||||
|
for view in ("overview", "monitor", "devices", "onboarding", "operations"):
|
||||||
|
self.assertGreaterEqual(self.html.count(f'data-view="{view}"'), 2)
|
||||||
|
self.assertIn(f'data-page="{view}"', self.html)
|
||||||
|
|
||||||
|
def test_capacity_and_preview_bounds_are_explicit(self):
|
||||||
|
self.assertIn('params.set("limit", String(state.config.page_size))', self.javascript)
|
||||||
|
self.assertIn("state.selected.size >= state.config.max_active_previews", self.javascript)
|
||||||
|
self.assertIn('state.config.page_size !== 16', self.javascript)
|
||||||
|
self.assertIn('state.config.max_active_previews !== 4', self.javascript)
|
||||||
|
self.assertIn('state.config.recording_and_playback !== false', self.javascript)
|
||||||
|
self.assertIn('$("#nextPage").disabled = loading || !state.nextCursor', self.javascript)
|
||||||
|
self.assertIn('$("#prevPage").disabled = loading || state.pageIndex === 0', self.javascript)
|
||||||
|
self.assertNotRegex(self.javascript, r"\.slice\(\s*0\s*,\s*16\s*\)")
|
||||||
|
|
||||||
|
def test_token_is_memory_only_and_sensitive_addresses_are_not_rendered(self):
|
||||||
|
for forbidden in ("local" + "Storage", "session" + "Storage", "document.cookie"):
|
||||||
|
self.assertNotIn(forbidden, self.javascript)
|
||||||
|
self.assertIn('state.token = ""', self.javascript)
|
||||||
|
self.assertIn('tokenInput.value = ""', self.javascript)
|
||||||
|
self.assertNotRegex(self.html, r"rtsp://|onvif://|/whep")
|
||||||
|
self.assertNotRegex(self.javascript, r"rtsp://|onvif://")
|
||||||
|
self.assertNotIn("innerHTML", self.javascript)
|
||||||
|
|
||||||
|
def test_accessibility_and_responsive_guards_are_present(self):
|
||||||
|
self.assertIn('href="#main-content"', self.html)
|
||||||
|
self.assertIn('aria-live="polite"', self.html)
|
||||||
|
self.assertIn('aria-current="page"', self.html)
|
||||||
|
self.assertIn("@media (max-width: 760px)", self.css)
|
||||||
|
self.assertIn("min-height: 44px", self.css)
|
||||||
|
self.assertIn("prefers-reduced-motion", self.css)
|
||||||
|
self.assertIn(":focus-visible", self.css)
|
||||||
|
ids = re.findall(r'\bid="([^"]+)"', self.html)
|
||||||
|
self.assertEqual(len(ids), len(set(ids)))
|
||||||
|
for attributes, content in re.findall(r"<button\b([^>]*)>(.*?)</button>", self.html, re.S):
|
||||||
|
visible_text = re.sub(r"<[^>]+>", "", content).strip()
|
||||||
|
self.assertTrue(
|
||||||
|
"aria-label=" in attributes or visible_text,
|
||||||
|
f"button lacks an accessible name: {attributes}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user