Merge T-020 Bell alert acknowledgement vertical slice
Implements append-only rule evaluation, Alert lifecycle, first-winner acknowledgement, loopback console, contracts, migrations and tests.
This commit is contained in:
+26
-4
@@ -1,6 +1,6 @@
|
|||||||
# Bell 事件存储、审计与 Brain 事件入口
|
# Bell 事件、规则与预警处置纵切
|
||||||
|
|
||||||
Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的 Brain 事件 ingress:
|
Bell 当前实现 M3 的事件域基础、Sense 审计 relay、默认关闭的 Brain 事件 ingress,以及默认关闭的规则→Alert→ack/close 工程纵切:
|
||||||
|
|
||||||
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
|
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
|
||||||
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
|
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
|
||||||
@@ -10,8 +10,11 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
|
|||||||
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
|
- `(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` 均失败关闭。
|
- 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`。
|
- `(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 和处置时间线;证据与升级/通知未实现时明确显示不可用,不伪造成功事实。
|
||||||
|
|
||||||
公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。两条 HMAC ingress 都是内部适配器,不得当成 Bell 公共协议或共用 key。
|
公共认证/事件 API、正式规则管理、证据对象存储、升级/通知和正式前端框架仍需后续任务冻结。两条 HMAC ingress 与回环控制台都是工程适配器,不得当成 Bell 公共协议或共用 key/token。
|
||||||
|
|
||||||
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
||||||
|
|
||||||
@@ -19,7 +22,7 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
|
|||||||
go -C Bell run ./cmd/bell-api
|
go -C Bell run ./cmd/bell-api
|
||||||
```
|
```
|
||||||
|
|
||||||
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`017` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
|
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`019` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
|
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
|
||||||
@@ -46,6 +49,25 @@ INSERT INTO bell.event_ingress_bindings(
|
|||||||
|
|
||||||
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
|
外键只负责资源存在;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
|
```powershell
|
||||||
|
|||||||
+123
-15
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,23 +20,32 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
"yovision/bell/contracts"
|
"yovision/bell/contracts"
|
||||||
|
"yovision/bell/internal/alert"
|
||||||
"yovision/bell/internal/audit"
|
"yovision/bell/internal/audit"
|
||||||
"yovision/bell/internal/event"
|
"yovision/bell/internal/event"
|
||||||
"yovision/bell/internal/ingress"
|
"yovision/bell/internal/ingress"
|
||||||
"yovision/bell/internal/store"
|
"yovision/bell/internal/store"
|
||||||
|
bellweb "yovision/bell/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
type configuration struct {
|
type configuration struct {
|
||||||
address string
|
address string
|
||||||
dsn string
|
dsn string
|
||||||
keyFile string
|
keyFile string
|
||||||
tlsCert string
|
tlsCert string
|
||||||
tlsKey string
|
tlsKey string
|
||||||
eventIngressEnabled bool
|
eventIngressEnabled bool
|
||||||
eventKeyFile string
|
eventKeyFile string
|
||||||
forbiddenNamesFile string
|
forbiddenNamesFile string
|
||||||
|
alertsEnabled bool
|
||||||
|
alertRulesFile string
|
||||||
|
alertConsoleEnabled bool
|
||||||
|
alertConsoleTokenFile string
|
||||||
|
alertConsoleTenantID int64
|
||||||
|
alertConsoleSiteID int64
|
||||||
|
alertConsoleActorRef string
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -48,13 +58,16 @@ func main() {
|
|||||||
|
|
||||||
func loadConfiguration() (configuration, error) {
|
func loadConfiguration() (configuration, error) {
|
||||||
value := configuration{
|
value := configuration{
|
||||||
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
||||||
dsn: os.Getenv("BELL_DB_DSN"),
|
dsn: os.Getenv("BELL_DB_DSN"),
|
||||||
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
||||||
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
||||||
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
||||||
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
||||||
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_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") {
|
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
|
||||||
case "", "false":
|
case "", "false":
|
||||||
@@ -89,6 +102,36 @@ func loadConfiguration() (configuration, error) {
|
|||||||
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
|
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
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +199,34 @@ func run(logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
mux.Handle(ingress.Path, eventHandler)
|
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) {
|
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
writeStatus(writer, http.StatusOK, "ok")
|
writeStatus(writer, http.StatusOK, "ok")
|
||||||
})
|
})
|
||||||
@@ -170,6 +241,12 @@ func run(logger *slog.Logger) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if cfg.alertsEnabled {
|
||||||
|
if err := repository.AlertReady(request.Context()); err != nil {
|
||||||
|
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
writeStatus(writer, http.StatusOK, "ready")
|
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}}
|
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}}
|
||||||
@@ -225,3 +302,34 @@ func envOr(name, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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) {
|
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
|
||||||
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||||
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
|
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,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 初始化
|
# YoVision PostgreSQL 初始化
|
||||||
|
|
||||||
本目录实现 T-009~T-012、T-015~T-016、T-019 的 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 和最小权限。全部 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 写入脚本或日志:
|
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
|||||||
- `bell.audit_events` 与事件事实一样不可更新/删除;`bell.audit_relay_receipts` 只为 10 分钟 nonce 幂等窗口保留,Bell runtime 仅可在这张限定表中查询、插入和清理过期记录。
|
- `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.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.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、状态和稳定错误,不保存连接秘密。
|
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
|
||||||
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
|
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
|
||||||
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ BEGIN
|
|||||||
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
|
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
|
||||||
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
|
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT max(version) FROM bell.schema_migrations) <> 5
|
IF (SELECT max(version) FROM bell.schema_migrations) <> 6
|
||||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
|
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
|
||||||
RAISE EXCEPTION 'schema migration version drift';
|
RAISE EXCEPTION 'schema migration version drift';
|
||||||
END IF;
|
END IF;
|
||||||
@@ -254,3 +254,24 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END
|
END
|
||||||
$event_ingress$;
|
$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、Brain 单路可视化工程原型、Sense 回环 NVR 管理面纵切和 Brain→Bell 可靠事件 ingress**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置;下一项建议把 Bell 规则→Alert→ack 最小纵切与证据切片拆成独立任务。
|
当前为 **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 切片。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
@@ -96,6 +96,6 @@ python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
|||||||
python -m compileall -q Brain
|
python -m compileall -q Brain
|
||||||
```
|
```
|
||||||
|
|
||||||
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;T-018 控制台必须在完成 PostgreSQL/Control API 外部安全配置后显式设置 `SENSE_CONSOLE_ENABLED=true`,再访问回环 `/sense-console/`。Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。工程页面默认只允许回环,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。
|
日常优先运行根目录 `./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)。
|
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|||||||
@@ -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 |
|
| 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` 组 |
|
| 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 和负向契约测试不变 |
|
| 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 类型当成公共网络协议。
|
T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进制。内部 factory 接收不含 `id` 的候选事实,由 Bell 生成 ULID 后才形成最终 v0.1 事件;不得把该 Go 类型当成公共网络协议。
|
||||||
|
|
||||||
@@ -93,6 +93,12 @@ T-019 不增加第三方依赖。Brain 继续使用 Python 3.10.11 标准库 `sq
|
|||||||
|
|
||||||
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 不足,必须发布兼容迁移方案,不能绕过来源幂等键。
|
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. 外部项目边界
|
## 2. 外部项目边界
|
||||||
|
|
||||||
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
||||||
@@ -136,7 +142,7 @@ go -C Sense build ./...
|
|||||||
go -C Sense run ./cmd/sense-api
|
go -C Sense run ./cmd/sense-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Bell 事件域与内部审计 receiver 单独执行:
|
Bell 事件、内部 ingress 与回环 Alert 纵切单独执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
go -C Bell mod download
|
go -C Bell mod download
|
||||||
@@ -170,7 +176,7 @@ python scripts/validate_harness_governance.py
|
|||||||
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
|
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
|
||||||
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
|
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
|
||||||
| Brain Python | 当前工程原型:`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 结果替代 |
|
| 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 ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 |
|
| 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、网络与存储必须使用目标环境分别验收 |
|
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
|
||||||
|
|
||||||
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
|
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
|
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
|
||||||
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 回环控制台不参与推理或事件生成。
|
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 仍未冻结。
|
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. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
|
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 提供切片接口。
|
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
|
||||||
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
|
|||||||
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
|
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
|
||||||
- T-019 的 Brain SQLite Outbox 与推理内存环分离:最多 10,000 条待投递,网络/5xx/认证故障按 1~300 秒重试,最多 100 次;稳定 4xx 进入 dead letter。Outbox 写入失败时不得在演示状态中伪装为已排队或已投递。
|
- 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 项内存环仍只服务页面显示,不承担可靠投递。
|
- Bell 先按 HMAC key 绑定 producer,再解析候选;永久来源收据、最终 event 和成功 nonce 响应同事务提交。同来源/同 canonical hash 返回原 Bell ID,同来源/不同 hash 返回冲突。T-017 的 100 项内存环仍只服务页面显示,不承担可靠投递。
|
||||||
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
- Alert 身份、Event 多对多关联与 `open → acknowledged → closed` transition 均只追加;当前状态从最新 transition 推导。规则 evaluation、Alert 创建和初始 transition 在同一事务中提交;无规则/未命中也写 durable sweep。命令永久幂等收据保证重启后同 key 重放原响应。投递和升级链尚未实现,页面不得伪造倒计时或送达事实。
|
||||||
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
||||||
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
||||||
|
|
||||||
@@ -119,17 +119,17 @@ Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trig
|
|||||||
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
||||||
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
||||||
Bell/{web,packs,contracts}
|
Bell/{web,packs,contracts}
|
||||||
deploy/postgres/{001_roles.sql,...,017_privileges_event_ingress.sql,tests}
|
deploy/postgres/{001_roles.sql,...,019_privileges_bell_alerts.sql,tests}
|
||||||
```
|
```
|
||||||
|
|
||||||
Sense 脚手架和 PostgreSQL `001`~`017` 已实现;Bell 已有事件校验/不可变存储、Sense 审计 relay 与默认关闭的 Brain 事件 ingress,但没有公共管理 API。Brain 已有单路工程原型和仓库外 SQLite 可靠事件 Outbox;尚无生产模型、GPU pipeline、证据切片或规则/Alert 链。
|
Sense 脚手架和 PostgreSQL `001`~`019` 已实现;Bell 已有事件校验/不可变存储、Sense 审计 relay、默认关闭的 Brain 事件 ingress,以及规则/Alert/ack 回环工程纵切,但没有公共管理 API。Brain 已有单路工程原型和仓库外 SQLite 可靠事件 Outbox;尚无生产模型、GPU pipeline、证据切片、升级或通知链。
|
||||||
|
|
||||||
## 10. 开发顺序
|
## 10. 开发顺序
|
||||||
|
|
||||||
- M0 不写生产代码。
|
- M0 不写生产代码。
|
||||||
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
||||||
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
||||||
- M3 Brain 与 Bell 同时起步;T-015~T-019 已打通不可变事件消费者、审计 relay、Brain 单路候选、Sense 回环管理纵切和 Brain→Bell 可靠事件 ingress。下一步仍应在证据切片与规则/Alert 之间按客户可见闭环拆分独立任务。
|
- 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 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||||
|
|
||||||
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
||||||
|
|||||||
+3
-1
@@ -41,7 +41,9 @@
|
|||||||
- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。
|
- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。
|
||||||
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
|
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
|
||||||
- T-019:建立 Brain→Bell 可靠业务事件 ingress;独立冻结数字事件身份到 Bell 逻辑身份的绑定、HMAC 认证、SQLite 持久 Outbox、Bell 永久来源收据和跨重启幂等确认,不得把 T-017 内存事件环当作生产投递。
|
- 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 与审计。
|
- 最小 Web/App 处置流程、RBAC 与审计。
|
||||||
- 现场误报基线和反馈队列。
|
- 现场误报基线和反馈队列。
|
||||||
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
|
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
|
||||||
|
|||||||
+19
-8
@@ -1,6 +1,6 @@
|
|||||||
# API 与契约
|
# API 与契约
|
||||||
|
|
||||||
> Brain → Bell 事件契约 v0.1 与内部 ingress v1、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 事件契约
|
## 1. 已冻结:Brain → Bell 事件契约
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ T-015 已实现 Bell 消费端的内部组装与存储边界;T-019 在 [`contr
|
|||||||
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
||||||
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
||||||
|
|
||||||
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/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/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 拥有投影源数据、视图、事件身份绑定与来源收据;未来分库必须发布新版本,不能在 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
|
## 3. 已冻结:Sense Control API v1
|
||||||
|
|
||||||
@@ -50,7 +50,18 @@ T-015 已实现 Bell 消费端的内部组装与存储边界;T-019 在 [`contr
|
|||||||
|
|
||||||
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。
|
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、处置、误报反馈、审计和报表。设计时必须满足:
|
资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足:
|
||||||
|
|
||||||
@@ -61,11 +72,11 @@ T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入
|
|||||||
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
|
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
|
||||||
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
|
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
|
||||||
|
|
||||||
## 5. MediaMTX 接口边界
|
## 6. MediaMTX 接口边界
|
||||||
|
|
||||||
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
||||||
|
|
||||||
### 5.1 T-003 已实现的内部适配契约
|
### 6.1 T-003 已实现的内部适配契约
|
||||||
|
|
||||||
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
|
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
|
||||||
|
|
||||||
@@ -77,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 生成包。
|
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` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
|
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
|
||||||
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
|
- 视频配额只统计 `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 生产等价实现。
|
- SQLite 表使用 `sense_` 前缀且只是 M1 实验室兼容路径;PostgreSQL 使用 `sense.devices`、能力/调和表、两个投影观察表和 `sense.device_operation_outbox`。PostgreSQL 不建立可写 Site/Area 真相副本;后续公共控制 API 只在 PostgreSQL 路径启用,不能把 SQLite 描述为 Area/Outbox 生产等价实现。
|
||||||
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
|
- 摄像头密码不进入设备普通字段。`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` 临时接口。
|
`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。
|
孤儿报告/处置不是公共 HTTP API。它由 PostgreSQL 专用 `cmd/sense-orphan` 在受控运维主机执行,使用 15 分钟 scan ID、actor、精确确认文本和运行前二次快照;不改变 Sense Control API v1 的 7 个 endpoint。
|
||||||
|
|
||||||
## 6. 变更流程
|
## 7. 变更流程
|
||||||
|
|
||||||
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
||||||
2. 更新本文和 schema/OpenAPI。
|
2. 更新本文和 schema/OpenAPI。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# YoVision 控制面、审计与内部事件传输契约 v1
|
# YoVision 控制面、审计与内部事件传输契约 v1
|
||||||
|
|
||||||
> 冻结日期:2026-08-11。Control API、审计 relay 与 Brain 事件 ingress 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额、事件与全局审计的所有者。T-016 已实现 Sense Outbox 审计 relay,T-019 已实现 Brain 事件 Outbox ingress;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 仍未实现。
|
||||||
|
|
||||||
## 契约文件
|
## 契约文件
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
||||||
| [`sense-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
|
| [`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 与跨重启幂等语义 |
|
| [`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。
|
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||||
|
|
||||||
@@ -87,6 +88,14 @@ Bell 的永久 `event_ingress_receipts` 以 `(producer_id, source_event_id)` 唯
|
|||||||
|
|
||||||
数字 `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。
|
数字 `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 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
|
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
|
||||||
@@ -103,10 +112,12 @@ python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
|||||||
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
||||||
python -m json.tool docs/contracts/sense-audit-relay-v1.openapi.json | Out-Null
|
python -m 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/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_contract.py"
|
||||||
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
||||||
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
|
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_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 消费方联合验收或客户现场容量验证。
|
测试同时校验 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"}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,29 +4,30 @@
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路匿名区域事件工程原型及 Brain→Bell 可靠事件 ingress。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
|
- 阶段: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。Brain 已包含单路 source/detector/track/zone-entry、回环可视化页与仓库外 SQLite 事件 Outbox,但仍没有生产模型/GPU pipeline、公共管理服务/JWT、规则/Alert、证据切片或正式 Web/H5。
|
- 生产代码: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 路,必须横向分片。
|
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
|
||||||
|
|
||||||
## 仓库现实
|
## 仓库现实
|
||||||
|
|
||||||
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 两条内部 ingress;`Brain/` 已有 Python 3.10 单路工程原型、30 项单元/HTTP/UI/Outbox 测试和 `docs/design/brain/index.html` 自包含页面。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、新增/启用和“降低配额不关闭已有流”均有测试。
|
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
|
||||||
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
|
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
|
||||||
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
|
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
|
||||||
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`017` migration 可重放;Sense Outbox fencing、Bell event/global-audit/Brain-ingress repository、nonce/永久来源收据、权限、并发幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
|
- 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-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-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-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-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-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、探活和只返回名称的受限分页枚举测试。
|
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
|
||||||
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
|
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
|
||||||
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
|
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
|
||||||
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
|
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
|
||||||
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
||||||
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
||||||
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json` 和 `brain-event-ingress-v1.openapi.json` 已分别由 T-016/T-019 实现。两条内部 ingress 使用独立 key;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 明确保留单实例开发语义。
|
- T-012 把 PostgreSQL schema 提升到 v5:due row 用数据库时钟、`FOR UPDATE SKIP LOCKED`、逐项续租和 fencing token 协调;MediaMTX Path 历史归属、15 分钟孤儿快照、最多 10%/128 项安全闸、无 bypass 的本地处置命令及 `/metrics` 已实现。SQLite 明确保留单实例开发语义。
|
||||||
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
|
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
|
||||||
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
|
||||||
@@ -48,7 +49,7 @@ Windows:
|
|||||||
go -C Sense run ./cmd/sense-api
|
go -C Sense run ./cmd/sense-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Bell 事件域与两条内部 ingress 验证:
|
Bell 事件、两条内部 ingress 与 Alert 工程纵切验证:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
go -C Bell test ./...
|
go -C Bell test ./...
|
||||||
@@ -99,12 +100,12 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
|
|||||||
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
||||||
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
||||||
- 生产 Brain 的 Python/Savant/DeepStream 精确版本、目标硬件和 Bell 前端栈尚未冻结;T-017 的 Python/OpenCV 只适用于工程原型,不能外推为生产选择。Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
|
- 生产 Brain 的 Python/Savant/DeepStream 精确版本、目标硬件和 Bell 前端栈尚未冻结;T-017 的 Python/OpenCV 只适用于工程原型,不能外推为生产选择。Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
|
||||||
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-019 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/两类 ingress key、Brain Outbox 路径、TLS 证书与备份方案。
|
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-020 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/两类 ingress key、Brain Outbox、Bell 规则/token/上下文、TLS 证书与备份方案。
|
||||||
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
客户网络仍未提供,T-013 WireGuard 继续后置。T-019 已完成本地 Brain→Bell 可靠业务事件 ingress;下一项建议独立建立 Bell 规则→Alert→ack 的最小可见纵切,证据/pre-roll 切片另立任务且不与 Alert 状态机混交。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 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 承诺。
|
||||||
|
|
||||||
## 已知风险
|
## 已知风险
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -26,8 +26,9 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 |
|
| `/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 公共业务路由 |
|
| `/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 回环控制台暴露到非可信网络或嵌入客户公共系统。
|
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页、T-018 Sense 控制台或 T-020 Bell 工程值班台暴露到非可信网络或嵌入客户公共系统。
|
||||||
|
|
||||||
## App 候选导航
|
## App 候选导航
|
||||||
|
|
||||||
@@ -53,6 +54,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审
|
|||||||
|
|
||||||
## 组件归属
|
## 组件归属
|
||||||
|
|
||||||
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页,不改变该归属。
|
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页。T-020 也使用 `Bell/web/`,但只是自包含工程台,不冻结正式前端框架或公共路由。
|
||||||
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
|
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
|
||||||
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
|
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
|
||||||
|
|||||||
+26
-4
@@ -3,12 +3,12 @@ id: T-020
|
|||||||
title: 建立 Bell 规则到 Alert 与 ack 的最小可见纵切
|
title: 建立 Bell 规则到 Alert 与 ack 的最小可见纵切
|
||||||
phase: 3
|
phase: 3
|
||||||
deps: [T-015, T-019]
|
deps: [T-015, T-019]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-08-11
|
created: 2026-08-11
|
||||||
issue: 71
|
issue: 71
|
||||||
context_ref: null
|
context_ref: 477afa6ba2def34224f2dfeedc5b14421ec27650
|
||||||
claim_branch: null
|
claim_branch: claims/T-020
|
||||||
work_branch: null
|
work_branch: agent/codex/T-020
|
||||||
write_paths:
|
write_paths:
|
||||||
- docs/tasks/T-020.md
|
- docs/tasks/T-020.md
|
||||||
- Bell/
|
- Bell/
|
||||||
@@ -86,3 +86,25 @@ T-019 已把 Brain 候选可靠地写成 Bell 不可变 Event,但 Bell 尚未
|
|||||||
- 复用已确认 Bell 原型及 US-003/US-005、IX-005~IX-008/IX-013;工程值班台不冻结最终前端框架。
|
- 复用已确认 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 全绿。
|
- 当前会话未提供 codebase-memory MCP 图工具,按仓库规则降级为定向读取与 `rg`。任务定义前 `./init.ps1` 基线通过:77 项根测试、30 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
|
||||||
- 任务定义已合入默认分支并创建唯一 Gitea Issue #71;本映射合入默认分支后才允许添加 `status/todo` 并由 dispatcher 分配。
|
- 任务定义已合入默认分支并创建唯一 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。
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ try {
|
|||||||
'014_audit_relay.sql',
|
'014_audit_relay.sql',
|
||||||
'015_privileges_audit_relay.sql',
|
'015_privileges_audit_relay.sql',
|
||||||
'016_event_ingress.sql',
|
'016_event_ingress.sql',
|
||||||
'017_privileges_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")
|
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()
|
||||||
@@ -64,6 +64,8 @@ class PostgresContractTests(unittest.TestCase):
|
|||||||
"015_privileges_audit_relay.sql",
|
"015_privileges_audit_relay.sql",
|
||||||
"016_event_ingress.sql",
|
"016_event_ingress.sql",
|
||||||
"017_privileges_event_ingress.sql",
|
"017_privileges_event_ingress.sql",
|
||||||
|
"018_bell_alerts.sql",
|
||||||
|
"019_privileges_bell_alerts.sql",
|
||||||
],
|
],
|
||||||
names,
|
names,
|
||||||
)
|
)
|
||||||
@@ -170,6 +172,24 @@ class PostgresContractTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertNotIn("endpoint_ref", privileges.split("revoke all on table sense.devices")[1])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user