Compare commits
25
Commits
bootstrap/T-017
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a38feb75c | ||
|
|
0ef23ff004 | ||
|
|
8208118904 | ||
|
|
477afa6ba2 | ||
|
|
e019f50915 | ||
|
|
e76a4ac0c8 | ||
|
|
3b2569ce04 | ||
|
|
47b91b691c | ||
|
|
bd964e8831 | ||
|
|
b7fe44eeb0 | ||
|
|
bebc23cb6a | ||
|
|
7b95c7d155 | ||
|
|
5a82035cc3 | ||
|
|
607860b79a | ||
|
|
7c202b7ac8 | ||
|
|
7db707e596 | ||
|
|
a4427a6d2b | ||
|
|
e3b166c5af | ||
|
|
e46f175551 | ||
|
|
2ca51aa2bc | ||
|
|
19a3233f11 | ||
|
|
a2790c1f5e | ||
|
|
2e04737922 | ||
|
|
2768cfeacb | ||
|
|
5c9318ceef |
+55
-4
@@ -1,15 +1,20 @@
|
||||
# Bell 事件存储与内部审计入口
|
||||
# Bell 事件、规则与预警处置纵切
|
||||
|
||||
Bell 当前实现 M3 的事件域基础及 Sense 审计 relay 的最小内部 HTTP 服务:
|
||||
Bell 当前实现 M3 的事件域基础、Sense 审计 relay、默认关闭的 Brain 事件 ingress,以及默认关闭的规则→Alert→ack/close 工程纵切:
|
||||
|
||||
- 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` 表查询、插入和清理过期收据。
|
||||
- `bell_runtime` 对事件、outcome、全局审计和 Brain 来源收据只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;只可清理两张短期 nonce 收据表。
|
||||
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
|
||||
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
|
||||
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
|
||||
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
|
||||
- 规则配置按 canonical hash 幂等发布不可变版本;worker 为每个 Event 写 durable sweep/evaluation,命中时由 Bell 生成 `alt_` ULID 并关联 Event。
|
||||
- Alert 的 `open → acknowledged → closed` 状态从 append-only transition 推导;8 路并发 ack 只有首个成功,后到者得到实际首位处置人/时间,同一幂等键跨重启重放原响应。
|
||||
- `/bell-console/` 是自包含、无 CDN 的回环工程值班台,显示真实规则版本、关联 Event 和处置时间线;证据与升级/通知未实现时明确显示不可用,不伪造成功事实。
|
||||
|
||||
Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。审计 relay 只服务 Sense,不得把 `internal/event` 的 Go 类型或该 HMAC 适配器当成公共协议。
|
||||
公共认证/事件 API、正式规则管理、证据对象存储、升级/通知和正式前端框架仍需后续任务冻结。两条 HMAC ingress 与回环控制台都是工程适配器,不得当成 Bell 公共协议或共用 key/token。
|
||||
|
||||
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
||||
|
||||
@@ -17,6 +22,52 @@ Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`019` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
|
||||
|
||||
```powershell
|
||||
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
|
||||
$env:BELL_EVENT_INGRESS_KEYS_FILE = 'D:\private\brain-event-keys.json'
|
||||
$env:BELL_EVIDENCE_FORBIDDEN_NAMES_FILE = 'D:\private\forbidden-evidence-names.txt'
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
forbidden-names 文件每行一个不得出现在证据 URI 的租户/客户标记,至少一行、最多 256 行。key 文件格式见 `docs/contracts/README.md`。仓库不提供真实 secret、DSN、绑定或客户名称;绑定只能引用已经存在且同 Area/modality 的设备。
|
||||
|
||||
管理员在事务中核对逻辑资源后,可按下列列签名配置一条视频绑定;数字 ID 是冻结事件契约使用的稳定正整数,不是把文本 ID 强转为数字:
|
||||
|
||||
```sql
|
||||
INSERT INTO bell.event_ingress_bindings(
|
||||
producer_id, tenant_id, site_id, device_id,
|
||||
logical_tenant_id, logical_site_id, logical_device_id, logical_area_id,
|
||||
modality, enabled
|
||||
) VALUES (
|
||||
'brain-main', 1, 1, 1,
|
||||
'tenant-logical-id', 'site-logical-id', 'device-logical-id', 'area-logical-id',
|
||||
'video', true
|
||||
);
|
||||
```
|
||||
|
||||
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
|
||||
|
||||
规则 worker 和工程值班台分别由 feature flag 开启;启用值班台必须同时启用规则 worker,整个 Bell 监听地址必须为显式回环。规则和 token 文件均在仓库外,规则文件结构如下(示例值不是客户配置):
|
||||
|
||||
```json
|
||||
{"version":1,"rules":[{"tenant_id":1,"site_id":1,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:BELL_ALERTS_ENABLED = 'true'
|
||||
$env:BELL_ALERT_RULES_FILE = 'D:\private\bell-alert-rules.json'
|
||||
$env:BELL_ALERT_CONSOLE_ENABLED = 'true'
|
||||
$env:BELL_ALERT_CONSOLE_TOKEN_FILE = 'D:\private\bell-console.token'
|
||||
$env:BELL_ALERT_CONSOLE_TENANT_ID = '1'
|
||||
$env:BELL_ALERT_CONSOLE_SITE_ID = '1'
|
||||
$env:BELL_ALERT_CONSOLE_ACTOR_REF = 'operator:local'
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
token 文件去除首尾换行后必须为 32~256 个非空白字符。浏览器访问 `http://127.0.0.1:8081/bell-console/` 后手动输入 token;页面只保存在内存,刷新即丢失。工程 API 冻结在 `docs/contracts/bell-alert-console-v1.openapi.json`,tenant/Site/actor 只来自启动上下文。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
|
||||
+193
-10
@@ -11,24 +11,41 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/alert"
|
||||
"yovision/bell/internal/audit"
|
||||
"yovision/bell/internal/event"
|
||||
"yovision/bell/internal/ingress"
|
||||
"yovision/bell/internal/store"
|
||||
bellweb "yovision/bell/web"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
type configuration struct {
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
eventIngressEnabled bool
|
||||
eventKeyFile string
|
||||
forbiddenNamesFile string
|
||||
alertsEnabled bool
|
||||
alertRulesFile string
|
||||
alertConsoleEnabled bool
|
||||
alertConsoleTokenFile string
|
||||
alertConsoleTenantID int64
|
||||
alertConsoleSiteID int64
|
||||
alertConsoleActorRef string
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -41,11 +58,23 @@ func main() {
|
||||
|
||||
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"),
|
||||
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"),
|
||||
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
||||
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
|
||||
alertRulesFile: os.Getenv("BELL_ALERT_RULES_FILE"),
|
||||
alertConsoleTokenFile: os.Getenv("BELL_ALERT_CONSOLE_TOKEN_FILE"),
|
||||
alertConsoleActorRef: os.Getenv("BELL_ALERT_CONSOLE_ACTOR_REF"),
|
||||
}
|
||||
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
|
||||
case "", "false":
|
||||
case "true":
|
||||
value.eventIngressEnabled = true
|
||||
default:
|
||||
return configuration{}, errors.New("BELL_EVENT_INGRESS_ENABLED must be true or false")
|
||||
}
|
||||
if value.dsn == "" {
|
||||
return configuration{}, errors.New("BELL_DB_DSN is required")
|
||||
@@ -65,6 +94,44 @@ func loadConfiguration() (configuration, error) {
|
||||
if (value.tlsCert == "") != (value.tlsKey == "") {
|
||||
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
|
||||
}
|
||||
if value.eventIngressEnabled {
|
||||
if value.eventKeyFile == "" || !filepath.IsAbs(value.eventKeyFile) {
|
||||
return configuration{}, errors.New("BELL_EVENT_INGRESS_KEYS_FILE must be an absolute external path when event ingress is enabled")
|
||||
}
|
||||
if value.forbiddenNamesFile == "" || !filepath.IsAbs(value.forbiddenNamesFile) {
|
||||
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
|
||||
}
|
||||
}
|
||||
value.alertsEnabled, err = strictBoolEnv("BELL_ALERTS_ENABLED")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
value.alertConsoleEnabled, err = strictBoolEnv("BELL_ALERT_CONSOLE_ENABLED")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
if value.alertsEnabled && (value.alertRulesFile == "" || !filepath.IsAbs(value.alertRulesFile)) {
|
||||
return configuration{}, errors.New("BELL_ALERT_RULES_FILE must be an absolute external path when alerts are enabled")
|
||||
}
|
||||
if value.alertConsoleEnabled {
|
||||
if !value.alertsEnabled || !loopback || os.Getenv("BELL_HTTP_ADDR") == "" {
|
||||
return configuration{}, errors.New("Bell alert console requires alerts and an explicit loopback bind")
|
||||
}
|
||||
if value.alertConsoleTokenFile == "" || !filepath.IsAbs(value.alertConsoleTokenFile) {
|
||||
return configuration{}, errors.New("BELL_ALERT_CONSOLE_TOKEN_FILE must be an absolute external path")
|
||||
}
|
||||
value.alertConsoleTenantID, err = positiveEnv("BELL_ALERT_CONSOLE_TENANT_ID")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
value.alertConsoleSiteID, err = positiveEnv("BELL_ALERT_CONSOLE_SITE_ID")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
if value.alertConsoleActorRef == "" {
|
||||
return configuration{}, errors.New("BELL_ALERT_CONSOLE_ACTOR_REF is required")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -106,6 +173,60 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(audit.RelayPath, handler)
|
||||
if cfg.eventIngressEnabled {
|
||||
if err := repository.EventIngressReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
eventKeys, err := ingress.LoadKeys(cfg.eventKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forbiddenNames, err := loadForbiddenNames(cfg.forbiddenNamesFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, err := event.NewEvidenceGuard(forbiddenNames...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
factory, err := event.NewFactory(contracts.EventV01Schema, event.ULIDGenerator{}, repository, guard)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventHandler, err := ingress.NewHandler(repository, eventKeys, factory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mux.Handle(ingress.Path, eventHandler)
|
||||
}
|
||||
if cfg.alertsEnabled {
|
||||
if err := repository.AlertReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := alert.LoadRules(cfg.alertRulesFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := alert.Publish(ctx, repository, rules); err != nil {
|
||||
return err
|
||||
}
|
||||
go alert.RunWorker(ctx, repository, func(workerErr error) {
|
||||
logger.Error("Bell alert worker retrying", "error", workerErr)
|
||||
})
|
||||
}
|
||||
if cfg.alertConsoleEnabled {
|
||||
token, err := loadConsoleToken(cfg.alertConsoleTokenFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
console, err := bellweb.NewHandler(repository, bellweb.Config{
|
||||
Token: token, TenantID: cfg.alertConsoleTenantID, SiteID: cfg.alertConsoleSiteID, ActorRef: cfg.alertConsoleActorRef,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
console.Register(mux)
|
||||
}
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatus(writer, http.StatusOK, "ok")
|
||||
})
|
||||
@@ -114,6 +235,18 @@ func run(logger *slog.Logger) error {
|
||||
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||
return
|
||||
}
|
||||
if cfg.eventIngressEnabled {
|
||||
if err := repository.EventIngressReady(request.Context()); err != nil {
|
||||
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||
return
|
||||
}
|
||||
}
|
||||
if cfg.alertsEnabled {
|
||||
if err := repository.AlertReady(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}}
|
||||
@@ -138,6 +271,25 @@ func run(logger *slog.Logger) error {
|
||||
return server.Shutdown(shutdownContext)
|
||||
}
|
||||
|
||||
func loadForbiddenNames(path string) ([]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || len(raw) > 64<<10 {
|
||||
return nil, errors.New("read Bell evidence forbidden-names file")
|
||||
}
|
||||
var values []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, line)
|
||||
}
|
||||
if len(values) == 0 || len(values) > 256 {
|
||||
return nil, errors.New("Bell evidence forbidden-names file must contain 1 to 256 names")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func writeStatus(writer http.ResponseWriter, status int, value string) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
@@ -150,3 +302,34 @@ func envOr(name, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func strictBoolEnv(name string) (bool, error) {
|
||||
switch os.Getenv(name) {
|
||||
case "", "false":
|
||||
return false, nil
|
||||
case "true":
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%s must be true or false", name)
|
||||
}
|
||||
}
|
||||
|
||||
func positiveEnv(name string) (int64, error) {
|
||||
value, err := strconv.ParseInt(os.Getenv(name), 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a positive integer", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func loadConsoleToken(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || len(raw) > 4096 {
|
||||
return "", errors.New("read Bell alert console token file")
|
||||
}
|
||||
value := strings.TrimSpace(string(raw))
|
||||
if len(value) < 32 || len(value) > 256 || strings.ContainsAny(value, " \t\r\n") {
|
||||
return "", errors.New("Bell alert console token must contain 32 to 256 non-whitespace characters")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -18,6 +19,49 @@ func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsAreDisabledByDefaultAndConsoleRequiresLoopbackContext(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(), "audit.json"))
|
||||
value, err := loadConfiguration()
|
||||
if err != nil || value.alertsEnabled || value.alertConsoleEnabled {
|
||||
t.Fatalf("default alert configuration: %+v %v", value, err)
|
||||
}
|
||||
t.Setenv("BELL_ALERTS_ENABLED", "true")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("alerts without an external rule file were accepted")
|
||||
}
|
||||
t.Setenv("BELL_ALERT_RULES_FILE", filepath.Join(t.TempDir(), "rules.json"))
|
||||
t.Setenv("BELL_ALERT_CONSOLE_ENABLED", "true")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("console without external context was accepted")
|
||||
}
|
||||
t.Setenv("BELL_ALERT_CONSOLE_TOKEN_FILE", filepath.Join(t.TempDir(), "token"))
|
||||
t.Setenv("BELL_ALERT_CONSOLE_TENANT_ID", "1")
|
||||
t.Setenv("BELL_ALERT_CONSOLE_SITE_ID", "2")
|
||||
t.Setenv("BELL_ALERT_CONSOLE_ACTOR_REF", "operator:local")
|
||||
t.Setenv("BELL_HTTP_ADDR", "127.0.0.1:8081")
|
||||
value, err = loadConfiguration()
|
||||
if err != nil || !value.alertsEnabled || !value.alertConsoleEnabled {
|
||||
t.Fatalf("valid alert console configuration: %+v %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConsoleToken(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(path, []byte("12345678901234567890123456789012\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, err := loadConsoleToken(path); err != nil || len(value) != 32 {
|
||||
t.Fatalf("valid token: %q %v", value, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadConsoleToken(path); err == nil {
|
||||
t.Fatal("short token 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"))
|
||||
@@ -31,3 +75,23 @@ func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
|
||||
t.Fatalf("remote TLS Bell bind rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventIngressIsDisabledByDefaultAndRequiresExternalPolicy(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(), "audit.json"))
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
|
||||
value, err := loadConfiguration()
|
||||
if err != nil || value.eventIngressEnabled {
|
||||
t.Fatalf("default event ingress configuration: %+v %v", value, err)
|
||||
}
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("event ingress without keys and evidence policy was accepted")
|
||||
}
|
||||
t.Setenv("BELL_EVENT_INGRESS_KEYS_FILE", filepath.Join(t.TempDir(), "event-keys.json"))
|
||||
t.Setenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE", filepath.Join(t.TempDir(), "names.txt"))
|
||||
value, err = loadConfiguration()
|
||||
if err != nil || !value.eventIngressEnabled {
|
||||
t.Fatalf("valid event ingress configuration rejected: %+v %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package alert owns Bell's small, deterministic rule and Alert domain.
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPageSize = 16
|
||||
MaxPageSize = 100
|
||||
MaxRulesFile = 256 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("alert not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key was used for another command")
|
||||
ruleKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`)
|
||||
eventKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
|
||||
actorRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]*$`)
|
||||
idempotencyKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
|
||||
)
|
||||
|
||||
type RuleSpec struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
SiteID *int64 `json:"site_id,omitempty"`
|
||||
RuleKey string `json:"rule_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
EventKind string `json:"event_kind"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
}
|
||||
|
||||
func (r RuleSpec) Validate() error {
|
||||
if r.TenantID < 1 || (r.SiteID != nil && *r.SiteID < 1) {
|
||||
return errors.New("rule scope must use positive identifiers")
|
||||
}
|
||||
if !ruleKeyPattern.MatchString(r.RuleKey) || !eventKindPattern.MatchString(r.EventKind) {
|
||||
return errors.New("rule key or event kind is invalid")
|
||||
}
|
||||
if strings.TrimSpace(r.DisplayName) == "" || utf8.RuneCountInString(r.DisplayName) > 120 || r.EffectiveFrom.IsZero() {
|
||||
return errors.New("rule name or effective time is invalid")
|
||||
}
|
||||
if _, ok := SeverityRank(r.MinimumSeverity); !ok {
|
||||
return errors.New("rule minimum severity is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RuleSpec) Digest() ([sha256.Size]byte, error) {
|
||||
value, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
return sha256.Sum256(value), nil
|
||||
}
|
||||
|
||||
type rulesDocument struct {
|
||||
Version int `json:"version"`
|
||||
Rules []RuleSpec `json:"rules"`
|
||||
}
|
||||
|
||||
func LoadRules(path string) ([]RuleSpec, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("open Bell alert rules file")
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.Size() > MaxRulesFile {
|
||||
return nil, errors.New("Bell alert rules file is too large")
|
||||
}
|
||||
decoder := json.NewDecoder(file)
|
||||
decoder.DisallowUnknownFields()
|
||||
var document rulesDocument
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("decode Bell alert rules file")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("Bell alert rules file contains multiple JSON values")
|
||||
}
|
||||
if document.Version != 1 || len(document.Rules) < 1 || len(document.Rules) > 256 {
|
||||
return nil, errors.New("Bell alert rules file must contain 1 to 256 v1 rules")
|
||||
}
|
||||
seen := make(map[string]bool, len(document.Rules))
|
||||
for _, rule := range document.Rules {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid Bell alert rule %q: %w", rule.RuleKey, err)
|
||||
}
|
||||
key := fmt.Sprintf("%d:%s", rule.TenantID, rule.RuleKey)
|
||||
if seen[key] {
|
||||
return nil, fmt.Errorf("duplicate Bell alert rule %q", rule.RuleKey)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return document.Rules, nil
|
||||
}
|
||||
|
||||
func SeverityRank(value string) (int, bool) {
|
||||
for rank, severity := range []string{"low", "medium", "high", "critical"} {
|
||||
if value == severity {
|
||||
return rank, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ValidActorRef(value string) bool {
|
||||
return len(value) <= 80 && actorRefPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func ValidIdempotencyKey(value string) bool {
|
||||
return len(value) >= 8 && len(value) <= 128 && idempotencyKeyPattern.MatchString(value)
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
RuleKey string `json:"rule_key"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type EventRef struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Transition struct {
|
||||
Sequence int `json:"sequence"`
|
||||
FromState *string `json:"from_state"`
|
||||
ToState string `json:"to_state"`
|
||||
ActorRef string `json:"actor_ref"`
|
||||
Note *string `json:"note"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Summary
|
||||
Events []EventRef `json:"events"`
|
||||
Transitions []Transition `json:"transitions"`
|
||||
EvidenceStatus string `json:"evidence_status"`
|
||||
DeliveryStatus string `json:"delivery_status"`
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Summary `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
type CommandResponse struct {
|
||||
AlertID string `json:"alert_id"`
|
||||
State string `json:"state"`
|
||||
ActorRef string `json:"actor_ref"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
AlertReady(context.Context) error
|
||||
PublishRule(context.Context, RuleSpec) (bool, error)
|
||||
EvaluateNext(context.Context) (bool, error)
|
||||
ListAlerts(context.Context, int64, int64, string, int, string) (Page, error)
|
||||
GetAlert(context.Context, int64, int64, string) (Detail, error)
|
||||
Command(context.Context, int64, int64, string, string, string, string, *string) (int, CommandResponse, error)
|
||||
}
|
||||
|
||||
func Publish(ctx context.Context, repository Repository, rules []RuleSpec) error {
|
||||
for _, rule := range rules {
|
||||
if _, err := repository.PublishRule(ctx, rule); err != nil {
|
||||
return fmt.Errorf("publish Bell alert rule %q: %w", rule.RuleKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunWorker(ctx context.Context, repository Repository, onError func(error)) {
|
||||
idle := time.NewTicker(250 * time.Millisecond)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
worked, err := repository.EvaluateNext(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if onError != nil {
|
||||
onError(err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if worked {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-idle.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadRulesRejectsUnknownAndDuplicateValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rules.json")
|
||||
valid := `{"version":1,"rules":[{"tenant_id":1,"site_id":2,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}`
|
||||
if err := os.WriteFile(path, []byte(valid), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := LoadRules(path)
|
||||
if err != nil || len(rules) != 1 {
|
||||
t.Fatalf("load valid rules: %v %#v", err, rules)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(path); err == nil {
|
||||
t.Fatal("unknown rule document property was accepted")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(valid+` trailing`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(path); err == nil {
|
||||
t.Fatal("trailing rule file data was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityRankIsStable(t *testing.T) {
|
||||
for index, value := range []string{"low", "medium", "high", "critical"} {
|
||||
rank, ok := SeverityRank(value)
|
||||
if !ok || rank != index {
|
||||
t.Fatalf("rank %q: %d %v", value, rank, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := SeverityRank("urgent"); ok {
|
||||
t.Fatal("unknown severity was accepted")
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ 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) Sensors() []Sensor { return append([]Sensor(nil), e.shape.Sensors...) }
|
||||
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 }
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package ingress authenticates Brain event candidates and delegates their
|
||||
// atomic persistence to Bell's repository.
|
||||
package ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
Path = "/internal/v1/event-candidates"
|
||||
MaxBodyBytes = 1 << 20
|
||||
HeaderKeyID = "X-YoVision-Key-Id"
|
||||
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||
HeaderNonce = "X-YoVision-Nonce"
|
||||
HeaderSignature = "X-YoVision-Signature"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReplayConflict = errors.New("event ingress replay conflict")
|
||||
ErrSourceConflict = errors.New("event ingress source event conflict")
|
||||
ErrIdentityDenied = errors.New("event ingress identity denied")
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
producerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus int `json:"-"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Replay(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
string,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
) (Result, bool, error)
|
||||
ProcessEvent(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
event.Event,
|
||||
) (Result, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository Repository
|
||||
keys map[string]Key
|
||||
factory *event.Factory
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHandler(repository Repository, keys map[string]Key, factory *event.Factory) (*Handler, error) {
|
||||
if repository == nil || factory == nil || len(keys) == 0 {
|
||||
return nil, errors.New("event ingress handler dependencies are required")
|
||||
}
|
||||
copyKeys := make(map[string]Key, len(keys))
|
||||
for id, key := range keys {
|
||||
if !keyIDPattern.MatchString(id) || !producerIDPattern.MatchString(key.ProducerID) || len(key.Secret) < 32 {
|
||||
return nil, errors.New("invalid event ingress handler key")
|
||||
}
|
||||
copyKeys[id] = Key{ProducerID: key.ProducerID, Secret: append([]byte(nil), key.Secret...)}
|
||||
}
|
||||
return &Handler{repository: repository, keys: copyKeys, factory: factory, now: time.Now}, nil
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
Candidate json.RawMessage `json:"candidate"`
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != Path {
|
||||
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)
|
||||
key, 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(key.Secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
|
||||
if !hmac.Equal(signatureBytes, expected) {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
|
||||
var value envelope
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || value.SchemaVersion != 1 || !producerIDPattern.MatchString(value.ProducerID) || len(value.Candidate) == 0 {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
|
||||
return
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
|
||||
return
|
||||
}
|
||||
if value.ProducerID != key.ProducerID {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
canonicalCandidate, err := canonicalJSON(value.Candidate)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
|
||||
return
|
||||
}
|
||||
sourceEventID, err := candidateSourceEventID(canonicalCandidate)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", "schema_invalid")
|
||||
return
|
||||
}
|
||||
requestHash := sha256.Sum256(body)
|
||||
candidateHash := sha256.Sum256(canonicalCandidate)
|
||||
replay, found, err := h.repository.Replay(
|
||||
request.Context(), keyID, nonce, requestHash, value.ProducerID, sourceEventID, candidateHash,
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, ErrReplayConflict):
|
||||
writeError(writer, http.StatusConflict, "replay_conflict", "")
|
||||
return
|
||||
case errors.Is(err, ErrSourceConflict):
|
||||
writeError(writer, http.StatusConflict, "source_event_conflict", "")
|
||||
return
|
||||
case err != nil:
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
|
||||
return
|
||||
case found:
|
||||
writeJSON(writer, replay.HTTPStatus, replay)
|
||||
return
|
||||
}
|
||||
created, err := h.factory.Create(request.Context(), canonicalCandidate)
|
||||
if err != nil {
|
||||
handleFactoryError(writer, err)
|
||||
return
|
||||
}
|
||||
result, err := h.repository.ProcessEvent(request.Context(), keyID, nonce, requestHash, value.ProducerID, candidateHash, created)
|
||||
switch {
|
||||
case errors.Is(err, ErrReplayConflict):
|
||||
writeError(writer, http.StatusConflict, "replay_conflict", "")
|
||||
case errors.Is(err, ErrSourceConflict):
|
||||
writeError(writer, http.StatusConflict, "source_event_conflict", "")
|
||||
case errors.Is(err, ErrIdentityDenied):
|
||||
writeError(writer, http.StatusForbidden, "identity_denied", "")
|
||||
case err != nil:
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
|
||||
default:
|
||||
writeJSON(writer, result.HTTPStatus, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleFactoryError(writer http.ResponseWriter, err error) {
|
||||
var validation *event.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
|
||||
return
|
||||
}
|
||||
switch validation.Code {
|
||||
case event.CodePayloadTooLarge:
|
||||
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
|
||||
case event.CodePrivacyDenied:
|
||||
writeError(writer, http.StatusForbidden, "privacy_denied", "")
|
||||
case event.CodePrivacyUnavailable:
|
||||
writeError(writer, http.StatusServiceUnavailable, "privacy_unavailable", "")
|
||||
default:
|
||||
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", string(validation.Code))
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil || value == nil {
|
||||
return nil, errors.New("invalid JSON")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("multiple JSON values")
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
var sourceEventIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
|
||||
func candidateSourceEventID(raw []byte) (string, error) {
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
return "", errors.New("candidate is not an object")
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(object["source_event_id"], &value); err != nil || !sourceEventIDPattern.MatchString(value) {
|
||||
return "", errors.New("candidate source event ID is invalid")
|
||||
}
|
||||
return value, 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) []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
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, code, detail string) {
|
||||
value := map[string]string{"error": code}
|
||||
if detail != "" {
|
||||
value["detail_code"] = detail
|
||||
}
|
||||
writeJSON(writer, status, value)
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
type ingressIDs struct{}
|
||||
|
||||
func (ingressIDs) NewEventID() (string, error) {
|
||||
return "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", nil
|
||||
}
|
||||
|
||||
type ingressPrivacy struct{}
|
||||
|
||||
func (ingressPrivacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
replayResult Result
|
||||
replayFound bool
|
||||
replayErr error
|
||||
processErr error
|
||||
processed int
|
||||
}
|
||||
|
||||
func (f *fakeRepository) Replay(context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte) (Result, bool, error) {
|
||||
return f.replayResult, f.replayFound, f.replayErr
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ProcessEvent(_ context.Context, _, _ string, _ [sha256.Size]byte, producer string, _ [sha256.Size]byte, value event.Event) (Result, error) {
|
||||
f.processed++
|
||||
if f.processErr != nil {
|
||||
return Result{}, f.processErr
|
||||
}
|
||||
return Result{SchemaVersion: 1, ProducerID: producer, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}, nil
|
||||
}
|
||||
|
||||
func ingressCandidate(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json"))
|
||||
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["kind"] = "zone_entry"
|
||||
object["severity"] = "medium"
|
||||
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
|
||||
encoded, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func newIngressHandler(t *testing.T, repository Repository) (*Handler, []byte) {
|
||||
t.Helper()
|
||||
guard, err := event.NewEvidenceGuard("private-customer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, err := event.NewFactory(contracts.EventV01Schema, ingressIDs{}, ingressPrivacy{}, guard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandler(repository, map[string]Key{"brain-a": {ProducerID: "brain-main", Secret: secret}}, factory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler.now = func() time.Time { return time.Unix(1_800_000_000, 0).UTC() }
|
||||
return handler, secret
|
||||
}
|
||||
|
||||
func signedRequest(t *testing.T, secret []byte, producer string, candidate []byte) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{"schema_version": 1, "producer_id": producer, "candidate": json.RawMessage(candidate)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, Path, bytes.NewReader(body))
|
||||
timestamp := strconv.FormatInt(1_800_000_000, 10)
|
||||
nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef"))
|
||||
request.Header.Set(HeaderKeyID, "brain-a")
|
||||
request.Header.Set(HeaderTimestamp, timestamp)
|
||||
request.Header.Set(HeaderNonce, nonce)
|
||||
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, Path, timestamp, nonce, body))))
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandlerAcceptsSignedCandidateAndRejectsProducerSpoofing(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
handler, secret := newIngressHandler(t, repository)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
|
||||
if response.Code != http.StatusCreated || repository.processed != 1 {
|
||||
t.Fatalf("signed candidate: status=%d body=%s processed=%d", response.Code, response.Body.String(), repository.processed)
|
||||
}
|
||||
var result Result
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Status != "accepted" || result.EventID == "" {
|
||||
t.Fatalf("invalid accepted response: %+v %v", result, err)
|
||||
}
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, secret, "brain-spoofed", ingressCandidate(t)))
|
||||
if response.Code != http.StatusUnauthorized || repository.processed != 1 {
|
||||
t.Fatalf("producer spoofing was not rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReturnsDurableReplayAndStableConflicts(t *testing.T) {
|
||||
repository := &fakeRepository{replayFound: true, replayResult: Result{
|
||||
SchemaVersion: 1, ProducerID: "brain-main", SourceEventID: "source-1",
|
||||
EventID: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", Status: "duplicate", HTTPStatus: 200,
|
||||
}}
|
||||
handler, secret := newIngressHandler(t, repository)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
|
||||
if response.Code != http.StatusOK || repository.processed != 0 {
|
||||
t.Fatalf("durable replay did not bypass factory persistence: %d", response.Code)
|
||||
}
|
||||
|
||||
repository.replayFound = false
|
||||
repository.replayErr = ErrSourceConflict
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("source conflict status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsInvalidSignatureAndUpstreamPlatformID(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
handler, secret := newIngressHandler(t, repository)
|
||||
request := signedRequest(t, secret, "brain-main", ingressCandidate(t))
|
||||
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(make([]byte, 32)))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("invalid signature status=%d", response.Code)
|
||||
}
|
||||
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(ingressCandidate(t), &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
object["id"] = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
|
||||
withID, _ := json.Marshal(object)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", withID))
|
||||
if response.Code != http.StatusUnprocessableEntity || repository.processed != 0 {
|
||||
t.Fatalf("upstream ID status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type keyDocument struct {
|
||||
Version int `json:"version"`
|
||||
Keys []struct {
|
||||
KeyID string `json:"key_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
Secret string `json:"secret_base64url"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
ProducerID string
|
||||
Secret []byte
|
||||
}
|
||||
|
||||
func LoadKeys(path string) (map[string]Key, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read Bell event ingress 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 event ingress key file")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid Bell event ingress key file")
|
||||
}
|
||||
values := make(map[string]Key, len(document.Keys))
|
||||
for _, item := range document.Keys {
|
||||
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
|
||||
if err != nil || !keyIDPattern.MatchString(item.KeyID) || !producerIDPattern.MatchString(item.ProducerID) || len(secret) < 32 {
|
||||
return nil, errors.New("invalid Bell event ingress key")
|
||||
}
|
||||
if _, exists := values[item.KeyID]; exists {
|
||||
return nil, errors.New("duplicate Bell event ingress key ID")
|
||||
}
|
||||
values[item.KeyID] = Key{ProducerID: item.ProducerID, Secret: append([]byte(nil), secret...)}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
func (p *Postgres) AlertReady(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 < 6 {
|
||||
return errors.New("postgres Bell schema migration v6 is required for alerts")
|
||||
}
|
||||
for _, table := range []string{"rule_versions", "event_rule_sweeps", "rule_evaluations", "alerts", "alert_events", "alert_transitions", "alert_command_receipts"} {
|
||||
var selectAllowed, insertAllowed, updateAllowed, deleteAllowed, truncateAllowed bool
|
||||
if err := p.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user,$1,'SELECT'), has_table_privilege(current_user,$1,'INSERT'),
|
||||
has_table_privilege(current_user,$1,'UPDATE'), has_table_privilege(current_user,$1,'DELETE'),
|
||||
has_table_privilege(current_user,$1,'TRUNCATE')`, "bell."+table).Scan(
|
||||
&selectAllowed, &insertAllowed, &updateAllowed, &deleteAllowed, &truncateAllowed,
|
||||
); err != nil || !selectAllowed || !insertAllowed || updateAllowed || deleteAllowed || truncateAllowed {
|
||||
return fmt.Errorf("Bell runtime alert privileges violate append-only boundary for %s", table)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) PublishRule(ctx context.Context, rule alert.RuleSpec) (bool, error) {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
digest, err := rule.Digest()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, errors.New("begin Bell rule publish")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("rule:%d:%s", rule.TenantID, rule.RuleKey)); err != nil {
|
||||
return false, errors.New("lock Bell rule key")
|
||||
}
|
||||
var existing string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM bell.rule_versions WHERE tenant_id=$1 AND rule_key=$2 AND config_hash=$3`, rule.TenantID, rule.RuleKey, digest[:]).Scan(&existing)
|
||||
if err == nil {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, errors.New("read Bell rule version")
|
||||
}
|
||||
var version int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0)+1 FROM bell.rule_versions WHERE tenant_id=$1 AND rule_key=$2`, rule.TenantID, rule.RuleKey).Scan(&version); err != nil {
|
||||
return false, errors.New("allocate Bell rule version")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.rule_versions(
|
||||
id,tenant_id,site_id,rule_key,version,display_name,event_kind,minimum_severity,enabled,effective_from,config_hash
|
||||
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, newPrefixedID("ruv_"), rule.TenantID, rule.SiteID,
|
||||
rule.RuleKey, version, rule.DisplayName, rule.EventKind, rule.MinimumSeverity, rule.Enabled, rule.EffectiveFrom, digest[:]); err != nil {
|
||||
return false, fmt.Errorf("insert Bell rule version: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, errors.New("commit Bell rule version")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) EvaluateNext(ctx context.Context) (bool, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, errors.New("begin Bell rule evaluation")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var eventID, kind, severity string
|
||||
var tenantID, siteID, deviceID int64
|
||||
var occurredAt time.Time
|
||||
err = tx.QueryRowContext(ctx, `SELECT e.id,e.tenant_id,e.site_id,e.device_id,e.kind,e.severity,e.occurred_at
|
||||
FROM bell.events e WHERE NOT EXISTS(SELECT 1 FROM bell.event_rule_sweeps s WHERE s.event_id=e.id)
|
||||
ORDER BY e.created_at,e.id LIMIT 1`).Scan(&eventID, &tenantID, &siteID, &deviceID, &kind, &severity, &occurredAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("select Bell event for rules")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "event-rule:"+eventID); err != nil {
|
||||
return false, errors.New("lock Bell event evaluation")
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO bell.event_rule_sweeps(event_id) VALUES($1) ON CONFLICT DO NOTHING`, eventID)
|
||||
if err != nil {
|
||||
return false, errors.New("claim Bell event evaluation")
|
||||
}
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,rule_key,version,display_name,event_kind,minimum_severity,enabled
|
||||
FROM (SELECT DISTINCT ON (rule_key) id,rule_key,version,display_name,event_kind,minimum_severity,enabled,effective_from
|
||||
FROM bell.rule_versions WHERE tenant_id=$1 AND effective_from <= $3 AND (site_id IS NULL OR site_id=$2)
|
||||
ORDER BY rule_key,version DESC,effective_from DESC) latest ORDER BY rule_key`, tenantID, siteID, occurredAt)
|
||||
if err != nil {
|
||||
return false, errors.New("read effective Bell rules")
|
||||
}
|
||||
defer rows.Close()
|
||||
type effectiveRule struct {
|
||||
id, key, name, kind, minimum string
|
||||
version int
|
||||
enabled bool
|
||||
}
|
||||
rules := make([]effectiveRule, 0)
|
||||
for rows.Next() {
|
||||
var rule effectiveRule
|
||||
if err := rows.Scan(&rule.id, &rule.key, &rule.version, &rule.name, &rule.kind, &rule.minimum, &rule.enabled); err != nil {
|
||||
return false, errors.New("scan effective Bell rule")
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, errors.New("iterate effective Bell rules")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return false, errors.New("close effective Bell rules")
|
||||
}
|
||||
eventRank, _ := alert.SeverityRank(severity)
|
||||
for _, rule := range rules {
|
||||
matched, reason := true, "matched"
|
||||
minimumRank, _ := alert.SeverityRank(rule.minimum)
|
||||
switch {
|
||||
case !rule.enabled:
|
||||
matched, reason = false, "disabled"
|
||||
case rule.kind != kind:
|
||||
matched, reason = false, "event_kind"
|
||||
case eventRank < minimumRank:
|
||||
matched, reason = false, "severity"
|
||||
}
|
||||
value := "no_match"
|
||||
if matched {
|
||||
value = "matched"
|
||||
}
|
||||
evaluationID := newPrefixedID("eva_")
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.rule_evaluations(id,event_id,rule_version_id,result,reason) VALUES($1,$2,$3,$4,$5)`, evaluationID, eventID, rule.id, value, reason); err != nil {
|
||||
return false, fmt.Errorf("append Bell rule evaluation: %w", err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
alertID := newPrefixedID("alt_")
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alerts(id,tenant_id,site_id,rule_evaluation_id,rule_version_id,severity,title) VALUES($1,$2,$3,$4,$5,$6,$7)`, alertID, tenantID, siteID, evaluationID, rule.id, severity, rule.name); err != nil {
|
||||
return false, errors.New("create Bell alert")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_events(alert_id,event_id) VALUES($1,$2)`, alertID, eventID); err != nil {
|
||||
return false, errors.New("link Bell alert event")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_transitions(id,alert_id,sequence,from_state,to_state,actor_ref) VALUES($1,$2,1,NULL,'open','system:rule-worker')`, newPrefixedID("trn_"), alertID); err != nil {
|
||||
return false, errors.New("open Bell alert")
|
||||
}
|
||||
_ = deviceID
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, errors.New("commit Bell rule evaluation")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListAlerts(ctx context.Context, tenantID, siteID int64, state string, limit int, cursor string) (alert.Page, error) {
|
||||
if limit < 1 || limit > alert.MaxPageSize {
|
||||
return alert.Page{}, errors.New("invalid alert page size")
|
||||
}
|
||||
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
|
||||
return alert.Page{}, errors.New("invalid alert state")
|
||||
}
|
||||
rows, err := p.db.QueryContext(ctx, `SELECT a.id,a.severity,a.title,t.to_state,r.rule_key,r.version,a.created_at
|
||||
FROM bell.alerts a JOIN bell.rule_versions r ON r.id=a.rule_version_id
|
||||
JOIN LATERAL(SELECT to_state FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
|
||||
WHERE a.tenant_id=$1 AND a.site_id=$2 AND ($3='' OR t.to_state=$3)
|
||||
AND ($4='' OR (a.created_at,a.id) < (SELECT c.created_at,c.id FROM bell.alerts c WHERE c.id=$4 AND c.tenant_id=$1 AND c.site_id=$2))
|
||||
ORDER BY a.created_at DESC,a.id DESC LIMIT $5`, tenantID, siteID, state, cursor, limit+1)
|
||||
if err != nil {
|
||||
return alert.Page{}, errors.New("list Bell alerts")
|
||||
}
|
||||
defer rows.Close()
|
||||
page := alert.Page{Items: make([]alert.Summary, 0, limit)}
|
||||
for rows.Next() {
|
||||
var item alert.Summary
|
||||
if err := rows.Scan(&item.ID, &item.Severity, &item.Title, &item.State, &item.RuleKey, &item.RuleVersion, &item.CreatedAt); err != nil {
|
||||
return alert.Page{}, errors.New("scan Bell alert list")
|
||||
}
|
||||
page.Items = append(page.Items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return alert.Page{}, errors.New("iterate Bell alert list")
|
||||
}
|
||||
if len(page.Items) > limit {
|
||||
cursor := page.Items[limit-1].ID
|
||||
page.NextCursor = &cursor
|
||||
page.Items = page.Items[:limit]
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) GetAlert(ctx context.Context, tenantID, siteID int64, alertID string) (alert.Detail, error) {
|
||||
var detail alert.Detail
|
||||
err := p.db.QueryRowContext(ctx, `SELECT a.id,a.severity,a.title,t.to_state,r.rule_key,r.version,a.created_at
|
||||
FROM bell.alerts a JOIN bell.rule_versions r ON r.id=a.rule_version_id
|
||||
JOIN LATERAL(SELECT to_state FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
|
||||
WHERE a.tenant_id=$1 AND a.site_id=$2 AND a.id=$3`, tenantID, siteID, alertID).Scan(
|
||||
&detail.ID, &detail.Severity, &detail.Title, &detail.State, &detail.RuleKey, &detail.RuleVersion, &detail.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return alert.Detail{}, alert.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return alert.Detail{}, errors.New("read Bell alert")
|
||||
}
|
||||
detail.EvidenceStatus = "not_enabled"
|
||||
detail.DeliveryStatus = "not_enabled"
|
||||
detail.Events = []alert.EventRef{}
|
||||
detail.Transitions = []alert.Transition{}
|
||||
rows, err := p.db.QueryContext(ctx, `SELECT e.id,e.device_id,e.kind,e.severity,e.occurred_at FROM bell.alert_events ae JOIN bell.events e ON e.id=ae.event_id WHERE ae.alert_id=$1 ORDER BY e.occurred_at,e.id`, alertID)
|
||||
if err != nil {
|
||||
return alert.Detail{}, errors.New("read Bell alert events")
|
||||
}
|
||||
for rows.Next() {
|
||||
var value alert.EventRef
|
||||
if err := rows.Scan(&value.ID, &value.DeviceID, &value.Kind, &value.Severity, &value.OccurredAt); err != nil {
|
||||
rows.Close()
|
||||
return alert.Detail{}, errors.New("scan Bell alert event")
|
||||
}
|
||||
detail.Events = append(detail.Events, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return alert.Detail{}, errors.New("close Bell alert events")
|
||||
}
|
||||
rows, err = p.db.QueryContext(ctx, `SELECT sequence,from_state,to_state,actor_ref,note,occurred_at FROM bell.alert_transitions WHERE alert_id=$1 ORDER BY sequence`, alertID)
|
||||
if err != nil {
|
||||
return alert.Detail{}, errors.New("read Bell alert transitions")
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var value alert.Transition
|
||||
if err := rows.Scan(&value.Sequence, &value.FromState, &value.ToState, &value.ActorRef, &value.Note, &value.OccurredAt); err != nil {
|
||||
return alert.Detail{}, errors.New("scan Bell alert transition")
|
||||
}
|
||||
detail.Transitions = append(detail.Transitions, value)
|
||||
}
|
||||
return detail, rows.Err()
|
||||
}
|
||||
|
||||
func (p *Postgres) Command(ctx context.Context, tenantID, siteID int64, alertID, command, key, actorRef string, note *string) (int, alert.CommandResponse, error) {
|
||||
if (command != "ack" && command != "close") || tenantID < 1 || siteID < 1 || !alert.ValidIdempotencyKey(key) || !alert.ValidActorRef(actorRef) {
|
||||
return 0, alert.CommandResponse{}, errors.New("invalid Bell alert command")
|
||||
}
|
||||
if note != nil && len([]rune(*note)) > 500 {
|
||||
return 0, alert.CommandResponse{}, errors.New("Bell alert command note is too long")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{alertID, command, actorRef, valueOrEmpty(note)}, "\x00")))
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("begin Bell alert command")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("alert-key:%d:%s", tenantID, key)); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("lock Bell alert command key")
|
||||
}
|
||||
var storedHash, storedBody []byte
|
||||
var storedStatus int
|
||||
err = tx.QueryRowContext(ctx, `SELECT command_hash,response_status,response_body::text FROM bell.alert_command_receipts WHERE tenant_id=$1 AND idempotency_key=$2`, tenantID, key).Scan(&storedHash, &storedStatus, &storedBody)
|
||||
if err == nil {
|
||||
if !bytes.Equal(storedHash, digest[:]) {
|
||||
return 0, alert.CommandResponse{}, alert.ErrIdempotencyConflict
|
||||
}
|
||||
var response alert.CommandResponse
|
||||
if err := json.Unmarshal(storedBody, &response); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("decode Bell alert command receipt")
|
||||
}
|
||||
return storedStatus, response, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, alert.CommandResponse{}, errors.New("read Bell alert command receipt")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "alert:"+alertID); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("lock Bell alert")
|
||||
}
|
||||
var current, currentActor string
|
||||
var currentTime time.Time
|
||||
var sequence int
|
||||
err = tx.QueryRowContext(ctx, `SELECT t.to_state,t.actor_ref,t.occurred_at,t.sequence FROM bell.alerts a
|
||||
JOIN LATERAL(SELECT to_state,actor_ref,occurred_at,sequence FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
|
||||
WHERE a.id=$1 AND a.tenant_id=$2 AND a.site_id=$3`, alertID, tenantID, siteID).Scan(¤t, ¤tActor, ¤tTime, &sequence)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, alert.CommandResponse{}, alert.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("read Bell alert state")
|
||||
}
|
||||
status := 200
|
||||
response := alert.CommandResponse{AlertID: alertID, State: current, ActorRef: currentActor, OccurredAt: currentTime}
|
||||
allowed := (command == "ack" && current == "open") || (command == "close" && current == "acknowledged")
|
||||
if !allowed {
|
||||
status = 409
|
||||
switch {
|
||||
case command == "ack" && (current == "acknowledged" || current == "closed"):
|
||||
response.Code = "already_acknowledged"
|
||||
_ = tx.QueryRowContext(ctx, `SELECT actor_ref,occurred_at FROM bell.alert_transitions WHERE alert_id=$1 AND to_state='acknowledged' ORDER BY sequence LIMIT 1`, alertID).Scan(&response.ActorRef, &response.OccurredAt)
|
||||
case command == "close" && current == "open":
|
||||
response.Code = "acknowledgement_required"
|
||||
case command == "close" && current == "closed":
|
||||
response.Code = "already_closed"
|
||||
default:
|
||||
response.Code = "invalid_state"
|
||||
}
|
||||
} else {
|
||||
next := "acknowledged"
|
||||
if command == "close" {
|
||||
next = "closed"
|
||||
}
|
||||
response.State = next
|
||||
response.ActorRef = ""
|
||||
response.OccurredAt = time.Time{}
|
||||
if err := tx.QueryRowContext(ctx, `INSERT INTO bell.alert_transitions(id,alert_id,sequence,from_state,to_state,actor_ref,note)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING actor_ref,occurred_at`,
|
||||
newPrefixedID("trn_"), alertID, sequence+1, current, next, actorRef, note).Scan(&response.ActorRef, &response.OccurredAt); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("append Bell alert transition")
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("encode Bell alert command response")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_command_receipts(tenant_id,idempotency_key,command_hash,response_status,response_body) VALUES($1,$2,$3,$4,$5::jsonb)`, tenantID, key, digest[:], status, body); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("append Bell alert command receipt")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("commit Bell alert command")
|
||||
}
|
||||
return status, response, nil
|
||||
}
|
||||
|
||||
func newPrefixedID(prefix string) string { return prefix + ulid.Make().String() }
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
func TestPostgresAlertRuleEvaluationAndFirstAckWins(t *testing.T) {
|
||||
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
|
||||
if dsn == "" || adminDSN == "" {
|
||||
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := sql.Open("pgx", adminDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.AlertReady(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
siteID := int64(902)
|
||||
rule := alert.RuleSpec{TenantID: 901, SiteID: &siteID, RuleKey: "zone-entry", DisplayName: "区域闯入", EventKind: "zone_entry", MinimumSeverity: "medium", Enabled: true, EffectiveFrom: time.Now().Add(-time.Hour).UTC()}
|
||||
created, err := repository.PublishRule(ctx, rule)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("publish rule: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = repository.PublishRule(ctx, rule)
|
||||
if err != nil || created {
|
||||
t.Fatalf("idempotent rule publish: created=%v err=%v", created, err)
|
||||
}
|
||||
eventID := "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2Q"
|
||||
payload := fmt.Sprintf(`{"id":%q,"tenant_id":901,"site_id":902,"device_id":903,"source_event_id":"T020-EVENT-1","kind":"zone_entry","severity":"high"}`, eventID)
|
||||
payloadHash := sha256.Sum256([]byte(payload))
|
||||
if _, err := admin.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,901,902,903,'T020-EVENT-1','zone_entry','high',clock_timestamp(),clock_timestamp(),$3,$2::jsonb)`, eventID, payload, payloadHash[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noMatchID := "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2R"
|
||||
noMatchPayload := fmt.Sprintf(`{"id":%q,"tenant_id":901,"site_id":902,"device_id":903,"source_event_id":"T020-EVENT-2","kind":"crowd","severity":"high"}`, noMatchID)
|
||||
noMatchHash := sha256.Sum256([]byte(noMatchPayload))
|
||||
if _, err := admin.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,901,902,903,'T020-EVENT-2','crowd','high',clock_timestamp(),clock_timestamp(),$3,$2::jsonb)`, noMatchID, noMatchPayload, noMatchHash[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var evaluators sync.WaitGroup
|
||||
errorsSeen := make(chan error, 8)
|
||||
for index := 0; index < 8; index++ {
|
||||
evaluators.Add(1)
|
||||
go func() {
|
||||
defer evaluators.Done()
|
||||
for attempts := 0; attempts < 32; attempts++ {
|
||||
worked, err := repository.EvaluateNext(ctx)
|
||||
if err != nil {
|
||||
errorsSeen <- err
|
||||
return
|
||||
}
|
||||
if !worked {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
evaluators.Wait()
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
t.Error(err)
|
||||
}
|
||||
var alertID string
|
||||
var alertCount, evaluationCount, sweepCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT
|
||||
(SELECT count(*) FROM bell.alerts WHERE tenant_id=901 AND site_id=902),
|
||||
(SELECT count(*) FROM bell.rule_evaluations e JOIN bell.events v ON v.id=e.event_id WHERE v.tenant_id=901),
|
||||
(SELECT count(*) FROM bell.event_rule_sweeps s JOIN bell.events v ON v.id=s.event_id WHERE v.tenant_id=901),
|
||||
(SELECT id FROM bell.alerts WHERE tenant_id=901 AND site_id=902)`).Scan(&alertCount, &evaluationCount, &sweepCount, &alertID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alertCount != 1 || evaluationCount != 2 || sweepCount != 2 {
|
||||
t.Fatalf("evaluation did not converge: alerts=%d evaluations=%d sweeps=%d", alertCount, evaluationCount, sweepCount)
|
||||
}
|
||||
preStatus, preResponse, err := repository.Command(ctx, 901, 902, alertID, "close", "close-before-ack", "operator:closer", nil)
|
||||
if err != nil || preStatus != 409 || preResponse.Code != "acknowledgement_required" || preResponse.State != "open" {
|
||||
t.Fatalf("close-before-ack: status=%d response=%+v err=%v", preStatus, preResponse, err)
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
status int
|
||||
response alert.CommandResponse
|
||||
err error
|
||||
}
|
||||
results := make(chan outcome, 8)
|
||||
var acknowledgers sync.WaitGroup
|
||||
for index := 0; index < 8; index++ {
|
||||
acknowledgers.Add(1)
|
||||
go func(index int) {
|
||||
defer acknowledgers.Done()
|
||||
status, response, err := repository.Command(ctx, 901, 902, alertID, "ack", fmt.Sprintf("ack-key-%02d", index), fmt.Sprintf("operator:%d", index), nil)
|
||||
results <- outcome{status, response, err}
|
||||
}(index)
|
||||
}
|
||||
acknowledgers.Wait()
|
||||
close(results)
|
||||
winners := 0
|
||||
var winner alert.CommandResponse
|
||||
losers := make([]alert.CommandResponse, 0, 7)
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
if result.status == 200 {
|
||||
winners++
|
||||
winner = result.response
|
||||
} else if result.status == 409 {
|
||||
losers = append(losers, result.response)
|
||||
}
|
||||
}
|
||||
if winners != 1 || len(losers) != 7 {
|
||||
t.Fatalf("ack winners=%d losers=%d", winners, len(losers))
|
||||
}
|
||||
for _, loser := range losers {
|
||||
if loser.ActorRef != winner.ActorRef || !loser.OccurredAt.Equal(winner.OccurredAt) || loser.Code != "already_acknowledged" {
|
||||
t.Fatalf("late ack did not expose first winner: winner=%+v loser=%+v", winner, loser)
|
||||
}
|
||||
}
|
||||
status, replayed, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", nil)
|
||||
if err != nil || status != 409 || replayed.ActorRef != winner.ActorRef {
|
||||
t.Fatalf("first replay receipt: status=%d response=%+v err=%v", status, replayed, err)
|
||||
}
|
||||
status2, replayed2, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", nil)
|
||||
if err != nil || status2 != status || replayed2 != replayed {
|
||||
t.Fatalf("stable receipt replay: status=%d response=%+v err=%v", status2, replayed2, err)
|
||||
}
|
||||
note := "different"
|
||||
if _, _, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", ¬e); !errors.Is(err, alert.ErrIdempotencyConflict) {
|
||||
t.Fatalf("expected idempotency conflict, got %v", err)
|
||||
}
|
||||
closeStatus, closeResponse, err := repository.Command(ctx, 901, 902, alertID, "close", "close-after-ack", "operator:closer", nil)
|
||||
if err != nil || closeStatus != 200 || closeResponse.State != "closed" || closeResponse.ActorRef != "operator:closer" {
|
||||
t.Fatalf("close-after-ack: status=%d response=%+v err=%v", closeStatus, closeResponse, err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE bell.alert_transitions SET actor_ref=actor_ref WHERE alert_id=$1`, alertID); err == nil {
|
||||
t.Fatal("runtime updated immutable alert transition")
|
||||
}
|
||||
reopened, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := reopened.GetAlert(ctx, 901, 902, alertID)
|
||||
if err != nil || detail.State != "closed" || len(detail.Transitions) != 3 || detail.EvidenceStatus != "not_enabled" {
|
||||
t.Fatalf("restart detail: %+v %v", detail, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"yovision/bell/internal/event"
|
||||
"yovision/bell/internal/ingress"
|
||||
)
|
||||
|
||||
func (p *Postgres) EventIngressReady(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 < 5 {
|
||||
return errors.New("postgres Bell schema migration v5 is required for event ingress")
|
||||
}
|
||||
var bindingSelect bool
|
||||
var receiptSelect, receiptInsert, receiptUpdate, receiptDelete, receiptTruncate bool
|
||||
var nonceSelect, nonceInsert, nonceUpdate, nonceDelete, nonceTruncate bool
|
||||
var deviceID, deviceArea, deviceModality bool
|
||||
var deviceEndpoint, deviceCredential, deviceProfile, devicePath bool
|
||||
var siteID, areaPolicy bool
|
||||
err := p.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'bell.event_ingress_bindings', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'INSERT'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'UPDATE'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'DELETE'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'TRUNCATE'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'INSERT'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'UPDATE'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'DELETE'),
|
||||
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'TRUNCATE'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'id', 'SELECT'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'area_id', 'SELECT'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'modality', 'SELECT'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'endpoint_ref', 'SELECT'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'credential_ref', 'SELECT'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'profile_token', 'SELECT'),
|
||||
has_column_privilege(current_user, 'sense.devices', 'path_name', 'SELECT'),
|
||||
has_column_privilege(current_user, 'bell.sites', 'id', 'SELECT'),
|
||||
has_column_privilege(current_user, 'bell.areas', 'capture_policy', 'SELECT')`).Scan(
|
||||
&bindingSelect,
|
||||
&receiptSelect, &receiptInsert, &receiptUpdate, &receiptDelete, &receiptTruncate,
|
||||
&nonceSelect, &nonceInsert, &nonceUpdate, &nonceDelete, &nonceTruncate,
|
||||
&deviceID, &deviceArea, &deviceModality,
|
||||
&deviceEndpoint, &deviceCredential, &deviceProfile, &devicePath,
|
||||
&siteID, &areaPolicy,
|
||||
)
|
||||
if err != nil {
|
||||
return errors.New("verify Bell event ingress privileges")
|
||||
}
|
||||
if !bindingSelect || !receiptSelect || !receiptInsert || receiptUpdate || receiptDelete || receiptTruncate ||
|
||||
!nonceSelect || !nonceInsert || nonceUpdate || !nonceDelete || nonceTruncate ||
|
||||
!deviceID || !deviceArea || !deviceModality || deviceEndpoint || deviceCredential || deviceProfile || devicePath ||
|
||||
!siteID || !areaPolicy {
|
||||
return errors.New("Bell event ingress privileges violate append-only boundary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VideoAllowed implements event.PrivacyPolicy using current Bell/Sense facts.
|
||||
// Exact producer ownership is checked again in ProcessEvent.
|
||||
func (p *Postgres) VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error) {
|
||||
return bindingAllowed(ctx, p.db, "", tenantID, siteID, deviceID, "video")
|
||||
}
|
||||
|
||||
type rowQuerier interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func bindingAllowed(
|
||||
ctx context.Context,
|
||||
query rowQuerier,
|
||||
producerID string,
|
||||
tenantID, siteID, deviceID int64,
|
||||
modality string,
|
||||
) (bool, error) {
|
||||
producerClause := ""
|
||||
arguments := []any{tenantID, siteID, deviceID, modality}
|
||||
if producerID != "" {
|
||||
producerClause = " AND binding.producer_id=$5"
|
||||
arguments = append(arguments, producerID)
|
||||
}
|
||||
statement := `SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM bell.event_ingress_bindings AS binding
|
||||
JOIN bell.sites AS site
|
||||
ON site.tenant_id=binding.logical_tenant_id
|
||||
AND site.id=binding.logical_site_id
|
||||
JOIN bell.areas AS area
|
||||
ON area.tenant_id=binding.logical_tenant_id
|
||||
AND area.site_id=binding.logical_site_id
|
||||
AND area.id=binding.logical_area_id
|
||||
JOIN sense.devices AS device
|
||||
ON device.tenant_id=binding.logical_tenant_id
|
||||
AND device.site_id=binding.logical_site_id
|
||||
AND device.id=binding.logical_device_id
|
||||
AND device.area_id=binding.logical_area_id
|
||||
AND device.modality=binding.modality
|
||||
WHERE binding.tenant_id=$1 AND binding.site_id=$2 AND binding.device_id=$3
|
||||
AND binding.modality=$4 AND binding.enabled
|
||||
AND site.deleted_at IS NULL AND area.deleted_at IS NULL
|
||||
AND (binding.modality <> 'video' OR area.capture_policy='video_allowed')` + producerClause + `
|
||||
)`
|
||||
var allowed bool
|
||||
if err := query.QueryRowContext(ctx, statement, arguments...).Scan(&allowed); err != nil {
|
||||
return false, fmt.Errorf("resolve Bell event ingress binding: %w", err)
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) Replay(
|
||||
ctx context.Context,
|
||||
keyID, nonce string,
|
||||
requestHash [sha256.Size]byte,
|
||||
producerID, sourceEventID string,
|
||||
candidateHash [sha256.Size]byte,
|
||||
) (ingress.Result, bool, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ingress.Result{}, false, errors.New("begin Bell event replay check")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := lockIngress(ctx, tx, keyID, nonce, producerID, sourceEventID); err != nil {
|
||||
return ingress.Result{}, false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
|
||||
return ingress.Result{}, false, errors.New("expire Bell event ingress nonces")
|
||||
}
|
||||
if value, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
return value, found, err
|
||||
}
|
||||
var storedHash []byte
|
||||
var eventID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
|
||||
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, sourceEventID).Scan(&storedHash, &eventID)
|
||||
if err == nil {
|
||||
if !bytes.Equal(storedHash, candidateHash[:]) {
|
||||
return ingress.Result{}, false, ingress.ErrSourceConflict
|
||||
}
|
||||
value := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: sourceEventID, EventID: eventID, Status: "duplicate", HTTPStatus: 200}
|
||||
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, value); err != nil {
|
||||
return ingress.Result{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ingress.Result{}, false, errors.New("commit Bell source replay")
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return ingress.Result{}, false, errors.New("read Bell event source receipt")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ingress.Result{}, false, errors.New("commit Bell event replay miss")
|
||||
}
|
||||
return ingress.Result{}, false, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ProcessEvent(
|
||||
ctx context.Context,
|
||||
keyID, nonce string,
|
||||
requestHash [sha256.Size]byte,
|
||||
producerID string,
|
||||
candidateHash [sha256.Size]byte,
|
||||
value event.Event,
|
||||
) (ingress.Result, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ingress.Result{}, errors.New("begin Bell event ingress")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := lockIngress(ctx, tx, keyID, nonce, producerID, value.SourceEventID()); err != nil {
|
||||
return ingress.Result{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
|
||||
return ingress.Result{}, errors.New("expire Bell event ingress nonces")
|
||||
}
|
||||
if replay, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
return replay, err
|
||||
}
|
||||
var storedHash []byte
|
||||
var storedEventID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
|
||||
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, value.SourceEventID()).Scan(&storedHash, &storedEventID)
|
||||
if err == nil {
|
||||
if !bytes.Equal(storedHash, candidateHash[:]) {
|
||||
return ingress.Result{}, ingress.ErrSourceConflict
|
||||
}
|
||||
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: storedEventID, Status: "duplicate", HTTPStatus: 200}
|
||||
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
|
||||
return ingress.Result{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ingress.Result{}, errors.New("commit Bell source duplicate")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return ingress.Result{}, errors.New("read Bell event source receipt")
|
||||
}
|
||||
for _, sensor := range value.Sensors() {
|
||||
allowed, err := bindingAllowed(ctx, tx, producerID, value.TenantID(), value.SiteID(), sensor.DeviceID, sensor.Modality)
|
||||
if err != nil {
|
||||
return ingress.Result{}, err
|
||||
}
|
||||
if !allowed {
|
||||
return ingress.Result{}, ingress.ErrIdentityDenied
|
||||
}
|
||||
}
|
||||
digest := value.Digest()
|
||||
if _, err := tx.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)`,
|
||||
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(), value.SourceEventID(),
|
||||
value.Kind(), value.Severity(), value.OccurredAt(), value.DetectedAt(), digest[:], value.JSON(),
|
||||
); err != nil {
|
||||
return ingress.Result{}, fmt.Errorf("insert Bell ingress event: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_receipts(
|
||||
producer_id, source_event_id, candidate_hash, event_id
|
||||
) VALUES ($1,$2,$3,$4)`, producerID, value.SourceEventID(), candidateHash[:], value.ID()); err != nil {
|
||||
return ingress.Result{}, fmt.Errorf("insert Bell event source receipt: %w", err)
|
||||
}
|
||||
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}
|
||||
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
|
||||
return ingress.Result{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ingress.Result{}, errors.New("commit Bell event ingress")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func lockIngress(ctx context.Context, tx *sql.Tx, keyID, nonce, producerID, sourceEventID string) error {
|
||||
for _, value := range []string{"event-nonce:" + keyID + ":" + nonce, "event-source:" + producerID + ":" + sourceEventID} {
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, value); err != nil {
|
||||
return errors.New("lock Bell event ingress identity")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte) (ingress.Result, bool, error) {
|
||||
var storedHash, body []byte
|
||||
var status int
|
||||
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text
|
||||
FROM bell.event_ingress_nonces WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&storedHash, &status, &body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ingress.Result{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ingress.Result{}, false, errors.New("read Bell event ingress nonce")
|
||||
}
|
||||
if !bytes.Equal(storedHash, requestHash[:]) {
|
||||
return ingress.Result{}, false, ingress.ErrReplayConflict
|
||||
}
|
||||
var value ingress.Result
|
||||
if err := json.Unmarshal(body, &value); err != nil {
|
||||
return ingress.Result{}, false, errors.New("decode Bell event ingress nonce")
|
||||
}
|
||||
value.HTTPStatus = status
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func insertNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte, value ingress.Result) error {
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return errors.New("encode Bell event ingress response")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_nonces(
|
||||
key_id, nonce, request_hash, response_status, response_body, expires_at
|
||||
) VALUES ($1,$2,$3,$4,$5::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], value.HTTPStatus, body); err != nil {
|
||||
return errors.New("insert Bell event ingress nonce")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/event"
|
||||
"yovision/bell/internal/ingress"
|
||||
)
|
||||
|
||||
type ingressStoreIDs struct{ id string }
|
||||
|
||||
func (value ingressStoreIDs) NewEventID() (string, error) { return value.id, nil }
|
||||
|
||||
func ingressStoreCandidate(t *testing.T, sourceEventID string) []byte {
|
||||
t.Helper()
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(testCandidate(t, "brain-demo-v1"), &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
object["source_event_id"] = sourceEventID
|
||||
object["tenant_id"] = float64(101)
|
||||
object["site_id"] = float64(201)
|
||||
object["device_id"] = float64(301)
|
||||
object["sensors"] = []any{map[string]any{"device_id": float64(301), "modality": "video", "role": "primary"}}
|
||||
object["kind"] = "zone_entry"
|
||||
object["severity"] = "medium"
|
||||
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
|
||||
encoded, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func ingressStoreEvent(t *testing.T, repository *Postgres, id, sourceEventID string) event.Event {
|
||||
t.Helper()
|
||||
guard, err := event.NewEvidenceGuard("private-customer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, err := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{id}, repository, guard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := factory.Create(context.Background(), ingressStoreCandidate(t, sourceEventID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestPostgresEventIngressAtomicSourceReceipt(t *testing.T) {
|
||||
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
|
||||
if dsn == "" || adminDSN == "" {
|
||||
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
|
||||
}
|
||||
admin, err := sql.Open("pgx", adminDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
statements := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
{`INSERT INTO bell.sites(tenant_id,id,name) VALUES ('ingress-tenant','ingress-site','Ingress Site')`, nil},
|
||||
{`INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('ingress-tenant','ingress-site','ingress-area','Ingress Area','video_allowed')`, nil},
|
||||
{`INSERT INTO sense.devices(id,tenant_id,site_id,serial_number,name,modality,desired_state,actual_state,area_id,created_at,updated_at)
|
||||
VALUES ('ingress-device','ingress-tenant','ingress-site','INGRESS-SERIAL','Ingress Device','video','enabled','online','ingress-area',$1,$1)`, []any{now}},
|
||||
{`INSERT INTO bell.event_ingress_bindings(
|
||||
producer_id,tenant_id,site_id,device_id,logical_tenant_id,logical_site_id,logical_device_id,logical_area_id,modality
|
||||
) VALUES ('brain-main',101,201,301,'ingress-tenant','ingress-site','ingress-device','ingress-area','video')`, nil},
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := admin.ExecContext(ctx, statement.query, statement.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.EventIngressReady(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate := ingressStoreCandidate(t, "BRN-ingress-0001")
|
||||
candidateHash := sha256.Sum256(candidate)
|
||||
value := ingressStoreEvent(t, repository, "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C", "BRN-ingress-0001")
|
||||
requestHash := sha256.Sum256([]byte("first-request"))
|
||||
result, err := repository.ProcessEvent(ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, "brain-main", candidateHash, value)
|
||||
if err != nil || result.Status != "accepted" || result.HTTPStatus != 201 {
|
||||
t.Fatalf("first ingress: %+v %v", result, err)
|
||||
}
|
||||
|
||||
// This is the crash-after-Bell-commit case: Brain uses a new nonce before
|
||||
// it has locally recorded the first response.
|
||||
replayed, found, err := repository.Replay(
|
||||
ctx, "brain-a", "BBBBBBBBBBBBBBBBBBBBBB", sha256.Sum256([]byte("retry-request")),
|
||||
"brain-main", "BRN-ingress-0001", candidateHash,
|
||||
)
|
||||
if err != nil || !found || replayed.Status != "duplicate" || replayed.EventID != result.EventID {
|
||||
t.Fatalf("durable source replay: %+v found=%v err=%v", replayed, found, err)
|
||||
}
|
||||
conflictHash := sha256.Sum256([]byte("changed-candidate"))
|
||||
if _, _, err := repository.Replay(
|
||||
ctx, "brain-a", "CCCCCCCCCCCCCCCCCCCCCC", sha256.Sum256([]byte("conflict-request")),
|
||||
"brain-main", "BRN-ingress-0001", conflictHash,
|
||||
); !errors.Is(err, ingress.ErrSourceConflict) {
|
||||
t.Fatalf("expected source conflict, got %v", err)
|
||||
}
|
||||
if _, _, err := repository.Replay(
|
||||
ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", sha256.Sum256([]byte("different-request")),
|
||||
"brain-main", "BRN-ingress-0001", candidateHash,
|
||||
); !errors.Is(err, ingress.ErrReplayConflict) {
|
||||
t.Fatalf("expected nonce replay conflict, got %v", err)
|
||||
}
|
||||
|
||||
var wait sync.WaitGroup
|
||||
errorsSeen := make(chan error, 8)
|
||||
concurrentValues := make([]event.Event, 8)
|
||||
for index := range concurrentValues {
|
||||
id := fmt.Sprintf("evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2%c", "DEFGHJKM"[index])
|
||||
concurrentValues[index] = ingressStoreEvent(t, repository, id, "BRN-ingress-0001")
|
||||
}
|
||||
for index := 0; index < 8; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
nonce := base64Nonce(index)
|
||||
response, err := repository.ProcessEvent(
|
||||
ctx, "brain-a", nonce, sha256.Sum256([]byte(nonce)), "brain-main", candidateHash, concurrentValues[index],
|
||||
)
|
||||
if err != nil || response.Status != "duplicate" || response.EventID != result.EventID {
|
||||
errorsSeen <- fmt.Errorf("concurrent duplicate %d: %+v %w", index, response, err)
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
t.Error(err)
|
||||
}
|
||||
var eventCount, receiptCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT
|
||||
(SELECT count(*) FROM bell.events WHERE tenant_id=101 AND site_id=201 AND source_event_id='BRN-ingress-0001'),
|
||||
(SELECT count(*) FROM bell.event_ingress_receipts WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001')`).Scan(&eventCount, &receiptCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if eventCount != 1 || receiptCount != 1 {
|
||||
t.Fatalf("concurrent ingress created events=%d receipts=%d", eventCount, receiptCount)
|
||||
}
|
||||
|
||||
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy='non_imaging_only'
|
||||
WHERE tenant_id='ingress-tenant' AND id='ingress-area'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed, err := repository.VideoAllowed(ctx, 101, 201, 301)
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("privacy change did not fail closed: allowed=%v err=%v", allowed, err)
|
||||
}
|
||||
guard, _ := event.NewEvidenceGuard("private-customer")
|
||||
factory, _ := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{"evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2N"}, repository, guard)
|
||||
_, err = factory.Create(ctx, ingressStoreCandidate(t, "BRN-ingress-0002"))
|
||||
var validation *event.ValidationError
|
||||
if !errors.As(err, &validation) || validation.Code != event.CodePrivacyDenied {
|
||||
t.Fatalf("privacy denial code drift: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE bell.event_ingress_receipts SET event_id=event_id
|
||||
WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001'`); err == nil {
|
||||
t.Fatal("runtime updated immutable event source receipt")
|
||||
}
|
||||
}
|
||||
|
||||
func base64Nonce(index int) string {
|
||||
return fmt.Sprintf("D%021d", index)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
let token="",state="",cursor=null,selected=null,busy=false;
|
||||
const $=id=>document.getElementById(id);
|
||||
const escTime=value=>new Intl.DateTimeFormat("zh-CN",{dateStyle:"medium",timeStyle:"medium",hour12:false}).format(new Date(value));
|
||||
function announce(message,error=false){const box=$(error?"errors":"status");box.textContent=message;box.hidden=false;window.setTimeout(()=>box.hidden=true,5000)}
|
||||
async function api(path,options={}){const response=await fetch(`/bell-console/api/v1${path}`,{...options,headers:{"Authorization":`Bearer ${token}`,...options.headers}});let body={};try{body=await response.json()}catch{}if(!response.ok){const error=new Error(body.message||`请求失败(${response.status})`);error.status=response.status;error.body=body;throw error}return body}
|
||||
function setBusy(value){busy=value;$("alerts").setAttribute("aria-busy",String(value));for(const id of ["refresh","more","ack","close"])$(id).disabled=value}
|
||||
function row(item){const button=document.createElement("button");button.type="button";button.className="alert-row"+(selected===item.id?" selected":"");button.dataset.id=item.id;const left=document.createElement("span"),right=document.createElement("span"),title=document.createElement("strong"),meta=document.createElement("small"),badge=document.createElement("span");title.textContent=item.title;meta.textContent=`${item.rule_key} · v${item.rule_version} · ${escTime(item.created_at)}`;badge.className="state";badge.dataset.state=item.state;badge.textContent={open:"待确认",acknowledged:"处理中",closed:"已关闭"}[item.state]||item.state;left.append(title,meta);right.append(badge);button.append(left,right);button.addEventListener("click",()=>loadDetail(item.id));return button}
|
||||
async function loadAlerts(append=false){if(busy)return;setBusy(true);if(!append){cursor=null;$("alerts").replaceChildren(...[1,2,3].map(()=>Object.assign(document.createElement("div"),{className:"skeleton"})))}try{const query=new URLSearchParams({limit:"16"});if(state)query.set("state",state);if(append&&cursor)query.set("cursor",cursor);const data=await api(`/alerts?${query}`);if(!append)$("alerts").replaceChildren();for(const item of data.items)$("alerts").append(row(item));if(!append&&!data.items.length){const empty=document.createElement("p");empty.className="offline";empty.textContent="当前筛选条件下没有预警。";$("alerts").append(empty)}cursor=data.next_cursor;$("more").hidden=!cursor;$("connectionText").textContent="已连接 · 显示真实状态";document.querySelector(".connection").classList.add("connected")}catch(error){if(!append){const retry=document.createElement("button");retry.className="secondary full";retry.textContent="加载失败,重试";retry.addEventListener("click",()=>loadAlerts());$("alerts").replaceChildren(retry)}announce(error.message,true);if(error.status===401)disconnect()}finally{setBusy(false)}}
|
||||
function fact(label,value){const box=document.createElement("div"),dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=label;dd.textContent=value;box.append(dt,dd);return box}
|
||||
async function loadDetail(id){if(busy)return;selected=id;document.querySelectorAll(".alert-row").forEach(el=>el.classList.toggle("selected",el.dataset.id===id));setBusy(true);try{const data=await api(`/alerts/${encodeURIComponent(id)}`);$("detailEmpty").hidden=true;$("detailContent").hidden=false;$("detailSeverity").textContent=data.severity.toUpperCase();$("detailName").textContent=data.title;$("detailId").textContent=data.id;$("detailState").textContent={open:"待确认",acknowledged:"处理中",closed:"已关闭"}[data.state];$("detailState").dataset.state=data.state;$("facts").replaceChildren(fact("规则",data.rule_key),fact("规则版本",`v${data.rule_version}`),fact("创建时间",escTime(data.created_at)));$("events").replaceChildren(...data.events.map(event=>{const box=document.createElement("div");box.className="event";const a=document.createElement("span"),b=document.createElement("span");a.textContent=`${event.kind} · 设备 ${event.device_id}`;b.textContent=`${event.severity} · ${escTime(event.occurred_at)}`;box.append(a,b);return box}));$("timeline").replaceChildren(...data.transitions.map(item=>{const li=document.createElement("li"),strong=document.createElement("strong"),small=document.createElement("small");strong.textContent=`${item.to_state} · ${item.actor_ref}`;small.textContent=escTime(item.occurred_at)+(item.note?` · ${item.note}`:"");li.append(strong,small);return li}));$("ack").hidden=data.state!=="open";$("close").hidden=data.state!=="acknowledged"}catch(error){announce(error.message,true)}finally{setBusy(false)}}
|
||||
async function command(name){if(!selected||busy)return;setBusy(true);const button=$(name);button.textContent=name==="ack"?"确认中…":"关闭中…";try{const result=await api(`/alerts/${selected}:${name}`,{method:"POST",headers:{"Content-Type":"application/json","Idempotency-Key":crypto.randomUUID()},body:JSON.stringify({note:$("note").value||null})});announce(name==="ack"?`已由 ${result.actor_ref} 接手`:"预警已关闭");$("note").value="";setBusy(false);await loadDetail(selected);await loadAlerts()}catch(error){if(error.status===409&&error.body?.code==="already_acknowledged")announce("该预警已被其他值班员确认,正在刷新实际处置人",true);else announce(error.message,true);setBusy(false);await loadDetail(selected)}finally{button.textContent=name==="ack"?"确认接手":"关闭预警";setBusy(false)}}
|
||||
function disconnect(){$("workspace").hidden=true;$("authPanel").hidden=false;token="";$("token").value="";$("connectionText").textContent="等待授权";document.querySelector(".connection").classList.remove("connected")}
|
||||
$("connect").addEventListener("click",()=>{const value=$("token").value;if(value.length<32){announce("令牌长度不足,请检查外部 token 文件",true);return}token=value;$("token").value="";$("authPanel").hidden=true;$("workspace").hidden=false;loadAlerts()});
|
||||
$("token").addEventListener("keydown",event=>{if(event.key==="Enter")$("connect").click()});
|
||||
$("refresh").addEventListener("click",()=>loadAlerts());$("more").addEventListener("click",()=>loadAlerts(true));$("ack").addEventListener("click",()=>command("ack"));$("close").addEventListener("click",()=>command("close"));
|
||||
document.querySelectorAll(".filter").forEach(button=>button.addEventListener("click",()=>{state=button.dataset.state;document.querySelectorAll(".filter").forEach(other=>{other.classList.toggle("active",other===button);other.setAttribute("aria-pressed",String(other===button))});loadAlerts()}));
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>YoVision Bell 值班台</title>
|
||||
<link rel="stylesheet" href="/bell-console/assets/style.css">
|
||||
<script defer src="/bell-console/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#alerts">跳到预警列表</a>
|
||||
<header>
|
||||
<div><p class="eyebrow">YOVISION · BELL</p><h1>预警处置值班台</h1></div>
|
||||
<div class="connection"><span class="dot" aria-hidden="true"></span><span id="connectionText">等待授权</span></div>
|
||||
</header>
|
||||
<main>
|
||||
<section id="authPanel" class="auth card" aria-labelledby="authTitle">
|
||||
<div><h2 id="authTitle">连接本机工程控制台</h2><p>令牌仅保存在当前页面内存,刷新后需重新输入。</p></div>
|
||||
<label>控制台令牌<input id="token" type="password" autocomplete="off" spellcheck="false"></label>
|
||||
<button id="connect" type="button">连接并加载</button>
|
||||
</section>
|
||||
|
||||
<div id="workspace" class="workspace" hidden>
|
||||
<section class="queue card" aria-labelledby="queueTitle">
|
||||
<div class="section-head"><div><p class="eyebrow">真实 PostgreSQL 状态</p><h2 id="queueTitle">预警队列</h2></div><button id="refresh" class="secondary" type="button">刷新</button></div>
|
||||
<div class="filters" role="group" aria-label="按状态筛选">
|
||||
<button class="filter active" data-state="" aria-pressed="true">全部</button>
|
||||
<button class="filter" data-state="open" aria-pressed="false">待确认</button>
|
||||
<button class="filter" data-state="acknowledged" aria-pressed="false">处理中</button>
|
||||
<button class="filter" data-state="closed" aria-pressed="false">已关闭</button>
|
||||
</div>
|
||||
<div id="alerts" tabindex="-1" aria-busy="false"></div>
|
||||
<button id="more" class="secondary full" type="button" hidden>加载更多</button>
|
||||
</section>
|
||||
|
||||
<section class="detail card" aria-labelledby="detailTitle">
|
||||
<div id="detailEmpty" class="empty"><span aria-hidden="true">◎</span><h2 id="detailTitle">选择一条预警</h2><p>查看关联事件、规则版本与不可变处置时间线。</p></div>
|
||||
<div id="detailContent" hidden>
|
||||
<div class="section-head"><div><p id="detailSeverity" class="badge"></p><h2 id="detailName"></h2><code id="detailId"></code></div><span id="detailState" class="state"></span></div>
|
||||
<dl id="facts" class="facts"></dl>
|
||||
<div class="notice"><strong>证据切片尚未启用</strong><span>当前只展示事件事实,不虚构截图或录像。</span></div>
|
||||
<div class="notice muted"><strong>升级与通知尚未启用</strong><span>当前没有倒计时、短信、语音或送达状态。</span></div>
|
||||
<h3>关联事件</h3><div id="events"></div>
|
||||
<h3>处置时间线</h3><ol id="timeline" class="timeline"></ol>
|
||||
<label for="note">处置备注(可选,最多 500 字)</label><textarea id="note" maxlength="500" rows="3"></textarea>
|
||||
<div class="actions"><button id="ack" type="button">确认接手</button><button id="close" type="button">关闭预警</button></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<div id="errors" class="toast error" role="alert" aria-live="assertive" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
:root{color-scheme:light;--ink:#17202a;--muted:#607080;--line:#d8e0e7;--paper:#fff;--canvas:#eef3f7;--blue:#075ea8;--blue-soft:#e8f2fb;--red:#b42318;--red-soft:#fff0ee;--amber:#8a4b08;--green:#167348;--shadow:0 8px 24px rgba(23,32,42,.08);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--canvas);color:var(--ink)}button,input,textarea{font:inherit}button{min-height:44px;border:0;border-radius:8px;padding:0 16px;background:var(--blue);color:#fff;font-weight:650;cursor:pointer}button:hover{filter:brightness(.94)}button:focus-visible,input:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #67b7ff;outline-offset:2px}button:disabled{cursor:not-allowed;opacity:.55}.secondary{background:#fff;color:var(--blue);border:1px solid #9bb9d3}.skip{position:fixed;left:12px;top:-60px;z-index:10;background:#fff;padding:10px}.skip:focus{top:12px}header{height:82px;padding:12px clamp(16px,4vw,48px);background:#12283b;color:#fff;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow)}h1,h2,h3,p{margin-top:0}h1{font-size:22px;margin-bottom:0}h2{font-size:19px;margin-bottom:7px}h3{font-size:15px;margin:24px 0 10px}.eyebrow{font-size:11px;letter-spacing:.13em;color:#79b9ed;margin-bottom:5px;font-weight:700}.connection{display:flex;gap:8px;align-items:center;font-size:13px}.dot{width:9px;height:9px;border-radius:50%;background:#f2b84b}.connected .dot{background:#45c58a}main{padding:24px clamp(16px,4vw,48px)}.card{background:var(--paper);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.auth{max-width:720px;margin:7vh auto;padding:24px;display:grid;grid-template-columns:1fr minmax(220px,300px) auto;gap:18px;align-items:end}.auth p,.empty p{color:var(--muted);margin-bottom:0}label{font-size:13px;font-weight:650;display:grid;gap:7px}input,textarea{width:100%;border:1px solid #aebcc8;border-radius:7px;padding:10px 11px;background:#fff;color:var(--ink)}.workspace{display:grid;grid-template-columns:minmax(330px,.82fr) minmax(420px,1.18fr);gap:20px;max-width:1440px;margin:auto}.queue,.detail{min-height:calc(100vh - 130px);padding:20px}.section-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.filters{display:flex;gap:7px;overflow:auto;padding:12px 0}.filter{background:#fff;color:var(--muted);border:1px solid var(--line);white-space:nowrap}.filter.active{background:var(--blue-soft);border-color:#74a9d3;color:#084e87}.alert-row{width:100%;height:auto;min-height:78px;text-align:left;background:#fff;color:var(--ink);border:1px solid var(--line);padding:13px;margin:0 0 8px;display:grid;grid-template-columns:1fr auto;gap:7px}.alert-row.selected{border-color:var(--blue);box-shadow:0 0 0 2px #d5ebff}.alert-row strong{display:block}.alert-row small{color:var(--muted)}.badge,.state{display:inline-block;width:max-content;border-radius:999px;padding:4px 9px;background:var(--red-soft);color:var(--red);font-size:12px;font-weight:700}.state[data-state=acknowledged]{background:#fff4db;color:var(--amber)}.state[data-state=closed]{background:#e9f7ef;color:var(--green)}.full{width:100%;margin-top:5px}.empty{text-align:center;color:var(--muted);padding:18vh 10px}.empty span{font-size:40px}.facts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:20px 0}.facts div{background:#f5f8fa;padding:10px;border-radius:7px}.facts dt{font-size:11px;color:var(--muted)}.facts dd{margin:4px 0 0;font-weight:650;overflow-wrap:anywhere}.notice{display:grid;gap:3px;border-left:4px solid var(--red);background:var(--red-soft);padding:11px 13px;margin:10px 0;font-size:13px}.notice span{color:var(--muted)}.notice.muted{border-color:#82909d;background:#f3f5f6}.event{padding:10px;border:1px solid var(--line);border-radius:7px;margin-bottom:7px;display:flex;justify-content:space-between;gap:10px;font-size:13px}.timeline{padding-left:22px}.timeline li{padding:0 0 14px 5px}.timeline small{display:block;color:var(--muted);margin-top:3px}.actions{display:flex;gap:10px;margin-top:12px}.toast{position:fixed;right:22px;bottom:22px;max-width:430px;background:#173b2c;color:#fff;padding:13px 16px;border-radius:8px;box-shadow:var(--shadow);z-index:5}.toast.error{background:#7d201a}.skeleton{height:76px;background:linear-gradient(90deg,#edf1f4,#f7f9fa,#edf1f4);border-radius:7px;margin-bottom:8px;background-size:200% 100%;animation:pulse 1.4s infinite}.offline{padding:22px;text-align:center;color:var(--muted)}code{font-size:12px;overflow-wrap:anywhere}
|
||||
@keyframes pulse{to{background-position:-200% 0}}
|
||||
@media(max-width:850px){.auth{grid-template-columns:1fr}.workspace{grid-template-columns:1fr}.queue,.detail{min-height:auto}.detail{min-height:520px}.facts{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:480px){header{height:auto;min-height:82px;align-items:flex-start;gap:12px}.connection{padding-top:5px}main{padding:12px}.queue,.detail{padding:14px}.facts{grid-template-columns:1fr}.actions{display:grid}.toast{left:12px;right:12px;bottom:12px}.event{display:grid}}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
// Package web exposes the loopback-only Bell engineering console.
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
var assets embed.FS
|
||||
|
||||
type Config struct {
|
||||
Token string
|
||||
TenantID int64
|
||||
SiteID int64
|
||||
ActorRef string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository alert.Repository
|
||||
config Config
|
||||
tokenHash [sha256.Size]byte
|
||||
}
|
||||
|
||||
var alertIDPattern = regexp.MustCompile(`^alt_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||
|
||||
func NewHandler(repository alert.Repository, config Config) (*Handler, error) {
|
||||
if repository == nil || len(config.Token) < 32 || len(config.Token) > 256 || config.TenantID < 1 || config.SiteID < 1 || !alert.ValidActorRef(config.ActorRef) {
|
||||
return nil, errors.New("invalid Bell alert console configuration")
|
||||
}
|
||||
return &Handler{repository: repository, config: config, tokenHash: sha256.Sum256([]byte(config.Token))}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /bell-console/", h.page)
|
||||
mux.HandleFunc("GET /bell-console/assets/{name}", h.asset)
|
||||
mux.HandleFunc("GET /bell-console/api/v1/alerts", h.list)
|
||||
mux.HandleFunc("GET /bell-console/api/v1/alerts/{id}", h.detail)
|
||||
mux.HandleFunc("POST /bell-console/api/v1/alerts/{action}", h.command)
|
||||
}
|
||||
|
||||
func secureHeaders(writer http.ResponseWriter) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("X-Frame-Options", "DENY")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
}
|
||||
|
||||
func (h *Handler) page(writer http.ResponseWriter, _ *http.Request) {
|
||||
secureHeaders(writer)
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
value, _ := assets.ReadFile("assets/index.html")
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func (h *Handler) asset(writer http.ResponseWriter, request *http.Request) {
|
||||
secureHeaders(writer)
|
||||
name := request.PathValue("name")
|
||||
if name != "app.js" && name != "style.css" {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
value, err := assets.ReadFile("assets/" + name)
|
||||
if err != nil {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(name, ".js") {
|
||||
writer.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
} else {
|
||||
writer.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
}
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func (h *Handler) authorize(writer http.ResponseWriter, request *http.Request) bool {
|
||||
secureHeaders(writer)
|
||||
prefix := "Bearer "
|
||||
value := request.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "需要控制台令牌")
|
||||
return false
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.TrimPrefix(value, prefix)))
|
||||
if subtle.ConstantTimeCompare(digest[:], h.tokenHash[:]) != 1 {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "控制台令牌无效")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) list(writer http.ResponseWriter, request *http.Request) {
|
||||
if !h.authorize(writer, request) {
|
||||
return
|
||||
}
|
||||
limit := alert.DefaultPageSize
|
||||
state := request.URL.Query().Get("state")
|
||||
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_state", "state 筛选无效")
|
||||
return
|
||||
}
|
||||
cursor := request.URL.Query().Get("cursor")
|
||||
if cursor != "" && !alertIDPattern.MatchString(cursor) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_cursor", "cursor 无效")
|
||||
return
|
||||
}
|
||||
if raw := request.URL.Query().Get("limit"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 || value > alert.MaxPageSize {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_limit", "limit 必须在 1 到 100 之间")
|
||||
return
|
||||
}
|
||||
limit = value
|
||||
}
|
||||
page, err := h.repository.ListAlerts(request.Context(), h.config.TenantID, h.config.SiteID, state, limit, cursor)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 列表失败,可重试")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, page)
|
||||
}
|
||||
|
||||
func (h *Handler) detail(writer http.ResponseWriter, request *http.Request) {
|
||||
if !h.authorize(writer, request) {
|
||||
return
|
||||
}
|
||||
id := request.PathValue("id")
|
||||
if !alertIDPattern.MatchString(id) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_alert_id", "Alert ID 无效")
|
||||
return
|
||||
}
|
||||
value, err := h.repository.GetAlert(request.Context(), h.config.TenantID, h.config.SiteID, id)
|
||||
if errors.Is(err, alert.ErrNotFound) {
|
||||
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 失败,可重试")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, value)
|
||||
}
|
||||
|
||||
func (h *Handler) command(writer http.ResponseWriter, request *http.Request) {
|
||||
if !h.authorize(writer, request) {
|
||||
return
|
||||
}
|
||||
action := request.PathValue("action")
|
||||
id, command, found := strings.Cut(action, ":")
|
||||
if !found || !alertIDPattern.MatchString(id) || (command != "ack" && command != "close") {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_command", "Alert 命令无效")
|
||||
return
|
||||
}
|
||||
key := request.Header.Get("Idempotency-Key")
|
||||
if !alert.ValidIdempotencyKey(key) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_idempotency_key", "Idempotency-Key 必须为 8 到 128 个安全字符")
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, 2048)
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var body struct {
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体必须是 JSON 对象")
|
||||
return
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体只能包含一个 JSON 对象")
|
||||
return
|
||||
}
|
||||
status, response, err := h.repository.Command(request.Context(), h.config.TenantID, h.config.SiteID, id, command, key, h.config.ActorRef, body.Note)
|
||||
switch {
|
||||
case errors.Is(err, alert.ErrNotFound):
|
||||
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
|
||||
case errors.Is(err, alert.ErrIdempotencyConflict):
|
||||
writeError(writer, http.StatusConflict, "idempotency_conflict", "该幂等键已用于另一条命令")
|
||||
case err != nil:
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "写入处置状态失败,可使用同一幂等键重试")
|
||||
default:
|
||||
writeJSON(writer, status, response)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(writer, status, map[string]string{"code": code, "message": message})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
limit int
|
||||
actor string
|
||||
}
|
||||
|
||||
func (*fakeRepository) AlertReady(context.Context) error { return nil }
|
||||
func (*fakeRepository) PublishRule(context.Context, alert.RuleSpec) (bool, error) { return true, nil }
|
||||
func (*fakeRepository) EvaluateNext(context.Context) (bool, error) { return false, nil }
|
||||
func (f *fakeRepository) ListAlerts(_ context.Context, _, _ int64, _ string, limit int, _ string) (alert.Page, error) {
|
||||
f.limit = limit
|
||||
return alert.Page{Items: []alert.Summary{}}, nil
|
||||
}
|
||||
func (*fakeRepository) GetAlert(context.Context, int64, int64, string) (alert.Detail, error) {
|
||||
return alert.Detail{}, alert.ErrNotFound
|
||||
}
|
||||
func (f *fakeRepository) Command(_ context.Context, _, _ int64, id, command, key, actor string, _ *string) (int, alert.CommandResponse, error) {
|
||||
f.actor = actor
|
||||
return 200, alert.CommandResponse{AlertID: id, State: "acknowledged", ActorRef: actor}, nil
|
||||
}
|
||||
|
||||
func testMux(t *testing.T) (*http.ServeMux, *fakeRepository) {
|
||||
t.Helper()
|
||||
repository := &fakeRepository{}
|
||||
handler, err := NewHandler(repository, Config{Token: "12345678901234567890123456789012", TenantID: 1, SiteID: 2, ActorRef: "operator:local"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
handler.Register(mux)
|
||||
return mux, repository
|
||||
}
|
||||
|
||||
func TestPageIsNoStoreAndDoesNotEmbedToken(t *testing.T) {
|
||||
mux, _ := testMux(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/bell-console/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != 200 || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("page response: %d %#v", response.Code, response.Header())
|
||||
}
|
||||
if body := response.Body.String(); body == "" || strings.Contains(body, "12345678901234567890123456789012") {
|
||||
t.Fatal("page missing or leaked console token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRequiresBearerAndDefaultsToSixteen(t *testing.T) {
|
||||
mux, repository := testMux(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/bell-console/api/v1/alerts", nil)
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized status %d", response.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodGet, "/bell-console/api/v1/alerts", nil)
|
||||
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || repository.limit != alert.DefaultPageSize {
|
||||
t.Fatalf("authorized list: status=%d limit=%d", response.Code, repository.limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandUsesServerActorAndRequiresIdempotencyKey(t *testing.T) {
|
||||
mux, repository := testMux(t)
|
||||
path := "/bell-console/api/v1/alerts/alt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C:ack"
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"note":null}`))
|
||||
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing idempotency status %d", response.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"note":null}`))
|
||||
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
|
||||
request.Header.Set("Idempotency-Key", "command-0001")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || repository.actor != "operator:local" {
|
||||
t.Fatalf("command status=%d actor=%q", response.Code, repository.actor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
artifacts/
|
||||
*.local.json
|
||||
*.url
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# YoVision Brain 单路工程原型
|
||||
|
||||
T-017 提供可见、可重复的单路工程链路;T-019 为 `zone_entry` 候选增加默认关闭的可靠 Brain→Bell transport。它仍不是生产模型或 NVR:推理演示保持单路,事件先进入仓库外 SQLite Outbox,再由内部 HMAC client 投递给 Bell。
|
||||
|
||||
## 环境
|
||||
|
||||
- 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 路能力。
|
||||
|
||||
## 可选 Brain → Bell 事件投递
|
||||
|
||||
默认不传 `--event-ingress-config`,工程原型行为与 T-017 相同。启用时,配置、key 和 SQLite 文件都必须位于仓库外绝对路径;HTTP 只允许显式回环,非回环必须 HTTPS。key 文件也供 Bell event ingress 读取,但不得与 Sense 审计 key 混用:
|
||||
|
||||
```json
|
||||
{"version":1,"keys":[{"key_id":"brain-a","producer_id":"brain-main","secret_base64url":"<至少32字节随机值的无填充base64url>"}]}
|
||||
```
|
||||
|
||||
Brain 配置示例(占位 ID 必须与 Bell 管理员创建的 `bell.event_ingress_bindings` 一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"producer_id": "brain-main",
|
||||
"tenant_id": 1,
|
||||
"site_id": 1,
|
||||
"device_id": 1,
|
||||
"modality": "video",
|
||||
"severity": "medium",
|
||||
"config_version": "brain-demo-v1",
|
||||
"bell_url": "http://127.0.0.1:8081/internal/v1/event-candidates",
|
||||
"key_id": "brain-a",
|
||||
"key_file": "D:\\private\\brain-event-keys.json",
|
||||
"outbox_path": "D:\\private\\brain-event-outbox.sqlite3"
|
||||
}
|
||||
```
|
||||
|
||||
```powershell
|
||||
python -m Brain.yovision_brain --source synthetic --event-ingress-config D:\private\brain-event-ingress.json
|
||||
```
|
||||
|
||||
Outbox 使用 WAL 和 `synchronous=FULL`,首次打开即绑定 producer/tenant/site/device;更换身份必须使用新的 Outbox 路径,不能让旧队列借新 producer 发送。最多保留 10,000 条待投递;只有 Bell `accepted/duplicate` 才确认 delivered。网络、401 与 5xx 按 1~300 秒退避、最多 100 次;稳定 4xx 进入 dead letter。终态行不会自动删除。状态页只显示计数和稳定错误码,不显示 URL、key、payload 或数据库路径;client 显式忽略环境 HTTP proxy。
|
||||
|
||||
## 验证
|
||||
|
||||
```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-019 后,可靠性由独立 SQLite Outbox 承担,不能从页面事件环推断投递状态。
|
||||
- Bell 平台 ID 只由 Bell 生成;相同 producer/source candidate 重投返回原 ID,Brain 不改写 source ID 规避冲突。
|
||||
- `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,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from Brain.yovision_brain.domain import EventCandidate
|
||||
from Brain.yovision_brain.ingress import (
|
||||
BrainEventIngress,
|
||||
DeliveryResult,
|
||||
EventIngressClient,
|
||||
EventOutbox,
|
||||
EventRelayWorker,
|
||||
IngressConfig,
|
||||
KeyMaterial,
|
||||
PermanentDelivery,
|
||||
RetryableDelivery,
|
||||
load_config,
|
||||
map_candidate,
|
||||
)
|
||||
|
||||
|
||||
EVENT_ID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
|
||||
|
||||
|
||||
def candidate(source_event_id: str = "BRN-test-0001", fixture: bool = True) -> EventCandidate:
|
||||
return EventCandidate(
|
||||
source_event_id=source_event_id,
|
||||
kind="zone_entry",
|
||||
source_ref="must-not-leave-brain",
|
||||
track_id="P-1",
|
||||
zone_id="zone-1",
|
||||
zone_version=3,
|
||||
occurred_at=datetime(2026, 8, 11, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
confidence=None,
|
||||
fixture=fixture,
|
||||
)
|
||||
|
||||
|
||||
def config(root: Path, bell_url: str = "http://127.0.0.1:8081/internal/v1/event-candidates") -> IngressConfig:
|
||||
return IngressConfig(
|
||||
producer_id="brain-main",
|
||||
tenant_id=1,
|
||||
site_id=2,
|
||||
device_id=3,
|
||||
modality="video",
|
||||
severity="medium",
|
||||
config_version="brain-demo-v1",
|
||||
bell_url=bell_url,
|
||||
key_id="brain-a",
|
||||
key_file=root / "keys.json",
|
||||
outbox_path=root / "outbox.sqlite3",
|
||||
)
|
||||
|
||||
|
||||
class ConfigAndMapperTests(unittest.TestCase):
|
||||
def test_external_config_binds_key_and_rejects_remote_plaintext(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
secret = bytes(range(32))
|
||||
key_file = root / "keys.json"
|
||||
key_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "brain-a",
|
||||
"producer_id": "brain-main",
|
||||
"secret_base64url": base64.urlsafe_b64encode(secret).rstrip(b"=").decode("ascii"),
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_file = root / "config.json"
|
||||
document = {
|
||||
"version": 1,
|
||||
"producer_id": "brain-main",
|
||||
"tenant_id": 1,
|
||||
"site_id": 2,
|
||||
"device_id": 3,
|
||||
"modality": "video",
|
||||
"severity": "medium",
|
||||
"config_version": "brain-demo-v1",
|
||||
"bell_url": "http://127.0.0.1:8081/internal/v1/event-candidates",
|
||||
"key_id": "brain-a",
|
||||
"key_file": str(key_file.resolve()),
|
||||
"outbox_path": str((root / "outbox.sqlite3").resolve()),
|
||||
}
|
||||
config_file.write_text(json.dumps(document), encoding="utf-8")
|
||||
loaded, key = load_config(str(config_file.resolve()))
|
||||
self.assertEqual(("brain-main", secret), (loaded.producer_id, key.secret))
|
||||
|
||||
document["bell_url"] = "http://camera.example/internal/v1/event-candidates"
|
||||
config_file.write_text(json.dumps(document), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "HTTPS"):
|
||||
load_config(str(config_file.resolve()))
|
||||
|
||||
def test_mapper_produces_complete_candidate_without_sensitive_source(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
payload = json.loads(map_candidate(candidate(), config(Path(directory))))
|
||||
self.assertNotIn("id", payload)
|
||||
self.assertNotIn("source_ref", json.dumps(payload))
|
||||
self.assertEqual("test", payload["outcome"])
|
||||
self.assertEqual("auto", payload["outcome_source"])
|
||||
self.assertIsNone(payload["confidence"])
|
||||
self.assertEqual(payload["occurred_at"], payload["detected_at"])
|
||||
self.assertEqual(0.0, payload["latency_seconds"])
|
||||
self.assertEqual([{"device_id": 3, "modality": "video", "role": "primary"}], payload["sensors"])
|
||||
expected = {
|
||||
"schema_version", "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",
|
||||
}
|
||||
self.assertEqual(expected, set(payload))
|
||||
|
||||
|
||||
class OutboxTests(unittest.TestCase):
|
||||
def test_crash_recovery_retry_and_terminal_records_are_durable(self) -> None:
|
||||
clock = [1_000.0]
|
||||
now = lambda: clock[0]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "outbox.sqlite3"
|
||||
payload = map_candidate(candidate(), config(Path(directory)))
|
||||
outbox = EventOutbox(path, now=now)
|
||||
self.assertTrue(outbox.enqueue(payload))
|
||||
self.assertFalse(outbox.enqueue(payload))
|
||||
leased = outbox.lease_one()
|
||||
self.assertIsNotNone(leased)
|
||||
outbox.close() # Simulate a process crash while the lease is held.
|
||||
|
||||
clock[0] += 31.0
|
||||
outbox = EventOutbox(path, now=now)
|
||||
recovered = outbox.lease_one()
|
||||
self.assertIsNotNone(recovered)
|
||||
self.assertEqual(2, recovered.attempt_count)
|
||||
outbox.mark_delivered(recovered, DeliveryResult("duplicate", EVENT_ID))
|
||||
self.assertEqual(1, outbox.status()["delivered"])
|
||||
outbox.close()
|
||||
|
||||
outbox = EventOutbox(path, now=now)
|
||||
status = outbox.status()
|
||||
self.assertEqual((1, 0), (status["delivered"], status["queued"]))
|
||||
outbox.close()
|
||||
|
||||
def test_source_id_conflict_is_not_silently_overwritten(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
outbox = EventOutbox(root / "outbox.sqlite3")
|
||||
outbox.enqueue(map_candidate(candidate(), config(root)))
|
||||
changed = json.loads(map_candidate(candidate(), config(root)))
|
||||
changed["config_version"] = "different"
|
||||
with self.assertRaisesRegex(ValueError, "different candidate"):
|
||||
outbox.enqueue(json.dumps(changed, sort_keys=True, separators=(",", ":")).encode())
|
||||
outbox.close()
|
||||
|
||||
def test_crash_on_final_attempt_becomes_dead_letter(self) -> None:
|
||||
clock = [1_000.0]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
path = root / "outbox.sqlite3"
|
||||
outbox = EventOutbox(path, now=lambda: clock[0])
|
||||
outbox.enqueue(map_candidate(candidate(), config(root)))
|
||||
for _ in range(99):
|
||||
item = outbox.lease_one()
|
||||
self.assertIsNotNone(item)
|
||||
outbox.mark_retry(item, "network_error")
|
||||
clock[0] += 301.0
|
||||
final = outbox.lease_one()
|
||||
self.assertEqual(100, final.attempt_count)
|
||||
outbox.close()
|
||||
clock[0] += 31.0
|
||||
outbox = EventOutbox(path, now=lambda: clock[0])
|
||||
self.assertIsNone(outbox.lease_one())
|
||||
self.assertEqual(1, outbox.status()["dead_letter"])
|
||||
outbox.close()
|
||||
|
||||
def test_outbox_cannot_be_reused_for_another_producer_identity(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
path = root / "outbox.sqlite3"
|
||||
outbox = EventOutbox(path)
|
||||
original = config(root)
|
||||
outbox.bind_identity(original)
|
||||
outbox.close()
|
||||
outbox = EventOutbox(path)
|
||||
changed = IngressConfig(**{**original.__dict__, "producer_id": "brain-other"})
|
||||
with self.assertRaisesRegex(ValueError, "different producer"):
|
||||
outbox.bind_identity(changed)
|
||||
outbox.close()
|
||||
|
||||
|
||||
class _BellHandler(BaseHTTPRequestHandler):
|
||||
secret = bytes(range(32))
|
||||
verified = False
|
||||
failure: str | None = None
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
try:
|
||||
length = int(self.headers["Content-Length"])
|
||||
body = self.rfile.read(length)
|
||||
timestamp = self.headers["X-YoVision-Timestamp"]
|
||||
nonce = self.headers["X-YoVision-Nonce"]
|
||||
canonical = "\n".join(("POST", self.path, timestamp, nonce, hashlib.sha256(body).hexdigest())).encode()
|
||||
encoded_signature = self.headers["X-YoVision-Signature"]
|
||||
supplied = base64.urlsafe_b64decode(encoded_signature + "=" * (-len(encoded_signature) % 4))
|
||||
type(self).verified = hmac.compare_digest(supplied, hmac.new(self.secret, canonical, hashlib.sha256).digest())
|
||||
envelope = json.loads(body)
|
||||
response = json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"producer_id": envelope["producer_id"],
|
||||
"source_event_id": envelope["candidate"]["source_event_id"],
|
||||
"event_id": EVENT_ID,
|
||||
"status": "accepted",
|
||||
}
|
||||
).encode()
|
||||
self.send_response(201)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
except Exception as exc: # pragma: no cover - only improves test diagnostics
|
||||
type(self).failure = repr(exc)
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
class ClientAndWorkerTests(unittest.TestCase):
|
||||
def test_client_signs_and_accepts_bell_response(self) -> None:
|
||||
server = HTTPServer(("127.0.0.1", 0), _BellHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
client = EventIngressClient(
|
||||
config(root, f"http://127.0.0.1:{server.server_port}/internal/v1/event-candidates"),
|
||||
KeyMaterial("brain-a", "brain-main", _BellHandler.secret),
|
||||
now=lambda: 1_800_000_000.0,
|
||||
)
|
||||
try:
|
||||
result = client.deliver(map_candidate(candidate(), config(root)))
|
||||
except RetryableDelivery as exc:
|
||||
self.fail(f"test Bell handler failed: {_BellHandler.failure}; client={exc.code}")
|
||||
self.assertEqual(("accepted", EVENT_ID), (result.status, result.event_id))
|
||||
self.assertTrue(_BellHandler.verified)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
def test_worker_distinguishes_retryable_and_permanent_failures(self) -> None:
|
||||
class FailingClient:
|
||||
def __init__(self, failure: Exception) -> None:
|
||||
self.failure = failure
|
||||
|
||||
def deliver(self, _payload: bytes) -> DeliveryResult:
|
||||
raise self.failure
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
payload = map_candidate(candidate(), config(root))
|
||||
outbox = EventOutbox(root / "retry.sqlite3", now=lambda: 100.0)
|
||||
outbox.enqueue(payload)
|
||||
EventRelayWorker(outbox, FailingClient(RetryableDelivery("unauthorized"))).run_once()
|
||||
self.assertEqual((1, 0), (outbox.status()["queued"], outbox.status()["dead_letter"]))
|
||||
outbox.close()
|
||||
|
||||
outbox = EventOutbox(root / "dead.sqlite3", now=lambda: 100.0)
|
||||
outbox.enqueue(payload)
|
||||
EventRelayWorker(outbox, FailingClient(PermanentDelivery("source_event_conflict"))).run_once()
|
||||
self.assertEqual(1, outbox.status()["dead_letter"])
|
||||
outbox.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from Brain.yovision_brain.domain import Box, Detection
|
||||
from Brain.yovision_brain.runtime import DemoEngine, EventIngressUnavailable
|
||||
from Brain.yovision_brain.source import FramePacket, SyntheticSource, cv2, np
|
||||
|
||||
|
||||
class _TwoFrameSource:
|
||||
fixture = True
|
||||
mode = "test"
|
||||
label = "test"
|
||||
source_ref = "safe-source"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.index = 0
|
||||
|
||||
def read(self) -> FramePacket:
|
||||
boxes = (Box(0.10, 0.4, 0.20, 0.8), Box(0.60, 0.4, 0.70, 0.8))
|
||||
detection = Detection("P-1", "person", boxes[min(self.index, 1)])
|
||||
self.index += 1
|
||||
return FramePacket(np.zeros((180, 320, 3), dtype=np.uint8), datetime.now(timezone.utc), (detection,))
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _Ingress:
|
||||
def __init__(self, fail: bool = False) -> None:
|
||||
self.items = []
|
||||
self.fail = fail
|
||||
|
||||
def submit(self, value: object) -> None:
|
||||
if self.fail:
|
||||
raise RuntimeError("disk unavailable")
|
||||
self.items.append(value)
|
||||
|
||||
def start(self) -> None:
|
||||
return
|
||||
|
||||
def stop(self) -> None:
|
||||
return
|
||||
|
||||
def status(self) -> dict[str, object]:
|
||||
return {"enabled": True, "queued": len(self.items)}
|
||||
|
||||
|
||||
@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"], "新区域")
|
||||
|
||||
def test_event_is_persisted_before_it_is_exposed(self) -> None:
|
||||
ingress = _Ingress()
|
||||
engine = DemoEngine(_TwoFrameSource(), event_ingress=ingress)
|
||||
engine.step()
|
||||
engine.step()
|
||||
state = engine.state()
|
||||
self.assertEqual(1, len(ingress.items))
|
||||
self.assertEqual("outbox_persisted", state["events"][0]["delivery_status"])
|
||||
self.assertEqual(1, state["event_ingress"]["queued"])
|
||||
|
||||
def test_outbox_failure_does_not_claim_delivery(self) -> None:
|
||||
engine = DemoEngine(_TwoFrameSource(), event_ingress=_Ingress(fail=True))
|
||||
engine.step()
|
||||
with self.assertRaises(EventIngressUnavailable):
|
||||
engine.step()
|
||||
self.assertEqual([], engine.state()["events"])
|
||||
|
||||
|
||||
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,61 @@
|
||||
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)
|
||||
|
||||
def test_reliable_delivery_status_is_visible_without_configuration_details(self) -> None:
|
||||
required = (
|
||||
'id="event-ingress"',
|
||||
'id="ingress-queued"',
|
||||
'id="ingress-dead-letter"',
|
||||
"Outbox 已持久化",
|
||||
"Bell 返回 accepted 或 duplicate 后才计为已送达",
|
||||
"event_ingress: {enabled: false}",
|
||||
)
|
||||
for marker in required:
|
||||
with self.subTest(marker=marker):
|
||||
self.assertIn(marker, self.html)
|
||||
for forbidden in ("bell_url", "key_file", "outbox_path"):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, self.html)
|
||||
|
||||
|
||||
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,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .ingress import BrainEventIngress
|
||||
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]")
|
||||
parser.add_argument("--event-ingress-config", help="absolute external Brain-to-Bell ingress configuration")
|
||||
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()
|
||||
event_ingress = BrainEventIngress.from_file(args.event_ingress_config) if args.event_ingress_config else None
|
||||
engine = DemoEngine(source, fps=args.fps, event_ingress=event_ingress)
|
||||
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,645 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
from .domain import EventCandidate
|
||||
|
||||
|
||||
INGRESS_PATH = "/internal/v1/event-candidates"
|
||||
MAX_PAYLOAD_BYTES = 1 << 20
|
||||
MAX_QUEUED = 10_000
|
||||
MAX_ATTEMPTS = 100
|
||||
HTTP_TIMEOUT_SECONDS = 10.0
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
_EVENT_ID = re.compile(r"^evt_[0-9A-HJKMNP-TV-Z]{26}$")
|
||||
_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngressConfig:
|
||||
producer_id: str
|
||||
tenant_id: int
|
||||
site_id: int
|
||||
device_id: int
|
||||
modality: str
|
||||
severity: str
|
||||
config_version: str
|
||||
bell_url: str
|
||||
key_id: str
|
||||
key_file: Path
|
||||
outbox_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyMaterial:
|
||||
key_id: str
|
||||
producer_id: str
|
||||
secret: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeasedEvent:
|
||||
source_event_id: str
|
||||
payload: bytes
|
||||
attempt_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
status: str
|
||||
event_id: str
|
||||
|
||||
|
||||
class RetryableDelivery(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = _safe_error(code)
|
||||
|
||||
|
||||
class PermanentDelivery(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = _safe_error(code)
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, _request: Request, _file_pointer: object, _code: int, _message: str, _headers: object, _new_url: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_within(path: Path, parent: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(parent)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _external_absolute_path(raw: object, field: str, *, must_exist: bool) -> Path:
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise ValueError(f"{field} must be an absolute external path")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
raise ValueError(f"{field} must be an absolute external path")
|
||||
resolved = path.resolve(strict=must_exist)
|
||||
if _is_within(resolved, _REPO_ROOT):
|
||||
raise ValueError(f"{field} must stay outside the repository")
|
||||
return resolved
|
||||
|
||||
|
||||
def _load_json_file(path: Path, maximum: int = 64 << 10) -> object:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > maximum:
|
||||
raise ValueError("external configuration file is too large")
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("external configuration is not valid JSON") from exc
|
||||
|
||||
|
||||
def _positive_int(value: object, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise ValueError(f"{field} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_bell_url(raw: object) -> str:
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError("bell_url must be a URL")
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.hostname is None or parsed.path != INGRESS_PATH:
|
||||
raise ValueError("bell_url must target the versioned event ingress path")
|
||||
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||
raise ValueError("bell_url cannot contain credentials, query or fragment")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("bell_url has an invalid port") from exc
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise ValueError("bell_url has an invalid port")
|
||||
if parsed.scheme == "http":
|
||||
host = parsed.hostname
|
||||
loopback = host == "localhost"
|
||||
if not loopback:
|
||||
try:
|
||||
loopback = ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
loopback = False
|
||||
if not loopback:
|
||||
raise ValueError("non-loopback event ingress requires HTTPS")
|
||||
return raw
|
||||
|
||||
|
||||
def load_config(path_value: str) -> tuple[IngressConfig, KeyMaterial]:
|
||||
config_path = _external_absolute_path(path_value, "event ingress config", must_exist=True)
|
||||
value = _load_json_file(config_path)
|
||||
expected = {
|
||||
"version",
|
||||
"producer_id",
|
||||
"tenant_id",
|
||||
"site_id",
|
||||
"device_id",
|
||||
"modality",
|
||||
"severity",
|
||||
"config_version",
|
||||
"bell_url",
|
||||
"key_id",
|
||||
"key_file",
|
||||
"outbox_path",
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != expected or value.get("version") != 1:
|
||||
raise ValueError("invalid event ingress configuration shape")
|
||||
producer_id = value["producer_id"]
|
||||
key_id = value["key_id"]
|
||||
config_version = value["config_version"]
|
||||
if not isinstance(producer_id, str) or not _ID.fullmatch(producer_id):
|
||||
raise ValueError("invalid producer_id")
|
||||
if not isinstance(key_id, str) or not _ID.fullmatch(key_id):
|
||||
raise ValueError("invalid key_id")
|
||||
if not isinstance(config_version, str) or not 1 <= len(config_version) <= 128:
|
||||
raise ValueError("config_version must contain 1 to 128 characters")
|
||||
if value["modality"] != "video":
|
||||
raise ValueError("T-019 Brain ingress supports only video primary sensors")
|
||||
if value["severity"] not in {"low", "medium", "high", "critical"}:
|
||||
raise ValueError("invalid severity")
|
||||
key_file = _external_absolute_path(value["key_file"], "key_file", must_exist=True)
|
||||
outbox_path = _external_absolute_path(value["outbox_path"], "outbox_path", must_exist=False)
|
||||
if not outbox_path.parent.is_dir():
|
||||
raise ValueError("outbox_path parent directory must already exist")
|
||||
config = IngressConfig(
|
||||
producer_id=producer_id,
|
||||
tenant_id=_positive_int(value["tenant_id"], "tenant_id"),
|
||||
site_id=_positive_int(value["site_id"], "site_id"),
|
||||
device_id=_positive_int(value["device_id"], "device_id"),
|
||||
modality="video",
|
||||
severity=value["severity"],
|
||||
config_version=config_version,
|
||||
bell_url=_validate_bell_url(value["bell_url"]),
|
||||
key_id=key_id,
|
||||
key_file=key_file,
|
||||
outbox_path=outbox_path,
|
||||
)
|
||||
document = _load_json_file(key_file)
|
||||
if not isinstance(document, dict) or set(document) != {"version", "keys"} or document.get("version") != 1:
|
||||
raise ValueError("invalid event ingress key document")
|
||||
keys = document.get("keys")
|
||||
if not isinstance(keys, list) or not keys:
|
||||
raise ValueError("event ingress key document has no keys")
|
||||
selected: KeyMaterial | None = None
|
||||
seen: set[str] = set()
|
||||
for item in keys:
|
||||
if not isinstance(item, dict) or set(item) != {"key_id", "producer_id", "secret_base64url"}:
|
||||
raise ValueError("invalid event ingress key entry")
|
||||
item_key = item["key_id"]
|
||||
item_producer = item["producer_id"]
|
||||
encoded = item["secret_base64url"]
|
||||
if not isinstance(item_key, str) or not _ID.fullmatch(item_key) or item_key in seen:
|
||||
raise ValueError("invalid or duplicate event ingress key ID")
|
||||
if not isinstance(item_producer, str) or not _ID.fullmatch(item_producer) or not isinstance(encoded, str):
|
||||
raise ValueError("invalid event ingress key entry")
|
||||
seen.add(item_key)
|
||||
try:
|
||||
secret = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise ValueError("invalid event ingress secret") from exc
|
||||
if len(secret) < 32:
|
||||
raise ValueError("event ingress secret must contain at least 32 bytes")
|
||||
if item_key == key_id:
|
||||
selected = KeyMaterial(item_key, item_producer, secret)
|
||||
if selected is None or selected.producer_id != producer_id:
|
||||
raise ValueError("selected key is not bound to the configured producer")
|
||||
return config, selected
|
||||
|
||||
|
||||
def map_candidate(value: EventCandidate, config: IngressConfig) -> bytes:
|
||||
if not _SOURCE_ID.fullmatch(value.source_event_id):
|
||||
raise ValueError("invalid source_event_id")
|
||||
payload = {
|
||||
"schema_version": "0.1",
|
||||
"source_event_id": value.source_event_id,
|
||||
"tenant_id": config.tenant_id,
|
||||
"site_id": config.site_id,
|
||||
"device_id": config.device_id,
|
||||
"sensors": [{"device_id": config.device_id, "modality": config.modality, "role": "primary"}],
|
||||
"kind": value.kind,
|
||||
"severity": config.severity,
|
||||
"confidence": None,
|
||||
"occurred_at": value.occurred_at,
|
||||
"detected_at": value.occurred_at,
|
||||
"latency_seconds": 0.0,
|
||||
"config_version": config.config_version,
|
||||
"rule": None,
|
||||
"subject": {
|
||||
"class": "person",
|
||||
"track_id": value.track_id,
|
||||
"attributes": {},
|
||||
"anon_id": None,
|
||||
"identity": None,
|
||||
"identity_status": "not_enabled",
|
||||
},
|
||||
"observation": {
|
||||
"zone": value.zone_id,
|
||||
"dwell_sec": 0.0,
|
||||
"bbox_seq_uri": None,
|
||||
"keypoint_seq_uri": None,
|
||||
"signal_seq_uri": None,
|
||||
},
|
||||
"evidence": {"snapshot_uris": [], "clip_uri": None, "clip_range": None},
|
||||
"dedup_key": None,
|
||||
"aggregated_into": None,
|
||||
"outcome": "test" if value.fixture else "unknown",
|
||||
"outcome_source": "auto" if value.fixture else None,
|
||||
"outcome_reason": None,
|
||||
"diagnostics": None,
|
||||
"ext": {"brain_candidate_version": "brain-demo-v1", "zone_version": value.zone_version},
|
||||
}
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_PAYLOAD_BYTES:
|
||||
raise ValueError("event candidate exceeds 1 MiB")
|
||||
return encoded
|
||||
|
||||
|
||||
class EventOutbox:
|
||||
def __init__(self, path: Path, *, now: Callable[[], float] = time.time) -> None:
|
||||
if not path.is_absolute() or _is_within(path.resolve(), _REPO_ROOT):
|
||||
raise ValueError("outbox path must be absolute and external")
|
||||
self._now = now
|
||||
self._lock = threading.RLock()
|
||||
self._connection = sqlite3.connect(str(path), timeout=5.0, isolation_level=None, check_same_thread=False)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA synchronous=FULL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
self._connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS event_outbox (
|
||||
source_event_id TEXT PRIMARY KEY,
|
||||
candidate_hash BLOB NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('queued','delivering','delivered','dead_letter')),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 100),
|
||||
available_at REAL NOT NULL,
|
||||
lease_until REAL,
|
||||
bell_event_id TEXT,
|
||||
last_error_code TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
delivered_at REAL,
|
||||
dead_lettered_at REAL,
|
||||
CHECK(length(candidate_hash)=32),
|
||||
CHECK(length(payload)<=1048576),
|
||||
CHECK((state='delivering')=(lease_until IS NOT NULL)),
|
||||
CHECK(delivered_at IS NULL OR dead_lettered_at IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS event_outbox_due_idx
|
||||
ON event_outbox(available_at, created_at, source_event_id)
|
||||
WHERE state='queued';
|
||||
CREATE TABLE IF NOT EXISTS event_outbox_identity (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||
producer_id TEXT NOT NULL,
|
||||
tenant_id INTEGER NOT NULL CHECK(tenant_id>=1),
|
||||
site_id INTEGER NOT NULL CHECK(site_id>=1),
|
||||
device_id INTEGER NOT NULL CHECK(device_id>=1)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def bind_identity(self, config: IngressConfig) -> None:
|
||||
identity = (config.producer_id, config.tenant_id, config.site_id, config.device_id)
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = self._connection.execute(
|
||||
"SELECT producer_id,tenant_id,site_id,device_id FROM event_outbox_identity WHERE singleton=1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self._connection.execute(
|
||||
"""INSERT INTO event_outbox_identity(
|
||||
singleton,producer_id,tenant_id,site_id,device_id
|
||||
) VALUES (1,?,?,?,?)""",
|
||||
identity,
|
||||
)
|
||||
elif tuple(row) != identity:
|
||||
raise ValueError("event outbox is bound to a different producer or device identity")
|
||||
self._connection.execute("COMMIT")
|
||||
except Exception:
|
||||
self._connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def enqueue(self, payload: bytes) -> bool:
|
||||
if not 0 < len(payload) <= MAX_PAYLOAD_BYTES:
|
||||
raise ValueError("event candidate payload must contain at most 1 MiB")
|
||||
try:
|
||||
candidate = json.loads(payload)
|
||||
source_event_id = candidate["source_event_id"]
|
||||
except (UnicodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
||||
raise ValueError("invalid event candidate payload") from exc
|
||||
if not isinstance(source_event_id, str) or not _SOURCE_ID.fullmatch(source_event_id):
|
||||
raise ValueError("invalid source_event_id")
|
||||
digest = hashlib.sha256(payload).digest()
|
||||
now = self._now()
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
existing = self._connection.execute(
|
||||
"SELECT candidate_hash FROM event_outbox WHERE source_event_id=?", (source_event_id,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if not hmac.compare_digest(existing["candidate_hash"], digest):
|
||||
raise ValueError("source_event_id already has a different candidate")
|
||||
self._connection.execute("COMMIT")
|
||||
return False
|
||||
active = self._connection.execute(
|
||||
"SELECT count(*) FROM event_outbox WHERE state IN ('queued','delivering')"
|
||||
).fetchone()[0]
|
||||
if active >= MAX_QUEUED:
|
||||
raise RuntimeError("event outbox capacity exceeded")
|
||||
self._connection.execute(
|
||||
"""INSERT INTO event_outbox(
|
||||
source_event_id,candidate_hash,payload,state,available_at,created_at,updated_at
|
||||
) VALUES (?,?,?,'queued',?,?,?)""",
|
||||
(source_event_id, digest, payload, now, now, now),
|
||||
)
|
||||
self._connection.execute("COMMIT")
|
||||
return True
|
||||
except Exception:
|
||||
self._connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def lease_one(self, lease_seconds: float = 30.0) -> LeasedEvent | None:
|
||||
now = self._now()
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"""UPDATE event_outbox SET state='dead_letter', lease_until=NULL,
|
||||
last_error_code='retry_exhausted', dead_lettered_at=?, updated_at=?
|
||||
WHERE attempt_count>=? AND (
|
||||
state='queued' OR (state='delivering' AND lease_until<=?)
|
||||
)""",
|
||||
(now, now, MAX_ATTEMPTS, now),
|
||||
)
|
||||
self._connection.execute(
|
||||
"""UPDATE event_outbox SET state='queued', lease_until=NULL,
|
||||
available_at=?, last_error_code='lease_expired', updated_at=?
|
||||
WHERE state='delivering' AND lease_until<=? AND attempt_count<?""",
|
||||
(now, now, now, MAX_ATTEMPTS),
|
||||
)
|
||||
row = self._connection.execute(
|
||||
"""SELECT source_event_id,payload,attempt_count FROM event_outbox
|
||||
WHERE state='queued' AND available_at<=? AND attempt_count<?
|
||||
ORDER BY available_at,created_at,source_event_id LIMIT 1""",
|
||||
(now, MAX_ATTEMPTS),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self._connection.execute("COMMIT")
|
||||
return None
|
||||
attempt = row["attempt_count"] + 1
|
||||
self._connection.execute(
|
||||
"""UPDATE event_outbox SET state='delivering',attempt_count=?,lease_until=?,updated_at=?
|
||||
WHERE source_event_id=? AND state='queued'""",
|
||||
(attempt, now + lease_seconds, now, row["source_event_id"]),
|
||||
)
|
||||
self._connection.execute("COMMIT")
|
||||
return LeasedEvent(row["source_event_id"], bytes(row["payload"]), attempt)
|
||||
except Exception:
|
||||
self._connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def mark_delivered(self, item: LeasedEvent, result: DeliveryResult) -> None:
|
||||
now = self._now()
|
||||
with self._lock:
|
||||
changed = self._connection.execute(
|
||||
"""UPDATE event_outbox SET state='delivered',lease_until=NULL,bell_event_id=?,
|
||||
last_error_code=NULL,delivered_at=?,updated_at=?
|
||||
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
|
||||
(result.event_id, now, now, item.source_event_id, item.attempt_count),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RuntimeError("event outbox delivery lease was lost")
|
||||
|
||||
def mark_retry(self, item: LeasedEvent, code: str) -> None:
|
||||
now = self._now()
|
||||
if item.attempt_count >= MAX_ATTEMPTS:
|
||||
self.mark_dead(item, "retry_exhausted")
|
||||
return
|
||||
delay = min(300.0, float(2 ** min(item.attempt_count - 1, 9)))
|
||||
with self._lock:
|
||||
changed = self._connection.execute(
|
||||
"""UPDATE event_outbox SET state='queued',lease_until=NULL,available_at=?,
|
||||
last_error_code=?,updated_at=?
|
||||
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
|
||||
(now + delay, _safe_error(code), now, item.source_event_id, item.attempt_count),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RuntimeError("event outbox retry lease was lost")
|
||||
|
||||
def mark_dead(self, item: LeasedEvent, code: str) -> None:
|
||||
now = self._now()
|
||||
with self._lock:
|
||||
changed = self._connection.execute(
|
||||
"""UPDATE event_outbox SET state='dead_letter',lease_until=NULL,
|
||||
last_error_code=?,dead_lettered_at=?,updated_at=?
|
||||
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
|
||||
(_safe_error(code), now, now, item.source_event_id, item.attempt_count),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RuntimeError("event outbox dead-letter lease was lost")
|
||||
|
||||
def status(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
counts = {row["state"]: row["count"] for row in self._connection.execute(
|
||||
"SELECT state,count(*) AS count FROM event_outbox GROUP BY state"
|
||||
)}
|
||||
error = self._connection.execute(
|
||||
"""SELECT last_error_code FROM event_outbox WHERE last_error_code IS NOT NULL
|
||||
ORDER BY updated_at DESC LIMIT 1"""
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": True,
|
||||
"queued": counts.get("queued", 0),
|
||||
"delivering": counts.get("delivering", 0),
|
||||
"delivered": counts.get("delivered", 0),
|
||||
"dead_letter": counts.get("dead_letter", 0),
|
||||
"last_error_code": None if error is None else error["last_error_code"],
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
|
||||
|
||||
class EventIngressClient:
|
||||
def __init__(self, config: IngressConfig, key: KeyMaterial, *, now: Callable[[], float] = time.time) -> None:
|
||||
self._config = config
|
||||
self._key = key
|
||||
self._now = now
|
||||
# Internal event payloads must never be redirected through ambient
|
||||
# HTTP(S)_PROXY settings.
|
||||
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def deliver(self, candidate: bytes) -> DeliveryResult:
|
||||
try:
|
||||
candidate_object = json.loads(candidate)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise PermanentDelivery("candidate_invalid") from exc
|
||||
body = json.dumps(
|
||||
{"schema_version": 1, "producer_id": self._config.producer_id, "candidate": candidate_object},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if len(body) > MAX_PAYLOAD_BYTES:
|
||||
raise PermanentDelivery("payload_too_large")
|
||||
timestamp = str(int(self._now()))
|
||||
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("ascii")
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
canonical = "\n".join(("POST", INGRESS_PATH, timestamp, nonce, digest)).encode("utf-8")
|
||||
signature_value = base64.urlsafe_b64encode(hmac.new(self._key.secret, canonical, hashlib.sha256).digest()).rstrip(b"=").decode("ascii")
|
||||
request = Request(self._config.bell_url, data=body, method="POST")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
request.add_header("X-YoVision-Key-Id", self._key.key_id)
|
||||
request.add_header("X-YoVision-Timestamp", timestamp)
|
||||
request.add_header("X-YoVision-Nonce", nonce)
|
||||
request.add_header("X-YoVision-Signature", signature_value)
|
||||
try:
|
||||
with self._opener.open(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
|
||||
status = response.status
|
||||
response_body = response.read(64 << 10)
|
||||
except HTTPError as exc:
|
||||
status = exc.code
|
||||
response_body = exc.read(64 << 10)
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
raise RetryableDelivery("network_error") from exc
|
||||
if status in {200, 201}:
|
||||
try:
|
||||
value = json.loads(response_body)
|
||||
response_status = value["status"]
|
||||
event_id = value["event_id"]
|
||||
if (
|
||||
value["schema_version"] != 1
|
||||
or value["producer_id"] != self._config.producer_id
|
||||
or value["source_event_id"] != candidate_object["source_event_id"]
|
||||
or response_status not in {"accepted", "duplicate"}
|
||||
or not isinstance(event_id, str)
|
||||
or not _EVENT_ID.fullmatch(event_id)
|
||||
):
|
||||
raise ValueError
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise RetryableDelivery("invalid_response") from exc
|
||||
return DeliveryResult(response_status, event_id)
|
||||
code = "http_error"
|
||||
try:
|
||||
error_value = json.loads(response_body)
|
||||
if isinstance(error_value, dict) and isinstance(error_value.get("error"), str):
|
||||
code = error_value["error"]
|
||||
except (UnicodeError, json.JSONDecodeError):
|
||||
pass
|
||||
if status == 401 or status >= 500:
|
||||
raise RetryableDelivery(code)
|
||||
raise PermanentDelivery(code)
|
||||
|
||||
|
||||
class EventRelayWorker:
|
||||
def __init__(self, outbox: EventOutbox, client: EventIngressClient) -> None:
|
||||
self._outbox = outbox
|
||||
self._client = client
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._last_error_code: str | None = None
|
||||
|
||||
def run_once(self) -> bool:
|
||||
item = self._outbox.lease_one()
|
||||
if item is None:
|
||||
return False
|
||||
try:
|
||||
result = self._client.deliver(item.payload)
|
||||
except PermanentDelivery as exc:
|
||||
self._outbox.mark_dead(item, exc.code)
|
||||
except RetryableDelivery as exc:
|
||||
self._outbox.mark_retry(item, exc.code)
|
||||
except Exception:
|
||||
self._outbox.mark_retry(item, "client_error")
|
||||
else:
|
||||
self._outbox.mark_delivered(item, result)
|
||||
return True
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name="brain-event-relay", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
worked = self.run_once()
|
||||
self._last_error_code = None
|
||||
except Exception:
|
||||
self._last_error_code = "event_outbox_unavailable"
|
||||
self._stop.wait(1.0)
|
||||
continue
|
||||
if not worked:
|
||||
self._stop.wait(0.25)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=HTTP_TIMEOUT_SECONDS + 2.0)
|
||||
self._thread = None
|
||||
|
||||
def last_error_code(self) -> str | None:
|
||||
return self._last_error_code
|
||||
|
||||
|
||||
class BrainEventIngress:
|
||||
def __init__(self, config: IngressConfig, key: KeyMaterial) -> None:
|
||||
self._config = config
|
||||
self._outbox = EventOutbox(config.outbox_path)
|
||||
self._outbox.bind_identity(config)
|
||||
self._worker = EventRelayWorker(self._outbox, EventIngressClient(config, key))
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> "BrainEventIngress":
|
||||
config, key = load_config(path)
|
||||
return cls(config, key)
|
||||
|
||||
def submit(self, value: EventCandidate) -> None:
|
||||
self._outbox.enqueue(map_candidate(value, self._config))
|
||||
|
||||
def start(self) -> None:
|
||||
self._worker.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._worker.stop()
|
||||
self._outbox.close()
|
||||
|
||||
def status(self) -> dict[str, object]:
|
||||
value = self._outbox.status()
|
||||
if self._worker.last_error_code() is not None:
|
||||
value["last_error_code"] = self._worker.last_error_code()
|
||||
return value
|
||||
|
||||
|
||||
def _safe_error(value: str) -> str:
|
||||
if not isinstance(value, str) or not _ERROR_CODE.fullmatch(value):
|
||||
return "unknown_error"
|
||||
return value
|
||||
@@ -0,0 +1,195 @@
|
||||
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 EventIngressUnavailable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DemoEngine:
|
||||
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100, event_ingress: Any | None = None) -> 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._event_ingress = event_ingress
|
||||
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
|
||||
if self._event_ingress is not None:
|
||||
self._event_ingress.start()
|
||||
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
|
||||
if self._event_ingress is not None:
|
||||
self._event_ingress.stop()
|
||||
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 EventIngressUnavailable:
|
||||
with self._lock:
|
||||
self._last_error_code = "event_outbox_unavailable"
|
||||
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)
|
||||
if self._event_ingress is not None:
|
||||
try:
|
||||
for item in new_events:
|
||||
self._event_ingress.submit(item)
|
||||
except Exception as exc:
|
||||
raise EventIngressUnavailable("persist event candidate") from exc
|
||||
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:
|
||||
serialized_event = event.as_dict()
|
||||
if self._event_ingress is not None:
|
||||
# This is an immutable handoff fact, not a live delivery
|
||||
# status. Current counts live under event_ingress.
|
||||
serialized_event["delivery_status"] = "outbox_persisted"
|
||||
self._events.appendleft(serialized_event)
|
||||
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
|
||||
if self._event_ingress is None:
|
||||
ingress_status: dict[str, object] = {"enabled": False}
|
||||
else:
|
||||
try:
|
||||
ingress_status = self._event_ingress.status()
|
||||
except Exception:
|
||||
ingress_status = {"enabled": True, "last_error_code": "event_outbox_unavailable"}
|
||||
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),
|
||||
"event_ingress": ingress_status,
|
||||
"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
|
||||
+18
-2
@@ -1,6 +1,6 @@
|
||||
# Sense M1/M2 接入骨架
|
||||
|
||||
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。默认关闭真实 ONVIF、公共业务路由和 relay;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
|
||||
```
|
||||
|
||||
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,8 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`
|
||||
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
|
||||
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
|
||||
| `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 字节 |
|
||||
@@ -122,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` 冒充完成。
|
||||
|
||||
### 开启 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(从仓库根目录执行):
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"yovision/sense/internal/auditrelay"
|
||||
"yovision/sense/internal/auth"
|
||||
"yovision/sense/internal/config"
|
||||
"yovision/sense/internal/console"
|
||||
"yovision/sense/internal/controlapi"
|
||||
"yovision/sense/internal/metrics"
|
||||
"yovision/sense/internal/mtx"
|
||||
@@ -160,6 +161,17 @@ func run(logger *slog.Logger) error {
|
||||
if cfg.ControlAPIEnabled {
|
||||
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{
|
||||
Addr: cfg.HTTPAddress, Handler: mux,
|
||||
@@ -173,6 +185,7 @@ func run(logger *slog.Logger) error {
|
||||
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
|
||||
"instance_id", instanceID,
|
||||
"control_api_enabled", cfg.ControlAPIEnabled,
|
||||
"console_enabled", cfg.ConsoleEnabled,
|
||||
"audit_relay_enabled", cfg.AuditRelayEnabled)
|
||||
serverErrors <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
defaultONVIFMode = "disabled"
|
||||
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}$`)
|
||||
@@ -54,6 +55,8 @@ type Config struct {
|
||||
ControlAuthFile string
|
||||
ControlCursorKeyFile string
|
||||
ControlAllowInsecureHTTP bool
|
||||
ConsoleEnabled bool
|
||||
ConsoleWebRTCBaseURL string
|
||||
AuditRelayEnabled bool
|
||||
AuditRelayURL string
|
||||
AuditRelayKeyFile string
|
||||
@@ -98,6 +101,10 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
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
|
||||
@@ -144,6 +151,8 @@ func Load() (Config, error) {
|
||||
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
|
||||
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
|
||||
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", ""),
|
||||
@@ -247,6 +256,26 @@ func (c Config) Validate() error {
|
||||
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")
|
||||
|
||||
@@ -167,6 +167,42 @@ func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -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,108 @@
|
||||
-- Bell v5 Brain event ingress identity bindings and durable receipts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.event_ingress_bindings (
|
||||
producer_id text NOT NULL,
|
||||
tenant_id bigint NOT NULL,
|
||||
site_id bigint NOT NULL,
|
||||
device_id bigint NOT NULL,
|
||||
logical_tenant_id text NOT NULL,
|
||||
logical_site_id text NOT NULL,
|
||||
logical_device_id text NOT NULL,
|
||||
logical_area_id text NOT NULL,
|
||||
modality text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (producer_id, tenant_id, site_id, device_id),
|
||||
CONSTRAINT bell_ingress_binding_site_fk
|
||||
FOREIGN KEY (logical_tenant_id, logical_site_id)
|
||||
REFERENCES bell.sites(tenant_id, id),
|
||||
CONSTRAINT bell_ingress_binding_area_fk
|
||||
FOREIGN KEY (logical_tenant_id, logical_area_id)
|
||||
REFERENCES bell.areas(tenant_id, id),
|
||||
CONSTRAINT bell_ingress_binding_device_fk
|
||||
FOREIGN KEY (logical_tenant_id, logical_site_id, logical_device_id)
|
||||
REFERENCES sense.devices(tenant_id, site_id, id),
|
||||
CONSTRAINT bell_ingress_binding_numeric_ids CHECK (
|
||||
tenant_id >= 1 AND site_id >= 1 AND device_id >= 1
|
||||
),
|
||||
CONSTRAINT bell_ingress_binding_producer CHECK (
|
||||
char_length(producer_id) BETWEEN 1 AND 64
|
||||
AND producer_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||
),
|
||||
CONSTRAINT bell_ingress_binding_logical_ids CHECK (
|
||||
btrim(logical_tenant_id) <> '' AND btrim(logical_site_id) <> ''
|
||||
AND btrim(logical_device_id) <> '' AND btrim(logical_area_id) <> ''
|
||||
),
|
||||
CONSTRAINT bell_ingress_binding_modality CHECK (
|
||||
modality IN ('video', 'radar', 'contact', 'button', 'wearable', 'other')
|
||||
)
|
||||
);
|
||||
ALTER TABLE bell.event_ingress_bindings OWNER TO bell_app;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bell_event_ingress_binding_numeric_idx
|
||||
ON bell.event_ingress_bindings(tenant_id, site_id, device_id)
|
||||
WHERE enabled;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.event_ingress_receipts (
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
candidate_hash bytea NOT NULL,
|
||||
event_id text NOT NULL REFERENCES bell.events(id),
|
||||
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (producer_id, source_event_id),
|
||||
CONSTRAINT bell_ingress_receipt_producer CHECK (
|
||||
char_length(producer_id) BETWEEN 1 AND 64
|
||||
AND producer_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||
),
|
||||
CONSTRAINT bell_ingress_receipt_source CHECK (
|
||||
source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'
|
||||
),
|
||||
CONSTRAINT bell_ingress_receipt_hash CHECK (octet_length(candidate_hash) = 32),
|
||||
CONSTRAINT bell_ingress_receipt_event_id CHECK (
|
||||
event_id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
|
||||
)
|
||||
);
|
||||
ALTER TABLE bell.event_ingress_receipts OWNER TO bell_app;
|
||||
|
||||
DROP TRIGGER IF EXISTS bell_event_ingress_receipts_immutable
|
||||
ON bell.event_ingress_receipts;
|
||||
CREATE TRIGGER bell_event_ingress_receipts_immutable
|
||||
BEFORE UPDATE OR DELETE ON bell.event_ingress_receipts
|
||||
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bell_event_ingress_receipt_event_idx
|
||||
ON bell.event_ingress_receipts(event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.event_ingress_nonces (
|
||||
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_ingress_nonce_key CHECK (
|
||||
char_length(key_id) BETWEEN 1 AND 64
|
||||
AND key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||
),
|
||||
CONSTRAINT bell_ingress_nonce_value CHECK (
|
||||
char_length(nonce) BETWEEN 22 AND 64
|
||||
AND nonce ~ '^[A-Za-z0-9_-]+$'
|
||||
),
|
||||
CONSTRAINT bell_ingress_nonce_hash CHECK (octet_length(request_hash) = 32),
|
||||
CONSTRAINT bell_ingress_nonce_status CHECK (response_status IN (200, 201)),
|
||||
CONSTRAINT bell_ingress_nonce_body CHECK (jsonb_typeof(response_body) = 'object'),
|
||||
CONSTRAINT bell_ingress_nonce_ttl CHECK (
|
||||
expires_at >= received_at + interval '10 minutes'
|
||||
AND expires_at <= received_at + interval '11 minutes'
|
||||
)
|
||||
);
|
||||
ALTER TABLE bell.event_ingress_nonces OWNER TO bell_app;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bell_event_ingress_nonce_expiry_idx
|
||||
ON bell.event_ingress_nonces(expires_at, key_id, nonce);
|
||||
|
||||
INSERT INTO bell.schema_migrations(version) VALUES (5)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Bell runtime may read controlled identity bindings, append events/source
|
||||
-- receipts and maintain only short-lived nonce receipts.
|
||||
|
||||
REVOKE ALL ON TABLE bell.event_ingress_bindings,
|
||||
bell.event_ingress_receipts, bell.event_ingress_nonces FROM PUBLIC;
|
||||
REVOKE ALL ON TABLE bell.event_ingress_bindings,
|
||||
bell.event_ingress_receipts, bell.event_ingress_nonces FROM bell_runtime;
|
||||
|
||||
GRANT SELECT ON TABLE bell.event_ingress_bindings TO bell_runtime;
|
||||
GRANT SELECT, INSERT ON TABLE bell.event_ingress_receipts TO bell_runtime;
|
||||
GRANT SELECT, INSERT, DELETE ON TABLE bell.event_ingress_nonces TO bell_runtime;
|
||||
|
||||
-- Column grants deliberately exclude endpoint_ref, credential_ref,
|
||||
-- profile_token and path_name.
|
||||
GRANT USAGE ON SCHEMA sense TO bell_runtime;
|
||||
REVOKE ALL ON TABLE sense.devices FROM bell_runtime;
|
||||
GRANT SELECT (id, tenant_id, site_id, area_id, modality)
|
||||
ON TABLE sense.devices TO bell_runtime;
|
||||
GRANT SELECT (tenant_id, id, deleted_at)
|
||||
ON TABLE bell.sites TO bell_runtime;
|
||||
GRANT SELECT (tenant_id, site_id, id, capture_policy, deleted_at)
|
||||
ON TABLE bell.areas TO bell_runtime;
|
||||
|
||||
REVOKE ALL ON TABLE bell.event_ingress_bindings,
|
||||
bell.event_ingress_receipts, bell.event_ingress_nonces FROM sense_app;
|
||||
@@ -0,0 +1,145 @@
|
||||
-- Bell v6 append-only rule evaluation, Alert identity and state transitions.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.rule_versions (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id bigint NOT NULL,
|
||||
site_id bigint,
|
||||
rule_key text NOT NULL,
|
||||
version integer NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
event_kind text NOT NULL,
|
||||
minimum_severity text NOT NULL,
|
||||
enabled boolean NOT NULL,
|
||||
effective_from timestamptz NOT NULL,
|
||||
config_hash bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT bell_rule_version_id CHECK (id ~ '^ruv_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
CONSTRAINT bell_rule_version_scope CHECK (tenant_id >= 1 AND (site_id IS NULL OR site_id >= 1)),
|
||||
CONSTRAINT bell_rule_version_key CHECK (rule_key ~ '^[a-z][a-z0-9_-]{2,63}$'),
|
||||
CONSTRAINT bell_rule_version_number CHECK (version >= 1),
|
||||
CONSTRAINT bell_rule_version_name CHECK (char_length(btrim(display_name)) BETWEEN 1 AND 120),
|
||||
CONSTRAINT bell_rule_version_kind CHECK (event_kind ~ '^[a-z][a-z0-9_]{2,63}$'),
|
||||
CONSTRAINT bell_rule_version_severity CHECK (minimum_severity IN ('low','medium','high','critical')),
|
||||
CONSTRAINT bell_rule_version_hash CHECK (octet_length(config_hash) = 32),
|
||||
UNIQUE (tenant_id, rule_key, version),
|
||||
UNIQUE (tenant_id, rule_key, config_hash)
|
||||
);
|
||||
ALTER TABLE bell.rule_versions OWNER TO bell_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.event_rule_sweeps (
|
||||
event_id text PRIMARY KEY REFERENCES bell.events(id),
|
||||
swept_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
ALTER TABLE bell.event_rule_sweeps OWNER TO bell_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.rule_evaluations (
|
||||
id text PRIMARY KEY,
|
||||
event_id text NOT NULL REFERENCES bell.events(id),
|
||||
rule_version_id text NOT NULL REFERENCES bell.rule_versions(id),
|
||||
result text NOT NULL,
|
||||
reason text NOT NULL,
|
||||
evaluated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT bell_rule_evaluation_id CHECK (id ~ '^eva_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
CONSTRAINT bell_rule_evaluation_result CHECK (result IN ('matched','no_match')),
|
||||
CONSTRAINT bell_rule_evaluation_reason CHECK (reason IN ('matched','disabled','event_kind','severity')),
|
||||
UNIQUE (event_id, rule_version_id)
|
||||
);
|
||||
ALTER TABLE bell.rule_evaluations OWNER TO bell_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.alerts (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id bigint NOT NULL,
|
||||
site_id bigint NOT NULL,
|
||||
rule_evaluation_id text NOT NULL UNIQUE REFERENCES bell.rule_evaluations(id),
|
||||
rule_version_id text NOT NULL REFERENCES bell.rule_versions(id),
|
||||
severity text NOT NULL,
|
||||
title text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT bell_alert_id CHECK (id ~ '^alt_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
CONSTRAINT bell_alert_scope CHECK (tenant_id >= 1 AND site_id >= 1),
|
||||
CONSTRAINT bell_alert_severity CHECK (severity IN ('low','medium','high','critical')),
|
||||
CONSTRAINT bell_alert_title CHECK (char_length(btrim(title)) BETWEEN 1 AND 120)
|
||||
);
|
||||
ALTER TABLE bell.alerts OWNER TO bell_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.alert_events (
|
||||
alert_id text NOT NULL REFERENCES bell.alerts(id),
|
||||
event_id text NOT NULL REFERENCES bell.events(id),
|
||||
linked_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (alert_id, event_id),
|
||||
UNIQUE (event_id, alert_id)
|
||||
);
|
||||
ALTER TABLE bell.alert_events OWNER TO bell_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.alert_transitions (
|
||||
id text PRIMARY KEY,
|
||||
alert_id text NOT NULL REFERENCES bell.alerts(id),
|
||||
sequence integer NOT NULL,
|
||||
from_state text,
|
||||
to_state text NOT NULL,
|
||||
actor_ref text NOT NULL,
|
||||
note text,
|
||||
occurred_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT bell_alert_transition_id CHECK (id ~ '^trn_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
CONSTRAINT bell_alert_transition_sequence CHECK (sequence >= 1),
|
||||
CONSTRAINT bell_alert_transition_from CHECK (from_state IS NULL OR from_state IN ('open','acknowledged')),
|
||||
CONSTRAINT bell_alert_transition_to CHECK (to_state IN ('open','acknowledged','closed')),
|
||||
CONSTRAINT bell_alert_transition_edge CHECK (
|
||||
(sequence = 1 AND from_state IS NULL AND to_state = 'open') OR
|
||||
(sequence > 1 AND from_state = 'open' AND to_state = 'acknowledged') OR
|
||||
(sequence > 1 AND from_state = 'acknowledged' AND to_state = 'closed')
|
||||
),
|
||||
CONSTRAINT bell_alert_transition_actor CHECK (
|
||||
char_length(actor_ref) BETWEEN 1 AND 80 AND actor_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:@/-]*$'
|
||||
),
|
||||
CONSTRAINT bell_alert_transition_note CHECK (note IS NULL OR char_length(note) <= 500),
|
||||
UNIQUE (alert_id, sequence)
|
||||
);
|
||||
ALTER TABLE bell.alert_transitions OWNER TO bell_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.alert_command_receipts (
|
||||
tenant_id bigint NOT NULL,
|
||||
idempotency_key text NOT NULL,
|
||||
command_hash bytea NOT NULL,
|
||||
response_status integer NOT NULL,
|
||||
response_body jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (tenant_id, idempotency_key),
|
||||
CONSTRAINT bell_alert_command_tenant CHECK (tenant_id >= 1),
|
||||
CONSTRAINT bell_alert_command_key CHECK (
|
||||
char_length(idempotency_key) BETWEEN 8 AND 128
|
||||
AND idempotency_key ~ '^[A-Za-z0-9][A-Za-z0-9._:-]*$'
|
||||
),
|
||||
CONSTRAINT bell_alert_command_hash CHECK (octet_length(command_hash) = 32),
|
||||
CONSTRAINT bell_alert_command_status CHECK (response_status IN (200,409)),
|
||||
CONSTRAINT bell_alert_command_body CHECK (jsonb_typeof(response_body) = 'object')
|
||||
);
|
||||
ALTER TABLE bell.alert_command_receipts OWNER TO bell_app;
|
||||
|
||||
DO $immutable$
|
||||
DECLARE
|
||||
table_name text;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'rule_versions','event_rule_sweeps','rule_evaluations','alerts',
|
||||
'alert_events','alert_transitions','alert_command_receipts'
|
||||
] LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON bell.%I', 'bell_' || table_name || '_immutable', table_name);
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE UPDATE OR DELETE ON bell.%I FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change()',
|
||||
'bell_' || table_name || '_immutable', table_name
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$immutable$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bell_rule_versions_effective_idx
|
||||
ON bell.rule_versions(tenant_id, rule_key, effective_from DESC, version DESC);
|
||||
CREATE INDEX IF NOT EXISTS bell_alerts_scope_time_idx
|
||||
ON bell.alerts(tenant_id, site_id, created_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS bell_alert_events_event_idx ON bell.alert_events(event_id, alert_id);
|
||||
CREATE INDEX IF NOT EXISTS bell_alert_transitions_latest_idx
|
||||
ON bell.alert_transitions(alert_id, sequence DESC);
|
||||
|
||||
INSERT INTO bell.schema_migrations(version) VALUES (6)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Bell runtime may only append and read rule/Alert history.
|
||||
|
||||
REVOKE ALL ON TABLE bell.rule_versions, bell.event_rule_sweeps,
|
||||
bell.rule_evaluations, bell.alerts, bell.alert_events,
|
||||
bell.alert_transitions, bell.alert_command_receipts FROM PUBLIC;
|
||||
REVOKE ALL ON TABLE bell.rule_versions, bell.event_rule_sweeps,
|
||||
bell.rule_evaluations, bell.alerts, bell.alert_events,
|
||||
bell.alert_transitions, bell.alert_command_receipts FROM bell_runtime;
|
||||
|
||||
GRANT SELECT, INSERT ON TABLE bell.rule_versions, bell.event_rule_sweeps,
|
||||
bell.rule_evaluations, bell.alerts, bell.alert_events,
|
||||
bell.alert_transitions, bell.alert_command_receipts TO bell_runtime;
|
||||
|
||||
REVOKE ALL ON TABLE bell.rule_versions, bell.event_rule_sweeps,
|
||||
bell.rule_evaluations, bell.alerts, bell.alert_events,
|
||||
bell.alert_transitions, bell.alert_command_receipts FROM sense_app;
|
||||
@@ -1,6 +1,6 @@
|
||||
# YoVision PostgreSQL 初始化
|
||||
|
||||
本目录实现 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`,仓库不保存登录凭据。
|
||||
本目录实现 T-009~T-012、T-015~T-016、T-019~T-020 的 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` 增加审计 relay;`016`~`017` 增加 Brain 事件身份绑定、永久来源收据、短期 nonce 和最小权限;`018`~`019` 增加不可变规则版本、durable evaluation、Alert/Event 关联、append-only 状态迁移、永久命令收据和最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
|
||||
|
||||
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||
|
||||
@@ -23,6 +23,8 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
||||
- `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 路径的意外事实改写。
|
||||
- `bell.event_ingress_bindings` 由管理员维护并引用当前 Bell Site/Area 和 Sense Device;Bell runtime 只读绑定及 Sense 设备的逻辑 ID/Area/modality 列,不可读取 endpoint、credential、profile token 或 MediaMTX path。`event_ingress_receipts` 永久只追加;只有 `event_ingress_nonces` 可按 10 分钟 TTL 清理。
|
||||
- `bell.rule_versions`、sweep/evaluation、Alert/Event 关联、transition 和命令收据均由 `bell_app` 拥有;`bell_runtime` 只有 `SELECT/INSERT`,数据库 trigger 拒绝 UPDATE/DELETE。当前状态从最新 transition 推导,同一 evaluation 由唯一约束最多创建一个 Alert;命令收据永久保留以支持跨重启幂等。
|
||||
- `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 写删除结果。
|
||||
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
||||
|
||||
@@ -71,7 +71,7 @@ BEGIN
|
||||
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
|
||||
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
|
||||
END IF;
|
||||
IF (SELECT max(version) FROM bell.schema_migrations) <> 4
|
||||
IF (SELECT max(version) FROM bell.schema_migrations) <> 6
|
||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
|
||||
RAISE EXCEPTION 'schema migration version drift';
|
||||
END IF;
|
||||
@@ -228,3 +228,50 @@ BEGIN
|
||||
END IF;
|
||||
END
|
||||
$audit_relay$;
|
||||
|
||||
DO $event_ingress$
|
||||
BEGIN
|
||||
IF NOT has_table_privilege('yovision_t015_bell', 'bell.event_ingress_bindings', 'SELECT')
|
||||
OR has_table_privilege('yovision_t015_bell', 'bell.event_ingress_bindings', 'INSERT,UPDATE,DELETE,TRUNCATE')
|
||||
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_ingress_receipts', 'SELECT,INSERT')
|
||||
OR has_table_privilege('yovision_t015_bell', 'bell.event_ingress_receipts', 'UPDATE,DELETE,TRUNCATE')
|
||||
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_ingress_nonces', 'SELECT,INSERT,DELETE')
|
||||
OR has_table_privilege('yovision_t015_bell', 'bell.event_ingress_nonces', 'UPDATE,TRUNCATE') THEN
|
||||
RAISE EXCEPTION 'Bell runtime violates event ingress privileges';
|
||||
END IF;
|
||||
IF NOT has_column_privilege('yovision_t015_bell', 'sense.devices', 'id', 'SELECT')
|
||||
OR NOT has_column_privilege('yovision_t015_bell', 'sense.devices', 'area_id', 'SELECT')
|
||||
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'endpoint_ref', 'SELECT')
|
||||
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'credential_ref', 'SELECT')
|
||||
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'profile_token', 'SELECT')
|
||||
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'path_name', 'SELECT') THEN
|
||||
RAISE EXCEPTION 'Bell runtime Sense device projection privilege drift';
|
||||
END IF;
|
||||
IF has_table_privilege('public', 'bell.event_ingress_bindings', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||
OR has_table_privilege('public', 'bell.event_ingress_receipts', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||
OR has_table_privilege('public', 'bell.event_ingress_nonces', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
|
||||
RAISE EXCEPTION 'Bell event ingress tables leaked to PUBLIC';
|
||||
END IF;
|
||||
END
|
||||
$event_ingress$;
|
||||
|
||||
DO $bell_alerts$
|
||||
DECLARE
|
||||
target text;
|
||||
BEGIN
|
||||
FOREACH target IN ARRAY ARRAY[
|
||||
'bell.rule_versions', 'bell.event_rule_sweeps', 'bell.rule_evaluations',
|
||||
'bell.alerts', 'bell.alert_events', 'bell.alert_transitions',
|
||||
'bell.alert_command_receipts'
|
||||
] LOOP
|
||||
IF NOT has_table_privilege('yovision_t015_bell', target, 'SELECT,INSERT')
|
||||
OR has_table_privilege('yovision_t015_bell', target, 'UPDATE,DELETE,TRUNCATE') THEN
|
||||
RAISE EXCEPTION 'Bell runtime violates append-only Alert privilege on %', target;
|
||||
END IF;
|
||||
IF has_table_privilege('public', target, 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||
OR has_table_privilege('sense_app', target, 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
|
||||
RAISE EXCEPTION 'Bell Alert history leaked on %', target;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$bell_alerts$;
|
||||
|
||||
@@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。
|
||||
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路可视化工程原型、Sense 回环 NVR 管理面纵切、Brain→Bell 可靠事件 ingress 和 Bell 规则→Alert→ack/close 回环工程纵切**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置;下一项建议独立建立 Bell→Sense 异步证据/pre-roll 切片。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -92,8 +92,10 @@ 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`。T-020 在 Bell migration/外部规则与 token 上下文就绪后显式开启 `BELL_ALERTS_ENABLED=true` 与 `BELL_ALERT_CONSOLE_ENABLED=true`,访问 `/bell-console/`;完整变量见 [`../Bell/README.md`](../Bell/README.md)。工程页面默认只允许回环,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md)、[`../Brain/README.md`](../Brain/README.md) 和 [`../Bell/README.md`](../Bell/README.md)。
|
||||
|
||||
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||
|
||||
+42
-6
@@ -63,7 +63,7 @@ T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模
|
||||
| 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_`,替换实现不得改变格式或所有权 |
|
||||
| ULID | `github.com/oklog/ulid/v2 v2.1.2` | Apache-2.0;module sum `h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=` | 只封装在 Bell ID generator 边界后;Event 前缀为 `evt_`,T-020 Alert 前缀为 `alt_`,替换实现不得改变格式或所有权 |
|
||||
|
||||
T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进制。内部 factory 接收不含 `id` 的候选事实,由 Bell 生成 ULID 后才形成最终 v0.1 事件;不得把该 Go 类型当成公共网络协议。
|
||||
|
||||
@@ -71,6 +71,34 @@ T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进
|
||||
|
||||
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 存储能力。
|
||||
|
||||
### 1.7 Brain → Bell 事件 ingress(T-019)
|
||||
|
||||
T-019 不增加第三方依赖。Brain 继续使用 Python 3.10.11 标准库 `sqlite3`、`urllib`、HMAC/SHA-256 和线程;SQLite Outbox 开启 WAL 与 `synchronous=FULL`,文件、配置和 key 都必须是仓库外绝对路径。内部 HTTP client 显式禁用环境代理,避免业务事件被 `HTTP(S)_PROXY` 意外转发;回环可用 HTTP,非回环只接受 HTTPS。队列最多 10,000 条待投递、单 payload 1 MiB、1~300 秒退避、最多 100 次,终态记录保留供运维核查。
|
||||
|
||||
Bell 复用已冻结 Go 1.26.5、PostgreSQL 17.10/pgx、JSON Schema 和 ULID,不新增消息总线或 SDK。`brain-event-ingress-v1` 是单事件、producer-bound HMAC 内部适配器;Bell 用永久来源收据和事务级 advisory lock 保证同 `(producer_id, source_event_id)` 只生成一个平台事件。该选择不冻结 Bell 公共认证、生产 Brain GPU/runtime 或未来高吞吐 transport;若容量基准证明单条 HTTP 不足,必须发布兼容迁移方案,不能绕过来源幂等键。
|
||||
|
||||
### 1.8 Bell 规则、Alert 与回环值班台(T-020)
|
||||
|
||||
T-020 不增加第三方依赖:规则/Alert 域继续使用 Bell 已冻结的 Go 1.26.5、PostgreSQL 17.10/pgx 和 ULID。规则配置是仓库外 JSON,首版只实现精确事件类型、最小严重度、可选 Site、启用状态和生效时间;canonical SHA-256 相同则幂等复用,变化只追加新版本。规则 worker 是单一可取消循环和有界空闲轮询,不按租户、站点或设备创建 goroutine;数据库 durable sweep、唯一键和事务级 advisory lock 负责多实例收敛。
|
||||
|
||||
回环值班台与 T-018 相同,使用 Go 标准库 `embed`/`net/http` 和自包含 HTML/CSS/原生 JavaScript,无 npm、框架、CDN 或浏览器持久存储。该选择只服务工程验收,不冻结 Bell 正式前端栈。控制台与规则 worker 分别默认关闭;控制台要求规则已启用、整个 Bell 监听地址显式回环、仓库外 token 文件及服务端 tenant/Site/actor 上下文。证据、升级/通知、JWT/OIDC/RBAC 和非回环部署不在本任务内。
|
||||
|
||||
## 2. 外部项目边界
|
||||
|
||||
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
||||
@@ -82,7 +110,7 @@ T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、
|
||||
|
||||
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
|
||||
- Bell 前端框架和组件库。
|
||||
- Brain→Bell 业务事件投递 transport;Sense→Bell 审计 relay 已独立冻结为内部 HTTP,不能据此默认 Brain transport。
|
||||
- Brain 生产推理 runtime 与更高吞吐 transport;T-019 已冻结首版 Brain→Bell 单事件内部 HTTP ingress,但不据此承诺 64/128 路吞吐或消息总线选型。
|
||||
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
||||
- MinIO/S3 的精确版本、加密实现,以及客户/法务确认后的最终生命周期策略。
|
||||
- 短信/语音供应商及生产双路径组合;是否开发原生 App 最早在 M4 根据试点反馈决定。
|
||||
@@ -91,7 +119,7 @@ T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、
|
||||
|
||||
## 4. 当前标准入口
|
||||
|
||||
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
||||
Sense 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
||||
|
||||
```powershell
|
||||
./init.ps1
|
||||
@@ -114,7 +142,7 @@ go -C Sense build ./...
|
||||
go -C Sense run ./cmd/sense-api
|
||||
```
|
||||
|
||||
Bell 事件域与内部审计 receiver 单独执行:
|
||||
Bell 事件、内部 ingress 与回环 Alert 纵切单独执行:
|
||||
|
||||
```powershell
|
||||
go -C Bell mod download
|
||||
@@ -123,6 +151,14 @@ 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
|
||||
@@ -139,8 +175,8 @@ python scripts/validate_harness_governance.py
|
||||
| `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 才要求客户/借用/租赁的真实多路矩阵 |
|
||||
| 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、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
|
||||
| Bell Go/Web | 当前后端:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 |
|
||||
| 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 | `go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;回环值班台静态门禁:`python -m unittest discover -s tests -p "test_bell_alert_contract.py"`;正式前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 正式 UI 流程由产品/值班角色验收;T-020 工程台检查 375px/桌面、键盘、错误态和 reduced-motion,不等于正式 UI 验收 |
|
||||
| 容量/分片 | 任务内基准脚本;本地 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`。
|
||||
|
||||
+14
-10
@@ -47,15 +47,15 @@ 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 语义。
|
||||
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
|
||||
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
|
||||
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。
|
||||
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
|
||||
3. Brain 消费视频与触发信号,产生符合冻结契约的事件候选。T-017 已建立单路 frame source、可替换 detector、轻量 track、多边形进入判定和回环可视化工程原型;T-019 把候选先写入仓库外 SQLite Outbox,再经 producer-bound HMAC 内部 HTTP 投递。合成 fixture 与 OpenCV HOG 均不是生产模型,Brain 只生成 `source_event_id`,不自报平台 `id`。T-018 的 Sense 回环控制台不参与推理或事件生成。
|
||||
4. Bell 做身份/Area 隐私、schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;T-019 增加数字事件身份到 Bell/Sense 逻辑身份的受控绑定、永久来源收据和短期 nonce 收据。该 ingress 是内部适配器,Bell 公共 API/JWT/OIDC 仍未冻结。
|
||||
5. T-020 已实现规则→Alert→ack/close 最小纵切:仓库外规则按 hash 发布不可变版本,worker 对每个 Event 写 durable sweep/evaluation;命中后由 Bell 创建独立 `alt_` ULID、Event 关联和初始 `open` transition。ack 首个竞争者获胜,后到者读取实际 actor/time;当前尚未实现投递或升级。
|
||||
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
|
||||
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
||||
|
||||
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
|
||||
|
||||
## 5. 十二条不可越界的决定
|
||||
## 5. 十三条不可越界的决定
|
||||
|
||||
1. MediaMTX 独立运行,Sense 管配置与生命周期。
|
||||
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
|
||||
@@ -69,6 +69,7 @@ Sense ── 视频流/触发信号 ──> Brain
|
||||
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
|
||||
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
|
||||
12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-016 已实现 HMAC/nonce 内部 HTTP relay、数据库时钟 lease/fencing、逐项确认与 Bell 不可变全局事实;Sense 不获得 Bell schema 权限,Bell 不读取 Sense Outbox。
|
||||
13. Brain 的业务事件同样先写仓库外持久 Outbox,再异步投递;Bell 以 `(producer_id, source_event_id)` 永久收据而非短期 nonce 保证跨重启幂等。只有 `accepted/duplicate` 可确认本地 delivered;事件 ID 始终由 Bell 生成,稳定冲突不得自动改写来源 ID 或 payload。
|
||||
|
||||
## 6. 容量架构
|
||||
|
||||
@@ -79,6 +80,8 @@ Sense ── 视频流/触发信号 ──> Brain
|
||||
- 单分片故障不能扩散到其他分片。
|
||||
- 管理端默认查看 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)。
|
||||
|
||||
## 7. 一致性与失败处理
|
||||
@@ -91,15 +94,16 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
|
||||
- 设备创建和期望态受理在本地事务内同时写脱敏 `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 投递失败落本地队列重试,不阻塞实时推理主链路。
|
||||
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
||||
- T-019 的 Brain SQLite Outbox 与推理内存环分离:最多 10,000 条待投递,网络/5xx/认证故障按 1~300 秒重试,最多 100 次;稳定 4xx 进入 dead letter。Outbox 写入失败时不得在演示状态中伪装为已排队或已投递。
|
||||
- Bell 先按 HMAC key 绑定 producer,再解析候选;永久来源收据、最终 event 和成功 nonce 响应同事务提交。同来源/同 canonical hash 返回原 Bell ID,同来源/不同 hash 返回冲突。T-017 的 100 项内存环仍只服务页面显示,不承担可靠投递。
|
||||
- Alert 身份、Event 多对多关联与 `open → acknowledged → closed` transition 均只追加;当前状态从最新 transition 推导。规则 evaluation、Alert 创建和初始 transition 在同一事务中提交;无规则/未命中也写 durable sweep。命令永久幂等收据保证重启后同 key 重放原响应。投递和升级链尚未实现,页面不得伪造倒计时或送达事实。
|
||||
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
||||
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
||||
|
||||
## 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` 六列。
|
||||
- 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/) 为准。
|
||||
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径。T-016 审计 relay 与 T-019 Brain 事件 ingress 各用独立外部 HMAC key/端点,均不等同于 Bell 公共管理认证。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。
|
||||
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
|
||||
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
|
||||
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
|
||||
@@ -115,17 +119,17 @@ Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trig
|
||||
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
||||
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
||||
Bell/{web,packs,contracts}
|
||||
deploy/postgres/{001_roles.sql,...,015_privileges_audit_relay.sql,tests}
|
||||
deploy/postgres/{001_roles.sql,...,019_privileges_bell_alerts.sql,tests}
|
||||
```
|
||||
|
||||
Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API,Brain 仍为目录占位。
|
||||
Sense 脚手架和 PostgreSQL `001`~`019` 已实现;Bell 已有事件校验/不可变存储、Sense 审计 relay、默认关闭的 Brain 事件 ingress,以及规则/Alert/ack 回环工程纵切,但没有公共管理 API。Brain 已有单路工程原型和仓库外 SQLite 可靠事件 Outbox;尚无生产模型、GPU pipeline、证据切片、升级或通知链。
|
||||
|
||||
## 10. 开发顺序
|
||||
|
||||
- M0 不写生产代码。
|
||||
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
||||
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
||||
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
|
||||
- M3 Brain 与 Bell 同时起步;T-015~T-020 已打通不可变事件消费者、审计 relay、Brain 单路候选、Sense 回环管理纵切、Brain→Bell 可靠事件 ingress 和 Bell 规则→Alert→ack/close 工程纵切。下一步应独立实现 Bell→Sense 异步证据/pre-roll 切片,不与已经冻结的 Alert 状态机混交。
|
||||
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||
|
||||
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
||||
|
||||
+6
-1
@@ -38,7 +38,12 @@
|
||||
- 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;独立冻结数字事件身份到 Bell 逻辑身份的绑定、HMAC 认证、SQLite 持久 Outbox、Bell 永久来源收据和跨重启幂等确认,不得把 T-017 内存事件环当作生产投递。
|
||||
- T-020:建立 Bell 规则→Alert→ack/close 最小可见纵切;规则版本、evaluation、Alert/Event 关联、状态迁移和命令收据均 append-only,工程值班台只允许回环且不伪造证据、升级或投递事实。
|
||||
- T-021(建议,尚未创建):建立 Bell→Sense 异步证据/pre-roll 切片,独立冻结请求、授权、幂等、状态、对象引用和生命周期;不得把常态录像或原始摄像头凭据交给 Bell。
|
||||
- 升级状态机、排班解析与双路径投递。
|
||||
- 最小 Web/App 处置流程、RBAC 与审计。
|
||||
- 现场误报基线和反馈队列。
|
||||
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
|
||||
|
||||
@@ -106,6 +106,14 @@
|
||||
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
|
||||
- 关联: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,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
|
||||
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
|
||||
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
|
||||
| IX-024 | Brain 单路工程演示 | 固定展示来源类型、fixture/真实检测边界、连接状态、detector 名称、帧序号、处理耗时、匿名 track、区域内外和最近候选;合成/离线数据不得伪装为模型输出。区域支持画布加点、撤销、清空、保存和坐标表单等效路径;保存失败保留草稿。页面不得展示流 URL、凭据、平台事件 ID、Alert 或虚构准确率 | US-014 | M3 |
|
||||
|
||||
## 全局状态
|
||||
|
||||
|
||||
+21
-9
@@ -1,6 +1,6 @@
|
||||
# API 与契约
|
||||
|
||||
> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1、Sense 本地设备审计事件 v1/v2 与 Sense→Bell 审计 relay v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
||||
> Brain → Bell 事件契约 v0.1 与内部 ingress v1、Sense Control API v1、Bell 配额/Area 只读投影 v1、Sense 本地设备审计事件 v1/v2、Sense→Bell 审计 relay v1 与 Bell 回环 Alert 控制台 v1 已冻结;其他 API 仍在设计阶段。不得把工程控制台当成 Bell 公共契约,也不得把本文的“待定”自行具体化。
|
||||
|
||||
## 1. 已冻结:Brain → Bell 事件契约
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
|
||||
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
|
||||
|
||||
T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress 先接收“不含平台 `id`”的候选事实,Bell 生成 `evt_` ULID 后形成最终 v0.1 对象,再执行 schema 与六项代码断言并不可变落库。该候选类型是 Bell 内部 port,不是 Brain 可依赖的 HTTP/消息总线协议;transport、认证和重放语义仍由后续任务冻结。
|
||||
T-015 已实现 Bell 消费端的内部组装与存储边界;T-019 在 [`contracts/brain-event-ingress-v1.openapi.json`](contracts/brain-event-ingress-v1.openapi.json) 冻结并实现网络适配器。Brain 发送“不含平台 `id`”的完整候选,Bell 生成 `evt_` ULID 后执行 schema、六项代码断言与不可变落库。内部 endpoint 使用独立 producer-bound HMAC key,不是 Bell 公共 API 或 JWT/OIDC 的替代品。
|
||||
|
||||
## 2. 跨系统接口状态
|
||||
|
||||
@@ -26,12 +26,13 @@ T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress
|
||||
| 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 | 汇入设备操作审计 | `POST /internal/v1/audit-events:batch`;1~100 项、1 MiB、10 秒 deadline、HMAC/nonce、逐项确认;非回环必须 HTTPS | T-016 已冻结并实现 |
|
||||
| Brain → Bell | 汇入业务事件候选 | `POST /internal/v1/event-candidates`;单条完整 v0.1-minus-id candidate、1 MiB、10 秒 deadline、producer-bound HMAC、永久来源收据;非回环必须 HTTPS | T-019 已冻结并实现 |
|
||||
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
|
||||
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
|
||||
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
||||
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
||||
|
||||
冻结签名和失败语义见 [`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 下把本地视图静默替换为网络调用。
|
||||
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/brain-event-ingress-v1.openapi.json`](contracts/brain-event-ingress-v1.openapi.json)、[`contracts/sense-audit-relay-v1.openapi.json`](contracts/sense-audit-relay-v1.openapi.json)、[`contracts/bell-alert-console-v1.openapi.json`](contracts/bell-alert-console-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 拥有投影源数据、视图、事件身份绑定、来源收据、规则版本和 Alert 历史;未来分库必须发布新版本,不能在 v1 下静默改变一致性或身份语义。
|
||||
|
||||
## 3. 已冻结:Sense Control API v1
|
||||
|
||||
@@ -49,7 +50,18 @@ T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress
|
||||
|
||||
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 回环 Alert 控制台 v1
|
||||
|
||||
- OpenAPI:[`contracts/bell-alert-console-v1.openapi.json`](contracts/bell-alert-console-v1.openapi.json)
|
||||
- 路由前缀:`/bell-console/api/v1`;仅在整个 Bell 服务显式回环监听且两个 Alert feature flag 开启时注册。
|
||||
- 范围:Alert 按状态稳定分页、Alert/Event/规则版本/transition 详情、首次 ack 和确认后 close。列表默认 16、最大 100。
|
||||
- tenant、Site 与 actor 只取启动时的仓库外上下文;Bearer token 只从仓库外文件加载并仅驻留页面内存。写命令要求 8~128 字符 `Idempotency-Key`。
|
||||
- 首次 ack 获胜;后到者返回 `409 already_acknowledged` 和实际 actor/time。同 key/同命令重放原 status/body,同 key/不同命令返回 `409 idempotency_conflict`。
|
||||
- API 不返回 Event 原始 payload、流 URI、凭据、DSN 或 token;当前明确返回证据和投递均 `not_enabled`。
|
||||
|
||||
该 API 用于本地工程/售前联调,不冻结正式客户 URL、JWT/OIDC/RBAC、前端框架或非回环部署。正式公共 API 不得无版本迁移地复用工程 token/上下文模式。
|
||||
|
||||
## 5. 待冻结的 Bell 公共 API
|
||||
|
||||
资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足:
|
||||
|
||||
@@ -60,11 +72,11 @@ T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入
|
||||
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
|
||||
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
|
||||
|
||||
## 5. MediaMTX 接口边界
|
||||
## 6. MediaMTX 接口边界
|
||||
|
||||
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
||||
|
||||
### 5.1 T-003 已实现的内部适配契约
|
||||
### 6.1 T-003 已实现的内部适配契约
|
||||
|
||||
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
|
||||
|
||||
@@ -76,20 +88,20 @@ Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代
|
||||
|
||||
MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}`、`/v3/config/paths/list` 与 `/v3/paths/get/{name}`。列表有最大页数和重复页保护,source URI 在薄封装内丢弃。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。
|
||||
|
||||
### 5.2 设备台账语义
|
||||
### 6.2 设备台账语义
|
||||
|
||||
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
|
||||
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
|
||||
- SQLite 表使用 `sense_` 前缀且只是 M1 实验室兼容路径;PostgreSQL 使用 `sense.devices`、能力/调和表、两个投影观察表和 `sense.device_operation_outbox`。PostgreSQL 不建立可写 Site/Area 真相副本;后续公共控制 API 只在 PostgreSQL 路径启用,不能把 SQLite 描述为 Area/Outbox 生产等价实现。
|
||||
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
|
||||
|
||||
### 5.3 Sense 进程 HTTP 面
|
||||
### 6.3 Sense 进程 HTTP 面
|
||||
|
||||
`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成;两者不要求认证,也不等价于摄像头、MediaMTX path 或里程碑健康。`GET /metrics` 默认输出无租户/设备/Path 标签的 Prometheus 汇总,可通过 `SENSE_METRICS_ENABLED=false` 关闭。T-011 在 `SENSE_CONTROL_API_ENABLED=true`、PostgreSQL v5 schema 和外部安全文件全部有效时注册冻结的 `/api/v1` 路由;默认 SQLite 不注册业务路由。业务路由不提供无 Site 边界的 `/api/v1/devices` 临时接口。
|
||||
|
||||
孤儿报告/处置不是公共 HTTP API。它由 PostgreSQL 专用 `cmd/sense-orphan` 在受控运维主机执行,使用 15 分钟 scan ID、actor、精确确认文本和运行前二次快照;不改变 Sense Control API v1 的 7 个 endpoint。
|
||||
|
||||
## 6. 变更流程
|
||||
## 7. 变更流程
|
||||
|
||||
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
||||
2. 更新本文和 schema/OpenAPI。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sense 控制面、准入投影与本地审计契约 v1
|
||||
# YoVision 控制面、审计与内部事件传输契约 v1
|
||||
|
||||
> 冻结日期:2026-08-11。Control API 与审计 relay 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-016 已实现 Sense Outbox 到 Bell 的内部 relay;Bell 公共管理服务与 JWT/OIDC 仍未实现。
|
||||
> 冻结日期:2026-08-11。Control API、审计 relay、Brain 事件 ingress 与 Bell 回环 Alert 控制台契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额、事件、Alert 与全局审计的所有者。T-016 已实现 Sense Outbox 审计 relay,T-019 已实现 Brain 事件 Outbox ingress,T-020 已实现 Bell 规则→Alert→ack/close 工程纵切;Bell 公共管理服务与 JWT/OIDC 仍未实现。
|
||||
|
||||
## 契约文件
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
| [`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-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
|
||||
| [`brain-event-ingress-v1.openapi.json`](brain-event-ingress-v1.openapi.json) | Bell | Brain | 单业务事件入站、producer/key 绑定、Bell ID 与跨重启幂等语义 |
|
||||
| [`bell-alert-console-v1.openapi.json`](bell-alert-console-v1.openapi.json) | Bell | 回环工程值班台 | Alert 分页/详情、首次 ack、确认后关闭与永久幂等收据 |
|
||||
|
||||
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||
|
||||
@@ -72,6 +74,28 @@ Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SK
|
||||
{"version":1,"keys":[{"key_id":"sense-a","secret_base64url":"<external-secret>"}]}
|
||||
```
|
||||
|
||||
## Brain → Bell 业务事件 ingress
|
||||
|
||||
Brain 向 `POST /internal/v1/event-candidates` 单条投递完整 event v0.1 candidate;candidate 与冻结最终事件的顶层字段完全一致,只省略 Bell 所有的 `id`。envelope 额外携带 `schema_version=1` 和 `producer_id`。Bell 校验 HMAC、key 与 producer 的绑定、当前逻辑设备/Area 映射、event schema 和六项语义断言后生成 `evt_` ULID;首次接受返回 `201 accepted`,同一来源事实重投返回 `200 duplicate` 和原 Bell ID。
|
||||
|
||||
认证 canonical string 与审计 relay 使用相同五行算法,但 key 文件独立且每项增加 `producer_id`,不得混用审计 key。正文最多 1 MiB、deadline 10 秒、允许 300 秒时钟偏差,nonce 收据至少保留 600 秒;HTTP 只允许回环地址,非回环必须 HTTPS。key 文件形态为:
|
||||
|
||||
```json
|
||||
{"version":1,"keys":[{"key_id":"brain-a","producer_id":"brain-main","secret_base64url":"<external-secret>"}]}
|
||||
```
|
||||
|
||||
Bell 的永久 `event_ingress_receipts` 以 `(producer_id, source_event_id)` 唯一并保存 canonical candidate SHA-256。相同 hash 重投返回原 event ID,不同 hash 返回 `409 source_event_conflict`;事件、来源收据和成功 nonce 响应在一个 PostgreSQL 事务中提交。`event_ingress_nonces` 只是 10 分钟防重表,可清理;来源收据和事件不自动删除、不可更新。Brain 必须先把 candidate 写入仓库外 SQLite Outbox,只有 `accepted/duplicate` 可标记 delivered;401、网络和 5xx 以 1~300 秒退避重试,稳定 4xx 进入 dead letter,最多 100 次、最多 10,000 条待投递。
|
||||
|
||||
数字 `tenant_id/site_id/device_id` 通过 Bell 所有的 `event_ingress_bindings` 映射到当前 Bell Site/Area 和 Sense Device 逻辑 ID。运行时只读取 Sense 设备的 ID、Area 与 modality 列,不获得 endpoint、credential、profile token 或 path;绑定缺失/禁用、设备或 Area 不一致、删除、非视频或 `capture_policy != video_allowed` 均失败关闭。首版绑定只由受控 migration/admin SQL 配置,没有公共 CRUD。
|
||||
|
||||
## Bell 回环规则与 Alert 控制台
|
||||
|
||||
T-020 的 [`bell-alert-console-v1.openapi.json`](bell-alert-console-v1.openapi.json) 只冻结工程 API,不占用未来 Bell 公共 `/api/v1`。整个 `bell-api` 必须显式监听回环,规则、token、tenant/Site 和 actor 上下文均从仓库外启动配置读取;请求不得自报这些上下文。列表按 `created_at DESC,id DESC` 稳定分页,默认 16、最大 100。
|
||||
|
||||
规则文件 v1 只支持精确 `event_kind`、最小严重度、可选 Site、启用状态和生效时间。相同 `(tenant_id,rule_key)` 的配置按 canonical SHA-256 幂等发布;变化生成不可变新版本。Event 无论有无规则/是否命中都会留下 durable sweep;每个被考虑版本写 matched/no_match evaluation,命中时 Bell 创建独立 `alt_` ULID 和 Event 多对多关联。Event、规则版本、evaluation、Alert 身份、关联和 transition 均只追加。
|
||||
|
||||
Alert 状态机为 `open → acknowledged → closed`。ack 由数据库事务与 advisory lock 串行化,只有首个竞争者成功;后到者返回 `409 already_acknowledged` 和实际首位 actor/time,不覆盖历史。close 只允许从 acknowledged 进入。所有命令要求 8~128 字符 `Idempotency-Key`;同租户同 key/同命令永久重放原 status/body,同 key/不同命令返回 `409 idempotency_conflict`。当前详情固定返回 `evidence_status=not_enabled`、`delivery_status=not_enabled`,不得伪造切片、升级或送达事实。
|
||||
|
||||
## 兼容与废弃
|
||||
|
||||
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
|
||||
@@ -87,9 +111,13 @@ Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SK
|
||||
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-audit-relay-v1.openapi.json | Out-Null
|
||||
python -m json.tool docs/contracts/brain-event-ingress-v1.openapi.json | Out-Null
|
||||
python -m json.tool docs/contracts/bell-alert-console-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_implementation.py"
|
||||
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
|
||||
python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py"
|
||||
python -m unittest discover -s tests -p "test_bell_alert_contract.py"
|
||||
```
|
||||
|
||||
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "YoVision Bell loopback alert console",
|
||||
"version": "1.0.0",
|
||||
"description": "Engineering-only, loopback API for the Event to Rule to Alert to acknowledgement vertical slice. Tenant, site and actor context are server-side configuration, never client input."
|
||||
},
|
||||
"servers": [{"url": "http://127.0.0.1:{port}/bell-console/api/v1", "variables": {"port": {"default": "8081"}}}],
|
||||
"security": [{"consoleBearer": []}],
|
||||
"paths": {
|
||||
"/alerts": {
|
||||
"get": {
|
||||
"operationId": "listAlerts",
|
||||
"parameters": [
|
||||
{"name": "state", "in": "query", "schema": {"$ref": "#/components/schemas/AlertState"}},
|
||||
{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 16}},
|
||||
{"name": "cursor", "in": "query", "schema": {"$ref": "#/components/schemas/AlertId"}}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Stable newest-first page", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertPage"}}}},
|
||||
"400": {"$ref": "#/components/responses/BadRequest"},
|
||||
"401": {"$ref": "#/components/responses/Unauthorized"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/alerts/{alert_id}": {
|
||||
"get": {
|
||||
"operationId": "getAlert",
|
||||
"parameters": [{"$ref": "#/components/parameters/AlertId"}],
|
||||
"responses": {
|
||||
"200": {"description": "Alert, related event facts and append-only transitions", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertDetail"}}}},
|
||||
"401": {"$ref": "#/components/responses/Unauthorized"},
|
||||
"404": {"$ref": "#/components/responses/NotFound"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/alerts/{alert_id}:ack": {
|
||||
"post": {
|
||||
"operationId": "acknowledgeAlert",
|
||||
"parameters": [{"$ref": "#/components/parameters/AlertId"}, {"$ref": "#/components/parameters/IdempotencyKey"}],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandBody"}}}},
|
||||
"responses": {
|
||||
"200": {"description": "First acknowledgement or exact idempotent replay", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
|
||||
"409": {"description": "Already acknowledged (including actual first actor/time), or reused key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
|
||||
"401": {"$ref": "#/components/responses/Unauthorized"},
|
||||
"404": {"$ref": "#/components/responses/NotFound"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/alerts/{alert_id}:close": {
|
||||
"post": {
|
||||
"operationId": "closeAlert",
|
||||
"parameters": [{"$ref": "#/components/parameters/AlertId"}, {"$ref": "#/components/parameters/IdempotencyKey"}],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandBody"}}}},
|
||||
"responses": {
|
||||
"200": {"description": "Close after acknowledgement or exact idempotent replay", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
|
||||
"409": {"description": "Acknowledgement required, already closed, or reused key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
|
||||
"401": {"$ref": "#/components/responses/Unauthorized"},
|
||||
"404": {"$ref": "#/components/responses/NotFound"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {"consoleBearer": {"type": "http", "scheme": "bearer", "description": "External engineering-console token; never persisted in browser storage."}},
|
||||
"parameters": {
|
||||
"AlertId": {"name": "alert_id", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/AlertId"}},
|
||||
"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": true, "schema": {"type": "string", "minLength": 8, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {"description": "Invalid bounded input", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"Unauthorized": {"description": "Missing or invalid console token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"NotFound": {"description": "Alert absent from configured tenant/site", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
|
||||
},
|
||||
"schemas": {
|
||||
"AlertId": {"type": "string", "pattern": "^alt_[0-9A-HJKMNP-TV-Z]{26}$"},
|
||||
"AlertState": {"type": "string", "enum": ["open", "acknowledged", "closed"]},
|
||||
"Severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
|
||||
"AlertSummary": {"type": "object", "additionalProperties": false, "required": ["id", "severity", "title", "state", "rule_key", "rule_version", "created_at"], "properties": {"id": {"$ref": "#/components/schemas/AlertId"}, "severity": {"$ref": "#/components/schemas/Severity"}, "title": {"type": "string", "minLength": 1, "maxLength": 120}, "state": {"$ref": "#/components/schemas/AlertState"}, "rule_key": {"type": "string"}, "rule_version": {"type": "integer", "minimum": 1}, "created_at": {"type": "string", "format": "date-time"}}},
|
||||
"AlertPage": {"type": "object", "additionalProperties": false, "required": ["items", "next_cursor"], "properties": {"items": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/AlertSummary"}}, "next_cursor": {"oneOf": [{"$ref": "#/components/schemas/AlertId"}, {"type": "null"}]}}},
|
||||
"EventRef": {"type": "object", "additionalProperties": false, "required": ["id", "device_id", "kind", "severity", "occurred_at"], "properties": {"id": {"type": "string", "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"}, "device_id": {"type": "integer", "minimum": 1}, "kind": {"type": "string"}, "severity": {"$ref": "#/components/schemas/Severity"}, "occurred_at": {"type": "string", "format": "date-time"}}},
|
||||
"Transition": {"type": "object", "additionalProperties": false, "required": ["sequence", "from_state", "to_state", "actor_ref", "note", "occurred_at"], "properties": {"sequence": {"type": "integer", "minimum": 1}, "from_state": {"oneOf": [{"$ref": "#/components/schemas/AlertState"}, {"type": "null"}]}, "to_state": {"$ref": "#/components/schemas/AlertState"}, "actor_ref": {"type": "string"}, "note": {"oneOf": [{"type": "string", "maxLength": 500}, {"type": "null"}]}, "occurred_at": {"type": "string", "format": "date-time"}}},
|
||||
"AlertDetail": {"allOf": [{"$ref": "#/components/schemas/AlertSummary"}, {"type": "object", "additionalProperties": false, "required": ["events", "transitions", "evidence_status", "delivery_status"], "properties": {"events": {"type": "array", "items": {"$ref": "#/components/schemas/EventRef"}}, "transitions": {"type": "array", "items": {"$ref": "#/components/schemas/Transition"}}, "evidence_status": {"const": "not_enabled"}, "delivery_status": {"const": "not_enabled"}}}]},
|
||||
"CommandBody": {"type": "object", "additionalProperties": false, "properties": {"note": {"oneOf": [{"type": "string", "maxLength": 500}, {"type": "null"}]}}},
|
||||
"CommandResponse": {"type": "object", "additionalProperties": false, "required": ["alert_id", "state", "actor_ref", "occurred_at"], "properties": {"alert_id": {"$ref": "#/components/schemas/AlertId"}, "state": {"$ref": "#/components/schemas/AlertState"}, "actor_ref": {"type": "string"}, "occurred_at": {"type": "string", "format": "date-time"}, "code": {"type": "string", "enum": ["already_acknowledged", "acknowledgement_required", "already_closed", "invalid_state"]}}},
|
||||
"Error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string"}, "message": {"type": "string"}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "YoVision Brain Event Ingress",
|
||||
"version": "1.0.0",
|
||||
"description": "Internal single-event transport. The candidate is the frozen event v0.1 object with only the Bell-owned id omitted."
|
||||
},
|
||||
"paths": {
|
||||
"/internal/v1/event-candidates": {
|
||||
"post": {
|
||||
"operationId": "ingestBrainEventCandidate",
|
||||
"summary": "Validate and persist one Brain event candidate",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/KeyId"},
|
||||
{"$ref": "#/components/parameters/Timestamp"},
|
||||
{"$ref": "#/components/parameters/Nonce"},
|
||||
{"$ref": "#/components/parameters/Signature"}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/IngressRequest"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {"description": "The same producer/source candidate was already stored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngressResponse"}}}},
|
||||
"201": {"description": "Candidate accepted and stored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngressResponse"}}}},
|
||||
"400": {"$ref": "#/components/responses/Error"},
|
||||
"401": {"$ref": "#/components/responses/Error"},
|
||||
"403": {"$ref": "#/components/responses/Error"},
|
||||
"409": {"$ref": "#/components/responses/Error"},
|
||||
"413": {"$ref": "#/components/responses/Error"},
|
||||
"422": {"$ref": "#/components/responses/Error"},
|
||||
"503": {"$ref": "#/components/responses/Error"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"KeyId": {"name": "X-YoVision-Key-Id", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}},
|
||||
"Timestamp": {"name": "X-YoVision-Timestamp", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[0-9]{10,}$"}},
|
||||
"Nonce": {"name": "X-YoVision-Nonce", "in": "header", "required": true, "schema": {"type": "string", "minLength": 22, "maxLength": 64}},
|
||||
"Signature": {"name": "X-YoVision-Signature", "in": "header", "required": true, "schema": {"type": "string", "minLength": 43, "maxLength": 43}}
|
||||
},
|
||||
"schemas": {
|
||||
"IngressRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "producer_id", "candidate"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"},
|
||||
"candidate": {"$ref": "#/components/schemas/EventV01Candidate"}
|
||||
}
|
||||
},
|
||||
"EventV01Candidate": {
|
||||
"description": "Exact docs/raw/contracts/event-v0.1.schema.json event object with the required Bell-owned id field removed; nested shapes and semantic assertions remain authoritative in that frozen contract.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "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": {"const": "0.1"},
|
||||
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "integer", "minimum": 1},
|
||||
"site_id": {"type": "integer", "minimum": 1},
|
||||
"device_id": {"type": "integer", "minimum": 1},
|
||||
"sensors": {"type": "array", "minItems": 1, "items": {"type": "object"}},
|
||||
"kind": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,63}$"},
|
||||
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
|
||||
"confidence": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
|
||||
"occurred_at": {"type": "string", "format": "date-time"},
|
||||
"detected_at": {"type": "string", "format": "date-time"},
|
||||
"latency_seconds": {"type": "number", "minimum": 0},
|
||||
"config_version": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"rule": {"type": ["object", "null"]},
|
||||
"subject": {"type": "object"},
|
||||
"observation": {"type": ["object", "null"]},
|
||||
"evidence": {"type": "object"},
|
||||
"dedup_key": {"type": ["string", "null"]},
|
||||
"aggregated_into": {"type": ["string", "null"]},
|
||||
"outcome": {"type": "string"},
|
||||
"outcome_source": {"type": ["string", "null"]},
|
||||
"outcome_reason": {"type": ["string", "null"]},
|
||||
"diagnostics": {"type": ["object", "null"]},
|
||||
"ext": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"IngressResponse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "producer_id", "source_event_id", "event_id", "status"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"producer_id": {"type": "string"},
|
||||
"source_event_id": {"type": "string"},
|
||||
"event_id": {"type": "string", "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"},
|
||||
"status": {"type": "string", "enum": ["accepted", "duplicate"]}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"error": {"type": "string"},
|
||||
"detail_code": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"Error": {"description": "Stable machine-readable failure", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
},
|
||||
"x-yovision-final-event-schema": "docs/raw/contracts/event-v0.1.schema.json",
|
||||
"x-yovision-signature": {
|
||||
"algorithm": "HMAC-SHA256",
|
||||
"canonical_lines": ["method", "escaped_path", "unix_timestamp_seconds", "base64url_nonce", "lowercase_hex_sha256_body"],
|
||||
"clock_skew_seconds": 300,
|
||||
"nonce_receipt_ttl_seconds": 600,
|
||||
"minimum_secret_bytes": 32,
|
||||
"key_document_shape": {"version": 1, "keys": [{"key_id": "brain-a", "producer_id": "brain-main", "secret_base64url": "external-secret"}]}
|
||||
},
|
||||
"x-yovision-delivery": {
|
||||
"maximum_body_bytes": 1048576,
|
||||
"deadline_seconds": 10,
|
||||
"initial_retry_seconds": 1,
|
||||
"maximum_retry_seconds": 300,
|
||||
"maximum_attempts": 100,
|
||||
"maximum_queued": 10000,
|
||||
"success_statuses": ["accepted", "duplicate"],
|
||||
"retryable_http": [401, 500, 502, 503, 504]
|
||||
},
|
||||
"x-yovision-idempotency": {
|
||||
"source_scope": ["producer_id", "source_event_id"],
|
||||
"same_candidate": "return duplicate with the original Bell event_id",
|
||||
"different_candidate": "409 source_event_conflict",
|
||||
"bell_owns_event_id": true
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -4,26 +4,30 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
|
||||
- 生产代码: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 事件 ingress、公共管理服务/JWT、规则/Alert 或 Web/H5。
|
||||
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路匿名区域事件工程原型、Brain→Bell 可靠事件 ingress 及 Bell 规则→Alert→ack/close 工程纵切。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
|
||||
- 生产代码: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 审计 relay、默认关闭的 Brain 事件 ingress、规则版本/evaluation/Alert 状态机和回环工程值班台。Brain 已包含单路 source/detector/track/zone-entry、回环可视化页与仓库外 SQLite 事件 Outbox,但仍没有生产模型/GPU pipeline、公共管理服务/JWT、证据切片、升级/通知或正式 Web/H5。
|
||||
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
|
||||
|
||||
## 仓库现实
|
||||
|
||||
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 仍只有目录占位。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
|
||||
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件/Alert 域 Go module、最小 `cmd/bell-api` 两条内部 ingress 和嵌入式 `/bell-console/`;`Brain/` 已有 Python 3.10 单路工程原型、30 项单元/HTTP/UI/Outbox 测试和 `docs/design/brain/index.html` 自包含页面。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
|
||||
- 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-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
|
||||
- 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 当成已上线入口。
|
||||
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`019` migration 可重放;Sense Outbox fencing、Bell event/global-audit/Brain-ingress/Alert repository、nonce/永久来源与命令收据、8 路并发 evaluation/ack、权限和不可变性测试通过后自动清理,现有 `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 与六项语义断言。T-019 已为该 factory 增加内部网络入口和生产隐私 resolver;公共 API、JWT/OIDC 仍未冻结。
|
||||
- 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。
|
||||
- T-019 冻结 `brain-event-ingress-v1`:单事件 1 MiB/10 秒、producer-bound HMAC、300 秒时钟窗、600 秒 nonce、SQLite WAL Outbox、1~300 秒退避、100 次/10,000 条边界。Bell 以受控绑定复查当前 Site/Area/Sense Device,永久 `(producer_id,source_event_id)` 收据与事件同事务提交;同 candidate 跨重启返回原 Bell ID,不同 candidate 稳定冲突。
|
||||
- T-020 冻结 `bell-alert-console-v1` 回环工程契约:规则配置从仓库外绝对 JSON 加载并按 canonical hash 幂等发布不可变版本;Event durable sweep、evaluation、`alt_` Alert、Event 关联和 `open → acknowledged → closed` transition 只追加。首次 ack 获胜,后到者返回实际 actor/time;8~128 字符命令 key 由永久收据跨重启重放。列表默认 16、最大 100;值班台无 CDN/浏览器持久存储,明确显示证据及升级/投递尚未启用。
|
||||
- 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-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。
|
||||
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
||||
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
||||
- `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 尚未实现。
|
||||
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json`、`brain-event-ingress-v1.openapi.json` 和回环 `bell-alert-console-v1.openapi.json` 已分别由 T-016/T-019/T-020 实现。两条内部 ingress 使用独立 key,Alert 工程台使用独立 token/服务端上下文;Bell 公共管理服务与 JWT/OIDC 尚未实现。
|
||||
- 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 模板和治理脚本已接入。
|
||||
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
||||
@@ -45,7 +49,7 @@ Windows:
|
||||
go -C Sense run ./cmd/sense-api
|
||||
```
|
||||
|
||||
Bell 事件域和内部审计 receiver 验证:
|
||||
Bell 事件、两条内部 ingress 与 Alert 工程纵切验证:
|
||||
|
||||
```powershell
|
||||
go -C Bell test ./...
|
||||
@@ -53,6 +57,15 @@ 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
|
||||
# 可选:追加 --event-ingress-config <仓库外绝对路径>
|
||||
```
|
||||
|
||||
跨平台直接验证:
|
||||
|
||||
```powershell
|
||||
@@ -86,17 +99,18 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
|
||||
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
|
||||
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
||||
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
||||
- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
|
||||
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-016 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/relay key 文件、TLS 证书与备份方案。
|
||||
- 生产 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-020 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/两类 ingress key、Brain Outbox、Bell 规则/token/上下文、TLS 证书与备份方案。
|
||||
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
||||
|
||||
## 下一步
|
||||
|
||||
客户网络仍未提供,T-013 WireGuard 继续后置。T-015/T-016 已分别落地 Bell 不可变事件存储和 Sense 全局审计 relay;下一项应独立冻结 Brain→Bell 业务事件 ingress,不把规则/Alert 或公共管理 API 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014~T-016 不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。
|
||||
客户网络仍未提供,T-013 WireGuard 继续后置。T-020 已完成本地 Event→Rule→Alert→ack/close 最小可见纵切;下一项建议创建 T-021,独立建立 Bell→Sense 异步证据/pre-roll 切片,冻结请求授权、幂等状态、对象引用和生命周期,不改 Alert 状态机。其后再拆升级计时/排班解析/双路径通知。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。
|
||||
|
||||
## 已知风险
|
||||
|
||||
- M0 临时 NVR 容易被误当生产基线,必须持续隔离。
|
||||
- 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。
|
||||
- 未经真实硬件压测不能给出 GPU 路数承诺。
|
||||
- 合成 fixture 和 HOG 演示框容易被误读为生产 AI 效果;页面、日志和交付说明必须持续显示“非模型输出/非生产模型”,效果验收必须使用冻结模型、数据集和目标硬件。
|
||||
- Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
<!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; }
|
||||
.ingress-summary { padding: 11px 12px; display: grid; gap: 8px; border-bottom: 1px solid var(--line); background: rgba(56, 189, 248, .04); }
|
||||
.ingress-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.ingress-head h3 { margin: 0; font-size: 13px; }
|
||||
.ingress-state { min-height: 28px; padding: 3px 9px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 12px; font-weight: 750; }
|
||||
.status-dot.error { background: var(--danger); box-shadow: 0 0 0 4px rgba(251, 113, 133, .12); }
|
||||
.ingress-copy { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.ingress-counts { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
.ingress-counts[hidden] { display: none; }
|
||||
.ingress-counts div { min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); }
|
||||
.ingress-counts dt { color: var(--muted); font-size: 11px; }
|
||||
.ingress-counts dd { margin: 2px 0 0; color: var(--text); font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.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-delivery { margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||
.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; }
|
||||
.ingress-head { align-items: flex-start; flex-direction: column; }
|
||||
.ingress-counts { grid-template-columns: 1fr 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 到区域进入候选事实的工程闭环。Brain 先持久化到 Outbox,再由 Bell 分配平台事件 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>内存环仅用于观察;可靠性以 Outbox 状态为准</p></div>
|
||||
<span id="event-count" class="chip"><strong>0</strong> 项</span>
|
||||
</div>
|
||||
<section id="event-ingress" class="ingress-summary" aria-labelledby="event-ingress-title" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="ingress-head">
|
||||
<h3 id="event-ingress-title">Bell 可靠投递</h3>
|
||||
<span class="ingress-state"><i id="ingress-dot" class="status-dot" aria-hidden="true"></i><span id="ingress-label">未启用</span></span>
|
||||
</div>
|
||||
<p id="ingress-copy" class="ingress-copy">可靠投递未启用;候选事实不会被标记为 Bell 已接收。</p>
|
||||
<dl id="ingress-counts" class="ingress-counts" aria-label="Outbox 投递计数" hidden>
|
||||
<div><dt>待投递</dt><dd id="ingress-queued">0</dd></div>
|
||||
<div><dt>投递中</dt><dd id="ingress-delivering">0</dd></div>
|
||||
<div><dt>已送达</dt><dd id="ingress-delivered">0</dd></div>
|
||||
<div><dt>死信</dt><dd id="ingress-dead-letter">0</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<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;
|
||||
let ingressSignature = "";
|
||||
|
||||
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,
|
||||
event_ingress: {enabled: false},
|
||||
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 delivery = document.createElement("div"); delivery.className = "event-delivery"; delivery.textContent = event.delivery_status === "outbox_persisted" ? "交接:Outbox 已持久化" : "交接:仅内存观察";
|
||||
const code = document.createElement("code"); code.textContent = event.source_event_id;
|
||||
article.append(head, time, delivery, code); return article;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderIngress(value = {enabled: false}) {
|
||||
const enabled = value.enabled === true;
|
||||
const queued = Number(value.queued) || 0;
|
||||
const delivering = Number(value.delivering) || 0;
|
||||
const delivered = Number(value.delivered) || 0;
|
||||
const deadLetter = Number(value.dead_letter) || 0;
|
||||
const errorCode = typeof value.last_error_code === "string" ? value.last_error_code : "";
|
||||
const nextSignature = JSON.stringify([enabled, queued, delivering, delivered, deadLetter, errorCode]);
|
||||
if (nextSignature === ingressSignature) return;
|
||||
ingressSignature = nextSignature;
|
||||
const dot = $("ingress-dot");
|
||||
const counts = $("ingress-counts");
|
||||
counts.hidden = !enabled;
|
||||
$("ingress-queued").textContent = String(queued);
|
||||
$("ingress-delivering").textContent = String(delivering);
|
||||
$("ingress-delivered").textContent = String(delivered);
|
||||
$("ingress-dead-letter").textContent = String(deadLetter);
|
||||
if (!enabled) {
|
||||
dot.className = "status-dot";
|
||||
$("ingress-label").textContent = "未启用";
|
||||
$("ingress-copy").textContent = "可靠投递未启用;候选事实不会被标记为 Bell 已接收。";
|
||||
} else if (deadLetter > 0) {
|
||||
dot.className = "status-dot error";
|
||||
$("ingress-label").textContent = "需要处理";
|
||||
$("ingress-copy").textContent = `存在死信${errorCode ? `(${errorCode})` : ""};请检查 Bell 绑定、鉴权或候选字段后再处理。`;
|
||||
} else if (errorCode || queued > 0 || delivering > 0) {
|
||||
dot.className = "status-dot warning";
|
||||
$("ingress-label").textContent = errorCode ? "正在重试" : "投递中";
|
||||
$("ingress-copy").textContent = errorCode ? `Outbox 已保留候选并自动重试(${errorCode})。` : "候选已持久化到本地 Outbox,正在等待 Bell 确认。";
|
||||
} else {
|
||||
dot.className = "status-dot online";
|
||||
$("ingress-label").textContent = "投递正常";
|
||||
$("ingress-copy").textContent = "Outbox 可用;Bell 返回 accepted 或 duplicate 后才计为已送达。";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
renderIngress(next.event_ingress);
|
||||
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>
|
||||
@@ -47,6 +47,7 @@
|
||||
| kind | 含义 | 默认 severity | 产出方 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `fall` | 人员摔倒确认 | `high` | silver_pose FSM 进入 CONFIRMED | ✅ 已上线 |
|
||||
| `zone_entry` | 匿名 track 从区域外进入区域内 | `medium` | Brain 区域进入判定 | ✅ T-019 ingress 已接入 |
|
||||
|
||||
## 4. silver_pose → 契约 映射表
|
||||
|
||||
|
||||
+11
-1
@@ -20,6 +20,16 @@
|
||||
|
||||
这些是页面职责占位,不等于已冻结 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-console/` | Bell T-020 回环工程服务 | 默认关闭;展示真实 Alert、关联 Event、规则版本和 append-only transition,支持首次 ack 与确认后关闭。整个 Bell 服务必须显式回环监听;token 仅驻留页面内存,tenant/Site/actor 取服务端上下文;证据、升级和投递未启用时必须明确降级 |
|
||||
|
||||
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页、T-018 Sense 控制台或 T-020 Bell 工程值班台暴露到非可信网络或嵌入客户公共系统。
|
||||
|
||||
## App 候选导航
|
||||
|
||||
- 预警:待确认与升级中的事件。
|
||||
@@ -44,6 +54,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审
|
||||
|
||||
## 组件归属
|
||||
|
||||
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain。
|
||||
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页。T-020 也使用 `Bell/web/`,但只是自包含工程台,不冻结正式前端框架或公共路由。
|
||||
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
|
||||
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
|
||||
|
||||
+10
-6
@@ -3,12 +3,12 @@ id: T-017
|
||||
title: 建立 Brain 单路匿名区域事件可视化工程原型
|
||||
phase: 3
|
||||
deps: [T-016]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-11
|
||||
issue: null
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
issue: 59
|
||||
context_ref: 2e047379228a30841da7ceed8cfb86275b3c330e
|
||||
claim_branch: claims/T-017
|
||||
work_branch: agent/codex/T-017
|
||||
write_paths:
|
||||
- docs/tasks/T-017.md
|
||||
- Brain/
|
||||
@@ -74,4 +74,8 @@ Sense 已能稳定提供 MediaMTX 路径,Bell 已有不可变事件存储,
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 2026-08-11:按用户指定顺序建立 T-017;实现尚未开始。主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
|
||||
- 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。
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
id: T-019
|
||||
title: 建立 Brain 到 Bell 的可靠事件入站链路
|
||||
phase: 3
|
||||
deps: [T-015, T-017]
|
||||
status: DONE
|
||||
created: 2026-08-11
|
||||
issue: 67
|
||||
context_ref: b7fe44eeb0caf8d41abef365ec2100c74373321f
|
||||
claim_branch: claims/T-019
|
||||
work_branch: agent/codex/T-019
|
||||
write_paths:
|
||||
- docs/tasks/T-019.md
|
||||
- docs/contracts/
|
||||
- docs/raw/contracts/README.md
|
||||
- Brain/
|
||||
- docs/design/brain/index.html
|
||||
- Bell/
|
||||
- deploy/postgres/
|
||||
- scripts/test_postgres.ps1
|
||||
- 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
|
||||
- tests/test_brain_event_ingress_contract.py
|
||||
- tests/test_postgres_contract.py
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-017 已能产生匿名区域事件候选,T-015 已能在 Bell 内部校验并保存不可变事件,但两者之间没有可部署的传输、认证、逻辑身份映射或跨重启幂等确认。直接把 T-017 的 100 项内存事件环当作投递队列,会在进程退出、网络中断或 Bell 已接收但 Brain 尚未确认时丢失或重复事件;让 Brain 自报 Bell 平台 `id` 又会破坏冻结事件契约的所有权。
|
||||
|
||||
本任务建立一个默认关闭、可本地联调的可靠纵切:Brain 把完整 v0.1 候选先写入 SQLite Outbox,再通过内部 HMAC HTTP 端点投递;Bell 在一个 PostgreSQL 事务中验证身份、Area 隐私策略、候选语义并保存事件与永久来源收据。该纵切为后续证据切片、规则、Alert 与 ack 提供可信事件入口,但不提前实现这些能力。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-005 的“判定结果形成事件”基础链路;不在本任务内实现业务预警、处置或误报反馈。
|
||||
- 交互清单:不适用。本任务是内部事件 ingress 和后台可靠投递,不新增客户页面或公共路由。
|
||||
- 相关页面 / 路由:新增内部 `POST /internal/v1/event-candidates`;T-017 `/brain-demo` 只增加脱敏投递状态,不把内部地址、key 或 payload 暴露给页面。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 `docs/contracts/brain-event-ingress-v1.openapi.json` 冻结单事件 envelope、响应、稳定错误码和 HMAC 规则。请求包含 `schema_version=1`、`producer_id` 与不含 Bell `id` 的完整 event v0.1 candidate;Brain 提供 `source_event_id`,Bell 生成并返回平台 `evt_` ULID。正文上限 1 MiB,deadline 10 秒。
|
||||
同步把 T-017 已产生的 `zone_entry` 登记到 event v0.1 kind 注册表;不修改冻结 JSON Schema 或既有 kind 语义。
|
||||
2. 复用 T-016 已验证的 HMAC-SHA256 canonical 形式:method、path、Unix 秒、随机 nonce、body SHA-256 以换行连接;使用独立的仓库外 key 文件,并把每个 key 绑定到一个 `producer_id`。允许 300 秒时钟偏差,nonce 防重收据保留 600 秒。回环可用 HTTP,非回环必须 HTTPS;事件 key 与审计 relay key 不混用。
|
||||
3. 新增 Bell 所有的 `event_ingress_bindings`,把 `(producer_id, tenant_id, site_id, device_id)` 数字事件身份绑定到现有 Bell tenant/site/device/area 逻辑身份和 `video` 模态。入站必须 fail closed:绑定缺失、禁用、站点/Area 已删除、设备或 Area 归属不一致、非视频或 `capture_policy != video_allowed` 均拒绝。首版只提供受控 SQL 配置方式,不新增公共绑定管理 API。
|
||||
4. 新增永久 append-only `event_ingress_receipts`,以 `(producer_id, source_event_id)` 唯一,保存 canonical candidate SHA-256 与 Bell event ID;另用短期 `event_ingress_nonces` 防请求重放。Bell 用单事务和事务级 advisory lock 完成 nonce、来源收据、事件创建与响应:同来源且同 payload 返回原 Bell ID 和 `duplicate`,同来源不同 payload 返回 `source_event_conflict`,不得产生第二条事件。
|
||||
5. `bell-api` 增加默认关闭的事件 ingress 配置和独立 handler。handler 使用现有 event factory、数据库隐私策略与证据授权校验;候选无法通过 schema、代码级语义或隐私断言时返回不可重试 4xx,数据库暂时失败返回可重试 503。Bell 事件和来源收据无 UPDATE/DELETE 权限;只有 nonce 表允许按 TTL 清理。
|
||||
6. Brain 使用 Python 标准库实现 v0.1 mapper、SQLite Outbox、HTTP client 和 worker,不新增生产 ML 或消息总线依赖。启用配置只从仓库外绝对路径 JSON 读取,secret 另从仓库外文件读取;SQLite 文件也必须是仓库外绝对路径。默认关闭时 T-017 行为不变。
|
||||
7. mapper 将 `EventCandidate` 转为完整 v0.1 candidate,不发送 `source_ref`、摄像头 URI、凭据或 Bell 平台 `id`。fixture 明确映射为 `outcome=test/outcome_source=auto`,真实源先保持 `outcome=unknown`;主传感器固定为已绑定的视频设备,区域、track、配置版本和候选版本只进入契约允许字段。
|
||||
8. Outbox 先持久化后暴露成功,WAL 模式,有界待处理量 10,000、单 payload 1 MiB、1~300 秒指数退避、最多 100 次尝试。`accepted/duplicate` 标记 delivered;稳定校验/冲突进入 dead letter;网络、5xx 和认证故障重试直至预算耗尽。Bell 成功后 Brain 在本地 ack 前崩溃,重启重投必须得到同一个 Bell ID。
|
||||
9. 添加 Python mapper/outbox/client/worker/运行时测试、Go auth/handler/store 测试、OpenAPI 静态契约测试,以及隔离 PostgreSQL migration replay、最小权限、并发幂等和重启重试联调。代码图 MCP 本轮不可用时,将定向读取/`rg` 的降级和实际验证结果记录在执行证据中。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:正文与单 Outbox payload 最大 1 MiB;HTTP deadline 10 秒;时钟偏差 300 秒;nonce 保留至少 600 秒;Brain 待投递上限 10,000,重试 1~300 秒、最多 100 次。默认站点 16 路、可扩展 128 路不变,这些投递阈值不得成为设备路数硬上限。
|
||||
- 判定式 / 状态转换:`queued -> delivering -> delivered | dead_letter`;只有 Bell `accepted/duplicate` 可进入 delivered。`(producer_id, source_event_id, candidate_hash)` 相同返回原 Bell ID;来源相同但 hash 不同永久冲突。事件、来源收据 append-only,Bell 平台 ID 只能由 Bell 生成。
|
||||
- 安全边界:key、连接串、Brain 配置、SQLite Outbox、摄像头 URI/凭据和真实事件数据都在仓库外;非回环必须 HTTPS;producer 不能自报未绑定身份;未知/删除/策略拒绝必须 fail closed。响应、日志、页面和提交物不得回显 secret、完整 URI、原始 payload 或客户信息。
|
||||
- 既有契约:最终事件必须严格符合 `docs/raw/contracts/event-v0.1.schema.json` 与语义 README;未知顶层字段拒绝,只能通过 `ext` 扩展。Sense/Bell 现有逻辑身份、Area `capture_policy`、T-015 Bell ULID 与不可变存储、T-016 审计 relay 均保持兼容;事件 ingress 使用独立端点、表和 key。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain`、`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`、`python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py" -v`、`./scripts/test_postgres.ps1`。
|
||||
- 完整门禁:运行 `./init.ps1`、三项文档治理基线与 `git diff --check`。PostgreSQL migration 必须从空库执行两遍且旧 migration 指纹不漂移;并发同源投递只落一条 event 和一条永久 receipt,运行角色不能更新/删除 event 或 receipt。
|
||||
- 人工 / 设备验收:不需要新增摄像头或目标 GPU。使用 T-017 synthetic fixture 和本机隔离 PostgreSQL 完成端到端 smoke:首次返回 accepted、模拟 ack 前崩溃后重投返回 duplicate 且 Bell ID 相同;伪造签名、过期时间、重放冲突、未绑定身份和 Area 隐私拒绝均按契约失败。结果不构成真实多路、算法效果或生产 SLA。
|
||||
- 构建产物:Bell `bell-api` 可执行程序与 Brain 源码 worker;OpenAPI、migration 和仓库外配置/key 示例写入各 README。不得提交运行时数据库、真实 key 或事件样本。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不实现录像/证据切片、对象存储、业务规则、Alert、ack/升级、误报反馈、公共 JWT/OIDC、绑定管理 UI/API、消息总线、生产模型、GPU pipeline 或 64/128 路压测;不修改 `_reference/`、MiBeeNvr、MediaMTX 数据面或冻结 event v0.1 文件。T-016 审计链路保持独立。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:codex。
|
||||
- 唯一写入者:codex。
|
||||
- 委派:不启用。
|
||||
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
|
||||
|
||||
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-11 任务定义
|
||||
|
||||
- T-019 独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress;不复用 T-017 内存事件环,也不扩张到规则、Alert 或证据链。
|
||||
- 当前会话未暴露 codebase-memory MCP 图工具,代码发现按仓库规则降级为定向读取与 `rg`;任务定义前主分支 `./init.ps1` 基线通过:72 项根测试、19 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
|
||||
- 任务定义已合入默认分支并创建唯一 Gitea Issue #67;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
|
||||
|
||||
### 2026-08-11 领取与基线
|
||||
|
||||
- dispatcher `ila` 检查依赖和开放工单后,从默认分支 `b7fe44eeb0caf8d41abef365ec2100c74373321f` 创建并读回 `claims/T-019` 与 `agent/codex/T-019`;Issue #67 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。
|
||||
- T-007 保持 `status/waiting`,不构成活跃写路径预留;本任务不启用委派。
|
||||
|
||||
### 2026-08-11 实现与自动化验收
|
||||
|
||||
- 冻结并实现 `brain-event-ingress-v1` 单事件 envelope。Bell event key 独立于审计 key,并把 `key_id` 绑定到唯一 `producer_id`;两端共享 method/path/timestamp/nonce/body-hash 五行 HMAC-SHA256,允许 300 秒时钟偏差,正文上限 1 MiB,非回环必须 HTTPS。Brain client 禁用环境代理和重定向,避免内部事件被旁路转发。
|
||||
- PostgreSQL 新增可重放 `016`~`017`:Bell 所有的数字→逻辑身份绑定引用现有 Site/Area/Sense Device;运行时只能读取设备 ID、Area 与 modality 列,不能读取 endpoint、credential、profile token 或 path。永久来源收据和事件不可更新/删除,只有 10 分钟 nonce 表允许清理。
|
||||
- Bell 在相同事务中提交最终 event、`(producer_id,source_event_id,candidate_hash)` 永久收据和成功 nonce 响应;同来源/同 canonical candidate 返回原 Bell ID,不同 candidate 返回稳定冲突。隔离 PostgreSQL 测试以 8 个并发重投确认只生成 1 条 event/1 条来源收据,并验证 Area 改为 `non_imaging_only` 后新视频事件失败关闭。
|
||||
- Brain 新增完整 v0.1-minus-id mapper、WAL/`synchronous=FULL` SQLite Outbox、HMAC client 和 worker。Outbox 首次打开绑定 producer/tenant/site/device,禁止换身份复用旧队列;候选持久化成功后才进入页面内存环。fixture 显式映射为 `outcome=test/outcome_source=auto`,URI/凭据/`source_ref` 不出 Brain。队列执行 1~300 秒退避、100 次/10,000 条边界,`accepted/duplicate` 才 delivered,稳定 4xx 进入 dead letter;租约崩溃恢复与 Bell 成功后本地未 ack 的重投均有测试。
|
||||
- Brain 工程原型在既有“最近候选事实”卡片内展示脱敏 Outbox 状态与待投递/投递中/已送达/死信计数;以文字和状态点共同区分未启用、正常、重试和死信,且明确单条候选的“仅内存观察”或“Outbox 已持久化”。状态无变化时不重复触发 `aria-live`,页面不暴露 Bell 地址、key 文件或 SQLite 路径。
|
||||
- `zone_entry` 已登记到 event v0.1 kind 注册表;冻结 JSON Schema 未修改。Bell/Brain/部署 README、API、架构、技术栈和当前状态均同步,未扩张到证据、规则、Alert、公共认证或生产模型。
|
||||
- 最终门禁通过:`./init.ps1`(77 项根测试、30 项 Brain 测试及 Sense/Bell generate/test/vet/build);独立 `go -C Bell test/vet/build ./...`、Brain compileall、4 项跨语言 ingress 契约测试和三项治理校验;`./scripts/test_postgres.ps1` 在 PostgreSQL 17.10 临时集群将 `001`~`017` 重放两次并通过真实 repository、权限、并发幂等、隐私和不可变性验证,随机端口/临时目录已清理且现有 5432 listener 未改变;`git diff --check` 通过。
|
||||
- 本任务不需要真实摄像头或目标 GPU;synthetic fixture mapper、SQLite 崩溃恢复、真实本地 HTTP HMAC client、Bell handler 和真实 PostgreSQL consumer 的联合自动化证据满足本地验收。结果不构成算法效果、真实多路或生产 SLA。
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
id: T-020
|
||||
title: 建立 Bell 规则到 Alert 与 ack 的最小可见纵切
|
||||
phase: 3
|
||||
deps: [T-015, T-019]
|
||||
status: DONE
|
||||
created: 2026-08-11
|
||||
issue: 71
|
||||
context_ref: 477afa6ba2def34224f2dfeedc5b14421ec27650
|
||||
claim_branch: claims/T-020
|
||||
work_branch: agent/codex/T-020
|
||||
write_paths:
|
||||
- docs/tasks/T-020.md
|
||||
- Bell/
|
||||
- deploy/postgres/
|
||||
- scripts/test_postgres.ps1
|
||||
- tests/test_postgres_contract.py
|
||||
- tests/test_bell_alert_contract.py
|
||||
- docs/contracts/
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
- docs/api.md
|
||||
- docs/contracts/README.md
|
||||
- docs/current-state.md
|
||||
- docs/routes.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-019 已把 Brain 候选可靠地写成 Bell 不可变 Event,但 Bell 尚未消费 Event、记录规则版本、创建独立 Alert 或接受值班员 ack。现有 Bell 原型能说明目标交互,却没有可运行数据/API;因此客户仍看不到从匿名区域事件到“有人确认处理”的业务闭环。
|
||||
|
||||
本任务建立默认关闭、只允许回环联调的最小可见纵切:Bell 从不可变 Event 运行一个受限规则内核,为匹配事件幂等创建 Alert,使用 append-only 状态迁移实现首次 ack 竞争者获胜与确认后关闭,并通过自包含值班台展示真实 PostgreSQL 状态。它为后续升级链、双路径通知、证据切片和正式公共认证提供基础,但不提前伪造这些事实。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-003(处置业务预警)、US-005(配置规则与升级链中的规则版本边界)。
|
||||
- 交互清单:IX-005(预警到达)、IX-006(ack 竞争)、IX-007(只展示实际已有的升级/投递事实)、IX-008(Event/Alert 双向关联与证据降级)、IX-013(权限与隐私)。
|
||||
- 相关页面 / 路由:复用已确认的 `docs/design/bell/index.html` 值班台信息架构;新增默认关闭的回环工程路由 `/bell-console/` 与 `/bell-console/api/v1/*`。这些路由不是待冻结的 Bell 公共 `/api/v1`,不冻结最终前端框架。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 `docs/contracts/bell-alert-console-v1.openapi.json` 冻结回环工程 API:按状态分页列出 Alert、读取详情、`ack` 和 `close`。列表默认 16、最大 100,稳定按 `created_at DESC,id DESC`;tenant/Site/actor 只来自启动时的仓库外控制台上下文,不接受客户端自报。
|
||||
2. PostgreSQL 新增 append-only `rule_versions`、`event_rule_sweeps`、`rule_evaluations`、`alerts`、`alert_events`、`alert_transitions` 和 `alert_command_receipts`。Rule version、evaluation、Alert 身份、Event 关联、状态迁移与幂等收据均禁止 UPDATE/DELETE/TRUNCATE;当前状态由最新 transition 推导,不把 Event 和 Alert 合并。
|
||||
3. 首版规则内核只支持精确 `event_kind`、最小严重度、可选 Site 范围和生效时间。每个稳定 `rule_key` 的最新生效版本决定 enabled/disabled;配置从仓库外绝对 JSON 文件加载并按 canonical hash 幂等发布,不提供公共规则编辑 API,也不实现任意表达式或场景 DSL。
|
||||
4. 规则 worker 扫描尚无 sweep 的不可变 Event;在单个 PostgreSQL 事务内写 sweep、每条已考虑规则的 evaluation、匹配 Alert、初始 `open` transition 与 Event 关联。并发 worker 通过唯一键和事务级 advisory lock 收敛;无规则/未命中也写 durable sweep,避免无限重试。崩溃前未提交可重做,提交后不得生成重复 Alert。
|
||||
5. Alert ID 由 Bell 生成 `alt_` ULID。首版每个命中 Rule/Event 生成一个 Alert,但关系表按多对多建模;Alert 保存命中时的 rule version、标题与严重度快照,后续规则变化不改写历史。
|
||||
6. 状态机固定为 `open -> acknowledged -> closed`。`ack` 的首个成功事务固化 actor/时间;并发后到者返回 `409 already_acknowledged` 并带当前处置人和时间,不覆盖、不双成功。`close` 只允许从 acknowledged 进入 closed。写请求必须携带 8~128 字符 `Idempotency-Key`;同 key/同命令重放原响应,同 key/不同命令稳定冲突。
|
||||
7. `bell-api` 以 `BELL_ALERTS_ENABLED=true` 显式启用规则 worker;规则文件必须是仓库外绝对路径。值班台另以 `BELL_ALERT_CONSOLE_ENABLED=true` 启用,只允许整个 Bell HTTP 监听地址为显式回环,并要求仓库外 token 文件、正整数 tenant/Site 与受限 actor ref。两项默认关闭,控制台 token 只进入页面内存和 `no-store` 响应,不记录。
|
||||
8. `Bell/web/` 使用 Go `embed`、HTML/CSS/原生 JavaScript 实现,不增加前端依赖或 CDN。页面显示真实 Alert 队列、关联 Event、规则版本和 append-only 时间线;无证据时明确标注“证据切片尚未启用”,无升级/投递实现时不显示虚构倒计时或成功状态。ack/close 有 loading、成功、并发冲突、可重试错误与无权限反馈,颜色不是唯一状态表达;375px 与桌面均可操作。
|
||||
9. 增加 Go 域/handler/store 测试、静态 OpenAPI/UI 契约测试,以及隔离 PostgreSQL migration replay、最小权限、规则幂等、并发 evaluation、并发 ack、幂等重放和重启后状态恢复验证。当前会话无 codebase-memory 图工具,代码发现降级为定向读取和 `rg` 并记录在执行证据。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:Alert 列表默认 16、最大 100;16 不是业务上限,站点仍允许 1~128 路。规则文件和控制台 token 均限制大小;actor/rule/idempotency 字段有显式长度与字符集边界。worker 必须可取消且空闲轮询有界,不按设备或 tenant 创建 goroutine。
|
||||
- 判定式 / 状态转换:Rule 最新生效版本决定是否启用;严重度顺序固定为 `low < medium < high < critical`。每个 `(event_id, rule_version_id)` 最多一个 evaluation/Alert;`open -> acknowledged -> closed` 之外的迁移拒绝。首次 ack 获胜,后到者不得覆盖。Event、Rule version、Alert 事实和所有 transition 均 append-only。
|
||||
- 安全边界:功能默认关闭;工程控制台只允许显式回环绑定。token、规则配置、DSN 与客户数据均在仓库外;页面/API/日志不得返回 Event 原始 payload、流 URI、凭据、内部 DSN 或 token。tenant/Site/actor 不来自请求体、查询参数或可修改浏览器存储。
|
||||
- 既有契约:冻结 Event v0.1、T-019 ingress、Bell 事件 ULID/不可变性和 Sense/Bell schema 所有权保持不变。Alert 是独立实体并通过关系表关联 Event;本任务不修改 `docs/raw/contracts/event-v0.1.schema.json`。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`、`python -m unittest discover -s tests -p "test_bell_alert_contract.py" -v`、`python -m unittest discover -s tests -p "test_postgres_contract.py" -v`、`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17`。
|
||||
- 完整门禁:运行 `./init.ps1`、三项治理基线、`git diff --check`。PostgreSQL migration 必须从空库执行两遍;8 个并发 evaluator 对同 Event/Rule 只创建 1 个 Alert,8 个并发 ack 只有 1 个成功且其余观察同一处置人;同幂等 key 重放稳定,重启后状态/时间线不丢失;运行角色不能改写或删除任一规则/Alert 历史事实。
|
||||
- 人工 / 设备验收:不需要摄像头、GPU、客户网络或通知供应商。页面沿用已由产品确认的 Bell 原型信息架构;任务责任人在本地以合成 Event 检查 375px/桌面、键盘、加载/空态/成功/冲突/错误和 reduced-motion。该工程验收不等于正式 Web/H5、JWT/RBAC、算法效果或生产 SLA 验收。
|
||||
- 构建产物:Bell `bell-api`、嵌入式 `/bell-console/`、OpenAPI 和 PostgreSQL migration;不提交运行时规则文件、token、数据库或事件样本。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不实现证据/pre-roll 切片、MinIO/S3、升级计时/重启续跑、联系人/排班、通知 provider、短信/语音/Webhook、本地声光、静默、交接班、误报反馈、正式公共 JWT/OIDC/RBAC、正式 Bell 前端框架、规则编辑器/试运行/回滚、生产模型或多路容量压测;不修改 Sense、Brain、MediaMTX、`_reference/` 或冻结 Event v0.1。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:codex。
|
||||
- 唯一写入者:codex。
|
||||
- 委派:不启用。
|
||||
- Gitea:任务定义先合入默认分支;创建唯一 Issue 并完成双向映射后,由 dispatcher 串行领取并回填 `context_ref`、claim / 工作分支。
|
||||
|
||||
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-11 任务定义
|
||||
|
||||
- T-020 独立冻结规则扫描、Alert 身份/关系和首次 ack 竞争语义;证据、升级/投递和正式公共认证继续拆分,不用占位状态伪装完成。
|
||||
- 复用已确认 Bell 原型及 US-003/US-005、IX-005~IX-008/IX-013;工程值班台不冻结最终前端框架。
|
||||
- 当前会话未提供 codebase-memory MCP 图工具,按仓库规则降级为定向读取与 `rg`。任务定义前 `./init.ps1` 基线通过:77 项根测试、30 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
|
||||
- 任务定义已合入默认分支并创建唯一 Gitea Issue #71;本映射合入默认分支后才允许添加 `status/todo` 并由 dispatcher 分配。
|
||||
|
||||
### 2026-08-11 领取
|
||||
|
||||
- dispatcher `ila` 已在 Issue #71 核对依赖、写路径与活跃任务,并分配给 `codex`。
|
||||
- 基线提交:`477afa6ba2def34224f2dfeedc5b14421ec27650`;claim 分支:`claims/T-020`;工作分支:`agent/codex/T-020`。
|
||||
|
||||
### 2026-08-11 实现与自动化验收
|
||||
|
||||
- PostgreSQL 新增可重放 `018`~`019`,Bell schema 提升到 v6:规则版本、Event sweep、rule evaluation、Alert 身份、Alert/Event 多对多关系、transition 和命令收据全部只追加;`bell_runtime` 仅有 `SELECT/INSERT`,数据库 trigger 同时拒绝 owner 路径的意外 UPDATE/DELETE。Alert 对 `rule_evaluation_id` 唯一,数据库层保证一个 Event/Rule evaluation 最多一个 Alert。
|
||||
- 规则配置从仓库外绝对 JSON 加载,限制 256 KiB/256 条、拒绝未知字段和重复 key;相同 canonical SHA-256 幂等复用版本。单一可取消 worker 使用 durable sweep 与事务级 advisory lock,多实例对同 Event 收敛;无规则和 no-match 同样落 sweep,规则发布不追溯重算已经 sweep 的历史 Event。
|
||||
- Bell 生成 `alt_` ULID,命中 evaluation、Alert、Event 关联和初始 `open` transition 同事务提交。状态只允许 `open → acknowledged → closed`;永久命令收据按 tenant/idempotency key 重放原 status/body,同 key 改命令/actor/note 稳定冲突。并发后到的 ack 返回 `409 already_acknowledged` 和真实首位 actor/time,不覆盖历史。
|
||||
- 冻结并实现 `bell-alert-console-v1` 回环工程 API:列表默认 16/最大 100,tenant/Site/actor 只取启动上下文;Bearer token 来自仓库外文件并用 constant-time 比较。规则 worker 与控制台分别默认关闭;控制台要求整个 Bell 监听地址显式回环,不返回 Event payload、流 URI、凭据、DSN 或 token。
|
||||
- `Bell/web/` 使用 Go embed、自包含 HTML/CSS/原生 JavaScript,无 npm/CDN/浏览器持久存储。页面显示真实规则版本、关联 Event 和 append-only 时间线;证据、升级/投递明确为未启用。按 `ui-ux-pro-max` 检查落实骨架加载、空态/重试、冲突/无权限反馈、颜色+文字、44px target、键盘入口、`aria-live` 和 reduced-motion。
|
||||
- Edge headless + 脱敏回环 fixture 完成 1440×900 与 CDP 375×812 渲染检查:桌面双栏、移动单栏均可操作,375 viewport 的 `innerWidth/scrollWidth/bodyWidth` 均为 `375`。首轮浏览器 QA 发现 `.workspace{display:grid}` 覆盖 `hidden` 导致授权前泄露空壳布局,已增加全局 `[hidden]{display:none!important}` 并复验;临时 fixture、token 和截图均未进入仓库。
|
||||
- `./init.ps1` 最终通过:83 项根测试、30 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。独立 Bell test/vet/build、OpenAPI JSON、5 项 Alert 静态契约、11 项 PostgreSQL 静态契约和 `node --check Bell/web/assets/app.js` 通过。
|
||||
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 最终通过:PostgreSQL 17.10 随机回环临时集群将 `001`~`019` 连续重放两遍;真实 repository 验证 rule publish/no-match、8 个 evaluator 只生成 1 个目标 Alert、8 个并发 ack 只有 1 个成功、late ack 观察同一 actor/time、ack 前 close 拒绝、ack 后 close、幂等冲突、重启恢复和不可变权限。临时集群已停止清理,现有 5432 listener 未改变。
|
||||
- 本任务不需要摄像头、GPU、客户网络或通知供应商;结果不等于正式 Bell Web/H5、公共 JWT/RBAC、证据/通知、算法效果、真实多路或生产 SLA 验收。
|
||||
|
||||
### 2026-08-11 收尾
|
||||
|
||||
- 工作提交 `8208118` 已推送并创建 PR #73;Issue #71 的实现证据、提交、分支和任务文件一致。
|
||||
- 自动化、隔离 PostgreSQL 与浏览器工程验收满足本任务门禁,无额外摄像头/客户网络人工门禁;任务标记 DONE,允许合并并关闭 Issue。
|
||||
@@ -10,9 +10,9 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
||||
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
|
||||
$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 }; 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 }"
|
||||
$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"
|
||||
|
||||
function Assert-Configured {
|
||||
|
||||
@@ -12,9 +12,9 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
||||
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
|
||||
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 ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell 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)
|
||||
|
||||
ensure_configured() {
|
||||
|
||||
@@ -93,7 +93,11 @@ try {
|
||||
'012_bell_events.sql',
|
||||
'013_privileges_bell_events.sql',
|
||||
'014_audit_relay.sql',
|
||||
'015_privileges_audit_relay.sql'
|
||||
'015_privileges_audit_relay.sql',
|
||||
'016_event_ingress.sql',
|
||||
'017_privileges_event_ingress.sql',
|
||||
'018_bell_alerts.sql',
|
||||
'019_privileges_bell_alerts.sql'
|
||||
)) {
|
||||
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot "deploy\postgres\$name")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Static contract checks for the T-020 Bell alert vertical slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OPENAPI = ROOT / "docs" / "contracts" / "bell-alert-console-v1.openapi.json"
|
||||
WEB = ROOT / "Bell" / "web" / "assets"
|
||||
|
||||
|
||||
class BellAlertContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.contract = json.loads(OPENAPI.read_text(encoding="utf-8"))
|
||||
cls.html = (WEB / "index.html").read_text(encoding="utf-8")
|
||||
cls.css = (WEB / "style.css").read_text(encoding="utf-8")
|
||||
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_loopback_routes_and_bounds_are_frozen(self) -> None:
|
||||
self.assertEqual("3.1.0", self.contract["openapi"])
|
||||
self.assertTrue(self.contract["servers"][0]["url"].startswith("http://127.0.0.1:"))
|
||||
paths = self.contract["paths"]
|
||||
self.assertEqual(
|
||||
{"/alerts", "/alerts/{alert_id}", "/alerts/{alert_id}:ack", "/alerts/{alert_id}:close"},
|
||||
set(paths),
|
||||
)
|
||||
limit = paths["/alerts"]["get"]["parameters"][1]["schema"]
|
||||
self.assertEqual({"type": "integer", "minimum": 1, "maximum": 100, "default": 16}, limit)
|
||||
key = self.contract["components"]["parameters"]["IdempotencyKey"]["schema"]
|
||||
self.assertEqual((8, 128), (key["minLength"], key["maxLength"]))
|
||||
|
||||
def test_alert_is_separate_from_event_and_missing_capabilities_are_explicit(self) -> None:
|
||||
detail = self.contract["components"]["schemas"]["AlertDetail"]
|
||||
encoded = json.dumps(detail, ensure_ascii=False)
|
||||
self.assertIn("events", encoded)
|
||||
self.assertIn("transitions", encoded)
|
||||
self.assertIn("evidence_status", encoded)
|
||||
self.assertIn("delivery_status", encoded)
|
||||
self.assertIn("证据切片尚未启用", self.html)
|
||||
self.assertIn("升级与通知尚未启用", self.html)
|
||||
self.assertNotRegex(self.html + self.js, r"短信已发送|语音已接通|倒计时[::]\s*\d")
|
||||
|
||||
def test_console_is_dependency_free_and_does_not_embed_secrets(self) -> None:
|
||||
combined = self.html + self.css + self.js
|
||||
self.assertNotRegex(combined, r"https?://|cdn\.|localStorage|sessionStorage")
|
||||
self.assertNotRegex(combined, r"postgres(?:ql)?://|rtsp://|BELL_DB_DSN")
|
||||
self.assertIn('type="password"', self.html)
|
||||
self.assertIn('Cache-Control", "no-store"', (ROOT / "Bell" / "web" / "web.go").read_text(encoding="utf-8"))
|
||||
|
||||
def test_accessibility_and_small_screen_contract(self) -> None:
|
||||
for marker in (
|
||||
'aria-live="polite"',
|
||||
'aria-live="assertive"',
|
||||
'aria-busy="false"',
|
||||
'aria-pressed="true"',
|
||||
'href="#alerts"',
|
||||
):
|
||||
self.assertIn(marker, self.html)
|
||||
self.assertIn("@media(max-width:480px)", self.css.replace(" ", ""))
|
||||
self.assertIn("prefers-reduced-motion:reduce", self.css.replace(" ", ""))
|
||||
self.assertIn("[hidden]{display:none!important}", self.css.replace(" ", ""))
|
||||
self.assertRegex(self.css, r"min-height:44px")
|
||||
self.assertIn("textContent", self.js)
|
||||
self.assertNotIn("innerHTML", self.js)
|
||||
|
||||
def test_console_has_loading_empty_retry_conflict_and_keyboard_paths(self) -> None:
|
||||
for marker in ("skeleton", "没有预警", "加载失败,重试", "already_acknowledged", "keydown"):
|
||||
self.assertIn(marker, self.js)
|
||||
self.assertRegex(self.js, re.escape('limit:"16"'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Cross-language invariants for Brain-to-Bell event ingress v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTRACT = ROOT / "docs" / "contracts" / "brain-event-ingress-v1.openapi.json"
|
||||
EVENT_SCHEMA = ROOT / "docs" / "raw" / "contracts" / "event-v0.1.schema.json"
|
||||
|
||||
|
||||
class BrainEventIngressContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.document = json.loads(CONTRACT.read_text(encoding="utf-8"))
|
||||
cls.event_schema = json.loads(EVENT_SCHEMA.read_text(encoding="utf-8"))
|
||||
|
||||
def test_candidate_is_exact_final_top_level_shape_minus_bell_id(self) -> None:
|
||||
candidate = self.document["components"]["schemas"]["EventV01Candidate"]
|
||||
expected = set(self.event_schema["required"]) - {"id"}
|
||||
self.assertEqual(expected, set(candidate["required"]))
|
||||
self.assertEqual(expected, set(candidate["properties"]))
|
||||
self.assertFalse(candidate["additionalProperties"])
|
||||
self.assertIn("id", self.event_schema["required"])
|
||||
|
||||
def test_transport_auth_and_statuses_are_frozen(self) -> None:
|
||||
operation = self.document["paths"]["/internal/v1/event-candidates"]["post"]
|
||||
self.assertEqual({"200", "201", "400", "401", "403", "409", "413", "422", "503"}, set(operation["responses"]))
|
||||
header_names = {
|
||||
self.document["components"]["parameters"][parameter["$ref"].split("/")[-1]]["name"]
|
||||
for parameter in operation["parameters"]
|
||||
}
|
||||
self.assertEqual(
|
||||
{"X-YoVision-Key-Id", "X-YoVision-Timestamp", "X-YoVision-Nonce", "X-YoVision-Signature"},
|
||||
header_names,
|
||||
)
|
||||
|
||||
def test_numeric_safety_bounds_match_both_implementations(self) -> None:
|
||||
signature = self.document["x-yovision-signature"]
|
||||
delivery = self.document["x-yovision-delivery"]
|
||||
self.assertEqual((300, 600, 32), (signature["clock_skew_seconds"], signature["nonce_receipt_ttl_seconds"], signature["minimum_secret_bytes"]))
|
||||
self.assertEqual((1048576, 10, 1, 300, 100, 10000), (
|
||||
delivery["maximum_body_bytes"], delivery["deadline_seconds"], delivery["initial_retry_seconds"],
|
||||
delivery["maximum_retry_seconds"], delivery["maximum_attempts"], delivery["maximum_queued"],
|
||||
))
|
||||
brain = (ROOT / "Brain" / "yovision_brain" / "ingress.py").read_text(encoding="utf-8")
|
||||
bell = (ROOT / "Bell" / "internal" / "ingress" / "ingress.go").read_text(encoding="utf-8")
|
||||
for marker in (
|
||||
"/internal/v1/event-candidates", "X-YoVision-Key-Id", "X-YoVision-Timestamp",
|
||||
"X-YoVision-Nonce", "X-YoVision-Signature",
|
||||
):
|
||||
self.assertIn(marker, brain)
|
||||
self.assertIn(marker, bell)
|
||||
self.assertIn("MAX_QUEUED = 10_000", brain)
|
||||
self.assertIn("MAX_ATTEMPTS = 100", brain)
|
||||
self.assertIn("300*time.Second", bell)
|
||||
|
||||
def test_event_key_is_bound_to_producer_and_separate_from_audit(self) -> None:
|
||||
key_shape = self.document["x-yovision-signature"]["key_document_shape"]["keys"][0]
|
||||
self.assertEqual({"key_id", "producer_id", "secret_base64url"}, set(key_shape))
|
||||
bell_main = (ROOT / "Bell" / "cmd" / "bell-api" / "main.go").read_text(encoding="utf-8")
|
||||
self.assertIn("BELL_EVENT_INGRESS_KEYS_FILE", bell_main)
|
||||
self.assertIn("BELL_AUDIT_KEYS_FILE", bell_main)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -62,6 +62,10 @@ class PostgresContractTests(unittest.TestCase):
|
||||
"013_privileges_bell_events.sql",
|
||||
"014_audit_relay.sql",
|
||||
"015_privileges_audit_relay.sql",
|
||||
"016_event_ingress.sql",
|
||||
"017_privileges_event_ingress.sql",
|
||||
"018_bell_alerts.sql",
|
||||
"019_privileges_bell_alerts.sql",
|
||||
],
|
||||
names,
|
||||
)
|
||||
@@ -146,6 +150,46 @@ class PostgresContractTests(unittest.TestCase):
|
||||
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)
|
||||
|
||||
def test_event_ingress_has_permanent_source_receipts_and_minimal_privileges(self) -> None:
|
||||
migration = normalized(migration_text("016_event_ingress.sql"))
|
||||
privileges = normalized(migration_text("017_privileges_event_ingress.sql"))
|
||||
for marker in (
|
||||
"create table if not exists bell.event_ingress_bindings",
|
||||
"create table if not exists bell.event_ingress_receipts",
|
||||
"primary key (producer_id, source_event_id)",
|
||||
"create table if not exists bell.event_ingress_nonces",
|
||||
"expires_at >= received_at + interval '10 minutes'",
|
||||
"insert into bell.schema_migrations(version) values (5)",
|
||||
):
|
||||
self.assertIn(marker, migration)
|
||||
self.assertIn(
|
||||
"grant select, insert on table bell.event_ingress_receipts to bell_runtime",
|
||||
privileges,
|
||||
)
|
||||
self.assertIn(
|
||||
"grant select, insert, delete on table bell.event_ingress_nonces to bell_runtime",
|
||||
privileges,
|
||||
)
|
||||
self.assertNotIn("endpoint_ref", privileges.split("revoke all on table sense.devices")[1])
|
||||
|
||||
def test_alert_history_is_append_only_and_runtime_is_least_privilege(self) -> None:
|
||||
migration = normalized(migration_text("018_bell_alerts.sql"))
|
||||
privileges = normalized(migration_text("019_privileges_bell_alerts.sql"))
|
||||
for marker in (
|
||||
"create table if not exists bell.rule_versions",
|
||||
"create table if not exists bell.event_rule_sweeps",
|
||||
"create table if not exists bell.rule_evaluations",
|
||||
"create table if not exists bell.alerts",
|
||||
"create table if not exists bell.alert_events",
|
||||
"create table if not exists bell.alert_transitions",
|
||||
"create table if not exists bell.alert_command_receipts",
|
||||
"insert into bell.schema_migrations(version) values (6)",
|
||||
):
|
||||
self.assertIn(marker, migration)
|
||||
self.assertIn("before update or delete", migration)
|
||||
self.assertIn("grant select, insert on table", privileges)
|
||||
self.assertNotRegex(privileges, r"grant\s+(?:update|delete|truncate|all)")
|
||||
|
||||
|
||||
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