Author SHA1 Message Date
ila 2a38feb75c Merge T-020 Bell alert acknowledgement vertical slice
Implements append-only rule evaluation, Alert lifecycle, first-winner acknowledgement, loopback console, contracts, migrations and tests.
2026-08-11 17:02:56 +08:00
QiuSW 0ef23ff004 docs(task): mark T-020 complete 2026-08-11 17:02:48 +08:00
QiuSW 8208118904 feat(bell): add alert acknowledgement vertical slice
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 17:01:53 +08:00
ila 477afa6ba2 Merge pull request 'T-020: 回填 Issue #71 映射' (#72) from docs/T-020-issue-map into main 2026-08-11 16:32:11 +08:00
29 changed files with 1825 additions and 56 deletions
+26 -4
View File
@@ -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。
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
@@ -10,8 +10,11 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
- 规则配置按 canonical hash 幂等发布不可变版本;worker 为每个 Event 写 durable sweep/evaluation,命中时由 Bell 生成 `alt_` ULID 并关联 Event。
- Alert 的 `open → acknowledged → closed` 状态从 append-only transition 推导;8 路并发 ack 只有首个成功,后到者得到实际首位处置人/时间,同一幂等键跨重启重放原响应。
- `/bell-console/` 是自包含、无 CDN 的回环工程值班台,显示真实规则版本、关联 Event 和处置时间线;证据与升级/通知未实现时明确显示不可用,不伪造成功事实。
公共认证/事件 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 或证书:
@@ -19,7 +22,7 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
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
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
@@ -46,6 +49,25 @@ INSERT INTO bell.event_ingress_bindings(
外键只负责资源存在;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
+123 -15
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -19,23 +20,32 @@ import (
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/alert"
"yovision/bell/internal/audit"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
"yovision/bell/internal/store"
bellweb "yovision/bell/web"
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
alertsEnabled bool
alertRulesFile string
alertConsoleEnabled bool
alertConsoleTokenFile string
alertConsoleTenantID int64
alertConsoleSiteID int64
alertConsoleActorRef string
}
func main() {
@@ -48,13 +58,16 @@ func main() {
func loadConfiguration() (configuration, error) {
value := configuration{
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
alertRulesFile: os.Getenv("BELL_ALERT_RULES_FILE"),
alertConsoleTokenFile: os.Getenv("BELL_ALERT_CONSOLE_TOKEN_FILE"),
alertConsoleActorRef: os.Getenv("BELL_ALERT_CONSOLE_ACTOR_REF"),
}
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
case "", "false":
@@ -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")
}
}
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
}
@@ -156,6 +199,34 @@ func run(logger *slog.Logger) error {
}
mux.Handle(ingress.Path, eventHandler)
}
if cfg.alertsEnabled {
if err := repository.AlertReady(ctx); err != nil {
return err
}
rules, err := alert.LoadRules(cfg.alertRulesFile)
if err != nil {
return err
}
if err := alert.Publish(ctx, repository, rules); err != nil {
return err
}
go alert.RunWorker(ctx, repository, func(workerErr error) {
logger.Error("Bell alert worker retrying", "error", workerErr)
})
}
if cfg.alertConsoleEnabled {
token, err := loadConsoleToken(cfg.alertConsoleTokenFile)
if err != nil {
return err
}
console, err := bellweb.NewHandler(repository, bellweb.Config{
Token: token, TenantID: cfg.alertConsoleTenantID, SiteID: cfg.alertConsoleSiteID, ActorRef: cfg.alertConsoleActorRef,
})
if err != nil {
return err
}
console.Register(mux)
}
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writeStatus(writer, http.StatusOK, "ok")
})
@@ -170,6 +241,12 @@ func run(logger *slog.Logger) error {
return
}
}
if cfg.alertsEnabled {
if err := repository.AlertReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
writeStatus(writer, http.StatusOK, "ready")
})
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
@@ -225,3 +302,34 @@ func envOr(name, fallback string) string {
}
return fallback
}
func strictBoolEnv(name string) (bool, error) {
switch os.Getenv(name) {
case "", "false":
return false, nil
case "true":
return true, nil
default:
return false, fmt.Errorf("%s must be true or false", name)
}
}
func positiveEnv(name string) (int64, error) {
value, err := strconv.ParseInt(os.Getenv(name), 10, 64)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a positive integer", name)
}
return value, nil
}
func loadConsoleToken(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 4096 {
return "", errors.New("read Bell alert console token file")
}
value := strings.TrimSpace(string(raw))
if len(value) < 32 || len(value) > 256 || strings.ContainsAny(value, " \t\r\n") {
return "", errors.New("Bell alert console token must contain 32 to 256 non-whitespace characters")
}
return value, nil
}
+44
View File
@@ -1,6 +1,7 @@
package main
import (
"os"
"path/filepath"
"testing"
)
@@ -18,6 +19,49 @@ func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
}
}
func TestAlertsAreDisabledByDefaultAndConsoleRequiresLoopbackContext(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "audit.json"))
value, err := loadConfiguration()
if err != nil || value.alertsEnabled || value.alertConsoleEnabled {
t.Fatalf("default alert configuration: %+v %v", value, err)
}
t.Setenv("BELL_ALERTS_ENABLED", "true")
if _, err := loadConfiguration(); err == nil {
t.Fatal("alerts without an external rule file were accepted")
}
t.Setenv("BELL_ALERT_RULES_FILE", filepath.Join(t.TempDir(), "rules.json"))
t.Setenv("BELL_ALERT_CONSOLE_ENABLED", "true")
if _, err := loadConfiguration(); err == nil {
t.Fatal("console without external context was accepted")
}
t.Setenv("BELL_ALERT_CONSOLE_TOKEN_FILE", filepath.Join(t.TempDir(), "token"))
t.Setenv("BELL_ALERT_CONSOLE_TENANT_ID", "1")
t.Setenv("BELL_ALERT_CONSOLE_SITE_ID", "2")
t.Setenv("BELL_ALERT_CONSOLE_ACTOR_REF", "operator:local")
t.Setenv("BELL_HTTP_ADDR", "127.0.0.1:8081")
value, err = loadConfiguration()
if err != nil || !value.alertsEnabled || !value.alertConsoleEnabled {
t.Fatalf("valid alert console configuration: %+v %v", value, err)
}
}
func TestLoadConsoleToken(t *testing.T) {
path := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(path, []byte("12345678901234567890123456789012\n"), 0o600); err != nil {
t.Fatal(err)
}
if value, err := loadConsoleToken(path); err != nil || len(value) != 32 {
t.Fatalf("valid token: %q %v", value, err)
}
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConsoleToken(path); err == nil {
t.Fatal("short token was accepted")
}
}
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
+221
View File
@@ -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:
}
}
}
+43
View File
@@ -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")
}
}
+355
View File
@@ -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(&current, &currentActor, &currentTime, &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
}
+174
View File
@@ -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", &note); !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)
}
}
+17
View File
@@ -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()}));
+54
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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})
}
+93
View File
@@ -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)
}
}
+145
View File
@@ -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;
+2 -1
View File
@@ -1,6 +1,6 @@
# 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 写入脚本或日志:
@@ -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.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。
- `bell.event_ingress_bindings` 由管理员维护并引用当前 Bell Site/Area 和 Sense Device;Bell runtime 只读绑定及 Sense 设备的逻辑 ID/Area/modality 列,不可读取 endpoint、credential、profile token 或 MediaMTX path。`event_ingress_receipts` 永久只追加;只有 `event_ingress_nonces` 可按 10 分钟 TTL 清理。
- `bell.rule_versions`、sweep/evaluation、Alert/Event 关联、transition 和命令收据均由 `bell_app` 拥有;`bell_runtime` 只有 `SELECT/INSERT`,数据库 trigger 拒绝 UPDATE/DELETE。当前状态从最新 transition 推导,同一 evaluation 由唯一约束最多创建一个 Alert;命令收据永久保留以支持跨重启幂等。
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
- PUBLIC 对两个业务 schema 的表和函数没有权限。
+22 -1
View File
@@ -71,7 +71,7 @@ BEGIN
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
END IF;
IF (SELECT max(version) FROM bell.schema_migrations) <> 5
IF (SELECT max(version) FROM bell.schema_migrations) <> 6
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
RAISE EXCEPTION 'schema migration version drift';
END IF;
@@ -254,3 +254,24 @@ BEGIN
END IF;
END
$event_ingress$;
DO $bell_alerts$
DECLARE
target text;
BEGIN
FOREACH target IN ARRAY ARRAY[
'bell.rule_versions', 'bell.event_rule_sweeps', 'bell.rule_evaluations',
'bell.alerts', 'bell.alert_events', 'bell.alert_transitions',
'bell.alert_command_receipts'
] LOOP
IF NOT has_table_privilege('yovision_t015_bell', target, 'SELECT,INSERT')
OR has_table_privilege('yovision_t015_bell', target, 'UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell runtime violates append-only Alert privilege on %', target;
END IF;
IF has_table_privilege('public', target, 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
OR has_table_privilege('sense_app', target, 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell Alert history leaked on %', target;
END IF;
END LOOP;
END
$bell_alerts$;
+2 -2
View File
@@ -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
```
日常优先运行根目录 `./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)。
+9 -3
View File
@@ -63,7 +63,7 @@ T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模
| Go | `1.26.5`(与 Sense 相同) | BSD-3-Clause;复用 §1.1 工具链与校验 | Bell 独立 Go module;升级时同时运行两个 module 的 test/vet/build |
| PostgreSQL / pgx | `17.10` / `github.com/jackc/pgx/v5 v5.10.0` | PostgreSQL License / MIT;module sum `h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=` | 复用 T-009 的 `database/sql` 边界;运行身份改用无 owner 权限的 `bell_runtime` 组 |
| JSON Schema | `github.com/santhosh-tekuri/jsonschema/v6 v6.0.2` | Apache-2.0;module sum `h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=` | 运行时校验 Draft 2020-12 与 format;退出时可替换 validator,但冻结 schema 和负向契约测试不变 |
| ULID | `github.com/oklog/ulid/v2 v2.1.2` | Apache-2.0;module sum `h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=` | 只封装在 Bell ID generator port 后;平台前缀仍为 `evt_`,替换实现不得改变格式或所有权 |
| ULID | `github.com/oklog/ulid/v2 v2.1.2` | Apache-2.0;module sum `h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=` | 只封装在 Bell ID generator 边界后;Event 前缀为 `evt_`,T-020 Alert 前缀为 `alt_`,替换实现不得改变格式或所有权 |
T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进制。内部 factory 接收不含 `id` 的候选事实,由 Bell 生成 ULID 后才形成最终 v0.1 事件;不得把该 Go 类型当成公共网络协议。
@@ -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 不足,必须发布兼容迁移方案,不能绕过来源幂等键。
### 1.8 Bell 规则、Alert 与回环值班台(T-020)
T-020 不增加第三方依赖:规则/Alert 域继续使用 Bell 已冻结的 Go 1.26.5、PostgreSQL 17.10/pgx 和 ULID。规则配置是仓库外 JSON,首版只实现精确事件类型、最小严重度、可选 Site、启用状态和生效时间;canonical SHA-256 相同则幂等复用,变化只追加新版本。规则 worker 是单一可取消循环和有界空闲轮询,不按租户、站点或设备创建 goroutine;数据库 durable sweep、唯一键和事务级 advisory lock 负责多实例收敛。
回环值班台与 T-018 相同,使用 Go 标准库 `embed`/`net/http` 和自包含 HTML/CSS/原生 JavaScript,无 npm、框架、CDN 或浏览器持久存储。该选择只服务工程验收,不冻结 Bell 正式前端栈。控制台与规则 worker 分别默认关闭;控制台要求规则已启用、整个 Bell 监听地址显式回环、仓库外 token 文件及服务端 tenant/Site/actor 上下文。证据、升级/通知、JWT/OIDC/RBAC 和非回环部署不在本任务内。
## 2. 外部项目边界
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
@@ -136,7 +142,7 @@ go -C Sense build ./...
go -C Sense run ./cmd/sense-api
```
Bell 事件域与内部审计 receiver 单独执行:
Bell 事件、内部 ingress 与回环 Alert 纵切单独执行:
```powershell
go -C Bell mod download
@@ -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 才要求客户/借用/租赁的真实多路矩阵 |
| 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 结果替代 |
| 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、网络与存储必须使用目标环境分别验收 |
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
+5 -5
View File
@@ -49,7 +49,7 @@ Sense ── 视频流/触发信号 ──> Brain
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 回环控制台不参与推理或事件生成。
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 提供切片接口。
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。
- T-019 的 Brain SQLite Outbox 与推理内存环分离:最多 10,000 条待投递,网络/5xx/认证故障按 1~300 秒重试,最多 100 次;稳定 4xx 进入 dead letter。Outbox 写入失败时不得在演示状态中伪装为已排队或已投递。
- Bell 先按 HMAC key 绑定 producer,再解析候选;永久来源收据、最终 event 和成功 nonce 响应同事务提交。同来源/同 canonical hash 返回原 Bell ID,同来源/不同 hash 返回冲突。T-017 的 100 项内存环仍只服务页面显示,不承担可靠投递。
- Alert 先落库再投递,进程重启恢复未完成升级链。
- Alert 身份、Event 多对多关联与 `open → acknowledged → closed` transition 均只追加;当前状态从最新 transition 推导。规则 evaluation、Alert 创建和初始 transition 在同一事务中提交;无规则/未命中也写 durable sweep。命令永久幂等收据保证重启后同 key 重放原响应。投递和升级链尚未实现,页面不得伪造倒计时或送达事实。
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
- 事件证据技术默认保留 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}
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
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. 开发顺序
- M0 不写生产代码。
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
- 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 接入雷达、门磁、按钮和可穿戴等非视频适配器。
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
+3 -1
View File
@@ -41,7 +41,9 @@
- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
- T-019:建立 Brain→Bell 可靠业务事件 ingress;独立冻结数字事件身份到 Bell 逻辑身份的绑定、HMAC 认证、SQLite 持久 Outbox、Bell 永久来源收据和跨重启幂等确认,不得把 T-017 内存事件环当作生产投递。
- 规则引擎、场景包加载、预警状态机与双路径投递。
- T-020:建立 Bell 规则→Alert→ack/close 最小可见纵切;规则版本、evaluation、Alert/Event 关联、状态迁移和命令收据均 append-only,工程值班台只允许回环且不伪造证据、升级或投递事实。
- T-021(建议,尚未创建):建立 Bell→Sense 异步证据/pre-roll 切片,独立冻结请求、授权、幂等、状态、对象引用和生命周期;不得把常态录像或原始摄像头凭据交给 Bell。
- 升级状态机、排班解析与双路径投递。
- 最小 Web/App 处置流程、RBAC 与审计。
- 现场误报基线和反馈队列。
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
+19 -8
View File
@@ -1,6 +1,6 @@
# 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 事件契约
@@ -32,7 +32,7 @@ T-015 已实现 Bell 消费端的内部组装与存储边界;T-019 在 [`contr
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/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
@@ -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。
## 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、处置、误报反馈、审计和报表。设计时必须满足:
@@ -61,11 +72,11 @@ T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
## 5. MediaMTX 接口边界
## 6. MediaMTX 接口边界
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
### 5.1 T-003 已实现的内部适配契约
### 6.1 T-003 已实现的内部适配契约
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
@@ -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 生成包。
### 5.2 设备台账语义
### 6.2 设备台账语义
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
- SQLite 表使用 `sense_` 前缀且只是 M1 实验室兼容路径;PostgreSQL 使用 `sense.devices`、能力/调和表、两个投影观察表和 `sense.device_operation_outbox`。PostgreSQL 不建立可写 Site/Area 真相副本;后续公共控制 API 只在 PostgreSQL 路径启用,不能把 SQLite 描述为 Area/Outbox 生产等价实现。
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
### 5.3 Sense 进程 HTTP 面
### 6.3 Sense 进程 HTTP 面
`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成;两者不要求认证,也不等价于摄像头、MediaMTX path 或里程碑健康。`GET /metrics` 默认输出无租户/设备/Path 标签的 Prometheus 汇总,可通过 `SENSE_METRICS_ENABLED=false` 关闭。T-011 在 `SENSE_CONTROL_API_ENABLED=true`、PostgreSQL v5 schema 和外部安全文件全部有效时注册冻结的 `/api/v1` 路由;默认 SQLite 不注册业务路由。业务路由不提供无 Site 边界的 `/api/v1/devices` 临时接口。
孤儿报告/处置不是公共 HTTP API。它由 PostgreSQL 专用 `cmd/sense-orphan` 在受控运维主机执行,使用 15 分钟 scan ID、actor、精确确认文本和运行前二次快照;不改变 Sense Control API v1 的 7 个 endpoint。
## 6. 变更流程
## 7. 变更流程
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
2. 更新本文和 schema/OpenAPI。
+12 -1
View File
@@ -1,6 +1,6 @@
# 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-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
| [`brain-event-ingress-v1.openapi.json`](brain-event-ingress-v1.openapi.json) | Bell | Brain | 单业务事件入站、producer/key 绑定、Bell ID 与跨重启幂等语义 |
| [`bell-alert-console-v1.openapi.json`](bell-alert-console-v1.openapi.json) | Bell | 回环工程值班台 | Alert 分页/详情、首次 ack、确认后关闭与永久幂等收据 |
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
@@ -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。
## 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 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
@@ -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-audit-relay-v1.openapi.json | Out-Null
python -m json.tool docs/contracts/brain-event-ingress-v1.openapi.json | Out-Null
python -m json.tool docs/contracts/bell-alert-console-v1.openapi.json | Out-Null
python -m unittest discover -s tests -p "test_sense_control_contract.py"
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py"
python -m unittest discover -s tests -p "test_bell_alert_contract.py"
```
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
@@ -0,0 +1,89 @@
{
"openapi": "3.1.0",
"info": {
"title": "YoVision Bell loopback alert console",
"version": "1.0.0",
"description": "Engineering-only, loopback API for the Event to Rule to Alert to acknowledgement vertical slice. Tenant, site and actor context are server-side configuration, never client input."
},
"servers": [{"url": "http://127.0.0.1:{port}/bell-console/api/v1", "variables": {"port": {"default": "8081"}}}],
"security": [{"consoleBearer": []}],
"paths": {
"/alerts": {
"get": {
"operationId": "listAlerts",
"parameters": [
{"name": "state", "in": "query", "schema": {"$ref": "#/components/schemas/AlertState"}},
{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 16}},
{"name": "cursor", "in": "query", "schema": {"$ref": "#/components/schemas/AlertId"}}
],
"responses": {
"200": {"description": "Stable newest-first page", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertPage"}}}},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"$ref": "#/components/responses/Unauthorized"}
}
}
},
"/alerts/{alert_id}": {
"get": {
"operationId": "getAlert",
"parameters": [{"$ref": "#/components/parameters/AlertId"}],
"responses": {
"200": {"description": "Alert, related event facts and append-only transitions", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertDetail"}}}},
"401": {"$ref": "#/components/responses/Unauthorized"},
"404": {"$ref": "#/components/responses/NotFound"}
}
}
},
"/alerts/{alert_id}:ack": {
"post": {
"operationId": "acknowledgeAlert",
"parameters": [{"$ref": "#/components/parameters/AlertId"}, {"$ref": "#/components/parameters/IdempotencyKey"}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandBody"}}}},
"responses": {
"200": {"description": "First acknowledgement or exact idempotent replay", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
"409": {"description": "Already acknowledged (including actual first actor/time), or reused key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
"401": {"$ref": "#/components/responses/Unauthorized"},
"404": {"$ref": "#/components/responses/NotFound"}
}
}
},
"/alerts/{alert_id}:close": {
"post": {
"operationId": "closeAlert",
"parameters": [{"$ref": "#/components/parameters/AlertId"}, {"$ref": "#/components/parameters/IdempotencyKey"}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandBody"}}}},
"responses": {
"200": {"description": "Close after acknowledgement or exact idempotent replay", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
"409": {"description": "Acknowledgement required, already closed, or reused key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResponse"}}}},
"401": {"$ref": "#/components/responses/Unauthorized"},
"404": {"$ref": "#/components/responses/NotFound"}
}
}
}
},
"components": {
"securitySchemes": {"consoleBearer": {"type": "http", "scheme": "bearer", "description": "External engineering-console token; never persisted in browser storage."}},
"parameters": {
"AlertId": {"name": "alert_id", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/AlertId"}},
"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": true, "schema": {"type": "string", "minLength": 8, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}
},
"responses": {
"BadRequest": {"description": "Invalid bounded input", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
"Unauthorized": {"description": "Missing or invalid console token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
"NotFound": {"description": "Alert absent from configured tenant/site", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
},
"schemas": {
"AlertId": {"type": "string", "pattern": "^alt_[0-9A-HJKMNP-TV-Z]{26}$"},
"AlertState": {"type": "string", "enum": ["open", "acknowledged", "closed"]},
"Severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"AlertSummary": {"type": "object", "additionalProperties": false, "required": ["id", "severity", "title", "state", "rule_key", "rule_version", "created_at"], "properties": {"id": {"$ref": "#/components/schemas/AlertId"}, "severity": {"$ref": "#/components/schemas/Severity"}, "title": {"type": "string", "minLength": 1, "maxLength": 120}, "state": {"$ref": "#/components/schemas/AlertState"}, "rule_key": {"type": "string"}, "rule_version": {"type": "integer", "minimum": 1}, "created_at": {"type": "string", "format": "date-time"}}},
"AlertPage": {"type": "object", "additionalProperties": false, "required": ["items", "next_cursor"], "properties": {"items": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/AlertSummary"}}, "next_cursor": {"oneOf": [{"$ref": "#/components/schemas/AlertId"}, {"type": "null"}]}}},
"EventRef": {"type": "object", "additionalProperties": false, "required": ["id", "device_id", "kind", "severity", "occurred_at"], "properties": {"id": {"type": "string", "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"}, "device_id": {"type": "integer", "minimum": 1}, "kind": {"type": "string"}, "severity": {"$ref": "#/components/schemas/Severity"}, "occurred_at": {"type": "string", "format": "date-time"}}},
"Transition": {"type": "object", "additionalProperties": false, "required": ["sequence", "from_state", "to_state", "actor_ref", "note", "occurred_at"], "properties": {"sequence": {"type": "integer", "minimum": 1}, "from_state": {"oneOf": [{"$ref": "#/components/schemas/AlertState"}, {"type": "null"}]}, "to_state": {"$ref": "#/components/schemas/AlertState"}, "actor_ref": {"type": "string"}, "note": {"oneOf": [{"type": "string", "maxLength": 500}, {"type": "null"}]}, "occurred_at": {"type": "string", "format": "date-time"}}},
"AlertDetail": {"allOf": [{"$ref": "#/components/schemas/AlertSummary"}, {"type": "object", "additionalProperties": false, "required": ["events", "transitions", "evidence_status", "delivery_status"], "properties": {"events": {"type": "array", "items": {"$ref": "#/components/schemas/EventRef"}}, "transitions": {"type": "array", "items": {"$ref": "#/components/schemas/Transition"}}, "evidence_status": {"const": "not_enabled"}, "delivery_status": {"const": "not_enabled"}}}]},
"CommandBody": {"type": "object", "additionalProperties": false, "properties": {"note": {"oneOf": [{"type": "string", "maxLength": 500}, {"type": "null"}]}}},
"CommandResponse": {"type": "object", "additionalProperties": false, "required": ["alert_id", "state", "actor_ref", "occurred_at"], "properties": {"alert_id": {"$ref": "#/components/schemas/AlertId"}, "state": {"$ref": "#/components/schemas/AlertState"}, "actor_ref": {"type": "string"}, "occurred_at": {"type": "string", "format": "date-time"}, "code": {"type": "string", "enum": ["already_acknowledged", "acknowledgement_required", "already_closed", "invalid_state"]}}},
"Error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string"}, "message": {"type": "string"}}}
}
}
}
+9 -8
View File
@@ -4,29 +4,30 @@
## 当前阶段
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路匿名区域事件工程原型及 Brain→Bell 可靠事件 ingress。客户网络尚未提供,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。
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路匿名区域事件工程原型、Brain→Bell 可靠事件 ingress 及 Bell 规则→Alert→ack/close 工程纵切。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome、Sense 审计 relay、默认关闭的 Brain 事件 ingress、规则版本/evaluation/Alert 状态机和回环工程值班台。Brain 已包含单路 source/detector/track/zone-entry、回环可视化页与仓库外 SQLite 事件 Outbox,但仍没有生产模型/GPU pipeline、公共管理服务/JWT、证据切片、升级/通知或正式 Web/H5。
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
## 仓库现实
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 两条内部 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、新增/启用和“降低配额不关闭已有流”均有测试。
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`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-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。
- T-017 冻结的只是工程原型:Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80;默认 2 FPS 合成 fixture,可选从仓库外文件读取 MediaMTX/RTSP。合成框不是模型输出,HOG/SVM 不是生产 detector,100 项内存事件环不是可靠投递;候选不含 Bell 平台 ID。
- T-018 Sense 回环工程控制台已通过项目负责人产品验收:默认关闭并强制 Sense/MediaMTX 播放端显式回环,直接读取 Control API v1 的设备、配额、期望态/实际态/收敛事实;设备 cursor 每页 16 项,只有成像、启用、在线且已收敛的设备可选,最多 4 路按需嵌入 MediaMTX WebRTC 页面。token 只驻留页面内存,刷新即丢失;该验收不包含录像/回放、非回环生产认证、真实 16 机或生产 SLA。
- T-019 冻结 `brain-event-ingress-v1`:单事件 1 MiB/10 秒、producer-bound HMAC、300 秒时钟窗、600 秒 nonce、SQLite WAL Outbox、1~300 秒退避、100 次/10,000 条边界。Bell 以受控绑定复查当前 Site/Area/Sense Device,永久 `(producer_id,source_event_id)` 收据与事件同事务提交;同 candidate 跨重启返回原 Bell ID,不同 candidate 稳定冲突。
- T-020 冻结 `bell-alert-console-v1` 回环工程契约:规则配置从仓库外绝对 JSON 加载并按 canonical hash 幂等发布不可变版本;Event durable sweep、evaluation、`alt_` Alert、Event 关联和 `open → acknowledged → closed` transition 只追加。首次 ack 获胜,后到者返回实际 actor/time;8~128 字符命令 key 由永久收据跨重启重放。列表默认 16、最大 100;值班台无 CDN/浏览器持久存储,明确显示证据及升级/投递尚未启用。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json` 和 `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 明确保留单实例开发语义。
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
@@ -48,7 +49,7 @@ Windows:
go -C Sense run ./cmd/sense-api
```
Bell 事件域与两条内部 ingress 验证:
Bell 事件、两条内部 ingress 与 Alert 工程纵切验证:
```powershell
go -C Bell test ./...
@@ -99,12 +100,12 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
- 生产 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` 处理文档与配置。
## 下一步
客户网络仍未提供,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
View File
@@ -26,8 +26,9 @@
| --- | --- | --- |
| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 |
| `/sense-console/` | Sense T-018 回环工程服务 | 默认关闭;复用 Control API v1,设备分页默认 16 项,最多 4 路按需 WebRTC 预览。只允许 Sense 与 MediaMTX 播放端均为显式回环地址;不是录像/回放或 Bell 公共业务路由 |
| `/bell-console/` | Bell T-020 回环工程服务 | 默认关闭;展示真实 Alert、关联 Event、规则版本和 append-only transition,支持首次 ack 与确认后关闭。整个 Bell 服务必须显式回环监听;token 仅驻留页面内存,tenant/Site/actor 取服务端上下文;证据、升级和投递未启用时必须明确降级 |
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页或 T-018 Sense 回环控制台暴露到非可信网络或嵌入客户公共系统。
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页、T-018 Sense 控制台或 T-020 Bell 工程值班台暴露到非可信网络或嵌入客户公共系统。
## 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 管理端暴露给业务用户。
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
+26 -4
View File
@@ -3,12 +3,12 @@ id: T-020
title: 建立 Bell 规则到 Alert 与 ack 的最小可见纵切
phase: 3
deps: [T-015, T-019]
status: TODO
status: DONE
created: 2026-08-11
issue: 71
context_ref: null
claim_branch: null
work_branch: null
context_ref: 477afa6ba2def34224f2dfeedc5b14421ec27650
claim_branch: claims/T-020
work_branch: agent/codex/T-020
write_paths:
- docs/tasks/T-020.md
- Bell/
@@ -86,3 +86,25 @@ T-019 已把 Brain 候选可靠地写成 Bell 不可变 Event,但 Bell 尚未
- 复用已确认 Bell 原型及 US-003/US-005、IX-005~IX-008/IX-013;工程值班台不冻结最终前端框架。
- 当前会话未提供 codebase-memory MCP 图工具,按仓库规则降级为定向读取与 `rg`。任务定义前 `./init.ps1` 基线通过:77 项根测试、30 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
- 任务定义已合入默认分支并创建唯一 Gitea Issue #71;本映射合入默认分支后才允许添加 `status/todo` 并由 dispatcher 分配。
### 2026-08-11 领取
- dispatcher `ila` 已在 Issue #71 核对依赖、写路径与活跃任务,并分配给 `codex`。
- 基线提交:`477afa6ba2def34224f2dfeedc5b14421ec27650`;claim 分支:`claims/T-020`;工作分支:`agent/codex/T-020`。
### 2026-08-11 实现与自动化验收
- PostgreSQL 新增可重放 `018`~`019`,Bell schema 提升到 v6:规则版本、Event sweep、rule evaluation、Alert 身份、Alert/Event 多对多关系、transition 和命令收据全部只追加;`bell_runtime` 仅有 `SELECT/INSERT`,数据库 trigger 同时拒绝 owner 路径的意外 UPDATE/DELETE。Alert 对 `rule_evaluation_id` 唯一,数据库层保证一个 Event/Rule evaluation 最多一个 Alert。
- 规则配置从仓库外绝对 JSON 加载,限制 256 KiB/256 条、拒绝未知字段和重复 key;相同 canonical SHA-256 幂等复用版本。单一可取消 worker 使用 durable sweep 与事务级 advisory lock,多实例对同 Event 收敛;无规则和 no-match 同样落 sweep,规则发布不追溯重算已经 sweep 的历史 Event。
- Bell 生成 `alt_` ULID,命中 evaluation、Alert、Event 关联和初始 `open` transition 同事务提交。状态只允许 `open → acknowledged → closed`;永久命令收据按 tenant/idempotency key 重放原 status/body,同 key 改命令/actor/note 稳定冲突。并发后到的 ack 返回 `409 already_acknowledged` 和真实首位 actor/time,不覆盖历史。
- 冻结并实现 `bell-alert-console-v1` 回环工程 API:列表默认 16/最大 100,tenant/Site/actor 只取启动上下文;Bearer token 来自仓库外文件并用 constant-time 比较。规则 worker 与控制台分别默认关闭;控制台要求整个 Bell 监听地址显式回环,不返回 Event payload、流 URI、凭据、DSN 或 token。
- `Bell/web/` 使用 Go embed、自包含 HTML/CSS/原生 JavaScript,无 npm/CDN/浏览器持久存储。页面显示真实规则版本、关联 Event 和 append-only 时间线;证据、升级/投递明确为未启用。按 `ui-ux-pro-max` 检查落实骨架加载、空态/重试、冲突/无权限反馈、颜色+文字、44px target、键盘入口、`aria-live` 和 reduced-motion。
- Edge headless + 脱敏回环 fixture 完成 1440×900 与 CDP 375×812 渲染检查:桌面双栏、移动单栏均可操作,375 viewport 的 `innerWidth/scrollWidth/bodyWidth` 均为 `375`。首轮浏览器 QA 发现 `.workspace{display:grid}` 覆盖 `hidden` 导致授权前泄露空壳布局,已增加全局 `[hidden]{display:none!important}` 并复验;临时 fixture、token 和截图均未进入仓库。
- `./init.ps1` 最终通过:83 项根测试、30 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。独立 Bell test/vet/build、OpenAPI JSON、5 项 Alert 静态契约、11 项 PostgreSQL 静态契约和 `node --check Bell/web/assets/app.js` 通过。
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 最终通过:PostgreSQL 17.10 随机回环临时集群将 `001`~`019` 连续重放两遍;真实 repository 验证 rule publish/no-match、8 个 evaluator 只生成 1 个目标 Alert、8 个并发 ack 只有 1 个成功、late ack 观察同一 actor/time、ack 前 close 拒绝、ack 后 close、幂等冲突、重启恢复和不可变权限。临时集群已停止清理,现有 5432 listener 未改变。
- 本任务不需要摄像头、GPU、客户网络或通知供应商;结果不等于正式 Bell Web/H5、公共 JWT/RBAC、证据/通知、算法效果、真实多路或生产 SLA 验收。
### 2026-08-11 收尾
- 工作提交 `8208118` 已推送并创建 PR #73;Issue #71 的实现证据、提交、分支和任务文件一致。
- 自动化、隔离 PostgreSQL 与浏览器工程验收满足本任务门禁,无额外摄像头/客户网络人工门禁;任务标记 DONE,允许合并并关闭 Issue。
+3 -1
View File
@@ -95,7 +95,9 @@ try {
'014_audit_relay.sql',
'015_privileges_audit_relay.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")
}
+78
View File
@@ -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()
+20
View File
@@ -64,6 +64,8 @@ class PostgresContractTests(unittest.TestCase):
"015_privileges_audit_relay.sql",
"016_event_ingress.sql",
"017_privileges_event_ingress.sql",
"018_bell_alerts.sql",
"019_privileges_bell_alerts.sql",
],
names,
)
@@ -170,6 +172,24 @@ class PostgresContractTests(unittest.TestCase):
)
self.assertNotIn("endpoint_ref", privileges.split("revoke all on table sense.devices")[1])
def test_alert_history_is_append_only_and_runtime_is_least_privilege(self) -> None:
migration = normalized(migration_text("018_bell_alerts.sql"))
privileges = normalized(migration_text("019_privileges_bell_alerts.sql"))
for marker in (
"create table if not exists bell.rule_versions",
"create table if not exists bell.event_rule_sweeps",
"create table if not exists bell.rule_evaluations",
"create table if not exists bell.alerts",
"create table if not exists bell.alert_events",
"create table if not exists bell.alert_transitions",
"create table if not exists bell.alert_command_receipts",
"insert into bell.schema_migrations(version) values (6)",
):
self.assertIn(marker, migration)
self.assertIn("before update or delete", migration)
self.assertIn("grant select, insert on table", privileges)
self.assertNotRegex(privileges, r"grant\s+(?:update|delete|truncate|all)")
if __name__ == "__main__":
unittest.main()