Author SHA1 Message Date
QiuSW 3b2569ce04 docs: define T-020 alert acknowledgement slice
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 16:31:12 +08:00
ila 47b91b691c Merge pull request 'T-019: 建立 Brain 到 Bell 的可靠事件入站链路' (#69) from agent/codex/T-019 into main 2026-08-11 15:42:04 +08:00
QiuSW bd964e8831 feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:41:07 +08:00
ila b7fe44eeb0 Merge T-019 issue mapping 2026-08-11 15:04:15 +08:00
QiuSW bebc23cb6a docs: map T-019 to issue 67
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:04:04 +08:00
ila 7b95c7d155 Merge T-019 task definition 2026-08-11 15:03:35 +08:00
QiuSW 5a82035cc3 docs: define T-019 event ingress
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:03:20 +08:00
ila 607860b79a Merge pull request #65: T-018 Sense NVR management console
Product acceptance confirmed on 2026-08-11. Closes #63.
2026-08-11 14:42:44 +08:00
QiuSW 7c202b7ac8 docs(T-018): record product acceptance 2026-08-11 14:42:25 +08:00
QiuSW 7db707e596 feat(sense): add loopback NVR management console
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:38:22 +08:00
ila a4427a6d2b Merge T-018 issue mapping
Maps T-018 to Gitea issue 63.
2026-08-11 14:06:26 +08:00
QiuSW e3b166c5af docs(tasks): map T-018 to issue 63
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:06:12 +08:00
ila e46f175551 Merge T-018 Sense console task definition
Defines the Sense-first NVR management vertical slice and defers Brain ingress.
2026-08-11 14:05:25 +08:00
QiuSW 2ca51aa2bc docs(tasks): define T-018 Sense console vertical slice
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:05:11 +08:00
ila 19a3233f11 Merge T-017 Brain single-stream visual prototype
Validated engineering-only Python/OpenCV prototype; closes #59.
2026-08-11 11:03:44 +08:00
QiuSW a2790c1f5e feat(brain): add single-stream visual prototype (T-017)
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 11:02:37 +08:00
63 changed files with 6051 additions and 58 deletions
+33 -4
View File
@@ -1,15 +1,17 @@
# Bell 事件存储与内部审计入口
# Bell 事件存储、审计与 Brain 事件入口
Bell 当前实现 M3 的事件域基础及 Sense 审计 relay 的最小内部 HTTP 服务:
Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的 Brain 事件 ingress:
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
- PostgreSQL `bell.events` 保存不可变事实;后续 outcome 追加到 `bell.event_outcomes`。
- `bell_runtime` 对三张不可变事实表只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;仅可在短期 `audit_relay_receipts` 表查询、插入和清理过期收据。
- `bell_runtime` 对事件、outcome、全局审计和 Brain 来源收据只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;只可清理两张短期 nonce 收据表。
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。审计 relay 只服务 Sense,不得把 `internal/event` 的 Go 类型或该 HMAC 适配器当成公共协议。
公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。两条 HMAC ingress 都是内部适配器,不得当成 Bell 公共协议或共用 key。
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
@@ -17,6 +19,33 @@ Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对
go -C Bell run ./cmd/bell-api
```
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`017` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
```powershell
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
$env:BELL_EVENT_INGRESS_KEYS_FILE = 'D:\private\brain-event-keys.json'
$env:BELL_EVIDENCE_FORBIDDEN_NAMES_FILE = 'D:\private\forbidden-evidence-names.txt'
go -C Bell run ./cmd/bell-api
```
forbidden-names 文件每行一个不得出现在证据 URI 的租户/客户标记,至少一行、最多 256 行。key 文件格式见 `docs/contracts/README.md`。仓库不提供真实 secret、DSN、绑定或客户名称;绑定只能引用已经存在且同 Area/modality 的设备。
管理员在事务中核对逻辑资源后,可按下列列签名配置一条视频绑定;数字 ID 是冻结事件契约使用的稳定正整数,不是把文本 ID 强转为数字:
```sql
INSERT INTO bell.event_ingress_bindings(
producer_id, tenant_id, site_id, device_id,
logical_tenant_id, logical_site_id, logical_device_id, logical_area_id,
modality, enabled
) VALUES (
'brain-main', 1, 1, 1,
'tenant-logical-id', 'site-logical-id', 'device-logical-id', 'area-logical-id',
'video', true
);
```
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
## 验证
```powershell
+85 -10
View File
@@ -11,24 +11,31 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/audit"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
"yovision/bell/internal/store"
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
}
func main() {
@@ -41,11 +48,20 @@ func main() {
func loadConfiguration() (configuration, error) {
value := configuration{
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
}
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
case "", "false":
case "true":
value.eventIngressEnabled = true
default:
return configuration{}, errors.New("BELL_EVENT_INGRESS_ENABLED must be true or false")
}
if value.dsn == "" {
return configuration{}, errors.New("BELL_DB_DSN is required")
@@ -65,6 +81,14 @@ func loadConfiguration() (configuration, error) {
if (value.tlsCert == "") != (value.tlsKey == "") {
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
}
if value.eventIngressEnabled {
if value.eventKeyFile == "" || !filepath.IsAbs(value.eventKeyFile) {
return configuration{}, errors.New("BELL_EVENT_INGRESS_KEYS_FILE must be an absolute external path when event ingress is enabled")
}
if value.forbiddenNamesFile == "" || !filepath.IsAbs(value.forbiddenNamesFile) {
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
}
}
return value, nil
}
@@ -106,6 +130,32 @@ func run(logger *slog.Logger) error {
}
mux := http.NewServeMux()
mux.Handle(audit.RelayPath, handler)
if cfg.eventIngressEnabled {
if err := repository.EventIngressReady(ctx); err != nil {
return err
}
eventKeys, err := ingress.LoadKeys(cfg.eventKeyFile)
if err != nil {
return err
}
forbiddenNames, err := loadForbiddenNames(cfg.forbiddenNamesFile)
if err != nil {
return err
}
guard, err := event.NewEvidenceGuard(forbiddenNames...)
if err != nil {
return err
}
factory, err := event.NewFactory(contracts.EventV01Schema, event.ULIDGenerator{}, repository, guard)
if err != nil {
return err
}
eventHandler, err := ingress.NewHandler(repository, eventKeys, factory)
if err != nil {
return err
}
mux.Handle(ingress.Path, eventHandler)
}
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writeStatus(writer, http.StatusOK, "ok")
})
@@ -114,6 +164,12 @@ func run(logger *slog.Logger) error {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
if cfg.eventIngressEnabled {
if err := repository.EventIngressReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
writeStatus(writer, http.StatusOK, "ready")
})
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
@@ -138,6 +194,25 @@ func run(logger *slog.Logger) error {
return server.Shutdown(shutdownContext)
}
func loadForbiddenNames(path string) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 64<<10 {
return nil, errors.New("read Bell evidence forbidden-names file")
}
var values []string
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
values = append(values, line)
}
if len(values) == 0 || len(values) > 256 {
return nil, errors.New("Bell evidence forbidden-names file must contain 1 to 256 names")
}
return values, nil
}
func writeStatus(writer http.ResponseWriter, status int, value string) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
+20
View File
@@ -31,3 +31,23 @@ func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
t.Fatalf("remote TLS Bell bind rejected: %v", err)
}
}
func TestEventIngressIsDisabledByDefaultAndRequiresExternalPolicy(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "audit.json"))
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
value, err := loadConfiguration()
if err != nil || value.eventIngressEnabled {
t.Fatalf("default event ingress configuration: %+v %v", value, err)
}
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
if _, err := loadConfiguration(); err == nil {
t.Fatal("event ingress without keys and evidence policy was accepted")
}
t.Setenv("BELL_EVENT_INGRESS_KEYS_FILE", filepath.Join(t.TempDir(), "event-keys.json"))
t.Setenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE", filepath.Join(t.TempDir(), "names.txt"))
value, err = loadConfiguration()
if err != nil || !value.eventIngressEnabled {
t.Fatalf("valid event ingress configuration rejected: %+v %v", value, err)
}
}
+1
View File
@@ -163,6 +163,7 @@ func (e Event) TenantID() int64 { return e.shape.TenantID }
func (e Event) SiteID() int64 { return e.shape.SiteID }
func (e Event) DeviceID() int64 { return e.shape.DeviceID }
func (e Event) SourceEventID() string { return e.shape.SourceEventID }
func (e Event) Sensors() []Sensor { return append([]Sensor(nil), e.shape.Sensors...) }
func (e Event) Kind() string { return e.shape.Kind }
func (e Event) Severity() string { return e.shape.Severity }
func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt }
+270
View File
@@ -0,0 +1,270 @@
// Package ingress authenticates Brain event candidates and delegates their
// atomic persistence to Bell's repository.
package ingress
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"yovision/bell/internal/event"
)
const (
Path = "/internal/v1/event-candidates"
MaxBodyBytes = 1 << 20
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
)
var (
ErrReplayConflict = errors.New("event ingress replay conflict")
ErrSourceConflict = errors.New("event ingress source event conflict")
ErrIdentityDenied = errors.New("event ingress identity denied")
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
producerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
type Result struct {
SchemaVersion int `json:"schema_version"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventID string `json:"event_id"`
Status string `json:"status"`
HTTPStatus int `json:"-"`
}
type Repository interface {
Replay(
context.Context,
string,
string,
[sha256.Size]byte,
string,
string,
[sha256.Size]byte,
) (Result, bool, error)
ProcessEvent(
context.Context,
string,
string,
[sha256.Size]byte,
string,
[sha256.Size]byte,
event.Event,
) (Result, error)
}
type Handler struct {
repository Repository
keys map[string]Key
factory *event.Factory
now func() time.Time
}
func NewHandler(repository Repository, keys map[string]Key, factory *event.Factory) (*Handler, error) {
if repository == nil || factory == nil || len(keys) == 0 {
return nil, errors.New("event ingress handler dependencies are required")
}
copyKeys := make(map[string]Key, len(keys))
for id, key := range keys {
if !keyIDPattern.MatchString(id) || !producerIDPattern.MatchString(key.ProducerID) || len(key.Secret) < 32 {
return nil, errors.New("invalid event ingress handler key")
}
copyKeys[id] = Key{ProducerID: key.ProducerID, Secret: append([]byte(nil), key.Secret...)}
}
return &Handler{repository: repository, keys: copyKeys, factory: factory, now: time.Now}, nil
}
type envelope struct {
SchemaVersion int `json:"schema_version"`
ProducerID string `json:"producer_id"`
Candidate json.RawMessage `json:"candidate"`
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != Path {
writeError(writer, http.StatusNotFound, "not_found", "")
return
}
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
if err != nil || len(body) > MaxBodyBytes {
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
return
}
keyID := request.Header.Get(HeaderKeyID)
timestamp := request.Header.Get(HeaderTimestamp)
nonce := request.Header.Get(HeaderNonce)
provided := request.Header.Get(HeaderSignature)
key, ok := h.keys[keyID]
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
expected := signature(key.Secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
if !hmac.Equal(signatureBytes, expected) {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
var value envelope
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil || value.SchemaVersion != 1 || !producerIDPattern.MatchString(value.ProducerID) || len(value.Candidate) == 0 {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
if value.ProducerID != key.ProducerID {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
canonicalCandidate, err := canonicalJSON(value.Candidate)
if err != nil {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
sourceEventID, err := candidateSourceEventID(canonicalCandidate)
if err != nil {
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", "schema_invalid")
return
}
requestHash := sha256.Sum256(body)
candidateHash := sha256.Sum256(canonicalCandidate)
replay, found, err := h.repository.Replay(
request.Context(), keyID, nonce, requestHash, value.ProducerID, sourceEventID, candidateHash,
)
switch {
case errors.Is(err, ErrReplayConflict):
writeError(writer, http.StatusConflict, "replay_conflict", "")
return
case errors.Is(err, ErrSourceConflict):
writeError(writer, http.StatusConflict, "source_event_conflict", "")
return
case err != nil:
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
return
case found:
writeJSON(writer, replay.HTTPStatus, replay)
return
}
created, err := h.factory.Create(request.Context(), canonicalCandidate)
if err != nil {
handleFactoryError(writer, err)
return
}
result, err := h.repository.ProcessEvent(request.Context(), keyID, nonce, requestHash, value.ProducerID, candidateHash, created)
switch {
case errors.Is(err, ErrReplayConflict):
writeError(writer, http.StatusConflict, "replay_conflict", "")
case errors.Is(err, ErrSourceConflict):
writeError(writer, http.StatusConflict, "source_event_conflict", "")
case errors.Is(err, ErrIdentityDenied):
writeError(writer, http.StatusForbidden, "identity_denied", "")
case err != nil:
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
default:
writeJSON(writer, result.HTTPStatus, result)
}
}
func handleFactoryError(writer http.ResponseWriter, err error) {
var validation *event.ValidationError
if !errors.As(err, &validation) {
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
return
}
switch validation.Code {
case event.CodePayloadTooLarge:
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
case event.CodePrivacyDenied:
writeError(writer, http.StatusForbidden, "privacy_denied", "")
case event.CodePrivacyUnavailable:
writeError(writer, http.StatusServiceUnavailable, "privacy_unavailable", "")
default:
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", string(validation.Code))
}
}
func canonicalJSON(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil || value == nil {
return nil, errors.New("invalid JSON")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("multiple JSON values")
}
return json.Marshal(value)
}
var sourceEventIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
func candidateSourceEventID(raw []byte) (string, error) {
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return "", errors.New("candidate is not an object")
}
var value string
if err := json.Unmarshal(object["source_event_id"], &value); err != nil || !sourceEventIDPattern.MatchString(value) {
return "", errors.New("candidate source event ID is invalid")
}
return value, nil
}
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func signature(secret []byte, canonical string) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return mac.Sum(nil)
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}
func writeError(writer http.ResponseWriter, status int, code, detail string) {
value := map[string]string{"error": code}
if detail != "" {
value["detail_code"] = detail
}
writeJSON(writer, status, value)
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
+176
View File
@@ -0,0 +1,176 @@
package ingress
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"yovision/bell/contracts"
"yovision/bell/internal/event"
)
type ingressIDs struct{}
func (ingressIDs) NewEventID() (string, error) {
return "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", nil
}
type ingressPrivacy struct{}
func (ingressPrivacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
return true, nil
}
type fakeRepository struct {
replayResult Result
replayFound bool
replayErr error
processErr error
processed int
}
func (f *fakeRepository) Replay(context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte) (Result, bool, error) {
return f.replayResult, f.replayFound, f.replayErr
}
func (f *fakeRepository) ProcessEvent(_ context.Context, _, _ string, _ [sha256.Size]byte, producer string, _ [sha256.Size]byte, value event.Event) (Result, error) {
f.processed++
if f.processErr != nil {
return Result{}, f.processErr
}
return Result{SchemaVersion: 1, ProducerID: producer, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}, nil
}
func ingressCandidate(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json"))
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
delete(object, "id")
object["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func newIngressHandler(t *testing.T, repository Repository) (*Handler, []byte) {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressIDs{}, ingressPrivacy{}, guard)
if err != nil {
t.Fatal(err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
handler, err := NewHandler(repository, map[string]Key{"brain-a": {ProducerID: "brain-main", Secret: secret}}, factory)
if err != nil {
t.Fatal(err)
}
handler.now = func() time.Time { return time.Unix(1_800_000_000, 0).UTC() }
return handler, secret
}
func signedRequest(t *testing.T, secret []byte, producer string, candidate []byte) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]any{"schema_version": 1, "producer_id": producer, "candidate": json.RawMessage(candidate)})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, Path, bytes.NewReader(body))
timestamp := strconv.FormatInt(1_800_000_000, 10)
nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef"))
request.Header.Set(HeaderKeyID, "brain-a")
request.Header.Set(HeaderTimestamp, timestamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, Path, timestamp, nonce, body))))
return request
}
func TestHandlerAcceptsSignedCandidateAndRejectsProducerSpoofing(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusCreated || repository.processed != 1 {
t.Fatalf("signed candidate: status=%d body=%s processed=%d", response.Code, response.Body.String(), repository.processed)
}
var result Result
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Status != "accepted" || result.EventID == "" {
t.Fatalf("invalid accepted response: %+v %v", result, err)
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-spoofed", ingressCandidate(t)))
if response.Code != http.StatusUnauthorized || repository.processed != 1 {
t.Fatalf("producer spoofing was not rejected: %d %s", response.Code, response.Body.String())
}
}
func TestHandlerReturnsDurableReplayAndStableConflicts(t *testing.T) {
repository := &fakeRepository{replayFound: true, replayResult: Result{
SchemaVersion: 1, ProducerID: "brain-main", SourceEventID: "source-1",
EventID: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", Status: "duplicate", HTTPStatus: 200,
}}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusOK || repository.processed != 0 {
t.Fatalf("durable replay did not bypass factory persistence: %d", response.Code)
}
repository.replayFound = false
repository.replayErr = ErrSourceConflict
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusConflict {
t.Fatalf("source conflict status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHandlerRejectsInvalidSignatureAndUpstreamPlatformID(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
request := signedRequest(t, secret, "brain-main", ingressCandidate(t))
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(make([]byte, 32)))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("invalid signature status=%d", response.Code)
}
var object map[string]any
if err := json.Unmarshal(ingressCandidate(t), &object); err != nil {
t.Fatal(err)
}
object["id"] = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
withID, _ := json.Marshal(object)
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", withID))
if response.Code != http.StatusUnprocessableEntity || repository.processed != 0 {
t.Fatalf("upstream ID status=%d body=%s", response.Code, response.Body.String())
}
}
+53
View File
@@ -0,0 +1,53 @@
package ingress
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
)
type keyDocument struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
ProducerID string `json:"producer_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
type Key struct {
ProducerID string
Secret []byte
}
func LoadKeys(path string) (map[string]Key, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Bell event ingress key file")
}
var document keyDocument
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid Bell event ingress key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid Bell event ingress key file")
}
values := make(map[string]Key, len(document.Keys))
for _, item := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
if err != nil || !keyIDPattern.MatchString(item.KeyID) || !producerIDPattern.MatchString(item.ProducerID) || len(secret) < 32 {
return nil, errors.New("invalid Bell event ingress key")
}
if _, exists := values[item.KeyID]; exists {
return nil, errors.New("duplicate Bell event ingress key ID")
}
values[item.KeyID] = Key{ProducerID: item.ProducerID, Secret: append([]byte(nil), secret...)}
}
return values, nil
}
@@ -0,0 +1,289 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
)
func (p *Postgres) EventIngressReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 5 {
return errors.New("postgres Bell schema migration v5 is required for event ingress")
}
var bindingSelect bool
var receiptSelect, receiptInsert, receiptUpdate, receiptDelete, receiptTruncate bool
var nonceSelect, nonceInsert, nonceUpdate, nonceDelete, nonceTruncate bool
var deviceID, deviceArea, deviceModality bool
var deviceEndpoint, deviceCredential, deviceProfile, devicePath bool
var siteID, areaPolicy bool
err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.event_ingress_bindings', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'INSERT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'UPDATE'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'DELETE'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'TRUNCATE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'INSERT'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'UPDATE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'DELETE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'TRUNCATE'),
has_column_privilege(current_user, 'sense.devices', 'id', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'area_id', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'modality', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'endpoint_ref', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'credential_ref', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'profile_token', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'path_name', 'SELECT'),
has_column_privilege(current_user, 'bell.sites', 'id', 'SELECT'),
has_column_privilege(current_user, 'bell.areas', 'capture_policy', 'SELECT')`).Scan(
&bindingSelect,
&receiptSelect, &receiptInsert, &receiptUpdate, &receiptDelete, &receiptTruncate,
&nonceSelect, &nonceInsert, &nonceUpdate, &nonceDelete, &nonceTruncate,
&deviceID, &deviceArea, &deviceModality,
&deviceEndpoint, &deviceCredential, &deviceProfile, &devicePath,
&siteID, &areaPolicy,
)
if err != nil {
return errors.New("verify Bell event ingress privileges")
}
if !bindingSelect || !receiptSelect || !receiptInsert || receiptUpdate || receiptDelete || receiptTruncate ||
!nonceSelect || !nonceInsert || nonceUpdate || !nonceDelete || nonceTruncate ||
!deviceID || !deviceArea || !deviceModality || deviceEndpoint || deviceCredential || deviceProfile || devicePath ||
!siteID || !areaPolicy {
return errors.New("Bell event ingress privileges violate append-only boundary")
}
return nil
}
// VideoAllowed implements event.PrivacyPolicy using current Bell/Sense facts.
// Exact producer ownership is checked again in ProcessEvent.
func (p *Postgres) VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error) {
return bindingAllowed(ctx, p.db, "", tenantID, siteID, deviceID, "video")
}
type rowQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func bindingAllowed(
ctx context.Context,
query rowQuerier,
producerID string,
tenantID, siteID, deviceID int64,
modality string,
) (bool, error) {
producerClause := ""
arguments := []any{tenantID, siteID, deviceID, modality}
if producerID != "" {
producerClause = " AND binding.producer_id=$5"
arguments = append(arguments, producerID)
}
statement := `SELECT EXISTS (
SELECT 1
FROM bell.event_ingress_bindings AS binding
JOIN bell.sites AS site
ON site.tenant_id=binding.logical_tenant_id
AND site.id=binding.logical_site_id
JOIN bell.areas AS area
ON area.tenant_id=binding.logical_tenant_id
AND area.site_id=binding.logical_site_id
AND area.id=binding.logical_area_id
JOIN sense.devices AS device
ON device.tenant_id=binding.logical_tenant_id
AND device.site_id=binding.logical_site_id
AND device.id=binding.logical_device_id
AND device.area_id=binding.logical_area_id
AND device.modality=binding.modality
WHERE binding.tenant_id=$1 AND binding.site_id=$2 AND binding.device_id=$3
AND binding.modality=$4 AND binding.enabled
AND site.deleted_at IS NULL AND area.deleted_at IS NULL
AND (binding.modality <> 'video' OR area.capture_policy='video_allowed')` + producerClause + `
)`
var allowed bool
if err := query.QueryRowContext(ctx, statement, arguments...).Scan(&allowed); err != nil {
return false, fmt.Errorf("resolve Bell event ingress binding: %w", err)
}
return allowed, nil
}
func (p *Postgres) Replay(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
producerID, sourceEventID string,
candidateHash [sha256.Size]byte,
) (ingress.Result, bool, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return ingress.Result{}, false, errors.New("begin Bell event replay check")
}
defer tx.Rollback()
if err := lockIngress(ctx, tx, keyID, nonce, producerID, sourceEventID); err != nil {
return ingress.Result{}, false, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
return ingress.Result{}, false, errors.New("expire Bell event ingress nonces")
}
if value, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
if err == nil {
err = tx.Commit()
}
return value, found, err
}
var storedHash []byte
var eventID string
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, sourceEventID).Scan(&storedHash, &eventID)
if err == nil {
if !bytes.Equal(storedHash, candidateHash[:]) {
return ingress.Result{}, false, ingress.ErrSourceConflict
}
value := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: sourceEventID, EventID: eventID, Status: "duplicate", HTTPStatus: 200}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, value); err != nil {
return ingress.Result{}, false, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, false, errors.New("commit Bell source replay")
}
return value, true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, false, errors.New("read Bell event source receipt")
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, false, errors.New("commit Bell event replay miss")
}
return ingress.Result{}, false, nil
}
func (p *Postgres) ProcessEvent(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
producerID string,
candidateHash [sha256.Size]byte,
value event.Event,
) (ingress.Result, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return ingress.Result{}, errors.New("begin Bell event ingress")
}
defer tx.Rollback()
if err := lockIngress(ctx, tx, keyID, nonce, producerID, value.SourceEventID()); err != nil {
return ingress.Result{}, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
return ingress.Result{}, errors.New("expire Bell event ingress nonces")
}
if replay, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
if err == nil {
err = tx.Commit()
}
return replay, err
}
var storedHash []byte
var storedEventID string
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, value.SourceEventID()).Scan(&storedHash, &storedEventID)
if err == nil {
if !bytes.Equal(storedHash, candidateHash[:]) {
return ingress.Result{}, ingress.ErrSourceConflict
}
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: storedEventID, Status: "duplicate", HTTPStatus: 200}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
return ingress.Result{}, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, errors.New("commit Bell source duplicate")
}
return result, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, errors.New("read Bell event source receipt")
}
for _, sensor := range value.Sensors() {
allowed, err := bindingAllowed(ctx, tx, producerID, value.TenantID(), value.SiteID(), sensor.DeviceID, sensor.Modality)
if err != nil {
return ingress.Result{}, err
}
if !allowed {
return ingress.Result{}, ingress.ErrIdentityDenied
}
}
digest := value.Digest()
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.events(
id, tenant_id, site_id, device_id, source_event_id, kind, severity,
occurred_at, detected_at, payload_hash, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(), value.SourceEventID(),
value.Kind(), value.Severity(), value.OccurredAt(), value.DetectedAt(), digest[:], value.JSON(),
); err != nil {
return ingress.Result{}, fmt.Errorf("insert Bell ingress event: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_receipts(
producer_id, source_event_id, candidate_hash, event_id
) VALUES ($1,$2,$3,$4)`, producerID, value.SourceEventID(), candidateHash[:], value.ID()); err != nil {
return ingress.Result{}, fmt.Errorf("insert Bell event source receipt: %w", err)
}
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
return ingress.Result{}, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, errors.New("commit Bell event ingress")
}
return result, nil
}
func lockIngress(ctx context.Context, tx *sql.Tx, keyID, nonce, producerID, sourceEventID string) error {
for _, value := range []string{"event-nonce:" + keyID + ":" + nonce, "event-source:" + producerID + ":" + sourceEventID} {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, value); err != nil {
return errors.New("lock Bell event ingress identity")
}
}
return nil
}
func readNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte) (ingress.Result, bool, error) {
var storedHash, body []byte
var status int
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text
FROM bell.event_ingress_nonces WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&storedHash, &status, &body)
if errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, false, nil
}
if err != nil {
return ingress.Result{}, false, errors.New("read Bell event ingress nonce")
}
if !bytes.Equal(storedHash, requestHash[:]) {
return ingress.Result{}, false, ingress.ErrReplayConflict
}
var value ingress.Result
if err := json.Unmarshal(body, &value); err != nil {
return ingress.Result{}, false, errors.New("decode Bell event ingress nonce")
}
value.HTTPStatus = status
return value, true, nil
}
func insertNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte, value ingress.Result) error {
body, err := json.Marshal(value)
if err != nil {
return errors.New("encode Bell event ingress response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_nonces(
key_id, nonce, request_hash, response_status, response_body, expires_at
) VALUES ($1,$2,$3,$4,$5::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], value.HTTPStatus, body); err != nil {
return errors.New("insert Bell event ingress nonce")
}
return nil
}
@@ -0,0 +1,197 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
)
type ingressStoreIDs struct{ id string }
func (value ingressStoreIDs) NewEventID() (string, error) { return value.id, nil }
func ingressStoreCandidate(t *testing.T, sourceEventID string) []byte {
t.Helper()
var object map[string]any
if err := json.Unmarshal(testCandidate(t, "brain-demo-v1"), &object); err != nil {
t.Fatal(err)
}
object["source_event_id"] = sourceEventID
object["tenant_id"] = float64(101)
object["site_id"] = float64(201)
object["device_id"] = float64(301)
object["sensors"] = []any{map[string]any{"device_id": float64(301), "modality": "video", "role": "primary"}}
object["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func ingressStoreEvent(t *testing.T, repository *Postgres, id, sourceEventID string) event.Event {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{id}, repository, guard)
if err != nil {
t.Fatal(err)
}
value, err := factory.Create(context.Background(), ingressStoreCandidate(t, sourceEventID))
if err != nil {
t.Fatal(err)
}
return value
}
func TestPostgresEventIngressAtomicSourceReceipt(t *testing.T) {
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
if dsn == "" || adminDSN == "" {
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
}
admin, err := sql.Open("pgx", adminDSN)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
ctx := context.Background()
now := time.Now().UTC()
statements := []struct {
query string
args []any
}{
{`INSERT INTO bell.sites(tenant_id,id,name) VALUES ('ingress-tenant','ingress-site','Ingress Site')`, nil},
{`INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('ingress-tenant','ingress-site','ingress-area','Ingress Area','video_allowed')`, nil},
{`INSERT INTO sense.devices(id,tenant_id,site_id,serial_number,name,modality,desired_state,actual_state,area_id,created_at,updated_at)
VALUES ('ingress-device','ingress-tenant','ingress-site','INGRESS-SERIAL','Ingress Device','video','enabled','online','ingress-area',$1,$1)`, []any{now}},
{`INSERT INTO bell.event_ingress_bindings(
producer_id,tenant_id,site_id,device_id,logical_tenant_id,logical_site_id,logical_device_id,logical_area_id,modality
) VALUES ('brain-main',101,201,301,'ingress-tenant','ingress-site','ingress-device','ingress-area','video')`, nil},
}
for _, statement := range statements {
if _, err := admin.ExecContext(ctx, statement.query, statement.args...); err != nil {
t.Fatal(err)
}
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := repository.EventIngressReady(ctx); err != nil {
t.Fatal(err)
}
candidate := ingressStoreCandidate(t, "BRN-ingress-0001")
candidateHash := sha256.Sum256(candidate)
value := ingressStoreEvent(t, repository, "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C", "BRN-ingress-0001")
requestHash := sha256.Sum256([]byte("first-request"))
result, err := repository.ProcessEvent(ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, "brain-main", candidateHash, value)
if err != nil || result.Status != "accepted" || result.HTTPStatus != 201 {
t.Fatalf("first ingress: %+v %v", result, err)
}
// This is the crash-after-Bell-commit case: Brain uses a new nonce before
// it has locally recorded the first response.
replayed, found, err := repository.Replay(
ctx, "brain-a", "BBBBBBBBBBBBBBBBBBBBBB", sha256.Sum256([]byte("retry-request")),
"brain-main", "BRN-ingress-0001", candidateHash,
)
if err != nil || !found || replayed.Status != "duplicate" || replayed.EventID != result.EventID {
t.Fatalf("durable source replay: %+v found=%v err=%v", replayed, found, err)
}
conflictHash := sha256.Sum256([]byte("changed-candidate"))
if _, _, err := repository.Replay(
ctx, "brain-a", "CCCCCCCCCCCCCCCCCCCCCC", sha256.Sum256([]byte("conflict-request")),
"brain-main", "BRN-ingress-0001", conflictHash,
); !errors.Is(err, ingress.ErrSourceConflict) {
t.Fatalf("expected source conflict, got %v", err)
}
if _, _, err := repository.Replay(
ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", sha256.Sum256([]byte("different-request")),
"brain-main", "BRN-ingress-0001", candidateHash,
); !errors.Is(err, ingress.ErrReplayConflict) {
t.Fatalf("expected nonce replay conflict, got %v", err)
}
var wait sync.WaitGroup
errorsSeen := make(chan error, 8)
concurrentValues := make([]event.Event, 8)
for index := range concurrentValues {
id := fmt.Sprintf("evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2%c", "DEFGHJKM"[index])
concurrentValues[index] = ingressStoreEvent(t, repository, id, "BRN-ingress-0001")
}
for index := 0; index < 8; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
nonce := base64Nonce(index)
response, err := repository.ProcessEvent(
ctx, "brain-a", nonce, sha256.Sum256([]byte(nonce)), "brain-main", candidateHash, concurrentValues[index],
)
if err != nil || response.Status != "duplicate" || response.EventID != result.EventID {
errorsSeen <- fmt.Errorf("concurrent duplicate %d: %+v %w", index, response, err)
}
}(index)
}
wait.Wait()
close(errorsSeen)
for err := range errorsSeen {
t.Error(err)
}
var eventCount, receiptCount int
if err := db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM bell.events WHERE tenant_id=101 AND site_id=201 AND source_event_id='BRN-ingress-0001'),
(SELECT count(*) FROM bell.event_ingress_receipts WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001')`).Scan(&eventCount, &receiptCount); err != nil {
t.Fatal(err)
}
if eventCount != 1 || receiptCount != 1 {
t.Fatalf("concurrent ingress created events=%d receipts=%d", eventCount, receiptCount)
}
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy='non_imaging_only'
WHERE tenant_id='ingress-tenant' AND id='ingress-area'`); err != nil {
t.Fatal(err)
}
allowed, err := repository.VideoAllowed(ctx, 101, 201, 301)
if err != nil || allowed {
t.Fatalf("privacy change did not fail closed: allowed=%v err=%v", allowed, err)
}
guard, _ := event.NewEvidenceGuard("private-customer")
factory, _ := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{"evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2N"}, repository, guard)
_, err = factory.Create(ctx, ingressStoreCandidate(t, "BRN-ingress-0002"))
var validation *event.ValidationError
if !errors.As(err, &validation) || validation.Code != event.CodePrivacyDenied {
t.Fatalf("privacy denial code drift: %v", err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.event_ingress_receipts SET event_id=event_id
WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001'`); err == nil {
t.Fatal("runtime updated immutable event source receipt")
}
}
func base64Nonce(index int) string {
return fmt.Sprintf("D%021d", index)
}
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.venv/
artifacts/
*.local.json
*.url
-1
View File
@@ -1 +0,0 @@
+86
View File
@@ -0,0 +1,86 @@
# YoVision Brain 单路工程原型
T-017 提供可见、可重复的单路工程链路;T-019 为 `zone_entry` 候选增加默认关闭的可靠 Brain→Bell transport。它仍不是生产模型或 NVR:推理演示保持单路,事件先进入仓库外 SQLite Outbox,再由内部 HMAC client 投递给 Bell。
## 环境
- Python `3.10.11`
- NumPy `1.26.4`
- OpenCV `4.9.0.80`
本任务复用本机已验证版本;新环境显式安装:
```powershell
python -m pip install -r Brain/requirements-demo.txt
```
本机冻结的是 `opencv-python`;同一环境只能安装一种提供 `cv2` 命名空间的 OpenCV wheel。不要同时安装标准、headless 和 contrib 变体。后续生产容器若改用 headless,必须在独立任务核对 wheel、许可证与完整回归,不能在本版本号下静默替换包名。
## 启动
无需摄像头的确定性合成回放:
```powershell
python -m Brain.yovision_brain --source synthetic
```
打开 `http://127.0.0.1:8090/brain-demo`。合成人员框由 fixture 提供,页面和事件均显式标识,不得拿它证明模型效果。
使用真实流时,把完整 RTSP URL 写入仓库外绝对路径文件。推荐指向 Sense 管理的 MediaMTX path,而不是绕过 Sense 固化摄像头地址:
```powershell
python -m Brain.yovision_brain --source stream --stream-url-file D:\private\brain-stream.url
```
URL 文件只允许一行、最多 4096 字节,服务不会在状态、页面和错误中返回其内容。演示服务只接受 `127.0.0.1`、`localhost` 或 `::1`;没有暴露到局域网的开关。
真实流使用 OpenCV 内置 HOG/SVM 人员检测和轻量 centroid tracker,只验证可替换端口与区域链路。它不是 M3 生产模型,不能据此承诺召回率、误报率、GPU 容量或 16/128 路能力。
## 可选 Brain → Bell 事件投递
默认不传 `--event-ingress-config`,工程原型行为与 T-017 相同。启用时,配置、key 和 SQLite 文件都必须位于仓库外绝对路径;HTTP 只允许显式回环,非回环必须 HTTPS。key 文件也供 Bell event ingress 读取,但不得与 Sense 审计 key 混用:
```json
{"version":1,"keys":[{"key_id":"brain-a","producer_id":"brain-main","secret_base64url":"<至少32字节随机值的无填充base64url>"}]}
```
Brain 配置示例(占位 ID 必须与 Bell 管理员创建的 `bell.event_ingress_bindings` 一致):
```json
{
"version": 1,
"producer_id": "brain-main",
"tenant_id": 1,
"site_id": 1,
"device_id": 1,
"modality": "video",
"severity": "medium",
"config_version": "brain-demo-v1",
"bell_url": "http://127.0.0.1:8081/internal/v1/event-candidates",
"key_id": "brain-a",
"key_file": "D:\\private\\brain-event-keys.json",
"outbox_path": "D:\\private\\brain-event-outbox.sqlite3"
}
```
```powershell
python -m Brain.yovision_brain --source synthetic --event-ingress-config D:\private\brain-event-ingress.json
```
Outbox 使用 WAL 和 `synchronous=FULL`,首次打开即绑定 producer/tenant/site/device;更换身份必须使用新的 Outbox 路径,不能让旧队列借新 producer 发送。最多保留 10,000 条待投递;只有 Bell `accepted/duplicate` 才确认 delivered。网络、401 与 5xx 按 1~300 秒退避、最多 100 次;稳定 4xx 进入 dead letter。终态行不会自动删除。状态页只显示计数和稳定错误码,不显示 URL、key、payload 或数据库路径;client 显式忽略环境 HTTP proxy。
## 验证
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
```
单文件 UI 原型位于 `docs/design/brain/index.html`,可直接打开;此时使用明确标识的离线原型数据。由本地服务打开时,同一文件消费回环 API,并用临时页面 token 保护区域写入。
## 边界
- `source_event_id` 由 Brain 产生,平台 `evt_` ULID 由 Bell 产生。
- 页面仍只保留最多 100 项的内存环;启用 T-019 后,可靠性由独立 SQLite Outbox 承担,不能从页面事件环推断投递状态。
- Bell 平台 ID 只由 Bell 生成;相同 producer/source candidate 重投返回原 ID,Brain 不改写 source ID 规避冲突。
- `D:\OPC\silver_pose` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
+1
View File
@@ -0,0 +1 @@
"""YoVision Brain package root."""
+3
View File
@@ -0,0 +1,3 @@
# T-017 engineering prototype only. Do not infer the production Brain stack.
numpy==1.26.4
opencv-python==4.9.0.80
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from Brain.yovision_brain.domain import (
Box,
Detection,
Point,
Zone,
ZoneEntryEvaluator,
point_in_polygon,
zone_from_payload,
)
class GeometryTests(unittest.TestCase):
def setUp(self) -> None:
self.zone = Zone(
"zone-1",
"危险区域",
1,
(Point(0.4, 0.2), Point(0.8, 0.2), Point(0.8, 0.8), Point(0.4, 0.8)),
)
def test_point_in_polygon_includes_boundary(self) -> None:
self.assertTrue(point_in_polygon(Point(0.6, 0.5), self.zone.points))
self.assertTrue(point_in_polygon(Point(0.4, 0.5), self.zone.points))
self.assertFalse(point_in_polygon(Point(0.2, 0.5), self.zone.points))
def test_zone_requires_normalized_three_to_thirty_two_points(self) -> None:
with self.assertRaisesRegex(ValueError, "3 to 32"):
Zone("zone-1", "bad", 1, (Point(0, 0), Point(1, 1)))
with self.assertRaisesRegex(ValueError, "within"):
Point(1.1, 0.5)
with self.assertRaisesRegex(ValueError, "within"):
Point(float("nan"), 0.5)
with self.assertRaisesRegex(ValueError, "within"):
Box(0.1, 0.1, float("inf"), 0.9)
def test_zone_payload_rejects_unknown_fields_and_increments_version(self) -> None:
updated = zone_from_payload(
{"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]},
self.zone,
)
self.assertEqual(updated.version, 2)
self.assertEqual(updated.name, "新区域")
with self.assertRaisesRegex(ValueError, "unknown"):
zone_from_payload({"points": [], "tenant_id": "must-not-be-here"}, self.zone)
class ZoneEntryTests(unittest.TestCase):
def setUp(self) -> None:
self.zone = Zone(
"zone-1",
"危险区域",
1,
(Point(0.5, 0.2), Point(0.9, 0.2), Point(0.9, 0.9), Point(0.5, 0.9)),
)
ids = iter(("BRN-0001", "BRN-0002", "BRN-0003"))
self.evaluator = ZoneEntryEvaluator("device-ref", True, track_ttl_frames=2, event_id_factory=lambda: next(ids))
self.now = datetime(2026, 8, 11, 1, 2, 3, tzinfo=timezone.utc)
@staticmethod
def detection(track: str, center_x: float) -> Detection:
return Detection(track, "person", Box(center_x - 0.05, 0.3, center_x + 0.05, 0.8))
def test_first_seen_inside_does_not_fake_an_entry(self) -> None:
events, states = self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.7)])
self.assertEqual(events, [])
self.assertTrue(states["P-1"])
def test_entry_fires_once_until_track_exits_and_reenters(self) -> None:
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
events, _ = self.evaluator.evaluate(2, self.now, self.zone, [self.detection("P-1", 0.6)])
repeated, _ = self.evaluator.evaluate(3, self.now, self.zone, [self.detection("P-1", 0.7)])
self.evaluator.evaluate(4, self.now, self.zone, [self.detection("P-1", 0.3)])
reentered, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.6)])
self.assertEqual([item.source_event_id for item in events], ["BRN-0001"])
self.assertEqual(repeated, [])
self.assertEqual([item.source_event_id for item in reentered], ["BRN-0002"])
payload = events[0].as_dict()
self.assertNotIn("id", payload)
self.assertEqual(payload["confidence"], None)
self.assertTrue(payload["fixture"])
def test_expired_track_reappearing_inside_is_not_an_entry(self) -> None:
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
self.evaluator.evaluate(4, self.now, self.zone, [])
events, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.7)])
self.assertEqual(events, [])
if __name__ == "__main__":
unittest.main()
+291
View File
@@ -0,0 +1,291 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import tempfile
import threading
import unittest
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from Brain.yovision_brain.domain import EventCandidate
from Brain.yovision_brain.ingress import (
BrainEventIngress,
DeliveryResult,
EventIngressClient,
EventOutbox,
EventRelayWorker,
IngressConfig,
KeyMaterial,
PermanentDelivery,
RetryableDelivery,
load_config,
map_candidate,
)
EVENT_ID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
def candidate(source_event_id: str = "BRN-test-0001", fixture: bool = True) -> EventCandidate:
return EventCandidate(
source_event_id=source_event_id,
kind="zone_entry",
source_ref="must-not-leave-brain",
track_id="P-1",
zone_id="zone-1",
zone_version=3,
occurred_at=datetime(2026, 8, 11, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z"),
confidence=None,
fixture=fixture,
)
def config(root: Path, bell_url: str = "http://127.0.0.1:8081/internal/v1/event-candidates") -> IngressConfig:
return IngressConfig(
producer_id="brain-main",
tenant_id=1,
site_id=2,
device_id=3,
modality="video",
severity="medium",
config_version="brain-demo-v1",
bell_url=bell_url,
key_id="brain-a",
key_file=root / "keys.json",
outbox_path=root / "outbox.sqlite3",
)
class ConfigAndMapperTests(unittest.TestCase):
def test_external_config_binds_key_and_rejects_remote_plaintext(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
secret = bytes(range(32))
key_file = root / "keys.json"
key_file.write_text(
json.dumps(
{
"version": 1,
"keys": [
{
"key_id": "brain-a",
"producer_id": "brain-main",
"secret_base64url": base64.urlsafe_b64encode(secret).rstrip(b"=").decode("ascii"),
}
],
}
),
encoding="utf-8",
)
config_file = root / "config.json"
document = {
"version": 1,
"producer_id": "brain-main",
"tenant_id": 1,
"site_id": 2,
"device_id": 3,
"modality": "video",
"severity": "medium",
"config_version": "brain-demo-v1",
"bell_url": "http://127.0.0.1:8081/internal/v1/event-candidates",
"key_id": "brain-a",
"key_file": str(key_file.resolve()),
"outbox_path": str((root / "outbox.sqlite3").resolve()),
}
config_file.write_text(json.dumps(document), encoding="utf-8")
loaded, key = load_config(str(config_file.resolve()))
self.assertEqual(("brain-main", secret), (loaded.producer_id, key.secret))
document["bell_url"] = "http://camera.example/internal/v1/event-candidates"
config_file.write_text(json.dumps(document), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "HTTPS"):
load_config(str(config_file.resolve()))
def test_mapper_produces_complete_candidate_without_sensitive_source(self) -> None:
with tempfile.TemporaryDirectory() as directory:
payload = json.loads(map_candidate(candidate(), config(Path(directory))))
self.assertNotIn("id", payload)
self.assertNotIn("source_ref", json.dumps(payload))
self.assertEqual("test", payload["outcome"])
self.assertEqual("auto", payload["outcome_source"])
self.assertIsNone(payload["confidence"])
self.assertEqual(payload["occurred_at"], payload["detected_at"])
self.assertEqual(0.0, payload["latency_seconds"])
self.assertEqual([{"device_id": 3, "modality": "video", "role": "primary"}], payload["sensors"])
expected = {
"schema_version", "source_event_id", "tenant_id", "site_id", "device_id", "sensors", "kind",
"severity", "confidence", "occurred_at", "detected_at", "latency_seconds", "config_version",
"rule", "subject", "observation", "evidence", "dedup_key", "aggregated_into", "outcome",
"outcome_source", "outcome_reason", "diagnostics", "ext",
}
self.assertEqual(expected, set(payload))
class OutboxTests(unittest.TestCase):
def test_crash_recovery_retry_and_terminal_records_are_durable(self) -> None:
clock = [1_000.0]
now = lambda: clock[0]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "outbox.sqlite3"
payload = map_candidate(candidate(), config(Path(directory)))
outbox = EventOutbox(path, now=now)
self.assertTrue(outbox.enqueue(payload))
self.assertFalse(outbox.enqueue(payload))
leased = outbox.lease_one()
self.assertIsNotNone(leased)
outbox.close() # Simulate a process crash while the lease is held.
clock[0] += 31.0
outbox = EventOutbox(path, now=now)
recovered = outbox.lease_one()
self.assertIsNotNone(recovered)
self.assertEqual(2, recovered.attempt_count)
outbox.mark_delivered(recovered, DeliveryResult("duplicate", EVENT_ID))
self.assertEqual(1, outbox.status()["delivered"])
outbox.close()
outbox = EventOutbox(path, now=now)
status = outbox.status()
self.assertEqual((1, 0), (status["delivered"], status["queued"]))
outbox.close()
def test_source_id_conflict_is_not_silently_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
outbox = EventOutbox(root / "outbox.sqlite3")
outbox.enqueue(map_candidate(candidate(), config(root)))
changed = json.loads(map_candidate(candidate(), config(root)))
changed["config_version"] = "different"
with self.assertRaisesRegex(ValueError, "different candidate"):
outbox.enqueue(json.dumps(changed, sort_keys=True, separators=(",", ":")).encode())
outbox.close()
def test_crash_on_final_attempt_becomes_dead_letter(self) -> None:
clock = [1_000.0]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "outbox.sqlite3"
outbox = EventOutbox(path, now=lambda: clock[0])
outbox.enqueue(map_candidate(candidate(), config(root)))
for _ in range(99):
item = outbox.lease_one()
self.assertIsNotNone(item)
outbox.mark_retry(item, "network_error")
clock[0] += 301.0
final = outbox.lease_one()
self.assertEqual(100, final.attempt_count)
outbox.close()
clock[0] += 31.0
outbox = EventOutbox(path, now=lambda: clock[0])
self.assertIsNone(outbox.lease_one())
self.assertEqual(1, outbox.status()["dead_letter"])
outbox.close()
def test_outbox_cannot_be_reused_for_another_producer_identity(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "outbox.sqlite3"
outbox = EventOutbox(path)
original = config(root)
outbox.bind_identity(original)
outbox.close()
outbox = EventOutbox(path)
changed = IngressConfig(**{**original.__dict__, "producer_id": "brain-other"})
with self.assertRaisesRegex(ValueError, "different producer"):
outbox.bind_identity(changed)
outbox.close()
class _BellHandler(BaseHTTPRequestHandler):
secret = bytes(range(32))
verified = False
failure: str | None = None
def log_message(self, _format: str, *_args: object) -> None:
return
def do_POST(self) -> None: # noqa: N802
try:
length = int(self.headers["Content-Length"])
body = self.rfile.read(length)
timestamp = self.headers["X-YoVision-Timestamp"]
nonce = self.headers["X-YoVision-Nonce"]
canonical = "\n".join(("POST", self.path, timestamp, nonce, hashlib.sha256(body).hexdigest())).encode()
encoded_signature = self.headers["X-YoVision-Signature"]
supplied = base64.urlsafe_b64decode(encoded_signature + "=" * (-len(encoded_signature) % 4))
type(self).verified = hmac.compare_digest(supplied, hmac.new(self.secret, canonical, hashlib.sha256).digest())
envelope = json.loads(body)
response = json.dumps(
{
"schema_version": 1,
"producer_id": envelope["producer_id"],
"source_event_id": envelope["candidate"]["source_event_id"],
"event_id": EVENT_ID,
"status": "accepted",
}
).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
except Exception as exc: # pragma: no cover - only improves test diagnostics
type(self).failure = repr(exc)
self.send_response(500)
self.end_headers()
class ClientAndWorkerTests(unittest.TestCase):
def test_client_signs_and_accepts_bell_response(self) -> None:
server = HTTPServer(("127.0.0.1", 0), _BellHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
client = EventIngressClient(
config(root, f"http://127.0.0.1:{server.server_port}/internal/v1/event-candidates"),
KeyMaterial("brain-a", "brain-main", _BellHandler.secret),
now=lambda: 1_800_000_000.0,
)
try:
result = client.deliver(map_candidate(candidate(), config(root)))
except RetryableDelivery as exc:
self.fail(f"test Bell handler failed: {_BellHandler.failure}; client={exc.code}")
self.assertEqual(("accepted", EVENT_ID), (result.status, result.event_id))
self.assertTrue(_BellHandler.verified)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2.0)
def test_worker_distinguishes_retryable_and_permanent_failures(self) -> None:
class FailingClient:
def __init__(self, failure: Exception) -> None:
self.failure = failure
def deliver(self, _payload: bytes) -> DeliveryResult:
raise self.failure
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
payload = map_candidate(candidate(), config(root))
outbox = EventOutbox(root / "retry.sqlite3", now=lambda: 100.0)
outbox.enqueue(payload)
EventRelayWorker(outbox, FailingClient(RetryableDelivery("unauthorized"))).run_once()
self.assertEqual((1, 0), (outbox.status()["queued"], outbox.status()["dead_letter"]))
outbox.close()
outbox = EventOutbox(root / "dead.sqlite3", now=lambda: 100.0)
outbox.enqueue(payload)
EventRelayWorker(outbox, FailingClient(PermanentDelivery("source_event_conflict"))).run_once()
self.assertEqual(1, outbox.status()["dead_letter"])
outbox.close()
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from Brain.yovision_brain.domain import Box, Detection
from Brain.yovision_brain.runtime import DemoEngine, EventIngressUnavailable
from Brain.yovision_brain.source import FramePacket, SyntheticSource, cv2, np
class _TwoFrameSource:
fixture = True
mode = "test"
label = "test"
source_ref = "safe-source"
def __init__(self) -> None:
self.index = 0
def read(self) -> FramePacket:
boxes = (Box(0.10, 0.4, 0.20, 0.8), Box(0.60, 0.4, 0.70, 0.8))
detection = Detection("P-1", "person", boxes[min(self.index, 1)])
self.index += 1
return FramePacket(np.zeros((180, 320, 3), dtype=np.uint8), datetime.now(timezone.utc), (detection,))
def close(self) -> None:
return
class _Ingress:
def __init__(self, fail: bool = False) -> None:
self.items = []
self.fail = fail
def submit(self, value: object) -> None:
if self.fail:
raise RuntimeError("disk unavailable")
self.items.append(value)
def start(self) -> None:
return
def stop(self) -> None:
return
def status(self) -> dict[str, object]:
return {"enabled": True, "queued": len(self.items)}
@unittest.skipIf(cv2 is None, "pinned OpenCV package is not installed")
class RuntimeTests(unittest.TestCase):
def test_rejects_non_finite_fps(self) -> None:
with self.assertRaisesRegex(ValueError, "fps"):
DemoEngine(SyntheticSource(width=320, height=180), fps=float("nan"))
def test_synthetic_step_produces_safe_state_and_jpeg(self) -> None:
engine = DemoEngine(SyntheticSource(width=320, height=180), fps=2.0)
engine.step()
state = engine.state()
self.assertTrue(state["source"]["fixture"])
self.assertEqual(state["detector"]["name"], "scripted_fixture")
self.assertEqual(state["frame"]["width"], 320)
self.assertGreater(len(engine.frame_jpeg() or b""), 100)
self.assertNotIn("url", state["source"])
def test_zone_update_increments_version(self) -> None:
engine = DemoEngine(SyntheticSource(width=320, height=180))
updated = engine.update_zone({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
self.assertEqual(updated.version, 2)
self.assertEqual(engine.state()["zone"]["name"], "新区域")
def test_event_is_persisted_before_it_is_exposed(self) -> None:
ingress = _Ingress()
engine = DemoEngine(_TwoFrameSource(), event_ingress=ingress)
engine.step()
engine.step()
state = engine.state()
self.assertEqual(1, len(ingress.items))
self.assertEqual("outbox_persisted", state["events"][0]["delivery_status"])
self.assertEqual(1, state["event_ingress"]["queued"])
def test_outbox_failure_does_not_claim_delivery(self) -> None:
engine = DemoEngine(_TwoFrameSource(), event_ingress=_Ingress(fail=True))
engine.step()
with self.assertRaises(EventIngressUnavailable):
engine.step()
self.assertEqual([], engine.state()["events"])
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import http.client
import json
import threading
import unittest
from Brain.yovision_brain.domain import Point, Zone, zone_from_payload
from Brain.yovision_brain.server import DemoHTTPServer, DemoHandler, index_path, parse_bind
class FakeEngine:
def __init__(self) -> None:
self.zone = Zone("zone-demo-01", "测试区域", 1, (Point(0.1, 0.1), Point(0.9, 0.1), Point(0.5, 0.9)))
def state(self) -> dict[str, object]:
return {"source": {"label": "safe", "connected": True}, "events": [], "zone": {"version": self.zone.version}}
def frame_jpeg(self) -> bytes:
return b"\xff\xd8safe-jpeg\xff\xd9"
def update_zone(self, payload: object) -> Zone:
self.zone = zone_from_payload(payload, self.zone)
return self.zone
class BindTests(unittest.TestCase):
def test_only_explicit_loopback_is_allowed(self) -> None:
self.assertEqual(parse_bind("127.0.0.1:8090"), ("127.0.0.1", 8090))
self.assertEqual(parse_bind("localhost:8090"), ("localhost", 8090))
with self.assertRaisesRegex(ValueError, "loopback"):
parse_bind("0.0.0.0:8090")
with self.assertRaisesRegex(ValueError, "loopback"):
parse_bind("192.168.1.10:8090")
class HTTPTests(unittest.TestCase):
def setUp(self) -> None:
html = index_path().read_text(encoding="utf-8")
self.server = DemoHTTPServer(("127.0.0.1", 0), FakeEngine(), "test-token", html)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
def tearDown(self) -> None:
self.connection.close()
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
def test_index_substitutes_token_and_sets_security_headers(self) -> None:
self.connection.request("GET", "/brain-demo")
response = self.connection.getresponse()
body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn('content="test-token"', body)
self.assertNotIn("__BRAIN_DEMO_TOKEN__", body)
self.assertEqual(response.getheader("X-Frame-Options"), "DENY")
self.assertIn("default-src 'self'", response.getheader("Content-Security-Policy"))
def test_zone_write_requires_token_and_rejects_unknown_fields(self) -> None:
body = json.dumps({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json"})
forbidden = self.connection.getresponse()
forbidden.read()
self.assertEqual(forbidden.status, 403)
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
accepted = self.connection.getresponse()
payload = json.loads(accepted.read())
self.assertEqual(accepted.status, 200)
self.assertEqual(payload["version"], 2)
invalid = json.dumps({"name": "bad", "points": [], "tenant_id": "leak"})
self.connection.request("PUT", "/api/v1/zones/active", body=invalid, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
rejected = self.connection.getresponse()
self.assertEqual(rejected.status, 400)
self.assertEqual(json.loads(rejected.read())["code"], "invalid_zone")
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from Brain.yovision_brain.domain import Box
from Brain.yovision_brain.source import CentroidTracker, read_stream_url
class StreamURLTests(unittest.TestCase):
def test_requires_absolute_single_rtsp_url_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "stream.url"
path.write_text("rtsp://user:secret@127.0.0.1:8554/camera\n", encoding="utf-8")
self.assertEqual(read_stream_url(str(path)), "rtsp://user:secret@127.0.0.1:8554/camera")
path.write_text("rtsp://127.0.0.1/a\nrtsp://127.0.0.1/b\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "exactly one") as caught:
read_stream_url(str(path))
self.assertNotIn("127.0.0.1", str(caught.exception))
def test_rejects_relative_path_without_echoing_input(self) -> None:
with self.assertRaisesRegex(ValueError, "absolute"):
read_stream_url("camera-secret.url")
def test_rejects_url_files_inside_repository(self) -> None:
repository_file = Path(__file__).resolve()
with self.assertRaisesRegex(ValueError, "outside the repository"):
read_stream_url(str(repository_file))
class TrackerTests(unittest.TestCase):
def test_nearby_boxes_retain_track_and_distant_box_gets_new_track(self) -> None:
tracker = CentroidTracker(max_distance=0.2)
first = tracker.update([(Box(0.1, 0.1, 0.2, 0.4), 0.8)], 1)
nearby = tracker.update([(Box(0.12, 0.1, 0.22, 0.4), 0.7)], 2)
distant = tracker.update([(Box(0.7, 0.1, 0.8, 0.4), 0.9)], 3)
self.assertEqual(first[0].track_id, nearby[0].track_id)
self.assertNotEqual(nearby[0].track_id, distant[0].track_id)
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
HTML_PATH = Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class UIContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = HTML_PATH.read_text(encoding="utf-8")
def test_prototype_is_self_contained_and_labels_fixture_truthfully(self) -> None:
self.assertIn("合成回放不等于模型效果", self.html)
self.assertIn("离线 HTML 原型数据", self.html)
self.assertNotRegex(self.html, r'(?:src|href)=["\']https?://')
self.assertNotIn("@import url", self.html)
def test_accessibility_and_responsive_guards_are_present(self) -> None:
required = (
'name="viewport"',
'class="skip-link"',
'aria-live="polite"',
':focus-visible',
'prefers-reduced-motion',
'min-height: 44px',
'@media (max-width: 420px)',
'.toolbar { display: grid; grid-template-columns: 1fr; }',
'键盘用户可使用右侧坐标表单',
)
for marker in required:
with self.subTest(marker=marker):
self.assertIn(marker, self.html)
def test_no_structural_emoji_or_unescaped_secret_placeholder_in_text(self) -> None:
visible_without_script = re.sub(r"<script[\s\S]*?</script>", "", self.html)
self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]")
self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1)
def test_reliable_delivery_status_is_visible_without_configuration_details(self) -> None:
required = (
'id="event-ingress"',
'id="ingress-queued"',
'id="ingress-dead-letter"',
"Outbox 已持久化",
"Bell 返回 accepted 或 duplicate 后才计为已送达",
"event_ingress: {enabled: false}",
)
for marker in required:
with self.subTest(marker=marker):
self.assertIn(marker, self.html)
for forbidden in ("bell_url", "key_file", "outbox_path"):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, self.html)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -0,0 +1,7 @@
"""Single-stream Brain engineering prototype.
This package intentionally exposes no Brain-to-Bell transport. T-018 owns
that contract and its delivery semantics.
"""
__version__ = "0.1.0"
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import argparse
import sys
from .ingress import BrainEventIngress
from .runtime import DemoEngine
from .server import parse_bind, serve
from .source import StreamSource, SyntheticSource, read_stream_url, require_opencv
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="YoVision Brain single-stream engineering prototype")
parser.add_argument("--source", choices=("synthetic", "stream"), default="synthetic")
parser.add_argument("--stream-url-file", help="absolute external file containing one RTSP URL")
parser.add_argument("--bind", default="127.0.0.1:8090", help="loopback bind address")
parser.add_argument("--fps", type=float, default=2.0, help="prototype processing FPS (0, 30]")
parser.add_argument("--event-ingress-config", help="absolute external Brain-to-Bell ingress configuration")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
require_opencv()
parse_bind(args.bind)
if args.source == "stream":
if not args.stream_url_file:
raise ValueError("--stream-url-file is required for stream mode")
source = StreamSource(read_stream_url(args.stream_url_file))
else:
if args.stream_url_file:
raise ValueError("--stream-url-file is only valid for stream mode")
source = SyntheticSource()
event_ingress = BrainEventIngress.from_file(args.event_ingress_config) if args.event_ingress_config else None
engine = DemoEngine(source, fps=args.fps, event_ingress=event_ingress)
except (RuntimeError, ValueError) as exc:
print(f"Brain demo configuration error: {exc}", file=sys.stderr)
return 2
print(f"Brain demo listening on http://{args.bind}/brain-demo")
print("Engineering prototype only; synthetic replay and HOG output are not production model evidence.")
try:
serve(engine, args.bind)
except KeyboardInterrupt:
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Iterable, Sequence
from uuid import uuid4
@dataclass(frozen=True)
class Point:
x: float
y: float
def __post_init__(self) -> None:
if not all(math.isfinite(value) for value in (self.x, self.y)) or not (
0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0
):
raise ValueError("point coordinates must be within [0, 1]")
@dataclass(frozen=True)
class Box:
x1: float
y1: float
x2: float
y2: float
def __post_init__(self) -> None:
values = (self.x1, self.y1, self.x2, self.y2)
if not all(math.isfinite(value) for value in values) or any(value < 0.0 or value > 1.0 for value in values):
raise ValueError("box coordinates must be within [0, 1]")
if self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("box must have positive area")
@property
def center(self) -> Point:
return Point((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
@dataclass(frozen=True)
class Detection:
track_id: str
class_name: str
box: Box
detector_score: float | None = None
def __post_init__(self) -> None:
if not self.track_id or len(self.track_id) > 128:
raise ValueError("track_id must contain 1 to 128 characters")
if self.class_name != "person":
raise ValueError("T-017 only supports anonymous person detections")
if self.detector_score is not None and not math.isfinite(self.detector_score):
raise ValueError("detector_score must be finite")
@dataclass(frozen=True)
class Zone:
zone_id: str
name: str
version: int
points: tuple[Point, ...]
def __post_init__(self) -> None:
if not self.zone_id or len(self.zone_id) > 128:
raise ValueError("zone_id must contain 1 to 128 characters")
if not self.name.strip() or len(self.name) > 80:
raise ValueError("zone name must contain 1 to 80 characters")
if self.version < 1:
raise ValueError("zone version must be positive")
if not 3 <= len(self.points) <= 32:
raise ValueError("zone must contain 3 to 32 points")
@dataclass(frozen=True)
class EventCandidate:
source_event_id: str
kind: str
source_ref: str
track_id: str
zone_id: str
zone_version: int
occurred_at: str
confidence: None
fixture: bool
def as_dict(self) -> dict[str, object]:
# Bell owns the platform `id`; it is deliberately absent here.
return {
"candidate_version": "brain-demo-v1",
"source_event_id": self.source_event_id,
"kind": self.kind,
"source_ref": self.source_ref,
"track_id": self.track_id,
"zone_id": self.zone_id,
"zone_version": self.zone_version,
"occurred_at": self.occurred_at,
"confidence": self.confidence,
"fixture": self.fixture,
}
def _on_segment(point: Point, start: Point, end: Point, epsilon: float = 1e-9) -> bool:
cross = (point.y - start.y) * (end.x - start.x) - (point.x - start.x) * (end.y - start.y)
if abs(cross) > epsilon:
return False
return (
min(start.x, end.x) - epsilon <= point.x <= max(start.x, end.x) + epsilon
and min(start.y, end.y) - epsilon <= point.y <= max(start.y, end.y) + epsilon
)
def point_in_polygon(point: Point, polygon: Sequence[Point]) -> bool:
if len(polygon) < 3:
return False
inside = False
previous = polygon[-1]
for current in polygon:
if _on_segment(point, previous, current):
return True
crosses = (current.y > point.y) != (previous.y > point.y)
if crosses:
boundary_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
if point.x < boundary_x:
inside = not inside
previous = current
return inside
class ZoneEntryEvaluator:
def __init__(
self,
source_ref: str,
fixture: bool,
track_ttl_frames: int = 8,
event_id_factory: Callable[[], str] | None = None,
) -> None:
if track_ttl_frames < 1:
raise ValueError("track_ttl_frames must be positive")
self._source_ref = source_ref
self._fixture = fixture
self._track_ttl_frames = track_ttl_frames
self._event_id_factory = event_id_factory or (lambda: f"BRN-{uuid4().hex}")
self._inside: dict[str, bool] = {}
self._last_seen: dict[str, int] = {}
def reset(self) -> None:
self._inside.clear()
self._last_seen.clear()
def evaluate(
self,
sequence: int,
occurred_at: datetime,
zone: Zone,
detections: Iterable[Detection],
) -> tuple[list[EventCandidate], dict[str, bool]]:
if sequence < 0:
raise ValueError("sequence cannot be negative")
if occurred_at.tzinfo is None:
raise ValueError("occurred_at must be timezone-aware")
expired = [
track_id
for track_id, last_seen in self._last_seen.items()
if sequence - last_seen > self._track_ttl_frames
]
for track_id in expired:
self._inside.pop(track_id, None)
self._last_seen.pop(track_id, None)
events: list[EventCandidate] = []
states: dict[str, bool] = {}
for detection in detections:
inside = point_in_polygon(detection.box.center, zone.points)
previous = self._inside.get(detection.track_id)
if previous is False and inside:
events.append(
EventCandidate(
source_event_id=self._event_id_factory(),
kind="zone_entry",
source_ref=self._source_ref,
track_id=detection.track_id,
zone_id=zone.zone_id,
zone_version=zone.version,
occurred_at=occurred_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
confidence=None,
fixture=self._fixture,
)
)
self._inside[detection.track_id] = inside
self._last_seen[detection.track_id] = sequence
states[detection.track_id] = inside
return events, states
def zone_from_payload(payload: object, current: Zone) -> Zone:
if not isinstance(payload, dict):
raise ValueError("request body must be an object")
if set(payload) - {"name", "points"}:
raise ValueError("unknown zone fields are not allowed")
name = payload.get("name", current.name)
raw_points = payload.get("points")
if not isinstance(name, str):
raise ValueError("zone name must be a string")
if not isinstance(raw_points, list):
raise ValueError("zone points must be an array")
points: list[Point] = []
for raw_point in raw_points:
if not isinstance(raw_point, dict) or set(raw_point) != {"x", "y"}:
raise ValueError("each point must contain only x and y")
x = raw_point["x"]
y = raw_point["y"]
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
raise ValueError("point coordinates must be numbers")
points.append(Point(float(x), float(y)))
return Zone(current.zone_id, name.strip(), current.version + 1, tuple(points))
+645
View File
@@ -0,0 +1,645 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import ipaddress
import json
import re
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
from .domain import EventCandidate
INGRESS_PATH = "/internal/v1/event-candidates"
MAX_PAYLOAD_BYTES = 1 << 20
MAX_QUEUED = 10_000
MAX_ATTEMPTS = 100
HTTP_TIMEOUT_SECONDS = 10.0
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_EVENT_ID = re.compile(r"^evt_[0-9A-HJKMNP-TV-Z]{26}$")
_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
_REPO_ROOT = Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class IngressConfig:
producer_id: str
tenant_id: int
site_id: int
device_id: int
modality: str
severity: str
config_version: str
bell_url: str
key_id: str
key_file: Path
outbox_path: Path
@dataclass(frozen=True)
class KeyMaterial:
key_id: str
producer_id: str
secret: bytes
@dataclass(frozen=True)
class LeasedEvent:
source_event_id: str
payload: bytes
attempt_count: int
@dataclass(frozen=True)
class DeliveryResult:
status: str
event_id: str
class RetryableDelivery(RuntimeError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = _safe_error(code)
class PermanentDelivery(RuntimeError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = _safe_error(code)
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, _request: Request, _file_pointer: object, _code: int, _message: str, _headers: object, _new_url: str) -> None:
return None
def _is_within(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def _external_absolute_path(raw: object, field: str, *, must_exist: bool) -> Path:
if not isinstance(raw, str) or not raw:
raise ValueError(f"{field} must be an absolute external path")
path = Path(raw)
if not path.is_absolute():
raise ValueError(f"{field} must be an absolute external path")
resolved = path.resolve(strict=must_exist)
if _is_within(resolved, _REPO_ROOT):
raise ValueError(f"{field} must stay outside the repository")
return resolved
def _load_json_file(path: Path, maximum: int = 64 << 10) -> object:
raw = path.read_bytes()
if len(raw) > maximum:
raise ValueError("external configuration file is too large")
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ValueError("external configuration is not valid JSON") from exc
def _positive_int(value: object, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(f"{field} must be a positive integer")
return value
def _validate_bell_url(raw: object) -> str:
if not isinstance(raw, str):
raise ValueError("bell_url must be a URL")
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or parsed.hostname is None or parsed.path != INGRESS_PATH:
raise ValueError("bell_url must target the versioned event ingress path")
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError("bell_url cannot contain credentials, query or fragment")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("bell_url has an invalid port") from exc
if port is not None and not 1 <= port <= 65535:
raise ValueError("bell_url has an invalid port")
if parsed.scheme == "http":
host = parsed.hostname
loopback = host == "localhost"
if not loopback:
try:
loopback = ipaddress.ip_address(host).is_loopback
except ValueError:
loopback = False
if not loopback:
raise ValueError("non-loopback event ingress requires HTTPS")
return raw
def load_config(path_value: str) -> tuple[IngressConfig, KeyMaterial]:
config_path = _external_absolute_path(path_value, "event ingress config", must_exist=True)
value = _load_json_file(config_path)
expected = {
"version",
"producer_id",
"tenant_id",
"site_id",
"device_id",
"modality",
"severity",
"config_version",
"bell_url",
"key_id",
"key_file",
"outbox_path",
}
if not isinstance(value, dict) or set(value) != expected or value.get("version") != 1:
raise ValueError("invalid event ingress configuration shape")
producer_id = value["producer_id"]
key_id = value["key_id"]
config_version = value["config_version"]
if not isinstance(producer_id, str) or not _ID.fullmatch(producer_id):
raise ValueError("invalid producer_id")
if not isinstance(key_id, str) or not _ID.fullmatch(key_id):
raise ValueError("invalid key_id")
if not isinstance(config_version, str) or not 1 <= len(config_version) <= 128:
raise ValueError("config_version must contain 1 to 128 characters")
if value["modality"] != "video":
raise ValueError("T-019 Brain ingress supports only video primary sensors")
if value["severity"] not in {"low", "medium", "high", "critical"}:
raise ValueError("invalid severity")
key_file = _external_absolute_path(value["key_file"], "key_file", must_exist=True)
outbox_path = _external_absolute_path(value["outbox_path"], "outbox_path", must_exist=False)
if not outbox_path.parent.is_dir():
raise ValueError("outbox_path parent directory must already exist")
config = IngressConfig(
producer_id=producer_id,
tenant_id=_positive_int(value["tenant_id"], "tenant_id"),
site_id=_positive_int(value["site_id"], "site_id"),
device_id=_positive_int(value["device_id"], "device_id"),
modality="video",
severity=value["severity"],
config_version=config_version,
bell_url=_validate_bell_url(value["bell_url"]),
key_id=key_id,
key_file=key_file,
outbox_path=outbox_path,
)
document = _load_json_file(key_file)
if not isinstance(document, dict) or set(document) != {"version", "keys"} or document.get("version") != 1:
raise ValueError("invalid event ingress key document")
keys = document.get("keys")
if not isinstance(keys, list) or not keys:
raise ValueError("event ingress key document has no keys")
selected: KeyMaterial | None = None
seen: set[str] = set()
for item in keys:
if not isinstance(item, dict) or set(item) != {"key_id", "producer_id", "secret_base64url"}:
raise ValueError("invalid event ingress key entry")
item_key = item["key_id"]
item_producer = item["producer_id"]
encoded = item["secret_base64url"]
if not isinstance(item_key, str) or not _ID.fullmatch(item_key) or item_key in seen:
raise ValueError("invalid or duplicate event ingress key ID")
if not isinstance(item_producer, str) or not _ID.fullmatch(item_producer) or not isinstance(encoded, str):
raise ValueError("invalid event ingress key entry")
seen.add(item_key)
try:
secret = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
except (ValueError, binascii.Error) as exc:
raise ValueError("invalid event ingress secret") from exc
if len(secret) < 32:
raise ValueError("event ingress secret must contain at least 32 bytes")
if item_key == key_id:
selected = KeyMaterial(item_key, item_producer, secret)
if selected is None or selected.producer_id != producer_id:
raise ValueError("selected key is not bound to the configured producer")
return config, selected
def map_candidate(value: EventCandidate, config: IngressConfig) -> bytes:
if not _SOURCE_ID.fullmatch(value.source_event_id):
raise ValueError("invalid source_event_id")
payload = {
"schema_version": "0.1",
"source_event_id": value.source_event_id,
"tenant_id": config.tenant_id,
"site_id": config.site_id,
"device_id": config.device_id,
"sensors": [{"device_id": config.device_id, "modality": config.modality, "role": "primary"}],
"kind": value.kind,
"severity": config.severity,
"confidence": None,
"occurred_at": value.occurred_at,
"detected_at": value.occurred_at,
"latency_seconds": 0.0,
"config_version": config.config_version,
"rule": None,
"subject": {
"class": "person",
"track_id": value.track_id,
"attributes": {},
"anon_id": None,
"identity": None,
"identity_status": "not_enabled",
},
"observation": {
"zone": value.zone_id,
"dwell_sec": 0.0,
"bbox_seq_uri": None,
"keypoint_seq_uri": None,
"signal_seq_uri": None,
},
"evidence": {"snapshot_uris": [], "clip_uri": None, "clip_range": None},
"dedup_key": None,
"aggregated_into": None,
"outcome": "test" if value.fixture else "unknown",
"outcome_source": "auto" if value.fixture else None,
"outcome_reason": None,
"diagnostics": None,
"ext": {"brain_candidate_version": "brain-demo-v1", "zone_version": value.zone_version},
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_PAYLOAD_BYTES:
raise ValueError("event candidate exceeds 1 MiB")
return encoded
class EventOutbox:
def __init__(self, path: Path, *, now: Callable[[], float] = time.time) -> None:
if not path.is_absolute() or _is_within(path.resolve(), _REPO_ROOT):
raise ValueError("outbox path must be absolute and external")
self._now = now
self._lock = threading.RLock()
self._connection = sqlite3.connect(str(path), timeout=5.0, isolation_level=None, check_same_thread=False)
self._connection.row_factory = sqlite3.Row
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS event_outbox (
source_event_id TEXT PRIMARY KEY,
candidate_hash BLOB NOT NULL,
payload BLOB NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','delivering','delivered','dead_letter')),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 100),
available_at REAL NOT NULL,
lease_until REAL,
bell_event_id TEXT,
last_error_code TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
delivered_at REAL,
dead_lettered_at REAL,
CHECK(length(candidate_hash)=32),
CHECK(length(payload)<=1048576),
CHECK((state='delivering')=(lease_until IS NOT NULL)),
CHECK(delivered_at IS NULL OR dead_lettered_at IS NULL)
);
CREATE INDEX IF NOT EXISTS event_outbox_due_idx
ON event_outbox(available_at, created_at, source_event_id)
WHERE state='queued';
CREATE TABLE IF NOT EXISTS event_outbox_identity (
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
producer_id TEXT NOT NULL,
tenant_id INTEGER NOT NULL CHECK(tenant_id>=1),
site_id INTEGER NOT NULL CHECK(site_id>=1),
device_id INTEGER NOT NULL CHECK(device_id>=1)
);
"""
)
def bind_identity(self, config: IngressConfig) -> None:
identity = (config.producer_id, config.tenant_id, config.site_id, config.device_id)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
row = self._connection.execute(
"SELECT producer_id,tenant_id,site_id,device_id FROM event_outbox_identity WHERE singleton=1"
).fetchone()
if row is None:
self._connection.execute(
"""INSERT INTO event_outbox_identity(
singleton,producer_id,tenant_id,site_id,device_id
) VALUES (1,?,?,?,?)""",
identity,
)
elif tuple(row) != identity:
raise ValueError("event outbox is bound to a different producer or device identity")
self._connection.execute("COMMIT")
except Exception:
self._connection.execute("ROLLBACK")
raise
def enqueue(self, payload: bytes) -> bool:
if not 0 < len(payload) <= MAX_PAYLOAD_BYTES:
raise ValueError("event candidate payload must contain at most 1 MiB")
try:
candidate = json.loads(payload)
source_event_id = candidate["source_event_id"]
except (UnicodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
raise ValueError("invalid event candidate payload") from exc
if not isinstance(source_event_id, str) or not _SOURCE_ID.fullmatch(source_event_id):
raise ValueError("invalid source_event_id")
digest = hashlib.sha256(payload).digest()
now = self._now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
existing = self._connection.execute(
"SELECT candidate_hash FROM event_outbox WHERE source_event_id=?", (source_event_id,)
).fetchone()
if existing is not None:
if not hmac.compare_digest(existing["candidate_hash"], digest):
raise ValueError("source_event_id already has a different candidate")
self._connection.execute("COMMIT")
return False
active = self._connection.execute(
"SELECT count(*) FROM event_outbox WHERE state IN ('queued','delivering')"
).fetchone()[0]
if active >= MAX_QUEUED:
raise RuntimeError("event outbox capacity exceeded")
self._connection.execute(
"""INSERT INTO event_outbox(
source_event_id,candidate_hash,payload,state,available_at,created_at,updated_at
) VALUES (?,?,?,'queued',?,?,?)""",
(source_event_id, digest, payload, now, now, now),
)
self._connection.execute("COMMIT")
return True
except Exception:
self._connection.execute("ROLLBACK")
raise
def lease_one(self, lease_seconds: float = 30.0) -> LeasedEvent | None:
now = self._now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self._connection.execute(
"""UPDATE event_outbox SET state='dead_letter', lease_until=NULL,
last_error_code='retry_exhausted', dead_lettered_at=?, updated_at=?
WHERE attempt_count>=? AND (
state='queued' OR (state='delivering' AND lease_until<=?)
)""",
(now, now, MAX_ATTEMPTS, now),
)
self._connection.execute(
"""UPDATE event_outbox SET state='queued', lease_until=NULL,
available_at=?, last_error_code='lease_expired', updated_at=?
WHERE state='delivering' AND lease_until<=? AND attempt_count<?""",
(now, now, now, MAX_ATTEMPTS),
)
row = self._connection.execute(
"""SELECT source_event_id,payload,attempt_count FROM event_outbox
WHERE state='queued' AND available_at<=? AND attempt_count<?
ORDER BY available_at,created_at,source_event_id LIMIT 1""",
(now, MAX_ATTEMPTS),
).fetchone()
if row is None:
self._connection.execute("COMMIT")
return None
attempt = row["attempt_count"] + 1
self._connection.execute(
"""UPDATE event_outbox SET state='delivering',attempt_count=?,lease_until=?,updated_at=?
WHERE source_event_id=? AND state='queued'""",
(attempt, now + lease_seconds, now, row["source_event_id"]),
)
self._connection.execute("COMMIT")
return LeasedEvent(row["source_event_id"], bytes(row["payload"]), attempt)
except Exception:
self._connection.execute("ROLLBACK")
raise
def mark_delivered(self, item: LeasedEvent, result: DeliveryResult) -> None:
now = self._now()
with self._lock:
changed = self._connection.execute(
"""UPDATE event_outbox SET state='delivered',lease_until=NULL,bell_event_id=?,
last_error_code=NULL,delivered_at=?,updated_at=?
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
(result.event_id, now, now, item.source_event_id, item.attempt_count),
).rowcount
if changed != 1:
raise RuntimeError("event outbox delivery lease was lost")
def mark_retry(self, item: LeasedEvent, code: str) -> None:
now = self._now()
if item.attempt_count >= MAX_ATTEMPTS:
self.mark_dead(item, "retry_exhausted")
return
delay = min(300.0, float(2 ** min(item.attempt_count - 1, 9)))
with self._lock:
changed = self._connection.execute(
"""UPDATE event_outbox SET state='queued',lease_until=NULL,available_at=?,
last_error_code=?,updated_at=?
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
(now + delay, _safe_error(code), now, item.source_event_id, item.attempt_count),
).rowcount
if changed != 1:
raise RuntimeError("event outbox retry lease was lost")
def mark_dead(self, item: LeasedEvent, code: str) -> None:
now = self._now()
with self._lock:
changed = self._connection.execute(
"""UPDATE event_outbox SET state='dead_letter',lease_until=NULL,
last_error_code=?,dead_lettered_at=?,updated_at=?
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
(_safe_error(code), now, now, item.source_event_id, item.attempt_count),
).rowcount
if changed != 1:
raise RuntimeError("event outbox dead-letter lease was lost")
def status(self) -> dict[str, object]:
with self._lock:
counts = {row["state"]: row["count"] for row in self._connection.execute(
"SELECT state,count(*) AS count FROM event_outbox GROUP BY state"
)}
error = self._connection.execute(
"""SELECT last_error_code FROM event_outbox WHERE last_error_code IS NOT NULL
ORDER BY updated_at DESC LIMIT 1"""
).fetchone()
return {
"enabled": True,
"queued": counts.get("queued", 0),
"delivering": counts.get("delivering", 0),
"delivered": counts.get("delivered", 0),
"dead_letter": counts.get("dead_letter", 0),
"last_error_code": None if error is None else error["last_error_code"],
}
def close(self) -> None:
with self._lock:
self._connection.close()
class EventIngressClient:
def __init__(self, config: IngressConfig, key: KeyMaterial, *, now: Callable[[], float] = time.time) -> None:
self._config = config
self._key = key
self._now = now
# Internal event payloads must never be redirected through ambient
# HTTP(S)_PROXY settings.
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
def deliver(self, candidate: bytes) -> DeliveryResult:
try:
candidate_object = json.loads(candidate)
except (UnicodeError, json.JSONDecodeError) as exc:
raise PermanentDelivery("candidate_invalid") from exc
body = json.dumps(
{"schema_version": 1, "producer_id": self._config.producer_id, "candidate": candidate_object},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
if len(body) > MAX_PAYLOAD_BYTES:
raise PermanentDelivery("payload_too_large")
timestamp = str(int(self._now()))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("ascii")
digest = hashlib.sha256(body).hexdigest()
canonical = "\n".join(("POST", INGRESS_PATH, timestamp, nonce, digest)).encode("utf-8")
signature_value = base64.urlsafe_b64encode(hmac.new(self._key.secret, canonical, hashlib.sha256).digest()).rstrip(b"=").decode("ascii")
request = Request(self._config.bell_url, data=body, method="POST")
request.add_header("Content-Type", "application/json")
request.add_header("X-YoVision-Key-Id", self._key.key_id)
request.add_header("X-YoVision-Timestamp", timestamp)
request.add_header("X-YoVision-Nonce", nonce)
request.add_header("X-YoVision-Signature", signature_value)
try:
with self._opener.open(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
status = response.status
response_body = response.read(64 << 10)
except HTTPError as exc:
status = exc.code
response_body = exc.read(64 << 10)
except (URLError, TimeoutError, OSError) as exc:
raise RetryableDelivery("network_error") from exc
if status in {200, 201}:
try:
value = json.loads(response_body)
response_status = value["status"]
event_id = value["event_id"]
if (
value["schema_version"] != 1
or value["producer_id"] != self._config.producer_id
or value["source_event_id"] != candidate_object["source_event_id"]
or response_status not in {"accepted", "duplicate"}
or not isinstance(event_id, str)
or not _EVENT_ID.fullmatch(event_id)
):
raise ValueError
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RetryableDelivery("invalid_response") from exc
return DeliveryResult(response_status, event_id)
code = "http_error"
try:
error_value = json.loads(response_body)
if isinstance(error_value, dict) and isinstance(error_value.get("error"), str):
code = error_value["error"]
except (UnicodeError, json.JSONDecodeError):
pass
if status == 401 or status >= 500:
raise RetryableDelivery(code)
raise PermanentDelivery(code)
class EventRelayWorker:
def __init__(self, outbox: EventOutbox, client: EventIngressClient) -> None:
self._outbox = outbox
self._client = client
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._last_error_code: str | None = None
def run_once(self) -> bool:
item = self._outbox.lease_one()
if item is None:
return False
try:
result = self._client.deliver(item.payload)
except PermanentDelivery as exc:
self._outbox.mark_dead(item, exc.code)
except RetryableDelivery as exc:
self._outbox.mark_retry(item, exc.code)
except Exception:
self._outbox.mark_retry(item, "client_error")
else:
self._outbox.mark_delivered(item, result)
return True
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._run, name="brain-event-relay", daemon=True)
self._thread.start()
def _run(self) -> None:
while not self._stop.is_set():
try:
worked = self.run_once()
self._last_error_code = None
except Exception:
self._last_error_code = "event_outbox_unavailable"
self._stop.wait(1.0)
continue
if not worked:
self._stop.wait(0.25)
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=HTTP_TIMEOUT_SECONDS + 2.0)
self._thread = None
def last_error_code(self) -> str | None:
return self._last_error_code
class BrainEventIngress:
def __init__(self, config: IngressConfig, key: KeyMaterial) -> None:
self._config = config
self._outbox = EventOutbox(config.outbox_path)
self._outbox.bind_identity(config)
self._worker = EventRelayWorker(self._outbox, EventIngressClient(config, key))
@classmethod
def from_file(cls, path: str) -> "BrainEventIngress":
config, key = load_config(path)
return cls(config, key)
def submit(self, value: EventCandidate) -> None:
self._outbox.enqueue(map_candidate(value, self._config))
def start(self) -> None:
self._worker.start()
def stop(self) -> None:
self._worker.stop()
self._outbox.close()
def status(self) -> dict[str, object]:
value = self._outbox.status()
if self._worker.last_error_code() is not None:
value["last_error_code"] = self._worker.last_error_code()
return value
def _safe_error(value: str) -> str:
if not isinstance(value, str) or not _ERROR_CODE.fullmatch(value):
return "unknown_error"
return value
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import math
import threading
import time
from collections import deque
from datetime import datetime, timezone
from typing import Any
from .domain import Detection, Point, Zone, ZoneEntryEvaluator, zone_from_payload
from .source import CentroidTracker, HOGPersonDetector, require_opencv
try:
import cv2 # type: ignore
except ImportError: # pragma: no cover
cv2 = None
DEFAULT_ZONE = Zone(
"zone-demo-01",
"楼梯口危险区",
1,
(Point(0.55, 0.30), Point(0.90, 0.30), Point(0.90, 0.88), Point(0.55, 0.88)),
)
class EventIngressUnavailable(RuntimeError):
pass
class DemoEngine:
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100, event_ingress: Any | None = None) -> None:
if not math.isfinite(fps) or fps <= 0.0 or fps > 30.0:
raise ValueError("fps must be within (0, 30]")
if not 1 <= event_limit <= 100:
raise ValueError("event_limit must be within [1, 100]")
require_opencv()
self._source = source
self._fps = fps
self._detector = None if source.fixture else HOGPersonDetector()
self._tracker = CentroidTracker()
self._evaluator = ZoneEntryEvaluator(source.source_ref, source.fixture)
self._event_ingress = event_ingress
self._zone = DEFAULT_ZONE
self._events: deque[dict[str, object]] = deque(maxlen=event_limit)
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._sequence = 0
self._frame_jpeg: bytes | None = None
self._frame_width = 0
self._frame_height = 0
self._captured_at: str | None = None
self._detections: list[dict[str, object]] = []
self._latency_ms: float | None = None
self._connected = False
self._last_error_code: str | None = None
def start(self) -> None:
if self._thread is not None:
return
if self._event_ingress is not None:
self._event_ingress.start()
self._thread = threading.Thread(target=self._run, name="brain-demo", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=3.0)
self._thread = None
if self._event_ingress is not None:
self._event_ingress.stop()
self._source.close()
def _run(self) -> None:
interval = 1.0 / self._fps
while not self._stop.is_set():
started = time.perf_counter()
try:
self.step()
except EventIngressUnavailable:
with self._lock:
self._last_error_code = "event_outbox_unavailable"
except RuntimeError:
with self._lock:
self._connected = False
self._last_error_code = "source_unavailable"
elapsed = time.perf_counter() - started
self._stop.wait(max(0.0, interval - elapsed))
def step(self) -> None:
started = time.perf_counter()
packet = self._source.read()
self._sequence += 1
if packet.scripted_detections is not None:
detections = list(packet.scripted_detections)
else:
raw_boxes = self._detector.detect(packet.frame) if self._detector is not None else []
detections = self._tracker.update(raw_boxes, self._sequence)
with self._lock:
zone = self._zone
new_events, inside_by_track = self._evaluator.evaluate(self._sequence, packet.captured_at, zone, detections)
if self._event_ingress is not None:
try:
for item in new_events:
self._event_ingress.submit(item)
except Exception as exc:
raise EventIngressUnavailable("persist event candidate") from exc
ok, encoded = cv2.imencode(".jpg", packet.frame, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
if not ok:
raise RuntimeError("frame encoding failed")
height, width = packet.frame.shape[:2]
serialized_detections = [self._serialize_detection(item, inside_by_track.get(item.track_id, False)) for item in detections]
with self._lock:
for event in new_events:
serialized_event = event.as_dict()
if self._event_ingress is not None:
# This is an immutable handoff fact, not a live delivery
# status. Current counts live under event_ingress.
serialized_event["delivery_status"] = "outbox_persisted"
self._events.appendleft(serialized_event)
self._frame_jpeg = encoded.tobytes()
self._frame_width = int(width)
self._frame_height = int(height)
self._captured_at = packet.captured_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
self._detections = serialized_detections
self._latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
self._connected = True
self._last_error_code = None
@staticmethod
def _serialize_detection(detection: Detection, inside: bool) -> dict[str, object]:
return {
"track_id": detection.track_id,
"class": detection.class_name,
"bbox": [detection.box.x1, detection.box.y1, detection.box.x2, detection.box.y2],
"detector_score": detection.detector_score,
"inside_zone": inside,
}
def update_zone(self, payload: object) -> Zone:
with self._lock:
updated = zone_from_payload(payload, self._zone)
self._zone = updated
self._evaluator.reset()
return updated
def frame_jpeg(self) -> bytes | None:
with self._lock:
return self._frame_jpeg
def state(self) -> dict[str, object]:
with self._lock:
zone = self._zone
if self._event_ingress is None:
ingress_status: dict[str, object] = {"enabled": False}
else:
try:
ingress_status = self._event_ingress.status()
except Exception:
ingress_status = {"enabled": True, "last_error_code": "event_outbox_unavailable"}
return {
"prototype": True,
"notice": "工程原型;合成回放不是模型输出,HOG 适配器不是生产检测模型。",
"source": {
"mode": self._source.mode,
"label": self._source.label,
"fixture": self._source.fixture,
"connected": self._connected,
"ref": self._source.source_ref,
},
"detector": {
"name": "scripted_fixture" if self._source.fixture else self._detector.name,
"production_ready": False,
},
"frame": {
"sequence": self._sequence,
"width": self._frame_width,
"height": self._frame_height,
"captured_at": self._captured_at,
},
"inference": {"target_fps": self._fps, "latency_ms": self._latency_ms},
"zone": {
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
"detections": list(self._detections),
"events": list(self._events),
"event_ingress": ingress_status,
"last_error_code": self._last_error_code,
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import hmac
import json
import secrets
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from .domain import Zone
MAX_BODY_BYTES = 64 * 1024
def parse_bind(value: str) -> tuple[str, int]:
parsed = urlsplit(f"//{value}")
host = parsed.hostname
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid demo bind address") from exc
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
return host, port
def index_path() -> Path:
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class DemoHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
self.engine = engine
self.demo_token = token
self.html = html
super().__init__(address, DemoHandler)
class DemoHandler(BaseHTTPRequestHandler):
server: DemoHTTPServer
def log_message(self, _format: str, *_args: object) -> None:
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
return
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self._security_headers()
self.end_headers()
self.wfile.write(body)
def _send_json(self, status: int, payload: object) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._send_bytes(status, "application/json; charset=utf-8", body)
def do_GET(self) -> None: # noqa: N802
path = urlsplit(self.path).path
if path in {"/", "/brain-demo"}:
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
return
if path == "/healthz":
self._send_json(HTTPStatus.OK, {"status": "ok"})
return
if path == "/api/v1/state":
self._send_json(HTTPStatus.OK, self.server.engine.state())
return
if path == "/api/v1/frame.jpg":
frame = self.server.engine.frame_jpeg()
if frame is None:
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
return
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
return
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
def do_PUT(self) -> None: # noqa: N802
if urlsplit(self.path).path != "/api/v1/zones/active":
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
return
raw_length = self.headers.get("Content-Length")
try:
length = int(raw_length or "-1")
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
return
body = self.rfile.read(length)
supplied = self.headers.get("X-Brain-Demo-Token", "")
if not hmac.compare_digest(supplied, self.server.demo_token):
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
return
try:
payload = json.loads(body.decode("utf-8"))
zone: Zone = self.server.engine.update_zone(payload)
except (UnicodeError, json.JSONDecodeError, ValueError):
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
return
self._send_json(
HTTPStatus.OK,
{
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
)
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
html = index_path().read_text(encoding="utf-8")
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
def serve(engine: Any, bind: str) -> None:
server = build_server(engine, bind)
engine.start()
try:
server.serve_forever(poll_interval=0.25)
finally:
server.server_close()
engine.stop()
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlsplit
from .domain import Box, Detection
try:
import cv2 # type: ignore
import numpy as np # type: ignore
except ImportError: # pragma: no cover - exercised by the startup failure path
cv2 = None
np = None
@dataclass(frozen=True)
class FramePacket:
frame: Any
captured_at: datetime
scripted_detections: tuple[Detection, ...] | None
def require_opencv() -> None:
if cv2 is None or np is None:
raise RuntimeError("Brain demo requires the pinned NumPy and OpenCV packages")
def read_stream_url(path_value: str) -> str:
path = Path(path_value)
if not path.is_absolute():
raise ValueError("stream URL file must be an absolute external path")
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise ValueError("stream URL file does not exist") from exc
if not resolved.is_file():
raise ValueError("stream URL file does not exist")
repository_root = Path(__file__).resolve().parents[2]
try:
resolved.relative_to(repository_root)
except ValueError:
pass
else:
raise ValueError("stream URL file must be outside the repository")
try:
with resolved.open("rb") as stream:
raw = stream.read(4097)
except OSError as exc:
raise ValueError("stream URL file cannot be read") from exc
if len(raw) > 4096:
raise ValueError("stream URL file exceeds 4096 bytes")
try:
lines = raw.decode("utf-8").splitlines()
except UnicodeError as exc:
raise ValueError("stream URL file must be UTF-8") from exc
values = [line.strip() for line in lines if line.strip()]
if len(values) != 1:
raise ValueError("stream URL file must contain exactly one non-empty line")
parsed = urlsplit(values[0])
if parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname:
raise ValueError("stream URL must be an RTSP URL")
return values[0]
class SyntheticSource:
mode = "synthetic"
label = "合成回放"
fixture = True
source_ref = "demo-camera-01"
def __init__(self, width: int = 960, height: int = 540) -> None:
require_opencv()
self.width = width
self.height = height
self._sequence = 0
def read(self) -> FramePacket:
self._sequence += 1
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
frame[:] = (20, 28, 42)
cv2.rectangle(frame, (0, int(self.height * 0.72)), (self.width, self.height), (31, 42, 58), -1)
for x in range(0, self.width, 80):
cv2.line(frame, (x, int(self.height * 0.72)), (x + 80, self.height), (43, 57, 75), 1)
cv2.putText(frame, "SYNTHETIC FIXTURE - NOT MODEL OUTPUT", (24, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.72, (82, 190, 245), 2)
phase = ((self._sequence - 1) % 160) / 159.0
center_x = -0.04 + phase * 1.08
x1 = max(0.0, center_x - 0.04)
x2 = min(1.0, center_x + 0.04)
detections: tuple[Detection, ...] = ()
if x2 - x1 > 0.01:
box = Box(x1, 0.34, x2, 0.82)
detections = (Detection("P-DEMO-001", "person", box, None),)
px = int(center_x * self.width)
head_y = int(self.height * 0.40)
cv2.circle(frame, (px, head_y), 17, (195, 210, 225), -1)
cv2.line(frame, (px, head_y + 18), (px, int(self.height * 0.65)), (195, 210, 225), 12)
cv2.line(frame, (px, int(self.height * 0.52)), (px - 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.52)), (px + 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.65)), (px - 24, int(self.height * 0.79)), (195, 210, 225), 9)
cv2.line(frame, (px, int(self.height * 0.65)), (px + 24, int(self.height * 0.79)), (195, 210, 225), 9)
return FramePacket(frame, datetime.now(timezone.utc), detections)
def close(self) -> None:
return
class StreamSource:
mode = "stream"
label = "外部 MediaMTX / RTSP"
fixture = False
source_ref = "configured-video-source"
def __init__(self, stream_url: str) -> None:
require_opencv()
self._stream_url = stream_url
self._capture: Any = None
def _open(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = cv2.VideoCapture(self._stream_url)
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
def read(self) -> FramePacket:
if self._capture is None or not self._capture.isOpened():
self._open()
ok, frame = self._capture.read()
if not ok or frame is None:
self._open()
raise RuntimeError("stream frame unavailable")
return FramePacket(frame, datetime.now(timezone.utc), None)
def close(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
class HOGPersonDetector:
name = "opencv_hog_person_demo"
production_ready = False
def __init__(self) -> None:
require_opencv()
self._hog = cv2.HOGDescriptor()
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
def detect(self, frame: Any) -> list[tuple[Box, float]]:
height, width = frame.shape[:2]
scale = min(1.0, 960.0 / max(width, 1))
working = frame if scale == 1.0 else cv2.resize(frame, (int(width * scale), int(height * scale)))
boxes, weights = self._hog.detectMultiScale(working, winStride=(8, 8), padding=(8, 8), scale=1.05)
result: list[tuple[Box, float]] = []
work_height, work_width = working.shape[:2]
for raw_box, weight in zip(boxes, weights):
x, y, box_width, box_height = (int(value) for value in raw_box)
x1 = max(0.0, min(1.0, x / work_width))
y1 = max(0.0, min(1.0, y / work_height))
x2 = max(0.0, min(1.0, (x + box_width) / work_width))
y2 = max(0.0, min(1.0, (y + box_height) / work_height))
if x2 > x1 and y2 > y1:
result.append((Box(x1, y1, x2, y2), float(weight)))
return result
class CentroidTracker:
def __init__(self, max_distance: float = 0.18, ttl_frames: int = 8) -> None:
self._max_distance = max_distance
self._ttl_frames = ttl_frames
self._next_id = 1
self._tracks: dict[str, tuple[Box, int]] = {}
def update(self, boxes: Iterable[tuple[Box, float]], sequence: int) -> list[Detection]:
incoming = list(boxes)
available = set(self._tracks)
detections: list[Detection] = []
for box, score in incoming:
center = box.center
selected: str | None = None
selected_distance = self._max_distance
for track_id in available:
old_center = self._tracks[track_id][0].center
distance = ((center.x - old_center.x) ** 2 + (center.y - old_center.y) ** 2) ** 0.5
if distance < selected_distance:
selected = track_id
selected_distance = distance
if selected is None:
selected = f"P-{self._next_id:04d}"
self._next_id += 1
else:
available.remove(selected)
self._tracks[selected] = (box, sequence)
detections.append(Detection(selected, "person", box, score))
for track_id, (_, last_seen) in list(self._tracks.items()):
if sequence - last_seen > self._ttl_frames:
del self._tracks[track_id]
return detections
+18 -2
View File
@@ -1,6 +1,6 @@
# Sense M1/M2 接入骨架
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。默认关闭真实 ONVIF、公共业务路由和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
本目录是 YoVision Sense 的接入与 NVR 管理面。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。T-018 增加默认关闭的回环工程控制台,用于展示设备、配额、收敛状态和最多 4 路按需 MediaMTX WebRTC 预览;它不包含录像/回放,也不是生产公网入口。默认关闭真实 ONVIF、公共业务路由、控制台和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
## 常用命令
@@ -14,7 +14,7 @@ go build -o bin/sense-api.exe ./cmd/sense-api
go run ./cmd/sense-api
```
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由。
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由,`/sense-console/` 还需独立显式开启。
常用环境变量:
@@ -42,6 +42,8 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
| `SENSE_CONSOLE_ENABLED` | `false` | 显式开启 `/sense-console/`;T-018 只允许与回环 Control API 一起使用 |
| `SENSE_CONSOLE_WEBRTC_BASE_URL` | `http://127.0.0.1:8889` | MediaMTX WebRTC 浏览器入口基地址;必须为无 userinfo/path/query/fragment 的显式回环 HTTP(S) URL |
| `SENSE_AUDIT_RELAY_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 |
| `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS |
| `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 |
@@ -122,6 +124,20 @@ go run ./cmd/sense-api
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
### 开启 T-018 回环控制台
先按上一节完成 PostgreSQL、Control API、仓库外 auth/cursor 文件和 MediaMTX 启动,再增加:
```powershell
$env:SENSE_CONSOLE_ENABLED = 'true'
$env:SENSE_CONSOLE_WEBRTC_BASE_URL = 'http://127.0.0.1:8889'
go run ./cmd/sense-api
```
浏览器打开 `http://127.0.0.1:8080/sense-console/`,输入已授权的 Site ID 和原始 Bearer token。token 只保留在当前页面 JavaScript 内存,输入框随即清空,刷新页面后必须重新输入;不得把 token 放进 URL、截图、命令历史或文档。设备列表默认每页 16 项,只有 `video_capture + enabled + online + converged` 的设备可选择,最多同时启动 4 路预览。
播放使用 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,Sense 不代理媒体字节,也不复制播放器源码。浏览器是否能解码取决于摄像头编码;首选已验收的低码率 H.264 子码流,H.265 或带 B-frame 的 H.264 不能因“Path 在线”就宣称浏览器可播放。本版 Sense 和播放端都必须显式回环;非回环 HTTPS、正式会话认证和 MediaMTX 外部鉴权需另立任务。控制台不会实现或暗示常态录像、录像计划和回放。
Windows 本地准备 MediaMTX(从仓库根目录执行):
```powershell
+13
View File
@@ -15,6 +15,7 @@ import (
"yovision/sense/internal/auditrelay"
"yovision/sense/internal/auth"
"yovision/sense/internal/config"
"yovision/sense/internal/console"
"yovision/sense/internal/controlapi"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx"
@@ -160,6 +161,17 @@ func run(logger *slog.Logger) error {
if cfg.ControlAPIEnabled {
mux.Handle("/api/v1/", controlHandler)
}
if cfg.ConsoleEnabled {
consoleHandler, err := console.NewHandler(cfg.ConsoleWebRTCBaseURL)
if err != nil {
return err
}
mux.Handle("/sense-console/", consoleHandler)
mux.HandleFunc("GET /sense-console", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Cache-Control", "no-store")
http.Redirect(writer, request, "/sense-console/", http.StatusTemporaryRedirect)
})
}
server := &http.Server{
Addr: cfg.HTTPAddress, Handler: mux,
@@ -173,6 +185,7 @@ func run(logger *slog.Logger) error {
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
"instance_id", instanceID,
"control_api_enabled", cfg.ControlAPIEnabled,
"console_enabled", cfg.ConsoleEnabled,
"audit_relay_enabled", cfg.AuditRelayEnabled)
serverErrors <- server.ListenAndServe()
}()
+29
View File
@@ -27,6 +27,7 @@ const (
defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256"
defaultAuditRelayPeriod = time.Second
defaultConsoleWebRTCURL = "http://127.0.0.1:8889"
)
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
@@ -54,6 +55,8 @@ type Config struct {
ControlAuthFile string
ControlCursorKeyFile string
ControlAllowInsecureHTTP bool
ConsoleEnabled bool
ConsoleWebRTCBaseURL string
AuditRelayEnabled bool
AuditRelayURL string
AuditRelayKeyFile string
@@ -98,6 +101,10 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false)
if err != nil {
return Config{}, err
}
auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false)
if err != nil {
return Config{}, err
@@ -144,6 +151,8 @@ func Load() (Config, error) {
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
ControlAllowInsecureHTTP: controlAllowInsecure,
ConsoleEnabled: consoleEnabled,
ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL),
AuditRelayEnabled: auditRelayEnabled,
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""),
@@ -247,6 +256,26 @@ func (c Config) Validate() error {
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
}
}
if c.ConsoleEnabled {
if !c.ControlAPIEnabled {
return fmt.Errorf("Sense console requires SENSE_CONTROL_API_ENABLED=true")
}
if !isLoopback {
return fmt.Errorf("Sense console requires an explicit loopback SENSE_HTTP_ADDR")
}
previewURL, err := url.Parse(c.ConsoleWebRTCBaseURL)
if err != nil || previewURL.Host == "" ||
(previewURL.Scheme != "http" && previewURL.Scheme != "https") ||
previewURL.User != nil || previewURL.RawQuery != "" || previewURL.Fragment != "" ||
(previewURL.Path != "" && previewURL.Path != "/") {
return fmt.Errorf("invalid SENSE_CONSOLE_WEBRTC_BASE_URL")
}
previewHost := previewURL.Hostname()
previewIP := net.ParseIP(previewHost)
if previewHost != "localhost" && (previewIP == nil || !previewIP.IsLoopback()) {
return fmt.Errorf("SENSE_CONSOLE_WEBRTC_BASE_URL must use an explicit loopback host")
}
}
if c.AuditRelayEnabled {
if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres")
+36
View File
@@ -167,6 +167,42 @@ func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T)
}
}
func TestValidateSenseConsoleSecurityBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
ConsoleEnabled: true, ConsoleWebRTCBaseURL: "http://127.0.0.1:8889",
}
if err := base.Validate(); err != nil {
t.Fatalf("valid loopback console rejected: %v", err)
}
withoutControl := base
withoutControl.ControlAPIEnabled = false
if err := withoutControl.Validate(); err == nil {
t.Fatal("console without Control API was accepted")
}
remoteBind := base
remoteBind.HTTPAddress, remoteBind.AllowNonLoopback = "0.0.0.0:8080", true
remoteBind.ControlAllowInsecureHTTP = true
if err := remoteBind.Validate(); err == nil {
t.Fatal("console on non-loopback Sense listener was accepted")
}
for _, invalidURL := range []string{
"http://media.example:8889", "ftp://127.0.0.1:8889", "http://127.0.0.1:8889/path",
"http://127.0.0.1:8889?token=hidden", "http://user@127.0.0.1:8889",
} {
candidate := base
candidate.ConsoleWebRTCBaseURL = invalidURL
if err := candidate.Validate(); err == nil {
t.Fatalf("invalid console WebRTC URL was accepted: %s", invalidURL)
}
}
}
func TestValidateAuditRelaySecurityBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
+227
View File
@@ -0,0 +1,227 @@
:root {
color-scheme: dark;
--bg: #06111f;
--sidebar: #071522;
--surface: #0d2033;
--surface-2: #112a40;
--surface-3: #16344d;
--border: #294762;
--text: #f5f9fc;
--muted: #a8bbcb;
--accent: #55dcc7;
--accent-strong: #2bbca9;
--accent-ink: #03231f;
--info: #6fc6ff;
--success: #71e19c;
--warning: #ffd16a;
--danger: #ff8a95;
--focus: #8bdcff;
--shadow: 0 18px 48px rgba(0, 0, 0, .26);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
line-height: 1.5;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body { margin: 0; min-width: 320px; min-height: 100dvh; background: radial-gradient(circle at 75% -10%, #103150 0, transparent 32rem), var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button, a, input, select { touch-action: manipulation; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: var(--info); }
[hidden] { display: none !important; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.skip-link { position: fixed; left: 12px; top: -80px; z-index: 1000; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--bg); font-weight: 700; }
.skip-link:focus { top: 8px; }
.engineering-banner { min-height: 30px; display: grid; place-items: center; padding: 4px 16px; background: #f5c95b; color: #1e1705; font-size: 12px; font-weight: 800; letter-spacing: .04em; text-align: center; }
.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: calc(100dvh - 30px); }
.sidebar { position: sticky; top: 0; height: calc(100dvh - 30px); display: flex; flex-direction: column; padding: 28px 14px 18px; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--sidebar) 95%, transparent); }
.brand { display: flex; align-items: center; gap: 12px; padding: 8px 8px 28px; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 17px; }
.brand small { color: var(--muted); font-size: 12px; }
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), #58a9ff); color: #06222a; font-weight: 900; box-shadow: 0 9px 24px rgba(85, 220, 199, .2); }
.primary-nav { display: grid; gap: 6px; }
.primary-nav button, .mobile-nav button { border: 0; cursor: pointer; }
.primary-nav button { min-height: 48px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; padding: 0 12px; border-radius: 12px; background: transparent; color: var(--muted); text-align: left; transition: background-color .18s ease, color .18s ease; }
.primary-nav button:hover { background: var(--surface); color: var(--text); }
.primary-nav button[aria-current="page"] { background: #123d3a; color: var(--text); box-shadow: inset 3px 0 var(--accent); }
.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: #d6e5ef; font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 9px; padding: 13px; border: 1px solid var(--border); border-radius: 12px; color: var(--muted); font-size: 12px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #73879a; box-shadow: 0 0 0 4px rgba(115, 135, 154, .13); }
.sidebar-foot.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px rgba(113, 225, 156, .13); }
.workspace { min-width: 0; }
.topbar { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 28px; border-bottom: 1px solid var(--border); background: rgba(8, 24, 39, .78); backdrop-filter: blur(18px); }
.topbar strong, .topbar .eyebrow { display: block; }
.topbar strong { margin-top: 2px; }
.topbar-actions, .heading-actions { display: flex; align-items: center; gap: 10px; }
.eyebrow { color: var(--info); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.connection-pill { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 13px; }
.connection-pill > .connection-dot { width: 7px; height: 7px; border-radius: 50%; background: #718497; }
.connection-pill.connected { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
.connection-pill.connected > .connection-dot { background: var(--success); }
.degraded-banner { display: flex; align-items: center; gap: 12px; padding: 12px 28px; border-bottom: 1px solid rgba(255, 138, 149, .38); background: rgba(96, 26, 39, .82); color: #ffe7ea; }
.degraded-banner span { flex: 1; color: #ffc4ca; }
main { padding: 32px clamp(18px, 3vw, 42px) 80px; }
.view { max-width: 1440px; margin: 0 auto; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 4px; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -.025em; }
h2 { margin-bottom: 4px; font-size: 17px; }
.page-heading p, .panel-heading p { margin-bottom: 0; color: var(--muted); }
.button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; text-decoration: none; font-weight: 700; font-size: 13px; transition: background-color .18s ease, border-color .18s ease, opacity .18s ease; }
.button.primary { background: var(--accent); color: var(--accent-ink); }
.button.primary:hover { background: #7ae7d5; }
.button.secondary { border-color: var(--border); background: var(--surface); color: var(--text); }
.button.secondary:hover, .button.ghost:hover { border-color: #48708f; background: var(--surface-2); }
.button.ghost { border-color: transparent; background: transparent; color: var(--info); }
.button:disabled { cursor: not-allowed; opacity: .42; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.metric-card, .panel { border: 1px solid var(--border); border-radius: 15px; background: linear-gradient(145deg, rgba(17, 42, 64, .82), rgba(10, 29, 47, .9)); box-shadow: var(--shadow); }
.metric-card { min-height: 140px; display: flex; flex-direction: column; justify-content: center; padding: 20px; }
.metric-card > span { color: var(--muted); font-size: 13px; }
.metric-card strong { margin: 7px 0 2px; font-size: 30px; font-variant-numeric: tabular-nums; }
.metric-card small { color: var(--muted); }
.metric-card.warning strong { color: var(--warning); }
.content-grid, .operations-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); gap: 18px; }
.panel { padding: 18px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.health-list { display: grid; gap: 8px; }
.health-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 13px; border: 1px solid rgba(74, 111, 140, .56); border-radius: 11px; background: rgba(4, 17, 29, .35); }
.health-item strong, .health-item small { display: block; }
.health-item small { margin-top: 2px; color: var(--muted); }
.health-item .value { color: var(--warning); font-weight: 800; font-variant-numeric: tabular-nums; }
.empty-compact { min-height: 100px; display: grid; place-items: center; padding: 18px; border: 1px dashed var(--border); border-radius: 11px; color: var(--muted); text-align: center; }
.boundary-card { position: relative; overflow: hidden; }
.boundary-card::after { content: ""; position: absolute; right: -45px; top: -45px; width: 130px; height: 130px; border-radius: 50%; background: rgba(85, 220, 199, .09); }
.boundary-label, .feature-state { display: inline-flex; padding: 4px 8px; border-radius: 999px; background: rgba(85, 220, 199, .12); color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .05em; }
.boundary-card h2 { margin-top: 14px; }
.boundary-card p, .boundary-card li { color: var(--muted); }
.boundary-card ul { padding-left: 18px; margin-bottom: 0; }
.monitor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.monitor-toolbar strong, .monitor-toolbar span { display: block; }
.monitor-toolbar span { color: var(--muted); font-size: 13px; }
.preview-grid { min-height: 500px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.preview-empty { grid-column: 1 / -1; min-height: 500px; display: grid; align-content: center; justify-items: center; padding: 30px; border: 1px dashed var(--border); border-radius: 15px; background: rgba(7, 20, 33, .55); color: var(--muted); text-align: center; }
.preview-empty > span { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--accent); font-size: 24px; }
.preview-empty strong { color: var(--text); font-size: 18px; }
.preview-empty p { margin: 4px 0 0; }
.preview-card { overflow: hidden; border: 1px solid var(--border); border-radius: 15px; background: #02070b; box-shadow: var(--shadow); }
.preview-frame { position: relative; aspect-ratio: 16 / 9; background: #02070b; }
.preview-frame iframe { width: 100%; height: 100%; display: block; border: 0; }
.preview-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--surface); }
.preview-meta strong, .preview-meta small { display: block; }
.preview-meta small { color: var(--muted); }
.filter-panel { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(140px, .7fr)) auto; align-items: end; gap: 12px; margin-bottom: 14px; }
label > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; font-weight: 700; }
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: #081927; color: var(--text); }
input::placeholder { color: #72899c; }
select { cursor: pointer; }
.table-panel { padding: 0; overflow: hidden; }
.table-status { padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.device-table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 900px; }
th, td { padding: 13px 14px; border-bottom: 1px solid rgba(41, 71, 98, .75); text-align: left; vertical-align: middle; }
th { color: var(--muted); font-size: 11px; letter-spacing: .04em; }
td { font-size: 13px; }
td strong, td small { display: block; }
td small { margin-top: 2px; color: var(--muted); }
tbody tr:hover { background: rgba(34, 66, 92, .34); }
.select-preview { width: 44px; height: 44px; display: grid; place-items: center; border: 0; }
.select-preview input { width: 18px; min-height: auto; height: 18px; accent-color: var(--accent); }
.status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; white-space: nowrap; }
.status-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-badge.success { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
.status-badge.warning { color: var(--warning); border-color: rgba(255, 209, 106, .35); background: rgba(255, 209, 106, .08); }
.status-badge.danger { color: var(--danger); border-color: rgba(255, 138, 149, .35); background: rgba(255, 138, 149, .08); }
.device-cards { display: none; }
.pagination { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 14px; }
.pagination > span { color: var(--muted); font-size: 13px; }
.pagination > div { display: flex; gap: 8px; }
.honest-empty { max-width: 820px; min-height: 340px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(28px, 5vw, 60px); }
.honest-empty h2 { margin: 18px 0 8px; font-size: 24px; }
.honest-empty > p { max-width: 70ch; color: var(--muted); }
.next-list { width: 100%; display: grid; gap: 8px; margin-top: 14px; }
.next-list span { padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); }
.next-list strong { margin-right: 10px; color: var(--text); }
dialog { width: min(520px, calc(100% - 28px)); padding: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 30px 90px rgba(0, 0, 0, .6); }
dialog::backdrop { background: rgba(0, 7, 13, .72); backdrop-filter: blur(4px); }
dialog form { padding: 22px; }
.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.dialog-heading h2 { margin-top: 5px; font-size: 22px; }
.icon-button { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: 10px; background: transparent; cursor: pointer; font-size: 20px; }
dialog p { color: var(--muted); }
dialog label { display: block; margin-top: 14px; }
.form-error { margin-top: 12px; padding: 10px 12px; border: 1px solid rgba(255, 138, 149, .5); border-radius: 9px; background: rgba(120, 28, 43, .28); color: #ffdce0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.toast { position: fixed; right: 20px; bottom: 24px; z-index: 200; max-width: min(420px, calc(100% - 40px)); padding: 13px 16px; border: 1px solid var(--border); border-radius: 11px; background: #173148; color: var(--text); box-shadow: var(--shadow); }
.mobile-nav { display: none; }
@media (max-width: 1080px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.content-grid, .operations-grid { grid-template-columns: 1fr; }
.filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.engineering-banner { min-height: 38px; }
.shell { display: block; min-height: calc(100dvh - 38px); }
.sidebar { display: none; }
.topbar { min-height: 72px; padding: 12px 16px; }
.connection-pill { display: none; }
.topbar-actions .button { min-height: 44px; }
.degraded-banner { align-items: flex-start; flex-wrap: wrap; padding: 12px 16px; }
.degraded-banner span { flex-basis: calc(100% - 10px); }
main { padding: 24px 14px 92px; }
.page-heading { align-items: stretch; flex-direction: column; }
.page-heading .button, .heading-actions { width: 100%; }
.heading-actions .button { flex: 1; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 9px; }
.metric-card { min-height: 126px; padding: 15px; }
.metric-card strong { font-size: 25px; }
.panel { padding: 15px; }
.panel-heading, .monitor-toolbar { align-items: stretch; flex-direction: column; }
.preview-grid { grid-template-columns: 1fr; min-height: 420px; }
.preview-empty { min-height: 420px; }
.filter-panel { grid-template-columns: 1fr; }
input, select { min-height: 46px; font-size: 16px; }
.device-table-wrap { display: none; }
.device-cards { display: grid; gap: 10px; padding: 12px; }
.device-card { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 18, 30, .45); }
.device-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.device-card strong, .device-card small { display: block; }
.device-card small { color: var(--muted); }
.device-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0 0; }
.device-card dt { color: var(--muted); font-size: 11px; }
.device-card dd { margin: 2px 0 0; font-size: 13px; }
.pagination { align-items: stretch; flex-direction: column; }
.pagination > div { display: grid; grid-template-columns: 1fr 1fr; }
.pagination .button { min-height: 44px; }
.mobile-nav { position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 100; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; border: 1px solid var(--border); border-radius: 15px; background: rgba(8, 24, 39, .96); box-shadow: 0 15px 40px rgba(0, 0, 0, .5); backdrop-filter: blur(18px); }
.mobile-nav button { min-height: 48px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 12px; }
.mobile-nav button[aria-current="page"] { background: #153c3a; color: var(--accent); font-weight: 800; }
dialog form { padding: 18px; }
.dialog-actions { display: grid; grid-template-columns: 1fr 1fr; }
.dialog-actions .button { min-height: 46px; }
.toast { left: 14px; right: 14px; bottom: 78px; max-width: none; }
}
@media (max-width: 420px) {
.metric-grid { grid-template-columns: 1fr; }
.topbar { gap: 8px; }
.topbar strong { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+594
View File
@@ -0,0 +1,594 @@
"use strict";
const state = {
token: "",
siteId: "",
config: { page_size: 16, max_active_previews: 4, webrtc_base_url: "" },
items: [],
quota: null,
cursors: [null],
pageIndex: 0,
nextCursor: null,
selected: new Map(),
previewing: new Set(),
loading: false,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const labels = {
modality: { video: "视频", radar: "雷达", contact: "门磁", button: "按钮", wearable: "可穿戴", other: "其他" },
desired: { enabled: "启用", disabled: "停用" },
actual: { online: "在线", offline: "离线", failed: "失败", pending: "收敛中" },
adapter: { ready: "适配器就绪", pending: "适配器处理中", adapter_not_ready: "适配器未交付", authentication_failed: "认证失败", unavailable: "适配器不可用" },
capability: { video_capture: "成像", audio_capture: "音频", spatial_rule: "空间配置", telemetry: "遥测" },
};
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.hidden = false;
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => { toast.hidden = true; }, 4200);
}
function setText(selector, value) {
const target = $(selector);
if (target) target.textContent = String(value);
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).format(date);
}
function isPreviewEligible(device) {
return Array.isArray(device.capabilities) && device.capabilities.includes("video_capture") &&
device.desired_state === "enabled" && device.actual_state === "online" && device.converged === true;
}
function attentionNeeded(device) {
return device.converged !== true || device.actual_state === "offline" || device.actual_state === "failed" ||
device.adapter_status === "authentication_failed" || device.adapter_status === "unavailable";
}
function statusFor(device) {
if (device.actual_state === "online" && device.converged) return { text: "在线 · 已收敛", tone: "success" };
if (device.actual_state === "failed" || device.adapter_status === "authentication_failed") return { text: labels.adapter[device.adapter_status] || "失败", tone: "danger" };
if (device.actual_state === "offline") return { text: "离线", tone: "danger" };
return { text: device.converged ? (labels.actual[device.actual_state] || "未知") : "等待收敛", tone: "warning" };
}
function createStatusBadge(device) {
const value = statusFor(device);
const badge = document.createElement("span");
badge.className = `status-badge ${value.tone}`;
badge.textContent = value.text;
return badge;
}
function setConnected(connected) {
const pill = $("#connectionPill");
const foot = $(".sidebar-foot");
pill.classList.toggle("connected", connected);
foot.classList.toggle("connected", connected);
setText("#connectionText", connected ? "已连接" : "未连接");
setText("#sessionState", connected ? "会话仅驻留当前页面" : "尚未建立会话");
}
function setLoading(loading) {
state.loading = loading;
$("#refreshDevices").disabled = loading || !state.token;
$("#refreshOperations").disabled = loading || !state.token;
$("#retryLoad").disabled = loading || !state.token;
$("#prevPage").disabled = loading || state.pageIndex === 0;
$("#nextPage").disabled = loading || !state.nextCursor;
setText("#deviceTableStatus", loading ? "正在读取最新状态…" : state.token ? "已读取当前页真实设备状态。" : "请先建立会话。");
}
function showDegraded(title, message) {
setText("#degradedTitle", title);
setText("#degradedMessage", message);
$("#degradedBanner").hidden = false;
}
function clearDegraded() {
$("#degradedBanner").hidden = true;
}
async function apiFetch(path) {
const response = await fetch(path, {
headers: { Authorization: `Bearer ${state.token}`, Accept: "application/json" },
cache: "no-store",
});
let payload = null;
try { payload = await response.json(); } catch (_) { payload = null; }
if (!response.ok) {
const error = new Error(payload && payload.code ? payload.code : `http_${response.status}`);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function buildDevicePath(cursor) {
const params = new URLSearchParams();
params.set("limit", String(state.config.page_size));
const modality = $("#modalityFilter").value;
const desired = $("#desiredFilter").value;
const actual = $("#actualFilter").value;
if (modality) params.set("modality", modality);
if (desired) params.set("desired_state", desired);
if (actual) params.set("actual_state", actual);
if (cursor) params.set("cursor", cursor);
return `/api/v1/sites/${encodeURIComponent(state.siteId)}/devices?${params.toString()}`;
}
async function loadDevices(options = {}) {
if (!state.token || state.loading) return;
const cursor = state.cursors[state.pageIndex] || null;
setLoading(true);
clearDegraded();
try {
const payload = await apiFetch(buildDevicePath(cursor));
state.items = Array.isArray(payload.items) ? payload.items : [];
state.quota = payload.quota || null;
state.nextCursor = payload.page && payload.page.has_more ? payload.page.next_cursor : null;
for (const device of state.items) {
if (state.selected.has(device.id)) {
if (isPreviewEligible(device)) state.selected.set(device.id, device);
else state.selected.delete(device.id);
}
}
setConnected(true);
renderAll();
if (options.announce !== false) showToast(`已刷新第 ${state.pageIndex + 1} 页,共 ${state.items.length} 台设备`);
return true;
} catch (error) {
handleLoadError(error);
throw error;
} finally {
setLoading(false);
}
}
function handleLoadError(error) {
let title = "无法读取最新状态";
let message = "未知不会显示为在线。请检查 Sense 进程、网络和依赖后重试。";
if (error.status === 401) {
state.token = "";
setConnected(false);
title = "会话无效或已过期";
message = "Bearer token 已从页面内存清除,请重新建立会话。";
} else if (error.status === 403) {
title = "当前角色没有设备读取权限";
message = "页面不会尝试绕过权限;请使用具备 devices:read 的会话。";
} else if (error.status === 404) {
title = "无法访问该站点";
message = "目标不存在或当前会话无权访问,页面不会区分这两种情况。";
} else if (error.payload && ["quota_projection_unavailable", "area_policy_unavailable"].includes(error.payload.code)) {
title = "策略投影暂时不可用";
message = "已有链路不会因此被静默停用;相关新写入应保持禁用。";
}
showDegraded(title, message);
state.items = [];
state.quota = null;
renderAll();
}
function visibleItems() {
const query = $("#deviceSearch").value.trim().toLocaleLowerCase("zh-CN");
if (!query) return state.items;
return state.items.filter((device) => `${device.name} ${device.serial_number}`.toLocaleLowerCase("zh-CN").includes(query));
}
function renderAll() {
renderDevices();
renderMetrics();
renderHealth();
renderOperations();
renderSelection();
}
function renderMetrics() {
const used = state.quota && Number.isInteger(state.quota.used_video_channels) ? state.quota.used_video_channels : null;
const max = state.quota && Number.isInteger(state.quota.max_video_channels) ? state.quota.max_video_channels : null;
setText("#quotaMetric", used === null || max === null ? "—" : `${used} / ${max}`);
setText("#quotaHint", state.quota ? `投影状态:${state.quota.status || "未知"}` : "连接后读取 Bell 投影");
setText("#pageMetric", state.items.length);
setText("#convergedMetric", state.items.filter((device) => device.converged === true).length);
const attention = state.items.filter(attentionNeeded).length;
setText("#attentionMetric", attention);
setText("#deviceCount", state.items.length);
setText("#issueCount", attention);
}
function addTextCell(row, primary, secondary) {
const cell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = primary || "—";
cell.appendChild(strong);
if (secondary) {
const small = document.createElement("small");
small.textContent = secondary;
cell.appendChild(small);
}
row.appendChild(cell);
}
function createPreviewCheckbox(device) {
const label = document.createElement("label");
label.className = "select-preview";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = state.selected.has(device.id);
checkbox.disabled = !isPreviewEligible(device);
checkbox.setAttribute("aria-label", checkbox.disabled ? `${device.name} 当前不可预览` : `选择 ${device.name} 进行预览`);
checkbox.addEventListener("change", () => toggleSelection(device, checkbox));
label.appendChild(checkbox);
return label;
}
function renderDevices() {
const rows = $("#deviceRows");
const cards = $("#deviceCards");
rows.replaceChildren();
cards.replaceChildren();
const items = visibleItems();
for (const device of items) {
const row = document.createElement("tr");
const selectCell = document.createElement("td");
selectCell.appendChild(createPreviewCheckbox(device));
row.appendChild(selectCell);
addTextCell(row, device.name, device.serial_number);
addTextCell(row, labels.modality[device.modality] || device.modality, (device.capabilities || []).map((value) => labels.capability[value] || value).join(" / "));
const statusCell = document.createElement("td");
statusCell.appendChild(createStatusBadge(device));
row.appendChild(statusCell);
addTextCell(row, labels.desired[device.desired_state] || device.desired_state, labels.actual[device.actual_state] || device.actual_state);
addTextCell(row, device.area_id || "—", device.projection_versions && device.projection_versions.area_policy_source_version ? `策略 v${device.projection_versions.area_policy_source_version}` : "策略版本未知");
addTextCell(row, formatTime(device.updated_at), device.next_attempt_at ? `重试 ${formatTime(device.next_attempt_at)}` : "");
rows.appendChild(row);
const card = document.createElement("article");
card.className = "device-card";
const cardHead = document.createElement("div");
cardHead.className = "device-card-head";
const identity = document.createElement("div");
const name = document.createElement("strong");
const serial = document.createElement("small");
name.textContent = device.name;
serial.textContent = device.serial_number;
identity.append(name, serial);
cardHead.append(identity, createPreviewCheckbox(device));
const status = createStatusBadge(device);
const details = document.createElement("dl");
const pairs = [
["状态", "", status],
["模态", labels.modality[device.modality] || device.modality],
["期望 / 实际", `${labels.desired[device.desired_state] || device.desired_state} / ${labels.actual[device.actual_state] || device.actual_state}`],
["Area", device.area_id || "—"],
];
for (const [term, value, node] of pairs) {
const wrapper = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
if (node) dd.appendChild(node); else dd.textContent = value;
wrapper.append(dt, dd);
details.appendChild(wrapper);
}
card.append(cardHead, details);
cards.appendChild(card);
}
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有符合条件的设备。" : "请先建立会话。";
cards.appendChild(empty.cloneNode(true));
const cell = document.createElement("td");
cell.colSpan = 7;
cell.appendChild(empty);
const row = document.createElement("tr");
row.appendChild(cell);
rows.appendChild(row);
}
setText("#pageLabel", `第 ${state.pageIndex + 1} 页 · 每页 ${state.config.page_size} · 当前显示 ${items.length} 项`);
$("#prevPage").disabled = state.loading || state.pageIndex === 0;
$("#nextPage").disabled = state.loading || !state.nextCursor;
}
function toggleSelection(device, checkbox) {
if (checkbox.checked) {
if (!isPreviewEligible(device)) {
checkbox.checked = false;
showToast("只有在线、已启用且已收敛的视频设备可以预览");
return;
}
if (!state.selected.has(device.id) && state.selected.size >= state.config.max_active_previews) {
checkbox.checked = false;
showToast(`最多同时选择 ${state.config.max_active_previews} 路预览`);
return;
}
state.selected.set(device.id, device);
} else {
state.selected.delete(device.id);
if (state.previewing.has(device.id)) stopPreview(device.id);
}
renderSelection();
renderDevices();
}
function renderSelection() {
setText("#selectionCount", state.selected.size);
setText("#previewCount", `${state.previewing.size}/${state.config.max_active_previews}`);
$("#startPreview").disabled = state.selected.size === 0;
$("#stopAll").disabled = state.previewing.size === 0;
}
function previewURL(deviceID) {
const url = new URL(`${state.config.webrtc_base_url}/devices/${encodeURIComponent(deviceID)}`);
url.searchParams.set("controls", "true");
url.searchParams.set("muted", "true");
url.searchParams.set("autoplay", "true");
url.searchParams.set("playsInline", "true");
return url.toString();
}
function startSelectedPreviews() {
if (state.selected.size === 0) return;
state.previewing = new Set(state.selected.keys());
const grid = $("#previewGrid");
grid.replaceChildren();
for (const device of state.selected.values()) {
const card = document.createElement("article");
card.className = "preview-card";
card.dataset.deviceId = device.id;
const frame = document.createElement("div");
frame.className = "preview-frame";
const iframe = document.createElement("iframe");
iframe.title = `${device.name} 实时预览`;
iframe.loading = "eager";
iframe.allow = "autoplay; fullscreen";
iframe.sandbox = "allow-scripts allow-same-origin";
iframe.referrerPolicy = "no-referrer";
iframe.src = previewURL(device.id);
frame.appendChild(iframe);
const meta = document.createElement("div");
meta.className = "preview-meta";
const identity = document.createElement("div");
const name = document.createElement("strong");
const note = document.createElement("small");
name.textContent = device.name;
note.textContent = "MediaMTX WebRTC · 按需读取";
identity.append(name, note);
const stop = document.createElement("button");
stop.type = "button";
stop.className = "button secondary";
stop.textContent = "停止";
stop.addEventListener("click", () => stopPreview(device.id));
meta.append(identity, stop);
card.append(frame, meta);
grid.appendChild(card);
}
switchView("monitor");
renderSelection();
showToast(`已按需启动 ${state.previewing.size} 路预览`);
}
function stopPreview(deviceID) {
state.previewing.delete(deviceID);
const card = $(`[data-device-id="${CSS.escape(deviceID)}"]`);
if (card) {
const iframe = card.querySelector("iframe");
if (iframe) iframe.removeAttribute("src");
card.remove();
}
if (state.previewing.size === 0) renderPreviewEmpty();
renderSelection();
}
function stopAllPreviews() {
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
state.previewing.clear();
renderPreviewEmpty();
renderSelection();
showToast("全部预览已停止");
}
function renderPreviewEmpty() {
const grid = $("#previewGrid");
grid.replaceChildren();
const empty = document.createElement("div");
empty.className = "preview-empty";
const mark = document.createElement("span");
mark.setAttribute("aria-hidden", "true");
mark.textContent = "▷";
const title = document.createElement("strong");
title.textContent = "尚未启动预览";
const note = document.createElement("p");
note.textContent = "前往设备页选择最多 4 路运行中的视频设备。";
empty.append(mark, title, note);
grid.appendChild(empty);
}
function renderHealth() {
const container = $("#overviewHealth");
container.replaceChildren();
if (!state.token || state.items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有设备事实。" : "建立会话后显示真实状态。";
container.appendChild(empty);
return;
}
const groups = [
["在线且已收敛", state.items.filter((device) => device.actual_state === "online" && device.converged).length, "设备可用于按需预览"],
["等待收敛", state.items.filter((device) => !device.converged).length, "期望 generation 尚未被完整观察"],
["离线或失败", state.items.filter((device) => ["offline", "failed"].includes(device.actual_state)).length, "查看重试时间和稳定错误码"],
];
for (const [title, count, note] of groups) container.appendChild(createHealthItem(title, count, note));
}
function createHealthItem(title, count, note) {
const item = document.createElement("div");
item.className = "health-item";
const text = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = title;
small.textContent = note;
text.append(strong, small);
const value = document.createElement("span");
value.className = "value";
value.textContent = String(count);
item.append(text, value);
return item;
}
function renderOperations() {
const container = $("#operationIssues");
container.replaceChildren();
const issues = state.items.filter(attentionNeeded);
if (issues.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有已知对账差异;这不代表未加载设备或其他基础设施正常。" : "建立会话后显示。";
container.appendChild(empty);
return;
}
for (const device of issues) {
const message = device.last_error_code ? `错误 ${device.last_error_code}` : `generation ${device.observed_generation || 0} / ${device.generation || 0}`;
container.appendChild(createHealthItem(device.name, device.failure_count || 0, device.next_attempt_at ? `${message} · 下次 ${formatTime(device.next_attempt_at)}` : message));
}
}
function switchView(name) {
$$(".view").forEach((view) => {
const active = view.dataset.page === name;
view.hidden = !active;
view.classList.toggle("active", active);
});
$$('[data-view]').forEach((button) => {
const active = button.dataset.view === name;
button.toggleAttribute("aria-current", active);
});
$("#main-content").focus({ preventScroll: true });
window.scrollTo({ top: 0, behavior: "auto" });
}
function resetPaging() {
state.cursors = [null];
state.pageIndex = 0;
state.nextCursor = null;
}
async function connect(event) {
event.preventDefault();
const siteInput = $("#siteInput");
const tokenInput = $("#tokenInput");
const site = siteInput.value.trim();
const token = tokenInput.value;
const errorBox = $("#contextError");
errorBox.hidden = true;
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(site)) {
errorBox.textContent = "Site ID 格式无效:只能使用 1~64 位字母、数字、点、下划线、冒号或连字符。";
errorBox.hidden = false;
siteInput.focus();
return;
}
if (!token || token.length > 4096) {
errorBox.textContent = "请输入有效的 Bearer token。";
errorBox.hidden = false;
tokenInput.focus();
return;
}
state.siteId = site;
state.token = token;
tokenInput.value = "";
setText("#siteContext", site);
resetPaging();
try {
await loadDevices({ announce: false });
$("#contextDialog").close();
showToast("会话已建立;token 仅驻留当前页面内存");
} catch (error) {
errorBox.textContent = error.status === 401 ? "认证失败,请检查 token 后重试。" : "无法读取该站点,请按页面提示检查权限或服务状态。";
errorBox.hidden = false;
$("#contextDialog").showModal();
}
}
async function initialize() {
try {
const response = await fetch("/sense-console/config", { cache: "no-store" });
if (!response.ok) throw new Error("config unavailable");
state.config = await response.json();
if (state.config.page_size !== 16 || state.config.max_active_previews !== 4 || state.config.recording_and_playback !== false) {
throw new Error("unsafe console bounds");
}
} catch (_) {
showDegraded("控制台配置不可用", "页面保持只读且不会尝试连接视频,请检查 Sense 启动配置。");
$$("button, input, select").forEach((control) => { control.disabled = true; });
return;
}
setConnected(false);
renderAll();
$("#contextDialog").showModal();
}
$$('[data-view]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)));
$$('[data-view-target]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewTarget)));
$("#changeContext").addEventListener("click", () => $("#contextDialog").showModal());
$("#closeContext").addEventListener("click", () => $("#contextDialog").close());
$("#cancelContext").addEventListener("click", () => $("#contextDialog").close());
$("#contextForm").addEventListener("submit", connect);
$("#refreshDevices").addEventListener("click", () => loadDevices());
$("#refreshOperations").addEventListener("click", () => loadDevices());
$("#retryLoad").addEventListener("click", () => state.token ? loadDevices() : $("#contextDialog").showModal());
$("#startPreview").addEventListener("click", startSelectedPreviews);
$("#stopAll").addEventListener("click", stopAllPreviews);
$("#deviceSearch").addEventListener("input", renderDevices);
for (const selector of ["#modalityFilter", "#desiredFilter", "#actualFilter"]) {
$(selector).addEventListener("change", () => {
resetPaging();
if (state.token) loadDevices();
});
}
$("#clearFilters").addEventListener("click", () => {
$("#deviceSearch").value = "";
$("#modalityFilter").value = "";
$("#desiredFilter").value = "";
$("#actualFilter").value = "";
resetPaging();
if (state.token) loadDevices(); else renderDevices();
});
$("#nextPage").addEventListener("click", async () => {
if (!state.nextCursor) return;
state.cursors[state.pageIndex + 1] = state.nextCursor;
state.pageIndex += 1;
try { await loadDevices(); } catch (_) { state.pageIndex -= 1; }
});
$("#prevPage").addEventListener("click", async () => {
if (state.pageIndex === 0) return;
state.pageIndex -= 1;
try { await loadDevices(); } catch (_) { state.pageIndex += 1; }
});
window.addEventListener("beforeunload", () => {
state.token = "";
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
});
initialize();
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>YoVision Sense · 接入运维工作台</title>
<link rel="stylesheet" href="/sense-console/app.css">
<script defer src="/sense-console/app.js"></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="engineering-banner" role="note">
回环工程控制台 · 非录像/回放 · 不代表生产公网入口
</div>
<div class="shell">
<aside class="sidebar" aria-label="Sense 主导航">
<div class="brand">
<span class="brand-mark" aria-hidden="true">S</span>
<span><strong>YoVision Sense</strong><small>接入运维工作台</small></span>
</div>
<nav class="primary-nav">
<button type="button" data-view="overview" aria-current="page"><span aria-hidden="true">◫</span>运行总览</button>
<button type="button" data-view="monitor"><span aria-hidden="true">▷</span>实时监控 <span class="nav-count" id="previewCount">0/4</span></button>
<button type="button" data-view="devices"><span aria-hidden="true">▣</span>设备 <span class="nav-count" id="deviceCount">0</span></button>
<button type="button" data-view="onboarding"><span aria-hidden="true">⇧</span>接入任务</button>
<button type="button" data-view="operations"><span aria-hidden="true">⌁</span>运维中心 <span class="nav-count" id="issueCount">0</span></button>
</nav>
<div class="sidebar-foot">
<span class="status-dot" aria-hidden="true"></span>
<span id="sessionState">尚未建立会话</span>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div>
<span class="eyebrow">当前站点</span>
<strong id="siteContext">未选择</strong>
</div>
<div class="topbar-actions">
<span class="connection-pill" id="connectionPill"><span class="connection-dot" aria-hidden="true"></span><span id="connectionText">未连接</span></span>
<button type="button" class="button secondary" id="changeContext">连接设置</button>
</div>
</header>
<div class="degraded-banner" id="degradedBanner" role="status" hidden>
<strong id="degradedTitle">无法读取最新状态</strong>
<span id="degradedMessage">未知不会显示为在线,请检查会话后重试。</span>
<button type="button" class="button ghost" id="retryLoad">重试</button>
</div>
<main id="main-content" tabindex="-1">
<section class="view active" data-page="overview" aria-labelledby="overviewTitle">
<div class="page-heading">
<div><span class="eyebrow">SENSE / OVERVIEW</span><h1 id="overviewTitle">运行总览</h1><p>先看已加载事实,再进入设备或运维中心处理。</p></div>
<button type="button" class="button primary" data-view-target="devices">查看设备</button>
</div>
<div class="metric-grid" aria-label="当前页运行指标">
<article class="metric-card"><span>视频配额</span><strong id="quotaMetric">—</strong><small id="quotaHint">连接后读取 Bell 投影</small></article>
<article class="metric-card"><span>当前页设备</span><strong id="pageMetric">0</strong><small>默认每页 16,不代表站点总数</small></article>
<article class="metric-card"><span>当前页已收敛</span><strong id="convergedMetric">0</strong><small>期望态与实际态分开计算</small></article>
<article class="metric-card warning"><span>当前页需关注</span><strong id="attentionMetric">0</strong><small>失败、离线或尚未收敛</small></article>
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-heading"><div><h2>接入健康</h2><p>只汇总当前设备页,不推断未加载设备。</p></div><button type="button" class="button ghost" data-view-target="operations">进入运维中心</button></div>
<div class="health-list" id="overviewHealth"><div class="empty-compact">建立会话后显示真实状态。</div></div>
</article>
<article class="panel boundary-card">
<span class="boundary-label">本纵切边界</span>
<h2>实时监看,不替代客户 NVR</h2>
<p>常态录像仍留在客户现有 NVR;当前仅验证 Sense 设备管理面和 MediaMTX 按需播放。录像计划、录像索引与回放未实现。</p>
<ul><li>同时最多 4 路预览</li><li>刷新后 Bearer token 自动丢失</li><li>Sense 与播放端均限回环地址</li></ul>
</article>
</div>
</section>
<section class="view" data-page="monitor" aria-labelledby="monitorTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / LIVE</span><h1 id="monitorTitle">实时监控</h1><p>从当前页设备中选择,只有明确在线且已收敛的视频设备可预览。</p></div>
<div class="heading-actions"><button type="button" class="button secondary" id="stopAll">停止全部</button><button type="button" class="button primary" id="startPreview">开始预览</button></div>
</div>
<div class="monitor-toolbar panel">
<div><strong>已选择 <span id="selectionCount">0</span> / 4</strong><span>默认不自动播放,不加载未选择设备。</span></div>
<button type="button" class="button ghost" data-view-target="devices">选择设备</button>
</div>
<div class="preview-grid" id="previewGrid" aria-live="polite">
<div class="preview-empty"><span aria-hidden="true">▷</span><strong>尚未启动预览</strong><p>前往设备页选择最多 4 路运行中的视频设备。</p></div>
</div>
</section>
<section class="view" data-page="devices" aria-labelledby="devicesTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / DEVICES</span><h1 id="devicesTitle">设备</h1><p>统一台账按模态与能力展示;列表只读取脱敏 Control API 字段。</p></div>
<button type="button" class="button primary" id="refreshDevices">刷新状态</button>
</div>
<section class="panel filter-panel" aria-label="设备筛选">
<label><span>当前页搜索</span><input type="search" id="deviceSearch" placeholder="名称 / 序列号" autocomplete="off"></label>
<label><span>模态</span><select id="modalityFilter"><option value="">全部模态</option><option value="video">视频</option><option value="radar">雷达</option><option value="contact">门磁</option><option value="button">按钮</option><option value="wearable">可穿戴</option><option value="other">其他</option></select></label>
<label><span>期望态</span><select id="desiredFilter"><option value="">全部期望态</option><option value="enabled">启用</option><option value="disabled">停用</option></select></label>
<label><span>实际态</span><select id="actualFilter"><option value="">全部实际态</option><option value="online">在线</option><option value="offline">离线</option><option value="failed">失败</option><option value="pending">收敛中</option></select></label>
<button type="button" class="button secondary" id="clearFilters">清除筛选</button>
</section>
<div class="table-panel panel">
<div class="table-status" id="deviceTableStatus" role="status">请先建立会话。</div>
<div class="device-table-wrap">
<table>
<thead><tr><th scope="col">预览</th><th scope="col">设备</th><th scope="col">模态 / 能力</th><th scope="col">状态</th><th scope="col">期望 / 实际</th><th scope="col">Area</th><th scope="col">最后变化</th></tr></thead>
<tbody id="deviceRows"></tbody>
</table>
</div>
<div class="device-cards" id="deviceCards"></div>
<div class="pagination"><span id="pageLabel">第 1 页 · 每页 16</span><div><button type="button" class="button secondary" id="prevPage" disabled>上一页</button><button type="button" class="button secondary" id="nextPage" disabled>下一页</button></div></div>
</div>
</section>
<section class="view" data-page="onboarding" aria-labelledby="onboardingTitle" hidden>
<div class="page-heading"><div><span class="eyebrow">SENSE / ONBOARDING</span><h1 id="onboardingTitle">接入任务</h1><p>批量导入仍是已确认需求,但不属于本次设备与实时监看纵切。</p></div></div>
<article class="panel honest-empty">
<span class="feature-state">尚未实现</span><h2>批量导入与逐项激活将在独立任务完成</h2>
<p>当前 Control API 支持单设备创建和最多 128 项的批量启停,但没有冻结批量 CSV 创建契约。本页不会用假上传或假成功伪装交付。</p>
<div class="next-list"><span><strong>已具备</strong>设备查询、期望态修改、批量启停与结果查询</span><span><strong>后续补齐</strong>模板下载、逐行校验、只重试失败项和任务历史</span></div>
</article>
</section>
<section class="view" data-page="operations" aria-labelledby="operationsTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / OPERATIONS</span><h1 id="operationsTitle">运维中心</h1><p>仅展示接入控制面的事实,不混入 Bell 业务预警。</p></div>
<a class="button secondary" href="/metrics" target="_blank" rel="noreferrer">打开低基数指标</a>
</div>
<div class="operations-grid">
<article class="panel"><div class="panel-heading"><div><h2>当前页对账差异</h2><p>失败、退避与尚未观察到当前 generation。</p></div><button type="button" class="button ghost" id="refreshOperations">刷新</button></div><div id="operationIssues" class="health-list"><div class="empty-compact">建立会话后显示。</div></div></article>
<article class="panel boundary-card"><span class="boundary-label">数据边界</span><h2>完整运维 API 尚未冻结</h2><p>分片、边缘隧道、补传、孤儿扫描和运维告警已经有后端能力或设计,但本纵切只消费设备 Control API;未取到的数据不会显示为正常。</p></article>
</div>
</section>
</main>
</div>
</div>
<nav class="mobile-nav" aria-label="移动端主导航">
<button type="button" data-view="overview" aria-current="page">总览</button><button type="button" data-view="monitor">监控</button><button type="button" data-view="devices">设备</button><button type="button" data-view="onboarding">接入</button><button type="button" data-view="operations">运维</button>
</nav>
<dialog id="contextDialog" aria-labelledby="contextTitle">
<form method="dialog" id="contextForm">
<div class="dialog-heading"><div><span class="eyebrow">LOOPBACK SESSION</span><h2 id="contextTitle">连接 Sense Control API</h2></div><button type="button" class="icon-button" id="closeContext" aria-label="关闭连接设置">×</button></div>
<p>Site ID 只用于当前页面上下文;Bearer token 仅保存在内存,刷新页面后自动丢失。</p>
<label><span>Site ID</span><input id="siteInput" name="site" required maxlength="128" autocomplete="off" placeholder="例如 school-main"></label>
<label><span>Bearer token</span><input id="tokenInput" name="token" type="password" required autocomplete="off" placeholder="不会保存或回显"></label>
<div class="form-error" id="contextError" role="alert" hidden></div>
<div class="dialog-actions"><button type="button" class="button secondary" id="cancelContext">取消</button><button type="submit" class="button primary" id="connectButton">连接并读取</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
// Package console serves the loopback-only Sense engineering console.
package console
import (
"embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
//go:embed assets/index.html assets/app.css assets/app.js
var assets embed.FS
type handler struct {
previewBaseURL string
previewOrigin string
}
// NewHandler builds the self-contained console handler. Config validation also
// enforces this boundary; validating here keeps the package safe in isolation.
func NewHandler(previewBaseURL string) (http.Handler, error) {
parsed, err := url.Parse(previewBaseURL)
if err != nil || parsed.Host == "" ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("invalid Sense console WebRTC base URL")
}
host := parsed.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return nil, errors.New("Sense console WebRTC base URL is not loopback")
}
base := strings.TrimRight(parsed.String(), "/")
return &handler{
previewBaseURL: base,
previewOrigin: parsed.Scheme + "://" + parsed.Host,
}, nil
}
func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.securityHeaders(writer)
if request.Method != http.MethodGet && request.Method != http.MethodHead {
writer.Header().Set("Allow", "GET, HEAD")
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
return
}
switch request.URL.Path {
case "/sense-console/":
h.serveAsset(writer, request, "assets/index.html", "text/html; charset=utf-8")
case "/sense-console/app.css":
h.serveAsset(writer, request, "assets/app.css", "text/css; charset=utf-8")
case "/sense-console/app.js":
h.serveAsset(writer, request, "assets/app.js", "text/javascript; charset=utf-8")
case "/sense-console/config":
writer.Header().Set("Content-Type", "application/json")
if request.Method == http.MethodHead {
writer.WriteHeader(http.StatusOK)
return
}
_ = json.NewEncoder(writer).Encode(map[string]any{
"webrtc_base_url": h.previewBaseURL,
"page_size": 16,
"max_active_previews": 4,
"recording_and_playback": false,
})
default:
http.NotFound(writer, request)
}
}
func (h *handler) securityHeaders(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Content-Security-Policy", fmt.Sprintf(
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src %s; img-src 'self' data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
h.previewOrigin,
))
writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
}
func (h *handler) serveAsset(writer http.ResponseWriter, request *http.Request, path, contentType string) {
contents, err := assets.ReadFile(path)
if err != nil {
http.Error(writer, "asset unavailable", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(contents)))
writer.WriteHeader(http.StatusOK)
if request.Method == http.MethodGet {
_, _ = writer.Write(contents)
}
}
+95
View File
@@ -0,0 +1,95 @@
package console
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlerServesSelfContainedConsoleWithSecurityHeaders(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889/")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/sense-console/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", response.Code)
}
body := response.Body.String()
for _, required := range []string{"YoVision Sense", "/sense-console/app.css", "/sense-console/app.js"} {
if !strings.Contains(body, required) {
t.Fatalf("console HTML lacks %q", required)
}
}
for _, forbidden := range []string{"http://", "https://", "<script>", "<style>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("console HTML contains forbidden inline/external marker %q", forbidden)
}
}
csp := response.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-src http://127.0.0.1:8889") ||
!strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("unexpected CSP: %s", csp)
}
if response.Header().Get("Cache-Control") != "no-store" ||
response.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers are incomplete")
}
}
func TestHandlerReturnsBoundedRuntimeConfig(t *testing.T) {
handler, err := NewHandler("http://localhost:8889")
if err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/sense-console/config", nil))
var payload struct {
WebRTCBaseURL string `json:"webrtc_base_url"`
PageSize int `json:"page_size"`
MaxActivePreviews int `json:"max_active_previews"`
RecordingAndPlayback bool `json:"recording_and_playback"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.WebRTCBaseURL != "http://localhost:8889" || payload.PageSize != 16 ||
payload.MaxActivePreviews != 4 || payload.RecordingAndPlayback {
t.Fatalf("unexpected runtime config: %+v", payload)
}
}
func TestHandlerRejectsRemoteOrCredentialedPreviewBase(t *testing.T) {
for _, value := range []string{
"https://media.example", "http://user@127.0.0.1:8889", "http://127.0.0.1:8889/path",
} {
if _, err := NewHandler(value); err == nil {
t.Fatalf("invalid preview base was accepted: %s", value)
}
}
}
func TestHandlerRejectsWritesAndUnknownAssets(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodPost, "/sense-console/", http.StatusMethodNotAllowed},
{http.MethodGet, "/sense-console/missing", http.StatusNotFound},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
-- Bell v5 Brain event ingress identity bindings and durable receipts.
CREATE TABLE IF NOT EXISTS bell.event_ingress_bindings (
producer_id text NOT NULL,
tenant_id bigint NOT NULL,
site_id bigint NOT NULL,
device_id bigint NOT NULL,
logical_tenant_id text NOT NULL,
logical_site_id text NOT NULL,
logical_device_id text NOT NULL,
logical_area_id text NOT NULL,
modality text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (producer_id, tenant_id, site_id, device_id),
CONSTRAINT bell_ingress_binding_site_fk
FOREIGN KEY (logical_tenant_id, logical_site_id)
REFERENCES bell.sites(tenant_id, id),
CONSTRAINT bell_ingress_binding_area_fk
FOREIGN KEY (logical_tenant_id, logical_area_id)
REFERENCES bell.areas(tenant_id, id),
CONSTRAINT bell_ingress_binding_device_fk
FOREIGN KEY (logical_tenant_id, logical_site_id, logical_device_id)
REFERENCES sense.devices(tenant_id, site_id, id),
CONSTRAINT bell_ingress_binding_numeric_ids CHECK (
tenant_id >= 1 AND site_id >= 1 AND device_id >= 1
),
CONSTRAINT bell_ingress_binding_producer CHECK (
char_length(producer_id) BETWEEN 1 AND 64
AND producer_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
),
CONSTRAINT bell_ingress_binding_logical_ids CHECK (
btrim(logical_tenant_id) <> '' AND btrim(logical_site_id) <> ''
AND btrim(logical_device_id) <> '' AND btrim(logical_area_id) <> ''
),
CONSTRAINT bell_ingress_binding_modality CHECK (
modality IN ('video', 'radar', 'contact', 'button', 'wearable', 'other')
)
);
ALTER TABLE bell.event_ingress_bindings OWNER TO bell_app;
CREATE INDEX IF NOT EXISTS bell_event_ingress_binding_numeric_idx
ON bell.event_ingress_bindings(tenant_id, site_id, device_id)
WHERE enabled;
CREATE TABLE IF NOT EXISTS bell.event_ingress_receipts (
producer_id text NOT NULL,
source_event_id text NOT NULL,
candidate_hash bytea NOT NULL,
event_id text NOT NULL REFERENCES bell.events(id),
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (producer_id, source_event_id),
CONSTRAINT bell_ingress_receipt_producer CHECK (
char_length(producer_id) BETWEEN 1 AND 64
AND producer_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
),
CONSTRAINT bell_ingress_receipt_source CHECK (
source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'
),
CONSTRAINT bell_ingress_receipt_hash CHECK (octet_length(candidate_hash) = 32),
CONSTRAINT bell_ingress_receipt_event_id CHECK (
event_id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
)
);
ALTER TABLE bell.event_ingress_receipts OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_event_ingress_receipts_immutable
ON bell.event_ingress_receipts;
CREATE TRIGGER bell_event_ingress_receipts_immutable
BEFORE UPDATE OR DELETE ON bell.event_ingress_receipts
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
CREATE INDEX IF NOT EXISTS bell_event_ingress_receipt_event_idx
ON bell.event_ingress_receipts(event_id);
CREATE TABLE IF NOT EXISTS bell.event_ingress_nonces (
key_id text NOT NULL,
nonce text NOT NULL,
request_hash bytea NOT NULL,
response_status integer NOT NULL,
response_body jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
expires_at timestamptz NOT NULL,
PRIMARY KEY (key_id, nonce),
CONSTRAINT bell_ingress_nonce_key CHECK (
char_length(key_id) BETWEEN 1 AND 64
AND key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
),
CONSTRAINT bell_ingress_nonce_value CHECK (
char_length(nonce) BETWEEN 22 AND 64
AND nonce ~ '^[A-Za-z0-9_-]+$'
),
CONSTRAINT bell_ingress_nonce_hash CHECK (octet_length(request_hash) = 32),
CONSTRAINT bell_ingress_nonce_status CHECK (response_status IN (200, 201)),
CONSTRAINT bell_ingress_nonce_body CHECK (jsonb_typeof(response_body) = 'object'),
CONSTRAINT bell_ingress_nonce_ttl CHECK (
expires_at >= received_at + interval '10 minutes'
AND expires_at <= received_at + interval '11 minutes'
)
);
ALTER TABLE bell.event_ingress_nonces OWNER TO bell_app;
CREATE INDEX IF NOT EXISTS bell_event_ingress_nonce_expiry_idx
ON bell.event_ingress_nonces(expires_at, key_id, nonce);
INSERT INTO bell.schema_migrations(version) VALUES (5)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,25 @@
-- Bell runtime may read controlled identity bindings, append events/source
-- receipts and maintain only short-lived nonce receipts.
REVOKE ALL ON TABLE bell.event_ingress_bindings,
bell.event_ingress_receipts, bell.event_ingress_nonces FROM PUBLIC;
REVOKE ALL ON TABLE bell.event_ingress_bindings,
bell.event_ingress_receipts, bell.event_ingress_nonces FROM bell_runtime;
GRANT SELECT ON TABLE bell.event_ingress_bindings TO bell_runtime;
GRANT SELECT, INSERT ON TABLE bell.event_ingress_receipts TO bell_runtime;
GRANT SELECT, INSERT, DELETE ON TABLE bell.event_ingress_nonces TO bell_runtime;
-- Column grants deliberately exclude endpoint_ref, credential_ref,
-- profile_token and path_name.
GRANT USAGE ON SCHEMA sense TO bell_runtime;
REVOKE ALL ON TABLE sense.devices FROM bell_runtime;
GRANT SELECT (id, tenant_id, site_id, area_id, modality)
ON TABLE sense.devices TO bell_runtime;
GRANT SELECT (tenant_id, id, deleted_at)
ON TABLE bell.sites TO bell_runtime;
GRANT SELECT (tenant_id, site_id, id, capture_policy, deleted_at)
ON TABLE bell.areas TO bell_runtime;
REVOKE ALL ON TABLE bell.event_ingress_bindings,
bell.event_ingress_receipts, bell.event_ingress_nonces FROM sense_app;
+2 -1
View File
@@ -1,6 +1,6 @@
# YoVision PostgreSQL 初始化
本目录实现 T-009~T-012、T-015~T-016 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与本地审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果;`012`~`013` 增加 Bell 不可变事件、append-only outcome 和独立 `bell_runtime` 最小权限;`014`~`015` 增加审计 Outbox relay fencing、Bell 全局审计事实、短期防重收据和双方最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
本目录实现 T-009~T-012、T-015~T-016、T-019 的 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`,仓库不保存登录凭据。
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
@@ -23,6 +23,7 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
- `sense.device_operation_outbox` 是本地持久化队列,Bell 全局审计真相只写入 `bell.audit_events`。Sense 只领取/确认本地行,不获得 Bell 表权限;Bell 只通过签名 HTTP ingress 收取,不读取 Outbox。
- `bell.audit_events` 与事件事实一样不可更新/删除;`bell.audit_relay_receipts` 只为 10 分钟 nonce 幂等窗口保留,Bell runtime 仅可在这张限定表中查询、插入和清理过期记录。
- `bell.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。
- `bell.event_ingress_bindings` 由管理员维护并引用当前 Bell Site/Area 和 Sense Device;Bell runtime 只读绑定及 Sense 设备的逻辑 ID/Area/modality 列,不可读取 endpoint、credential、profile token 或 MediaMTX path。`event_ingress_receipts` 永久只追加;只有 `event_ingress_nonces` 可按 10 分钟 TTL 清理。
- `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 的表和函数没有权限。
+27 -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) <> 4
IF (SELECT max(version) FROM bell.schema_migrations) <> 5
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
RAISE EXCEPTION 'schema migration version drift';
END IF;
@@ -228,3 +228,29 @@ BEGIN
END IF;
END
$audit_relay$;
DO $event_ingress$
BEGIN
IF NOT has_table_privilege('yovision_t015_bell', 'bell.event_ingress_bindings', 'SELECT')
OR has_table_privilege('yovision_t015_bell', 'bell.event_ingress_bindings', 'INSERT,UPDATE,DELETE,TRUNCATE')
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_ingress_receipts', 'SELECT,INSERT')
OR has_table_privilege('yovision_t015_bell', 'bell.event_ingress_receipts', 'UPDATE,DELETE,TRUNCATE')
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_ingress_nonces', 'SELECT,INSERT,DELETE')
OR has_table_privilege('yovision_t015_bell', 'bell.event_ingress_nonces', 'UPDATE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell runtime violates event ingress privileges';
END IF;
IF NOT has_column_privilege('yovision_t015_bell', 'sense.devices', 'id', 'SELECT')
OR NOT has_column_privilege('yovision_t015_bell', 'sense.devices', 'area_id', 'SELECT')
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'endpoint_ref', 'SELECT')
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'credential_ref', 'SELECT')
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'profile_token', 'SELECT')
OR has_column_privilege('yovision_t015_bell', 'sense.devices', 'path_name', 'SELECT') THEN
RAISE EXCEPTION 'Bell runtime Sense device projection privilege drift';
END IF;
IF has_table_privilege('public', 'bell.event_ingress_bindings', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
OR has_table_privilege('public', 'bell.event_ingress_receipts', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
OR has_table_privilege('public', 'bell.event_ingress_nonces', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell event ingress tables leaked to PUBLIC';
END IF;
END
$event_ingress$;
+4 -2
View File
@@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
## 当前阶段
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路可视化工程原型、Sense 回环 NVR 管理面纵切和 Brain→Bell 可靠事件 ingress**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置;下一项建议把 Bell 规则→Alert→ack 最小纵切与证据切片拆成独立任务。
优先路径:
@@ -92,8 +92,10 @@ go -C Sense build ./...
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
```
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;T-018 控制台必须在完成 PostgreSQL/Control API 外部安全配置后显式设置 `SENSE_CONSOLE_ENABLED=true`,再访问回环 `/sense-console/`。Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。工程页面默认只允许回环,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
+33 -3
View File
@@ -71,6 +71,28 @@ T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进
T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、SHA-256、base64url 和 constant-time compare,数据库继续使用已冻结的 PostgreSQL 17.10/pgx。`cmd/bell-api` 只提供回环 health/ready 和 Sense 审计内部端点;非回环监听必须同时提供绝对路径 TLS 证书/私钥。HMAC key 使用仓库外 version 1 JSON 文件,secret 至少 32 字节;该适配器不替代未来 Bell 公共 JWT/OIDC。
### 1.5 Brain 单路工程原型(T-017)
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
| --- | --- | --- | --- |
| Python | `3.10.11` | PSF License;本机 `python --version` 已验证 | 仅冻结 T-017 工程原型和测试语法基线,不等于生产 Savant/DeepStream Python 版本;生产脚手架冻结时通过标准模块边界迁移 |
| NumPy | `1.26.4` | BSD-3-Clause;本机 `numpy.__version__` 已验证 | 只在 frame fixture/OpenCV 数组边界使用;不把 NumPy 类型写入事件候选或 HTTP JSON |
| OpenCV Python | `opencv-python 4.9.0.80` | wheel 构建脚本 MIT、OpenCV Apache-2.0;Windows amd64 wheel SHA-256 `3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0` | 内置 HOG/SVM 只作为可替换 `Detector` 的匿名人员检测演示,不是生产模型;生产退出时替换 detector port,区域判定、候选事实和 UI 状态不依赖 HOG 类型。不得在同一环境混装标准/headless/contrib wheel |
T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/ReID 或 `max_sources`。默认合成 fixture 可重复展示工程链路,并必须标识为“非模型输出”;真实流只从仓库外绝对 URL 文件读取,优先消费 Sense 管理的 MediaMTX path。`/brain-demo` 只监听显式回环地址,事件候选最多保留 100 项内存环,不形成 Brain 业务数据库。OpenCV wheel 是 CPU-only;本机存在 NVIDIA GPU 也不能据此宣称 GPU 推理已接入。
### 1.6 Sense 回环 NVR 管理面纵切(T-018)
T-018 不增加生产或前端依赖:Go 使用标准库 `embed`/`net/http` 提供自包含 HTML、CSS 和原生 JavaScript,页面直接消费已冻结的 Sense Control API v1;实时预览使用同版 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,不 vendor `reader.js`、不引入 CDN/播放器库,也不让 Sense 代理媒体字节。
该选择只适用于默认关闭的回环工程控制台,不冻结 Bell 最终前端框架。页面默认 16 项 cursor 分页、最多 4 路按需预览且不自动加载视频;Sense HTTP 与 WebRTC 基地址必须同时是显式回环地址。非回环 HTTPS/JWT/OIDC、MediaMTX 外部认证、录像/回放和正式客户会话均保持待冻结,不能从 T-018 的本地演示外推为生产安全或 NVR 存储能力。
### 1.7 Brain → Bell 事件 ingress(T-019)
T-019 不增加第三方依赖。Brain 继续使用 Python 3.10.11 标准库 `sqlite3`、`urllib`、HMAC/SHA-256 和线程;SQLite Outbox 开启 WAL 与 `synchronous=FULL`,文件、配置和 key 都必须是仓库外绝对路径。内部 HTTP client 显式禁用环境代理,避免业务事件被 `HTTP(S)_PROXY` 意外转发;回环可用 HTTP,非回环只接受 HTTPS。队列最多 10,000 条待投递、单 payload 1 MiB、1~300 秒退避、最多 100 次,终态记录保留供运维核查。
Bell 复用已冻结 Go 1.26.5、PostgreSQL 17.10/pgx、JSON Schema 和 ULID,不新增消息总线或 SDK。`brain-event-ingress-v1` 是单事件、producer-bound HMAC 内部适配器;Bell 用永久来源收据和事务级 advisory lock 保证同 `(producer_id, source_event_id)` 只生成一个平台事件。该选择不冻结 Bell 公共认证、生产 Brain GPU/runtime 或未来高吞吐 transport;若容量基准证明单条 HTTP 不足,必须发布兼容迁移方案,不能绕过来源幂等键。
## 2. 外部项目边界
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
@@ -82,7 +104,7 @@ T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
- Bell 前端框架和组件库。
- Brain→Bell 业务事件投递 transport;Sense→Bell 审计 relay 已独立冻结为内部 HTTP,不能据此默认 Brain transport。
- Brain 生产推理 runtime 与更高吞吐 transport;T-019 已冻结首版 Brain→Bell 单事件内部 HTTP ingress,但不据此承诺 64/128 路吞吐或消息总线选型。
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
- MinIO/S3 的精确版本、加密实现,以及客户/法务确认后的最终生命周期策略。
- 短信/语音供应商及生产双路径组合;是否开发原生 App 最早在 M4 根据试点反馈决定。
@@ -91,7 +113,7 @@ T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、
## 4. 当前标准入口
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
Sense 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
```powershell
./init.ps1
@@ -123,6 +145,14 @@ go -C Bell vet ./...
go -C Bell build ./...
```
Brain 单路工程原型单独执行:
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
python -m Brain.yovision_brain --source synthetic
```
直接验证:
```powershell
@@ -139,7 +169,7 @@ python scripts/validate_harness_governance.py
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
| 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 人工验收 |
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
+12 -8
View File
@@ -47,15 +47,15 @@ Sense ── 视频流/触发信号 ──> Brain
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。
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 并按策略升级。
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
## 5. 十二条不可越界的决定
## 5. 十三条不可越界的决定
1. MediaMTX 独立运行,Sense 管配置与生命周期。
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
@@ -69,6 +69,7 @@ Sense ── 视频流/触发信号 ──> Brain
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-016 已实现 HMAC/nonce 内部 HTTP relay、数据库时钟 lease/fencing、逐项确认与 Bell 不可变全局事实;Sense 不获得 Bell schema 权限,Bell 不读取 Sense Outbox。
13. Brain 的业务事件同样先写仓库外持久 Outbox,再异步投递;Bell 以 `(producer_id, source_event_id)` 永久收据而非短期 nonce 保证跨重启幂等。只有 `accepted/duplicate` 可确认本地 delivered;事件 ID 始终由 Bell 生成,稳定冲突不得自动改写来源 ID 或 payload。
## 6. 容量架构
@@ -79,6 +80,8 @@ Sense ── 视频流/触发信号 ──> Brain
- 单分片故障不能扩散到其他分片。
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
T-018 先实现其中的回环工程纵切:设备 cursor 默认每页 16 项,同时预览最多 4 路且默认不播放;只有 `video_capture + enabled + online + converged` 的设备允许打开预览。该上限是浏览器工程台保护值,不是站点视频配额或 MediaMTX 分片容量。常态录像仍由客户现有 NVR 承担,Sense 不因提供监看页而变成媒体代理或录像真相源。
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
## 7. 一致性与失败处理
@@ -91,7 +94,8 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
- 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。relay 最多领取 100 行,以 30 秒数据库 lease 和单调 fencing token 防止过期 worker 确认;成功和 dead letter 都保留本地事实。
- Bell 先验证时间窗、nonce 和 constant-time HMAC,再逐项校验 v1/v2 事件;同 nonce/同摘要重放原结果,同 nonce/不同摘要拒绝。`bell.audit_events` 只追加且不自动清理,只有 10 分钟幂等收据允许 Bell runtime 删除过期行。
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
- 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 先落库再投递,进程重启恢复未完成升级链。
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
@@ -99,7 +103,7 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
## 8. 数据与契约
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及只记录已观察版本的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 为五列,Area v1 固定为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at` 六列。
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。T-016 审计 relay 采用独立外部 HMAC key 文件和内部端点,不等同于 Bell 公共管理认证。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径。T-016 审计 relay 与 T-019 Brain 事件 ingress 各用独立外部 HMAC key/端点,均不等同于 Bell 公共管理认证。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
@@ -115,17 +119,17 @@ Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trig
Brain/{pipeline,models,judge,emit,trigger,contracts}
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
Bell/{web,packs,contracts}
deploy/postgres/{001_roles.sql,...,015_privileges_audit_relay.sql,tests}
deploy/postgres/{001_roles.sql,...,017_privileges_event_ingress.sql,tests}
```
Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API,Brain 仍为目录占位。
Sense 脚手架和 PostgreSQL `001`~`017` 已实现;Bell 已有事件校验/不可变存储、Sense 审计 relay 与默认关闭的 Brain 事件 ingress,但没有公共管理 API。Brain 已有单路工程原型和仓库外 SQLite 可靠事件 Outbox;尚无生产模型、GPU pipeline、证据切片或规则/Alert 链。
## 10. 开发顺序
- M0 不写生产代码。
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
- M3 Brain 与 Bell 同时起步;T-015~T-019 已打通不可变事件消费者、审计 relay、Brain 单路候选、Sense 回环管理纵切和 Brain→Bell 可靠事件 ingress。下一步仍应在证据切片与规则/Alert 之间按客户可见闭环拆分独立任务。
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
+3
View File
@@ -38,6 +38,9 @@
- Bell 事件校验、不可变存储和 ULID。
- T-015:建立 Bell Go 事件域基础,复制并校验冻结 v0.1 schema,由 Bell 生成平台 ULID,执行六项代码断言,并以 `bell_runtime` 最小权限保存不可变事件和 append-only outcome;不冻结 Brain transport 或公共 API。
- T-016:冻结并实现 Sense Outbox → Bell 内部审计 relay;使用 HMAC、nonce 收据、数据库时钟 lease/fencing、逐项确认和 dead letter,在不共享 schema 权限的前提下写入 Bell 不可变全局审计事实。
- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
- T-019:建立 Brain→Bell 可靠业务事件 ingress;独立冻结数字事件身份到 Bell 逻辑身份的绑定、HMAC 认证、SQLite 持久 Outbox、Bell 永久来源收据和跨重启幂等确认,不得把 T-017 内存事件环当作生产投递。
- 规则引擎、场景包加载、预警状态机与双路径投递。
- 最小 Web/App 处置流程、RBAC 与审计。
- 现场误报基线和反馈队列。
+8
View File
@@ -106,6 +106,14 @@
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
## US-014 演示单路匿名区域事件链
- 角色:售前/实施工程师、客户项目负责人。
- 目标:在没有完整 Bell 业务闭环前,直接看到一条视频源如何形成匿名人员框、track、区域命中和事件候选,并能当场调整多边形区域。
- 价值:尽早验证客户能理解的主链路,同时把“工程已连通”和“生产模型效果已验收”严格分开。
- 验收:默认合成回放无外部条件即可重复运行且始终标识为 fixture;真实流只通过仓库外配置接入且页面不显示 URL/凭据;HOG 适配器明确不是生产模型,无检测时不伪造人员框;区域支持画布加点和键盘坐标等效路径;候选事实只有 `source_event_id`,不伪造 Bell 平台 ID、Alert 或处置状态。
- 关联:RQ-C-11~RQ-C-15,IX-017、IX-024;T-017。
## 追溯规则
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
+1
View File
@@ -27,6 +27,7 @@
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
| IX-024 | Brain 单路工程演示 | 固定展示来源类型、fixture/真实检测边界、连接状态、detector 名称、帧序号、处理耗时、匿名 track、区域内外和最近候选;合成/离线数据不得伪装为模型输出。区域支持画布加点、撤销、清空、保存和坐标表单等效路径;保存失败保留草稿。页面不得展示流 URL、凭据、平台事件 ID、Alert 或虚构准确率 | US-014 | M3 |
## 全局状态
+4 -3
View File
@@ -1,6 +1,6 @@
# API 与契约
> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1、Sense 本地设备审计事件 v1/v2 与 Sense→Bell 审计 relay v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
> Brain → Bell 事件契约 v0.1 与内部 ingress v1、Sense Control API v1、Bell 配额/Area 只读投影 v1、Sense 本地设备审计事件 v1/v2 与 Sense→Bell 审计 relay v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
## 1. 已冻结:Brain → Bell 事件契约
@@ -17,7 +17,7 @@
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress 先接收“不含平台 `id`”的候选事实,Bell 生成 `evt_` ULID 后形成最终 v0.1 对象,再执行 schema 与六项代码断言并不可变落库。该候选类型是 Bell 内部 port,不是 Brain 可依赖的 HTTP/消息总线协议;transport、认证和重放语义仍由后续任务冻结。
T-015 已实现 Bell 消费端的内部组装与存储边界;T-019 在 [`contracts/brain-event-ingress-v1.openapi.json`](contracts/brain-event-ingress-v1.openapi.json) 冻结并实现网络适配器。Brain 发送“不含平台 `id`”的完整候选,Bell 生成 `evt_` ULID 后执行 schema、六项代码断言与不可变落库。内部 endpoint 使用独立 producer-bound HMAC key,不是 Bell 公共 API 或 JWT/OIDC 的替代品。
## 2. 跨系统接口状态
@@ -26,12 +26,13 @@ T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 冻结,T-009 已实现数据路径 |
| Sense → Bell | 读取 Area 成像准入 | 只读 `bell.area_policy_v1`;`video_allowed | non_imaging_only`;缺失/非法/回退失败关闭但不影响已有设备 | T-010 已冻结并实现数据路径 |
| Sense → Bell | 汇入设备操作审计 | `POST /internal/v1/audit-events:batch`;1~100 项、1 MiB、10 秒 deadline、HMAC/nonce、逐项确认;非回环必须 HTTPS | T-016 已冻结并实现 |
| Brain → Bell | 汇入业务事件候选 | `POST /internal/v1/event-candidates`;单条完整 v0.1-minus-id candidate、1 MiB、10 秒 deadline、producer-bound HMAC、永久来源收据;非回环必须 HTTPS | T-019 已冻结并实现 |
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/sense-audit-relay-v1.openapi.json`](contracts/sense-audit-relay-v1.openapi.json)、[`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)、[`contracts/area-policy-v1.sql`](contracts/area-policy-v1.sql) 与 [`contracts/sense-device-audit-v1.schema.json`](contracts/sense-device-audit-v1.schema.json)。Bell 拥有投影源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/brain-event-ingress-v1.openapi.json`](contracts/brain-event-ingress-v1.openapi.json)、[`contracts/sense-audit-relay-v1.openapi.json`](contracts/sense-audit-relay-v1.openapi.json)、[`contracts/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 下静默改变一致性或身份语义。
## 3. 已冻结:Sense Control API v1
+19 -2
View File
@@ -1,6 +1,6 @@
# Sense 控制面、准入投影与本地审计契约 v1
# YoVision 控制面、审计与内部事件传输契约 v1
> 冻结日期:2026-08-11。Control API 与审计 relay 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-016 已实现 Sense Outbox 到 Bell 的内部 relay;Bell 公共管理服务与 JWT/OIDC 仍未实现。
> 冻结日期:2026-08-11。Control API、审计 relay 与 Brain 事件 ingress 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额、事件与全局审计的所有者。T-016 已实现 Sense Outbox 审计 relay,T-019 已实现 Brain 事件 Outbox ingress;Bell 公共管理服务与 JWT/OIDC 仍未实现。
## 契约文件
@@ -12,6 +12,7 @@
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
| [`sense-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
| [`brain-event-ingress-v1.openapi.json`](brain-event-ingress-v1.openapi.json) | Bell | Brain | 单业务事件入站、producer/key 绑定、Bell ID 与跨重启幂等语义 |
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
@@ -72,6 +73,20 @@ Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SK
{"version":1,"keys":[{"key_id":"sense-a","secret_base64url":"<external-secret>"}]}
```
## Brain → Bell 业务事件 ingress
Brain 向 `POST /internal/v1/event-candidates` 单条投递完整 event v0.1 candidate;candidate 与冻结最终事件的顶层字段完全一致,只省略 Bell 所有的 `id`。envelope 额外携带 `schema_version=1` 和 `producer_id`。Bell 校验 HMAC、key 与 producer 的绑定、当前逻辑设备/Area 映射、event schema 和六项语义断言后生成 `evt_` ULID;首次接受返回 `201 accepted`,同一来源事实重投返回 `200 duplicate` 和原 Bell ID。
认证 canonical string 与审计 relay 使用相同五行算法,但 key 文件独立且每项增加 `producer_id`,不得混用审计 key。正文最多 1 MiB、deadline 10 秒、允许 300 秒时钟偏差,nonce 收据至少保留 600 秒;HTTP 只允许回环地址,非回环必须 HTTPS。key 文件形态为:
```json
{"version":1,"keys":[{"key_id":"brain-a","producer_id":"brain-main","secret_base64url":"<external-secret>"}]}
```
Bell 的永久 `event_ingress_receipts` 以 `(producer_id, source_event_id)` 唯一并保存 canonical candidate SHA-256。相同 hash 重投返回原 event ID,不同 hash 返回 `409 source_event_conflict`;事件、来源收据和成功 nonce 响应在一个 PostgreSQL 事务中提交。`event_ingress_nonces` 只是 10 分钟防重表,可清理;来源收据和事件不自动删除、不可更新。Brain 必须先把 candidate 写入仓库外 SQLite Outbox,只有 `accepted/duplicate` 可标记 delivered;401、网络和 5xx 以 1~300 秒退避重试,稳定 4xx 进入 dead letter,最多 100 次、最多 10,000 条待投递。
数字 `tenant_id/site_id/device_id` 通过 Bell 所有的 `event_ingress_bindings` 映射到当前 Bell Site/Area 和 Sense Device 逻辑 ID。运行时只读取 Sense 设备的 ID、Area 与 modality 列,不获得 endpoint、credential、profile token 或 path;绑定缺失/禁用、设备或 Area 不一致、删除、非视频或 `capture_policy != video_allowed` 均失败关闭。首版绑定只由受控 migration/admin SQL 配置,没有公共 CRUD。
## 兼容与废弃
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
@@ -87,9 +102,11 @@ Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SK
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
python -m json.tool docs/contracts/sense-audit-relay-v1.openapi.json | Out-Null
python -m json.tool docs/contracts/brain-event-ingress-v1.openapi.json | Out-Null
python -m 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"
```
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
@@ -0,0 +1,147 @@
{
"openapi": "3.1.0",
"info": {
"title": "YoVision Brain Event Ingress",
"version": "1.0.0",
"description": "Internal single-event transport. The candidate is the frozen event v0.1 object with only the Bell-owned id omitted."
},
"paths": {
"/internal/v1/event-candidates": {
"post": {
"operationId": "ingestBrainEventCandidate",
"summary": "Validate and persist one Brain event candidate",
"parameters": [
{"$ref": "#/components/parameters/KeyId"},
{"$ref": "#/components/parameters/Timestamp"},
{"$ref": "#/components/parameters/Nonce"},
{"$ref": "#/components/parameters/Signature"}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/IngressRequest"}
}
}
},
"responses": {
"200": {"description": "The same producer/source candidate was already stored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngressResponse"}}}},
"201": {"description": "Candidate accepted and stored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngressResponse"}}}},
"400": {"$ref": "#/components/responses/Error"},
"401": {"$ref": "#/components/responses/Error"},
"403": {"$ref": "#/components/responses/Error"},
"409": {"$ref": "#/components/responses/Error"},
"413": {"$ref": "#/components/responses/Error"},
"422": {"$ref": "#/components/responses/Error"},
"503": {"$ref": "#/components/responses/Error"}
}
}
}
},
"components": {
"parameters": {
"KeyId": {"name": "X-YoVision-Key-Id", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}},
"Timestamp": {"name": "X-YoVision-Timestamp", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[0-9]{10,}$"}},
"Nonce": {"name": "X-YoVision-Nonce", "in": "header", "required": true, "schema": {"type": "string", "minLength": 22, "maxLength": 64}},
"Signature": {"name": "X-YoVision-Signature", "in": "header", "required": true, "schema": {"type": "string", "minLength": 43, "maxLength": 43}}
},
"schemas": {
"IngressRequest": {
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "producer_id", "candidate"],
"properties": {
"schema_version": {"const": 1},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"},
"candidate": {"$ref": "#/components/schemas/EventV01Candidate"}
}
},
"EventV01Candidate": {
"description": "Exact docs/raw/contracts/event-v0.1.schema.json event object with the required Bell-owned id field removed; nested shapes and semantic assertions remain authoritative in that frozen contract.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "source_event_id", "tenant_id", "site_id", "device_id", "sensors",
"kind", "severity", "confidence", "occurred_at", "detected_at", "latency_seconds",
"config_version", "rule", "subject", "observation", "evidence", "dedup_key",
"aggregated_into", "outcome", "outcome_source", "outcome_reason", "diagnostics", "ext"
],
"properties": {
"schema_version": {"const": "0.1"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "integer", "minimum": 1},
"site_id": {"type": "integer", "minimum": 1},
"device_id": {"type": "integer", "minimum": 1},
"sensors": {"type": "array", "minItems": 1, "items": {"type": "object"}},
"kind": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,63}$"},
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"confidence": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
"occurred_at": {"type": "string", "format": "date-time"},
"detected_at": {"type": "string", "format": "date-time"},
"latency_seconds": {"type": "number", "minimum": 0},
"config_version": {"type": "string", "minLength": 1, "maxLength": 128},
"rule": {"type": ["object", "null"]},
"subject": {"type": "object"},
"observation": {"type": ["object", "null"]},
"evidence": {"type": "object"},
"dedup_key": {"type": ["string", "null"]},
"aggregated_into": {"type": ["string", "null"]},
"outcome": {"type": "string"},
"outcome_source": {"type": ["string", "null"]},
"outcome_reason": {"type": ["string", "null"]},
"diagnostics": {"type": ["object", "null"]},
"ext": {"type": "object"}
}
},
"IngressResponse": {
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "producer_id", "source_event_id", "event_id", "status"],
"properties": {
"schema_version": {"const": 1},
"producer_id": {"type": "string"},
"source_event_id": {"type": "string"},
"event_id": {"type": "string", "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"},
"status": {"type": "string", "enum": ["accepted", "duplicate"]}
}
},
"Error": {
"type": "object",
"additionalProperties": false,
"required": ["error"],
"properties": {
"error": {"type": "string"},
"detail_code": {"type": "string"}
}
}
},
"responses": {
"Error": {"description": "Stable machine-readable failure", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
}
},
"x-yovision-final-event-schema": "docs/raw/contracts/event-v0.1.schema.json",
"x-yovision-signature": {
"algorithm": "HMAC-SHA256",
"canonical_lines": ["method", "escaped_path", "unix_timestamp_seconds", "base64url_nonce", "lowercase_hex_sha256_body"],
"clock_skew_seconds": 300,
"nonce_receipt_ttl_seconds": 600,
"minimum_secret_bytes": 32,
"key_document_shape": {"version": 1, "keys": [{"key_id": "brain-a", "producer_id": "brain-main", "secret_base64url": "external-secret"}]}
},
"x-yovision-delivery": {
"maximum_body_bytes": 1048576,
"deadline_seconds": 10,
"initial_retry_seconds": 1,
"maximum_retry_seconds": 300,
"maximum_attempts": 100,
"maximum_queued": 10000,
"success_statuses": ["accepted", "duplicate"],
"retryable_http": [401, 500, 502, 503, 504]
},
"x-yovision-idempotency": {
"source_scope": ["producer_id", "source_event_id"],
"same_candidate": "return duplicate with the original Bell event_id",
"different_candidate": "409 source_event_conflict",
"bell_owns_event_id": true
}
}
+23 -10
View File
@@ -4,26 +4,29 @@
## 当前阶段
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository,但仍没有 Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或 Web/H5。
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路匿名区域事件工程原型及 Brain→Bell 可靠事件 ingress。客户网络尚未提供,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。
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
## 仓库现实
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 仍只有目录占位。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 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 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`015` migration 可重放;Sense Outbox fencing、Bell event/global-audit repository、nonce 收据、权限、幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
- T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`017` migration 可重放;Sense Outbox fencing、Bell event/global-audit/Brain-ingress repository、nonce/永久来源收据、权限、并发幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
- T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。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 稳定冲突。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json` 已由 T-016 实现。默认 SQLite 只暴露运维探针与低基数 `/metrics`,不注册业务路由或 relay;Bell 管理服务与 JWT/OIDC 尚未实现。
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json` 和 `brain-event-ingress-v1.openapi.json` 已分别由 T-016/T-019 实现。两条内部 ingress 使用独立 key;Bell 管理服务与 JWT/OIDC 尚未实现。
- T-012 把 PostgreSQL schema 提升到 v5:due row 用数据库时钟、`FOR UPDATE SKIP LOCKED`、逐项续租和 fencing token 协调;MediaMTX Path 历史归属、15 分钟孤儿快照、最多 10%/128 项安全闸、无 bypass 的本地处置命令及 `/metrics` 已实现。SQLite 明确保留单实例开发语义。
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
@@ -45,7 +48,7 @@ Windows:
go -C Sense run ./cmd/sense-api
```
Bell 事件域和内部审计 receiver 验证:
Bell 事件域与两条内部 ingress 验证:
```powershell
go -C Bell test ./...
@@ -53,6 +56,15 @@ go -C Bell vet ./...
go -C Bell build ./...
```
Brain 单路工程原型验证与启动:
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
python -m Brain.yovision_brain --source synthetic
# 可选:追加 --event-ingress-config <仓库外绝对路径>
```
跨平台直接验证:
```powershell
@@ -86,17 +98,18 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-016 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/relay key 文件、TLS 证书与备份方案。
- 生产 Brain 的 Python/Savant/DeepStream 精确版本、目标硬件和 Bell 前端栈尚未冻结;T-017 的 Python/OpenCV 只适用于工程原型,不能外推为生产选择。Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-019 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/两类 ingress key、Brain Outbox 路径、TLS 证书与备份方案。
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
## 下一步
客户网络仍未提供,T-013 WireGuard 继续后置。T-015/T-016 已分别落地 Bell 不可变事件存储和 Sense 全局审计 relay;下一项应独立冻结 Brain→Bell 业务事件 ingress,不把规则/Alert 或公共管理 API 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014~T-016 不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。
客户网络仍未提供,T-013 WireGuard 继续后置。T-019 已完成本地 Brain→Bell 可靠业务事件 ingress;下一项建议独立建立 Bell 规则→Alert→ack 的最小可见纵切,证据/pre-roll 切片另立任务且不与 Alert 状态机混交。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。
## 已知风险
- M0 临时 NVR 容易被误当生产基线,必须持续隔离。
- 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。
- 未经真实硬件压测不能给出 GPU 路数承诺。
- 合成 fixture 和 HOG 演示框容易被误读为生产 AI 效果;页面、日志和交付说明必须持续显示“非模型输出/非生产模型”,效果验收必须使用冻结模型、数据集和目标硬件。
- Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。
+543
View File
@@ -0,0 +1,543 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="brain-demo-token" content="__BRAIN_DEMO_TOKEN__">
<link rel="icon" href="data:,">
<title>YoVision Brain · 单路区域事件工程原型</title>
<style>
:root {
--bg: #0b1120;
--surface: #111a2b;
--surface-2: #172236;
--surface-3: #1e2c43;
--text: #f5f7fb;
--muted: #aebbd0;
--subtle: #8291a9;
--line: #2b3a52;
--primary: #8b7cf6;
--primary-strong: #7567e8;
--cyan: #38bdf8;
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--focus: #b8e5ff;
--radius: 14px;
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.55 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.skip-link { position: fixed; z-index: 100; top: 8px; left: 8px; padding: 10px 14px; border-radius: 8px; background: white; color: #101827; transform: translateY(-150%); }
.skip-link:focus { transform: none; }
.prototype-banner { min-height: 30px; padding: 5px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f4c84a; color: #241b00; font-size: 12px; font-weight: 800; text-align: center; letter-spacing: .04em; }
.app-shell { width: min(1540px, 100%); margin: 0 auto; padding: 0 18px 24px; }
.topbar { min-height: 72px; display: flex; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 11px; min-width: 230px; }
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--primary), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px rgba(56, 189, 248, .18); }
.brand strong, .brand span { display: block; }
.brand strong { font-size: 16px; }
.brand span { color: var(--muted); font-size: 12px; }
.top-context { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.chip { min-height: 32px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; }
.chip strong { color: var(--text); font-variant-numeric: tabular-nums; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
.status-dot.online { background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
.status-dot.warning { background: var(--warning); box-shadow: 0 0 0 4px rgba(251, 191, 36, .12); }
main { padding-top: 18px; }
.page-head { margin-bottom: 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.page-head h1 { margin: 0 0 4px; font-size: clamp(22px, 2.4vw, 30px); line-height: 1.25; }
.page-head p { max-width: 800px; margin: 0; color: var(--muted); }
.mode-badge { min-height: 36px; padding: 7px 11px; border: 1px solid rgba(56, 189, 248, .35); border-radius: 9px; background: rgba(56, 189, 248, .09); color: #b8e5ff; font-weight: 750; white-space: nowrap; }
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .65fr); gap: 14px; }
.card { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
.card-head { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
.card-head h2 { margin: 0; font-size: 15px; }
.card-head p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.btn { min-height: 44px; padding: 8px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-weight: 750; transition: border-color .18s ease, background .18s ease, opacity .18s ease; }
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
.btn:disabled { cursor: not-allowed; opacity: .45; }
.btn-primary { border-color: var(--primary-strong); background: var(--primary-strong); color: white; }
.btn-quiet { background: transparent; }
.icon { width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.video-stage { position: relative; aspect-ratio: 16/9; background: #050912; overflow: hidden; }
.video-stage img, .video-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.video-stage img { object-fit: contain; background: #050912; }
.video-stage canvas { touch-action: manipulation; }
.video-label { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 8px; background: rgba(3, 8, 18, .78); color: #dce6f5; font-size: 12px; backdrop-filter: blur(8px); pointer-events: none; }
.video-footer { min-height: 48px; padding: 8px 13px; display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.video-footer strong { color: var(--text); font-variant-numeric: tabular-nums; }
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 3px; display: inline-block; background: var(--cyan); }
.legend .zone-key { background: var(--warning); }
.metrics { padding: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); }
.metric { min-height: 82px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.metric span { display: block; color: var(--muted); font-size: 12px; }
.metric strong { display: block; margin-top: 8px; font-size: 20px; font-variant-numeric: tabular-nums; }
.side-stack { display: grid; align-content: start; gap: 14px; }
.form-body { padding: 14px; display: grid; gap: 12px; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 750; }
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
textarea { min-height: 124px; resize: vertical; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
.helper { margin: 0; color: var(--subtle); font-size: 12px; }
.form-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.inline-state { min-height: 42px; padding: 9px 10px; border-left: 3px solid var(--cyan); background: rgba(56, 189, 248, .07); color: var(--muted); font-size: 12px; }
.event-list { max-height: 330px; padding: 8px; display: grid; gap: 7px; overflow-y: auto; }
.ingress-summary { padding: 11px 12px; display: grid; gap: 8px; border-bottom: 1px solid var(--line); background: rgba(56, 189, 248, .04); }
.ingress-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.ingress-head h3 { margin: 0; font-size: 13px; }
.ingress-state { min-height: 28px; padding: 3px 9px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 12px; font-weight: 750; }
.status-dot.error { background: var(--danger); box-shadow: 0 0 0 4px rgba(251, 113, 133, .12); }
.ingress-copy { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
.ingress-counts { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.ingress-counts[hidden] { display: none; }
.ingress-counts div { min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); }
.ingress-counts dt { color: var(--muted); font-size: 11px; }
.ingress-counts dd { margin: 2px 0 0; color: var(--text); font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums; }
.event { padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.event-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.event strong { font-size: 13px; }
.event time, .event code { color: var(--muted); font-size: 12px; }
.event code { display: block; margin-top: 6px; overflow-wrap: anywhere; }
.event-delivery { margin-top: 5px; color: var(--muted); font-size: 12px; }
.event-tag { padding: 2px 7px; border: 1px solid rgba(251, 191, 36, .35); border-radius: 999px; background: rgba(251, 191, 36, .08); color: #ffe18a; font-size: 11px; }
.empty { min-height: 120px; padding: 26px 16px; display: grid; place-items: center; color: var(--muted); text-align: center; }
.architecture-note { margin-top: 14px; padding: 13px 15px; display: grid; grid-template-columns: auto 1fr; gap: 11px; border: 1px solid rgba(139, 124, 246, .30); border-radius: 12px; background: rgba(139, 124, 246, .07); color: var(--muted); }
.architecture-note strong { color: var(--text); }
.architecture-note p { margin: 2px 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
.side-stack { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
@media (max-width: 700px) {
.app-shell { padding-inline: 12px; }
.topbar { align-items: flex-start; flex-direction: column; padding: 12px 0; }
.top-context { margin-left: 0; justify-content: flex-start; }
.page-head { flex-direction: column; }
.mode-badge { white-space: normal; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.side-stack { grid-template-columns: 1fr; }
.card-head { align-items: flex-start; flex-direction: column; }
.toolbar { width: 100%; }
.toolbar .btn { flex: 1; }
.video-footer { align-items: flex-start; flex-direction: column; }
.legend { margin-left: 0; }
}
@media (max-width: 420px) {
.prototype-banner { letter-spacing: 0; }
.chip { width: 100%; justify-content: flex-start; }
.toolbar { display: grid; grid-template-columns: 1fr; }
.toolbar .btn { width: 100%; min-width: 0; }
.metrics { grid-template-columns: 1fr 1fr; }
.metric strong { font-size: 17px; }
.form-actions { grid-template-columns: 1fr; }
.ingress-head { align-items: flex-start; flex-direction: column; }
.ingress-counts { grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<div class="prototype-banner">
工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺
</div>
<div class="app-shell">
<header class="topbar">
<div class="brand" aria-label="YoVision Brain">
<div class="brand-mark" aria-hidden="true">Y</div>
<div><strong>YoVision Brain</strong><span>单路匿名区域事件原型</span></div>
</div>
<div class="top-context" aria-label="运行上下文">
<span class="chip"><i id="connection-dot" class="status-dot warning" aria-hidden="true"></i><span id="connection-text">正在连接</span></span>
<span class="chip">来源 <strong id="source-label">合成回放</strong></span>
<span class="chip">区域版本 <strong id="zone-version">v1</strong></span>
</div>
</header>
<main id="main" tabindex="-1">
<div class="page-head">
<div>
<h1>单路检测与区域判定</h1>
<p>验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。Brain 先持久化到 Outbox,再由 Bell 分配平台事件 ID;Alert 仍由后续 Bell 链路负责。</p>
</div>
<div id="mode-badge" class="mode-badge">加载运行状态</div>
</div>
<div class="grid">
<section class="card" aria-labelledby="preview-title">
<div class="card-head">
<div><h2 id="preview-title">实时预览</h2><p id="preview-description">检测框与区域均使用归一化坐标叠加</p></div>
<div class="toolbar">
<button id="toggle-preview" class="btn btn-quiet" type="button" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<span>暂停刷新</span>
</button>
<button id="edit-zone" class="btn" type="button" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 4 6 6L8 22H2v-6zM12 6l6 6"/></svg>
<span>画布加点</span>
</button>
</div>
</div>
<div class="video-stage">
<img id="video-frame" alt="单路视频画面;检测框与区域由叠加层描述" width="960" height="540">
<canvas id="overlay" width="960" height="540" role="img" aria-label="匿名人员检测框与危险区域叠加;键盘用户可使用右侧坐标表单编辑区域"></canvas>
<div id="video-label" class="video-label">等待首帧</div>
</div>
<div class="video-footer">
<span>帧序号 <strong id="sequence">—</strong></span>
<span>采集时间 <strong id="captured-at">—</strong></span>
<div class="legend" aria-label="画面图例">
<span><i aria-hidden="true"></i>匿名人员框</span>
<span><i class="zone-key" aria-hidden="true"></i>活动区域</span>
</div>
</div>
<div class="metrics" aria-label="推理摘要">
<article class="metric"><span>目标处理帧率</span><strong id="fps">2.0 FPS</strong></article>
<article class="metric"><span>单帧耗时</span><strong id="latency">—</strong></article>
<article class="metric"><span>当前人员</span><strong id="person-count">0</strong></article>
<article class="metric"><span>区域内人员</span><strong id="inside-count">0</strong></article>
</div>
</section>
<aside class="side-stack" aria-label="区域配置与事件候选">
<section class="card" aria-labelledby="zone-title">
<div class="card-head">
<div><h2 id="zone-title">活动区域</h2><p>3–32 个归一化顶点;保存后重置 track 区域状态</p></div>
</div>
<form id="zone-form" class="form-body">
<label for="zone-name">区域名称
<input id="zone-name" name="zone-name" maxlength="80" autocomplete="off" value="楼梯口危险区">
</label>
<label for="zone-points">顶点坐标(每行 x,y)
<textarea id="zone-points" name="zone-points" spellcheck="false" aria-describedby="zone-helper">0.55,0.30&#10;0.90,0.30&#10;0.90,0.88&#10;0.55,0.88</textarea>
</label>
<p id="zone-helper" class="helper">坐标范围 0.00–1.00。可直接输入,或启用“画布加点”后点击画面;无需精确鼠标操作。</p>
<div class="form-actions">
<button id="undo-point" class="btn" type="button">撤销一点</button>
<button id="clear-points" class="btn" type="button">清空顶点</button>
</div>
<button id="save-zone" class="btn btn-primary" type="submit">保存区域版本</button>
<div id="form-state" class="inline-state" role="status" aria-live="polite">区域尚未修改。</div>
</form>
</section>
<section class="card" aria-labelledby="event-title">
<div class="card-head">
<div><h2 id="event-title">最近候选事实</h2><p>内存环仅用于观察;可靠性以 Outbox 状态为准</p></div>
<span id="event-count" class="chip"><strong>0</strong> 项</span>
</div>
<section id="event-ingress" class="ingress-summary" aria-labelledby="event-ingress-title" role="status" aria-live="polite" aria-atomic="true">
<div class="ingress-head">
<h3 id="event-ingress-title">Bell 可靠投递</h3>
<span class="ingress-state"><i id="ingress-dot" class="status-dot" aria-hidden="true"></i><span id="ingress-label">未启用</span></span>
</div>
<p id="ingress-copy" class="ingress-copy">可靠投递未启用;候选事实不会被标记为 Bell 已接收。</p>
<dl id="ingress-counts" class="ingress-counts" aria-label="Outbox 投递计数" hidden>
<div><dt>待投递</dt><dd id="ingress-queued">0</dd></div>
<div><dt>投递中</dt><dd id="ingress-delivering">0</dd></div>
<div><dt>已送达</dt><dd id="ingress-delivered">0</dd></div>
<div><dt>死信</dt><dd id="ingress-dead-letter">0</dd></div>
</dl>
</section>
<div id="event-list" class="event-list" aria-live="polite">
<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>
</div>
</section>
</aside>
</div>
<section class="architecture-note" aria-label="系统边界说明">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
<div><strong>边界清楚比动画更重要</strong><p>Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。</p></div>
</section>
<div id="announcer" class="sr-only" aria-live="polite"></div>
</main>
</div>
<script>
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const frame = $("video-frame");
const canvas = $("overlay");
const context = canvas.getContext("2d");
const token = document.querySelector('meta[name="brain-demo-token"]').content;
let runtimeAvailable = location.protocol === "http:" || location.protocol === "https:";
let paused = false;
let editMode = false;
let state = null;
let offlineSequence = 0;
let offlineTriggered = false;
let ingressSignature = "";
const offlineState = () => {
offlineSequence += 1;
const phase = ((offlineSequence - 1) % 160) / 159;
const center = -0.04 + phase * 1.08;
const bbox = [Math.max(0, center - .04), .34, Math.min(1, center + .04), .82];
const inside = center >= .55 && center <= .90;
const events = state && state.events ? state.events : [];
if (inside && !offlineTriggered) {
offlineTriggered = true;
events.unshift({source_event_id: `BRN-OFFLINE-${String(offlineSequence).padStart(6, "0")}`, kind: "zone_entry", track_id: "P-DEMO-001", zone_id: "zone-demo-01", occurred_at: new Date().toISOString(), fixture: true});
}
if (!inside && center < .50) offlineTriggered = false;
return {
prototype: true,
notice: "离线 HTML 原型数据;不是服务端或模型输出。",
source: {mode: "offline", label: "离线原型回放", fixture: true, connected: true, ref: "prototype-only"},
detector: {name: "scripted_prototype", production_ready: false},
frame: {sequence: offlineSequence, width: 960, height: 540, captured_at: new Date().toISOString()},
inference: {target_fps: 2, latency_ms: 0},
zone: state && state.zone ? state.zone : {id: "zone-demo-01", name: "楼梯口危险区", version: 1, points: parsePoints(false)},
detections: bbox[2] - bbox[0] > .01 ? [{track_id: "P-DEMO-001", class: "person", bbox, detector_score: null, inside_zone: inside}] : [],
events,
event_ingress: {enabled: false},
last_error_code: null
};
};
function parsePoints(showError = true) {
try {
const points = $("zone-points").value.split(/\r?\n/).filter(line => line.trim()).map(line => {
const parts = line.split(",").map(value => Number(value.trim()));
if (parts.length !== 2 || parts.some(value => !Number.isFinite(value) || value < 0 || value > 1)) throw new Error();
return {x: parts[0], y: parts[1]};
});
if (points.length < 3 || points.length > 32) throw new Error();
return points;
} catch (_) {
if (showError) setFormState("请输入 3–32 行有效坐标,每个值须在 0.00–1.00。", true);
return [];
}
}
function setPoints(points) {
$("zone-points").value = points.map(point => `${Number(point.x).toFixed(3)},${Number(point.y).toFixed(3)}`).join("\n");
}
function setFormState(message, error = false) {
const target = $("form-state");
target.textContent = message;
target.style.borderLeftColor = error ? "var(--danger)" : "var(--cyan)";
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : new Intl.DateTimeFormat("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false}).format(date);
}
function drawOfflineBackground() {
context.fillStyle = "#111a2b";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#1e2c43";
context.fillRect(0, canvas.height * .72, canvas.width, canvas.height * .28);
context.strokeStyle = "#2b3a52";
for (let x = 0; x < canvas.width; x += 80) {
context.beginPath(); context.moveTo(x, canvas.height * .72); context.lineTo(x + 80, canvas.height); context.stroke();
}
context.fillStyle = "#7dd3fc";
context.font = "700 18px Segoe UI";
context.fillText("OFFLINE PROTOTYPE DATA", 24, 38);
}
function drawOverlay() {
if (!state) return;
context.clearRect(0, 0, canvas.width, canvas.height);
if (!runtimeAvailable) drawOfflineBackground();
const points = parsePoints(false).length ? parsePoints(false) : state.zone.points;
if (points.length >= 3) {
context.beginPath();
points.forEach((point, index) => {
const x = point.x * canvas.width, y = point.y * canvas.height;
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = "rgba(251, 191, 36, .10)";
context.fill();
context.strokeStyle = "#fbbf24";
context.lineWidth = 3;
context.stroke();
points.forEach((point, index) => {
context.beginPath(); context.arc(point.x * canvas.width, point.y * canvas.height, 6, 0, Math.PI * 2);
context.fillStyle = "#fbbf24"; context.fill();
context.fillStyle = "#111827"; context.font = "700 10px Segoe UI"; context.fillText(String(index + 1), point.x * canvas.width - 3, point.y * canvas.height + 3);
});
}
state.detections.forEach(item => {
const [x1, y1, x2, y2] = item.bbox;
const x = x1 * canvas.width, y = y1 * canvas.height, width = (x2 - x1) * canvas.width, height = (y2 - y1) * canvas.height;
context.strokeStyle = item.inside_zone ? "#fb7185" : "#38bdf8";
context.lineWidth = 3;
context.strokeRect(x, y, width, height);
const label = `${item.track_id} · ${item.inside_zone ? "区域内" : "区域外"}`;
context.font = "700 14px Segoe UI";
const labelWidth = context.measureText(label).width + 14;
context.fillStyle = item.inside_zone ? "#be4058" : "#0878a4";
context.fillRect(x, Math.max(0, y - 25), labelWidth, 24);
context.fillStyle = "#fff";
context.fillText(label, x + 7, Math.max(17, y - 8));
});
}
function renderEvents(events) {
$("event-count").innerHTML = `<strong>${events.length}</strong> 项`;
if (!events.length) {
$("event-list").innerHTML = '<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>';
return;
}
$("event-list").replaceChildren(...events.slice(0, 100).map(event => {
const article = document.createElement("article"); article.className = "event";
const head = document.createElement("div"); head.className = "event-head";
const title = document.createElement("strong"); title.textContent = `${event.track_id} 进入 ${state.zone.name}`;
const tag = document.createElement("span"); tag.className = "event-tag"; tag.textContent = event.fixture ? "夹具候选" : "检测候选";
head.append(title, tag);
const time = document.createElement("time"); time.dateTime = event.occurred_at; time.textContent = `${formatTime(event.occurred_at)} · ${event.kind}`;
const delivery = document.createElement("div"); delivery.className = "event-delivery"; delivery.textContent = event.delivery_status === "outbox_persisted" ? "交接:Outbox 已持久化" : "交接:仅内存观察";
const code = document.createElement("code"); code.textContent = event.source_event_id;
article.append(head, time, delivery, code); return article;
}));
}
function renderIngress(value = {enabled: false}) {
const enabled = value.enabled === true;
const queued = Number(value.queued) || 0;
const delivering = Number(value.delivering) || 0;
const delivered = Number(value.delivered) || 0;
const deadLetter = Number(value.dead_letter) || 0;
const errorCode = typeof value.last_error_code === "string" ? value.last_error_code : "";
const nextSignature = JSON.stringify([enabled, queued, delivering, delivered, deadLetter, errorCode]);
if (nextSignature === ingressSignature) return;
ingressSignature = nextSignature;
const dot = $("ingress-dot");
const counts = $("ingress-counts");
counts.hidden = !enabled;
$("ingress-queued").textContent = String(queued);
$("ingress-delivering").textContent = String(delivering);
$("ingress-delivered").textContent = String(delivered);
$("ingress-dead-letter").textContent = String(deadLetter);
if (!enabled) {
dot.className = "status-dot";
$("ingress-label").textContent = "未启用";
$("ingress-copy").textContent = "可靠投递未启用;候选事实不会被标记为 Bell 已接收。";
} else if (deadLetter > 0) {
dot.className = "status-dot error";
$("ingress-label").textContent = "需要处理";
$("ingress-copy").textContent = `存在死信${errorCode ? `(${errorCode})` : ""};请检查 Bell 绑定、鉴权或候选字段后再处理。`;
} else if (errorCode || queued > 0 || delivering > 0) {
dot.className = "status-dot warning";
$("ingress-label").textContent = errorCode ? "正在重试" : "投递中";
$("ingress-copy").textContent = errorCode ? `Outbox 已保留候选并自动重试(${errorCode})。` : "候选已持久化到本地 Outbox,正在等待 Bell 确认。";
} else {
dot.className = "status-dot online";
$("ingress-label").textContent = "投递正常";
$("ingress-copy").textContent = "Outbox 可用;Bell 返回 accepted 或 duplicate 后才计为已送达。";
}
}
function render(next) {
state = next;
$("connection-dot").className = `status-dot ${next.source.connected ? "online" : "warning"}`;
$("connection-text").textContent = next.source.connected ? "画面在线" : "来源不可用";
$("source-label").textContent = next.source.label;
$("zone-version").textContent = `v${next.zone.version}`;
$("mode-badge").textContent = next.source.fixture ? "夹具模式 · 非模型输出" : "HOG 演示适配器 · 非生产模型";
$("sequence").textContent = String(next.frame.sequence);
$("captured-at").textContent = formatTime(next.frame.captured_at);
$("fps").textContent = `${Number(next.inference.target_fps).toFixed(1)} FPS`;
$("latency").textContent = next.inference.latency_ms == null ? "—" : `${Number(next.inference.latency_ms).toFixed(1)} ms`;
$("person-count").textContent = String(next.detections.length);
$("inside-count").textContent = String(next.detections.filter(item => item.inside_zone).length);
$("video-label").textContent = `${next.source.label} · ${next.detector.name} · ${next.notice}`;
if (document.activeElement !== $("zone-name") && document.activeElement !== $("zone-points")) {
$("zone-name").value = next.zone.name; setPoints(next.zone.points);
}
renderIngress(next.event_ingress);
renderEvents(next.events);
drawOverlay();
}
async function poll() {
if (paused) return;
if (runtimeAvailable) {
try {
const response = await fetch("/api/v1/state", {cache: "no-store"});
if (!response.ok) throw new Error();
const next = await response.json();
frame.hidden = false;
frame.src = `/api/v1/frame.jpg?t=${encodeURIComponent(next.frame.sequence)}`;
render(next);
return;
} catch (_) {
runtimeAvailable = false;
frame.hidden = true;
setFormState("未连接本地 Brain 服务,已切换为离线原型数据。", false);
}
}
render(offlineState());
}
frame.addEventListener("load", drawOverlay);
$("toggle-preview").addEventListener("click", event => {
paused = !paused;
event.currentTarget.setAttribute("aria-pressed", String(paused));
event.currentTarget.querySelector("span").textContent = paused ? "继续刷新" : "暂停刷新";
$("announcer").textContent = paused ? "预览刷新已暂停" : "预览刷新已继续";
if (!paused) poll();
});
$("edit-zone").addEventListener("click", event => {
editMode = !editMode;
event.currentTarget.setAttribute("aria-pressed", String(editMode));
event.currentTarget.querySelector("span").textContent = editMode ? "结束加点" : "画布加点";
setFormState(editMode ? "画布加点已启用;键盘用户可继续使用坐标表单。" : "画布加点已结束。", false);
});
canvas.addEventListener("pointerdown", event => {
if (!editMode) return;
const points = parsePoints(false);
if (points.length >= 32) { setFormState("区域最多 32 个顶点。", true); return; }
const rect = canvas.getBoundingClientRect();
points.push({x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))});
setPoints(points); drawOverlay(); setFormState(`已添加第 ${points.length} 个顶点,尚未保存。`);
});
$("undo-point").addEventListener("click", () => {
const points = parsePoints(false); if (points.length) points.pop(); setPoints(points); drawOverlay(); setFormState("已撤销最后一个顶点,尚未保存。", false);
});
$("clear-points").addEventListener("click", () => { $("zone-points").value = ""; drawOverlay(); setFormState("顶点已清空;至少输入 3 个顶点后才能保存。", false); });
$("zone-points").addEventListener("input", drawOverlay);
$("zone-form").addEventListener("submit", async event => {
event.preventDefault();
const points = parsePoints(true); if (!points.length) return;
const name = $("zone-name").value.trim();
if (!name) { setFormState("请输入区域名称。", true); $("zone-name").focus(); return; }
const button = $("save-zone"); button.disabled = true; button.textContent = "正在保存";
if (runtimeAvailable) {
try {
const response = await fetch("/api/v1/zones/active", {method: "PUT", headers: {"Content-Type": "application/json", "X-Brain-Demo-Token": token}, body: JSON.stringify({name, points})});
if (!response.ok) throw new Error();
const zone = await response.json();
state.zone = zone; render(state); setFormState(`区域 v${zone.version} 已保存;track 区域状态已重新建立。`);
} catch (_) { setFormState("区域保存失败,请检查本地服务后重试;草稿仍保留。", true); }
} else {
state.zone = {...state.zone, name, points, version: state.zone.version + 1}; render(state); setFormState(`离线原型区域 v${state.zone.version} 已在当前页面保存,刷新后不会保留。`);
}
button.disabled = false; button.textContent = "保存区域版本";
});
poll();
window.setInterval(poll, 500);
})();
</script>
</body>
</html>
+1
View File
@@ -47,6 +47,7 @@
| kind | 含义 | 默认 severity | 产出方 | 状态 |
| --- | --- | --- | --- | --- |
| `fall` | 人员摔倒确认 | `high` | silver_pose FSM 进入 CONFIRMED | ✅ 已上线 |
| `zone_entry` | 匿名 track 从区域外进入区域内 | `medium` | Brain 区域进入判定 | ✅ T-019 ingress 已接入 |
## 4. silver_pose → 契约 映射表
+10 -1
View File
@@ -20,6 +20,15 @@
这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。
## 工程演示路由
| 路由 | 所有者 | 关键约束 |
| --- | --- | --- |
| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 |
| `/sense-console/` | Sense T-018 回环工程服务 | 默认关闭;复用 Control API v1,设备分页默认 16 项,最多 4 路按需 WebRTC 预览。只允许 Sense 与 MediaMTX 播放端均为显式回环地址;不是录像/回放或 Bell 公共业务路由 |
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页或 T-018 Sense 回环控制台暴露到非可信网络或嵌入客户公共系统。
## App 候选导航
- 预警:待确认与升级中的事件。
@@ -44,6 +53,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审
## 组件归属
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain。
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页,不改变该归属。
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
+9 -5
View File
@@ -3,12 +3,12 @@ id: T-017
title: 建立 Brain 单路匿名区域事件可视化工程原型
phase: 3
deps: [T-016]
status: TODO
status: DONE
created: 2026-08-11
issue: 59
context_ref: null
claim_branch: null
work_branch: null
context_ref: 2e047379228a30841da7ceed8cfb86275b3c330e
claim_branch: claims/T-017
work_branch: agent/codex/T-017
write_paths:
- docs/tasks/T-017.md
- Brain/
@@ -74,4 +74,8 @@ Sense 已能稳定提供 MediaMTX 路径,Bell 已有不可变事件存储,
## 执行记录
- 2026-08-11:按用户指定顺序建立 T-017;实现尚未开始。主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
- 2026-08-11:按用户指定顺序建立 T-017;主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
- 2026-08-11:建立 `FrameSource → Detector → Tracker → ZoneEntryEvaluator → EventCandidate` 单路流水线。默认合成 fixture 可重复运行;可选真实流只从仓库外绝对路径文件读取 RTSP(S) URL,OpenCV HOG 仅作为非生产演示 detector port。区域进入只在 `outside → inside` 时触发,候选不含 Bell 平台 `id`。
- 2026-08-11:实现只允许显式回环地址的工程服务和自包含 `/brain-demo` 页面;HTTP body 上限 64 KiB,区域写入使用进程级临时 token,响应带 CSP、拒绝 framing、禁止缓存且不记录请求路径。桌面截图已目检;Edge CDP 在 375×900 设备视口实测 `innerWidth=clientWidth=scrollWidth=375`,可见按钮最小高度 44 px,移动端工具栏单列且无横向溢出。截图仅在系统临时目录用于 QA,未提交。
- 2026-08-11:独立 CLI 合成模式回环 smoke 通过:`health/state/frame/page/zone PUT` 均为 HTTP 200,source 为 `synthetic`、`fixture=true`、JPEG 可读、区域版本由 v1 递增至 v2。使用标准入口 `./init.ps1` 通过 68 项根测试、19 项 Brain 测试、Brain compileall,以及 Sense/Bell generate/test/vet/build;`git diff --check` 通过。
- 边界:未连接真实摄像头或目标 GPU,未引入生产模型、Brain→Bell transport、持久 Outbox、规则/Alert 或客户产品 UI;这些结果只关闭匿名单路工程原型,不构成算法效果、真实多路或生产 SLA 验收。
+103
View File
@@ -0,0 +1,103 @@
---
id: T-018
title: 建立 Sense NVR 管理面设备与实时监看纵切
phase: 3
deps: [T-004, T-014]
status: DONE
created: 2026-08-11
issue: 63
context_ref: a4427a6d2b08e6e8a86d913b24abb61ebcfff906
claim_branch: claims/T-018
work_branch: agent/codex/T-018
write_paths:
- docs/tasks/T-018.md
- Sense/cmd/sense-api/
- Sense/internal/config/
- Sense/internal/console/
- Sense/README.md
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/routes.md
- docs/current-state.md
- tests/test_sense_console_contract.py
---
## 问题 / 背景
Sense 已有 ONVIF/RTSP 接入、MediaMTX 控制、Control API v1、对账、探活和本地 16 路容量证据,但客户目前只能通过 API、脚本或静态原型理解这些能力。T-004 已确认 Sense 的五入口信息架构;本任务把其中最能证明 NVR 管理面价值的“设备 + 实时监看”落成可运行纵切,并保留总览、接入任务和运维中心的诚实入口。
本任务不是完整 NVR:常态录像仍由客户现有 NVR 承担,YoVision 暂不实现录像计划、录像索引或回放;MediaMTX 继续作为独立数据面,Sense 只提供管理面和受限播放入口。为避免在 JWT/OIDC、反向代理和 MediaMTX 外部认证尚未冻结时暴露视频,本版控制台只允许回环工程环境启用。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009;本任务只完成其中设备查询、状态理解和按需监看的首个纵切,不宣称批量导入、完整能力探测或全量运维 API 已完成。
- 交互清单:IX-002~IX-004、IX-013~IX-015、IX-019、IX-020,以及“桌面与大屏”中的按需预览和 128 路分页约束。
- 相关页面 / 路由:已确认原型 `docs/design/sense/index.html`;新增工程路由 `/sense-console/`,不冻结为 Bell 客户公共路由。
## 方案
1. 在 `Sense/internal/console/` 增加由 Go `embed` 提供的自包含 HTML/CSS/JavaScript 控制台,不引入前端框架、CDN、字体或第三方播放器依赖;外观沿用 T-004 已确认的深色接入运维台,不重新设计信息架构。
2. 控制台复用同源 Sense Control API v1。操作者输入 Site ID 和 Bearer token 后加载设备页;token 只保存在当前 JavaScript 内存中,刷新即丢失,不进入 URL、DOM 可见文本、`localStorage`、`sessionStorage`、cookie 或日志。
3. 设备列表默认每页 16 项,使用服务端 cursor,支持模态、期望态和实际态筛选;显示配额、期望态/实际态、收敛、失败码、重试时间和策略投影版本。页面只把当前页统计标为“当前页”,不伪造站点总量。
4. 实时监看只允许从当前已授权设备中选择具有 `video_capture` 能力的设备,最多同时打开 4 路;默认不自动播放。播放使用 MediaMTX v1.19.3 官方浏览器 WebRTC 页面,地址由仓库外环境配置提供,UI 不显示 RTSP/ONVIF、凭据或完整播放地址。
5. `/sense-console/` 默认关闭。启用时 Sense HTTP 监听与 MediaMTX WebRTC 基地址都必须是显式回环地址;配置含 userinfo、查询、fragment、非 HTTP(S) 或非回环主机时启动失败。页面添加 CSP、`frame-ancestors 'none'`、`nosniff`、`no-store` 和严格 Referrer Policy。
6. 总览、接入任务和运维中心继续保留为五入口结构:总览只汇总已加载事实;接入任务明确标识本纵切尚未实现批量导入;运维中心显示当前页未收敛项并提供受限 `/metrics` 入口,不伪造分片、隧道或告警数据。
7. 添加 Go handler/config/UI 契约测试;在 Edge 以 1440×900、375×812 和 812×375 检查焦点、无横向溢出、44px 触控目标、空态/鉴权失败/依赖失败和 `prefers-reduced-motion`。实流人工验收可继续使用一台已准入海康相机,不要求新增摄像头。
## 不可变约束
- 阈值 / 数值边界:站点默认 16 路、可配置 1~128;设备列表默认页长 16;本任务浏览器同时预览上限固定为 4,128 路不得一次加载全部详情或视频。
- 判定式 / 状态转换:Control API 写入受理不等于实际态收敛;未知或请求失败不得显示在线;只有 `video_capture + desired_state=enabled + actual_state=online + converged=true` 的设备可启动预览。
- 安全边界:控制台和 WebRTC 基地址仅回环;默认关闭;Bearer token 仅驻留页面内存;不得显示或持久化摄像头凭据、完整 RTSP/ONVIF URI、MediaMTX source、token、客户数据或真实视频证据。非回环/TLS/JWT/OIDC/MediaMTX 外部认证另立任务。
- 既有契约:不修改 Sense Control API v1、事件 v0.1、Bell schema 或 MediaMTX 生成客户端;MediaMTX 仍是独立二进制,Sense 不代理媒体字节;T-004 原型保持产品结构权威,本任务只实现其明确子集。
## 验收要点
- 任务相关验证:`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v`;覆盖默认关闭、回环限制、配置拒绝、路由/安全头、内存 token、16 项 cursor 页面、最多 4 路按需预览和状态降级。
- 完整门禁:运行 `./init.ps1`、`git diff --check`。本任务不改变 PostgreSQL migration、Control API OpenAPI 或 MediaMTX 生成源,因此不触发 `scripts/test_postgres.ps1` 和生成客户端漂移以外的新增 schema 门禁;根入口仍会执行既有 generate/test/vet/build。
- 人工 / 设备验收:必需。项目负责人打开实际 `/sense-console/`,确认与 T-004 五入口一致、设备/配额/状态可理解、最多 4 路按需监看、错误状态不伪装在线;可使用一台已准入海康设备或 T-014 合成流。未确认前任务文件保持 `DOING`,Issue 使用 `status/review`,不得标 `DONE`。
- 构建产物:`Sense` Go 二进制内嵌控制台静态资源;`go -C Sense build ./...` 可重现。无独立前端构建产物。
## 边界(不改什么)
不实现录像计划、常态录像、回放、证据切片、批量 CSV 导入、Site/Area/RBAC CRUD、Bell 业务预警、Brain 推理、非回环部署或生产会话认证;不修改 `_reference/`,不复制 MiBeeNvr 或 MediaMTX UI/播放器源码,不改已确认的 `docs/design/sense/index.html`。
## 协作约束
- 责任 Agent:codex。
- 唯一写入者:codex。
- 委派:不启用。
- Gitea:主 Issue 为 #63;领取时填写 `context_ref`、`claims/T-018` 与 `agent/codex/T-018`。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-11 任务定义
- 项目负责人将开发优先级从 Brain/Bell 后续链调整为先做 Sense;T-018 因此定义为 Sense NVR 管理面首个可运行纵切,原 Brain→Bell ingress 顺延为后续建议任务。
- 复用 T-004 已确认的信息架构和现有 Control API/MediaMTX 基础,不新增前端或媒体依赖;录像/回放、非回环认证和完整运维 API 明确排除。
- 基线 `./init.ps1` 通过:68 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿。
- 任务定义已合入默认分支并创建唯一 Gitea Issue #63;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
### 2026-08-11 领取与基线
- dispatcher `ila` 从默认分支 `a4427a6d2b08e6e8a86d913b24abb61ebcfff906` 创建并读回 `claims/T-018`、`agent/codex/T-018`;Issue #63 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。
- T-007 仍为 `status/waiting`,不属于活跃写路径预留;T-017 的残留 PR 元数据异常不对应活跃 Issue,默认分支已包含其合并提交。
### 2026-08-11 实现与自动化验收
- `sense-api` 新增默认关闭的 `/sense-console/` 内嵌路由和运行时配置;配置层与 handler 双重拒绝非回环、userinfo、路径、query、fragment 和非 HTTP(S) 的播放基地址,控制台只能与已开启的回环 PostgreSQL Control API 一起运行。
- 自包含 HTML/CSS/原生 JavaScript 保留 T-004 已确认的运行总览、实时监控、设备、接入任务、运维中心五入口。设备页使用服务端 cursor 和固定 16 项页长;期望态、实际态、收敛与错误分别呈现,未知/请求失败不显示在线。接入任务明确标为未实现,不用假上传伪装交付。
- Bearer token 只保存于页面内存,输入后立即清空且刷新后会话清零;页面不使用 local/session storage、cookie、URL token 或 `innerHTML`。只有 `video_capture + enabled + online + converged` 可选,选择和已启动预览均硬限制为 4,iframe 停止时移除 `src`。
- Edge headless + 本地脱敏 Control API fixture 完成浏览器 QA:1440×900 显示 16 项和 `16 / 128` 配额,无横向溢出且 cursor 下一页可用;第五路选择被拒绝,实际 iframe 保持 4;375×812 切换为设备卡片和五入口底栏,无横向溢出、最小可见按钮 44 px;844×390 无横向溢出。刷新后连接对话框重新打开、token 输入为空、会话回到未建立。该 QA 只验证 UI/嵌入 URL,不替代真实 MediaMTX 解码验收。
- 浏览器 QA 首轮发现加载完成后分页按钮仍保留禁用态;已在 `setLoading(false)` 时重新计算前后页状态并复验通过。
- 任务验证通过:`node --check Sense/internal/console/assets/app.js`;`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v` 4 项;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`;`./init.ps1` 72 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿;`git diff --check` 在提交前复核。
- 人工门禁已满足:项目负责人于 2026-08-11 明确回复“验收通过”,接受实际 `/sense-console/` 的五入口、设备/配额/收敛语义和 MediaMTX WebRTC 播放纵切。该确认不扩大到录像/回放、非回环生产认证、真实 16 机或生产 SLA。
### 2026-08-11 产品验收
- 项目负责人在本轮明确确认 T-018 验收通过;任务状态更新为 `DONE`,允许合并 PR #65 并关闭 Issue #63。
- 交付边界保持不变:这是默认关闭的 Sense 回环 NVR 管理面纵切,常态录像仍由客户既有 NVR 承担,后续 Brain→Bell ingress 仍需独立 T-019。
+105
View File
@@ -0,0 +1,105 @@
---
id: T-019
title: 建立 Brain 到 Bell 的可靠事件入站链路
phase: 3
deps: [T-015, T-017]
status: DONE
created: 2026-08-11
issue: 67
context_ref: b7fe44eeb0caf8d41abef365ec2100c74373321f
claim_branch: claims/T-019
work_branch: agent/codex/T-019
write_paths:
- docs/tasks/T-019.md
- docs/contracts/
- docs/raw/contracts/README.md
- Brain/
- docs/design/brain/index.html
- Bell/
- deploy/postgres/
- scripts/test_postgres.ps1
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- tests/test_brain_event_ingress_contract.py
- tests/test_postgres_contract.py
---
## 问题 / 背景
T-017 已能产生匿名区域事件候选,T-015 已能在 Bell 内部校验并保存不可变事件,但两者之间没有可部署的传输、认证、逻辑身份映射或跨重启幂等确认。直接把 T-017 的 100 项内存事件环当作投递队列,会在进程退出、网络中断或 Bell 已接收但 Brain 尚未确认时丢失或重复事件;让 Brain 自报 Bell 平台 `id` 又会破坏冻结事件契约的所有权。
本任务建立一个默认关闭、可本地联调的可靠纵切:Brain 把完整 v0.1 候选先写入 SQLite Outbox,再通过内部 HMAC HTTP 端点投递;Bell 在一个 PostgreSQL 事务中验证身份、Area 隐私策略、候选语义并保存事件与永久来源收据。该纵切为后续证据切片、规则、Alert 与 ack 提供可信事件入口,但不提前实现这些能力。
## 关联需求与交互(如适用)
- 用户故事:US-005 的“判定结果形成事件”基础链路;不在本任务内实现业务预警、处置或误报反馈。
- 交互清单:不适用。本任务是内部事件 ingress 和后台可靠投递,不新增客户页面或公共路由。
- 相关页面 / 路由:新增内部 `POST /internal/v1/event-candidates`;T-017 `/brain-demo` 只增加脱敏投递状态,不把内部地址、key 或 payload 暴露给页面。
## 方案
1. 在 `docs/contracts/brain-event-ingress-v1.openapi.json` 冻结单事件 envelope、响应、稳定错误码和 HMAC 规则。请求包含 `schema_version=1`、`producer_id` 与不含 Bell `id` 的完整 event v0.1 candidate;Brain 提供 `source_event_id`,Bell 生成并返回平台 `evt_` ULID。正文上限 1 MiB,deadline 10 秒。
同步把 T-017 已产生的 `zone_entry` 登记到 event v0.1 kind 注册表;不修改冻结 JSON Schema 或既有 kind 语义。
2. 复用 T-016 已验证的 HMAC-SHA256 canonical 形式:method、path、Unix 秒、随机 nonce、body SHA-256 以换行连接;使用独立的仓库外 key 文件,并把每个 key 绑定到一个 `producer_id`。允许 300 秒时钟偏差,nonce 防重收据保留 600 秒。回环可用 HTTP,非回环必须 HTTPS;事件 key 与审计 relay key 不混用。
3. 新增 Bell 所有的 `event_ingress_bindings`,把 `(producer_id, tenant_id, site_id, device_id)` 数字事件身份绑定到现有 Bell tenant/site/device/area 逻辑身份和 `video` 模态。入站必须 fail closed:绑定缺失、禁用、站点/Area 已删除、设备或 Area 归属不一致、非视频或 `capture_policy != video_allowed` 均拒绝。首版只提供受控 SQL 配置方式,不新增公共绑定管理 API。
4. 新增永久 append-only `event_ingress_receipts`,以 `(producer_id, source_event_id)` 唯一,保存 canonical candidate SHA-256 与 Bell event ID;另用短期 `event_ingress_nonces` 防请求重放。Bell 用单事务和事务级 advisory lock 完成 nonce、来源收据、事件创建与响应:同来源且同 payload 返回原 Bell ID 和 `duplicate`,同来源不同 payload 返回 `source_event_conflict`,不得产生第二条事件。
5. `bell-api` 增加默认关闭的事件 ingress 配置和独立 handler。handler 使用现有 event factory、数据库隐私策略与证据授权校验;候选无法通过 schema、代码级语义或隐私断言时返回不可重试 4xx,数据库暂时失败返回可重试 503。Bell 事件和来源收据无 UPDATE/DELETE 权限;只有 nonce 表允许按 TTL 清理。
6. Brain 使用 Python 标准库实现 v0.1 mapper、SQLite Outbox、HTTP client 和 worker,不新增生产 ML 或消息总线依赖。启用配置只从仓库外绝对路径 JSON 读取,secret 另从仓库外文件读取;SQLite 文件也必须是仓库外绝对路径。默认关闭时 T-017 行为不变。
7. mapper 将 `EventCandidate` 转为完整 v0.1 candidate,不发送 `source_ref`、摄像头 URI、凭据或 Bell 平台 `id`。fixture 明确映射为 `outcome=test/outcome_source=auto`,真实源先保持 `outcome=unknown`;主传感器固定为已绑定的视频设备,区域、track、配置版本和候选版本只进入契约允许字段。
8. Outbox 先持久化后暴露成功,WAL 模式,有界待处理量 10,000、单 payload 1 MiB、1~300 秒指数退避、最多 100 次尝试。`accepted/duplicate` 标记 delivered;稳定校验/冲突进入 dead letter;网络、5xx 和认证故障重试直至预算耗尽。Bell 成功后 Brain 在本地 ack 前崩溃,重启重投必须得到同一个 Bell ID。
9. 添加 Python mapper/outbox/client/worker/运行时测试、Go auth/handler/store 测试、OpenAPI 静态契约测试,以及隔离 PostgreSQL migration replay、最小权限、并发幂等和重启重试联调。代码图 MCP 本轮不可用时,将定向读取/`rg` 的降级和实际验证结果记录在执行证据中。
## 不可变约束
- 阈值 / 数值边界:正文与单 Outbox payload 最大 1 MiB;HTTP deadline 10 秒;时钟偏差 300 秒;nonce 保留至少 600 秒;Brain 待投递上限 10,000,重试 1~300 秒、最多 100 次。默认站点 16 路、可扩展 128 路不变,这些投递阈值不得成为设备路数硬上限。
- 判定式 / 状态转换:`queued -> delivering -> delivered | dead_letter`;只有 Bell `accepted/duplicate` 可进入 delivered。`(producer_id, source_event_id, candidate_hash)` 相同返回原 Bell ID;来源相同但 hash 不同永久冲突。事件、来源收据 append-only,Bell 平台 ID 只能由 Bell 生成。
- 安全边界:key、连接串、Brain 配置、SQLite Outbox、摄像头 URI/凭据和真实事件数据都在仓库外;非回环必须 HTTPS;producer 不能自报未绑定身份;未知/删除/策略拒绝必须 fail closed。响应、日志、页面和提交物不得回显 secret、完整 URI、原始 payload 或客户信息。
- 既有契约:最终事件必须严格符合 `docs/raw/contracts/event-v0.1.schema.json` 与语义 README;未知顶层字段拒绝,只能通过 `ext` 扩展。Sense/Bell 现有逻辑身份、Area `capture_policy`、T-015 Bell ULID 与不可变存储、T-016 审计 relay 均保持兼容;事件 ingress 使用独立端点、表和 key。
## 验收要点
- 任务相关验证:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain`、`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`、`python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py" -v`、`./scripts/test_postgres.ps1`。
- 完整门禁:运行 `./init.ps1`、三项文档治理基线与 `git diff --check`。PostgreSQL migration 必须从空库执行两遍且旧 migration 指纹不漂移;并发同源投递只落一条 event 和一条永久 receipt,运行角色不能更新/删除 event 或 receipt。
- 人工 / 设备验收:不需要新增摄像头或目标 GPU。使用 T-017 synthetic fixture 和本机隔离 PostgreSQL 完成端到端 smoke:首次返回 accepted、模拟 ack 前崩溃后重投返回 duplicate 且 Bell ID 相同;伪造签名、过期时间、重放冲突、未绑定身份和 Area 隐私拒绝均按契约失败。结果不构成真实多路、算法效果或生产 SLA。
- 构建产物:Bell `bell-api` 可执行程序与 Brain 源码 worker;OpenAPI、migration 和仓库外配置/key 示例写入各 README。不得提交运行时数据库、真实 key 或事件样本。
## 边界(不改什么)
不实现录像/证据切片、对象存储、业务规则、Alert、ack/升级、误报反馈、公共 JWT/OIDC、绑定管理 UI/API、消息总线、生产模型、GPU pipeline 或 64/128 路压测;不修改 `_reference/`、MiBeeNvr、MediaMTX 数据面或冻结 event v0.1 文件。T-016 审计链路保持独立。
## 协作约束
- 责任 Agent:codex。
- 唯一写入者:codex。
- 委派:不启用。
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-11 任务定义
- T-019 独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress;不复用 T-017 内存事件环,也不扩张到规则、Alert 或证据链。
- 当前会话未暴露 codebase-memory MCP 图工具,代码发现按仓库规则降级为定向读取与 `rg`;任务定义前主分支 `./init.ps1` 基线通过:72 项根测试、19 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
- 任务定义已合入默认分支并创建唯一 Gitea Issue #67;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
### 2026-08-11 领取与基线
- dispatcher `ila` 检查依赖和开放工单后,从默认分支 `b7fe44eeb0caf8d41abef365ec2100c74373321f` 创建并读回 `claims/T-019` 与 `agent/codex/T-019`;Issue #67 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。
- T-007 保持 `status/waiting`,不构成活跃写路径预留;本任务不启用委派。
### 2026-08-11 实现与自动化验收
- 冻结并实现 `brain-event-ingress-v1` 单事件 envelope。Bell event key 独立于审计 key,并把 `key_id` 绑定到唯一 `producer_id`;两端共享 method/path/timestamp/nonce/body-hash 五行 HMAC-SHA256,允许 300 秒时钟偏差,正文上限 1 MiB,非回环必须 HTTPS。Brain client 禁用环境代理和重定向,避免内部事件被旁路转发。
- PostgreSQL 新增可重放 `016`~`017`:Bell 所有的数字→逻辑身份绑定引用现有 Site/Area/Sense Device;运行时只能读取设备 ID、Area 与 modality 列,不能读取 endpoint、credential、profile token 或 path。永久来源收据和事件不可更新/删除,只有 10 分钟 nonce 表允许清理。
- Bell 在相同事务中提交最终 event、`(producer_id,source_event_id,candidate_hash)` 永久收据和成功 nonce 响应;同来源/同 canonical candidate 返回原 Bell ID,不同 candidate 返回稳定冲突。隔离 PostgreSQL 测试以 8 个并发重投确认只生成 1 条 event/1 条来源收据,并验证 Area 改为 `non_imaging_only` 后新视频事件失败关闭。
- Brain 新增完整 v0.1-minus-id mapper、WAL/`synchronous=FULL` SQLite Outbox、HMAC client 和 worker。Outbox 首次打开绑定 producer/tenant/site/device,禁止换身份复用旧队列;候选持久化成功后才进入页面内存环。fixture 显式映射为 `outcome=test/outcome_source=auto`,URI/凭据/`source_ref` 不出 Brain。队列执行 1~300 秒退避、100 次/10,000 条边界,`accepted/duplicate` 才 delivered,稳定 4xx 进入 dead letter;租约崩溃恢复与 Bell 成功后本地未 ack 的重投均有测试。
- Brain 工程原型在既有“最近候选事实”卡片内展示脱敏 Outbox 状态与待投递/投递中/已送达/死信计数;以文字和状态点共同区分未启用、正常、重试和死信,且明确单条候选的“仅内存观察”或“Outbox 已持久化”。状态无变化时不重复触发 `aria-live`,页面不暴露 Bell 地址、key 文件或 SQLite 路径。
- `zone_entry` 已登记到 event v0.1 kind 注册表;冻结 JSON Schema 未修改。Bell/Brain/部署 README、API、架构、技术栈和当前状态均同步,未扩张到证据、规则、Alert、公共认证或生产模型。
- 最终门禁通过:`./init.ps1`(77 项根测试、30 项 Brain 测试及 Sense/Bell generate/test/vet/build);独立 `go -C Bell test/vet/build ./...`、Brain compileall、4 项跨语言 ingress 契约测试和三项治理校验;`./scripts/test_postgres.ps1` 在 PostgreSQL 17.10 临时集群将 `001`~`017` 重放两次并通过真实 repository、权限、并发幂等、隐私和不可变性验证,随机端口/临时目录已清理且现有 5432 listener 未改变;`git diff --check` 通过。
- 本任务不需要真实摄像头或目标 GPU;synthetic fixture mapper、SQLite 崩溃恢复、真实本地 HTTP HMAC client、Bell handler 和真实 PostgreSQL consumer 的联合自动化证据满足本地验收。结果不构成算法效果、真实多路或生产 SLA。
+87
View File
@@ -0,0 +1,87 @@
---
id: T-020
title: 建立 Bell 规则到 Alert 与 ack 的最小可见纵切
phase: 3
deps: [T-015, T-019]
status: TODO
created: 2026-08-11
issue: null
context_ref: null
claim_branch: null
work_branch: null
write_paths:
- docs/tasks/T-020.md
- Bell/
- deploy/postgres/
- scripts/test_postgres.ps1
- tests/test_postgres_contract.py
- tests/test_bell_alert_contract.py
- docs/contracts/
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/contracts/README.md
- docs/current-state.md
- docs/routes.md
---
## 问题 / 背景
T-019 已把 Brain 候选可靠地写成 Bell 不可变 Event,但 Bell 尚未消费 Event、记录规则版本、创建独立 Alert 或接受值班员 ack。现有 Bell 原型能说明目标交互,却没有可运行数据/API;因此客户仍看不到从匿名区域事件到“有人确认处理”的业务闭环。
本任务建立默认关闭、只允许回环联调的最小可见纵切:Bell 从不可变 Event 运行一个受限规则内核,为匹配事件幂等创建 Alert,使用 append-only 状态迁移实现首次 ack 竞争者获胜与确认后关闭,并通过自包含值班台展示真实 PostgreSQL 状态。它为后续升级链、双路径通知、证据切片和正式公共认证提供基础,但不提前伪造这些事实。
## 关联需求与交互(如适用)
- 用户故事:US-003(处置业务预警)、US-005(配置规则与升级链中的规则版本边界)。
- 交互清单:IX-005(预警到达)、IX-006(ack 竞争)、IX-007(只展示实际已有的升级/投递事实)、IX-008(Event/Alert 双向关联与证据降级)、IX-013(权限与隐私)。
- 相关页面 / 路由:复用已确认的 `docs/design/bell/index.html` 值班台信息架构;新增默认关闭的回环工程路由 `/bell-console/` 与 `/bell-console/api/v1/*`。这些路由不是待冻结的 Bell 公共 `/api/v1`,不冻结最终前端框架。
## 方案
1. 在 `docs/contracts/bell-alert-console-v1.openapi.json` 冻结回环工程 API:按状态分页列出 Alert、读取详情、`ack` 和 `close`。列表默认 16、最大 100,稳定按 `created_at DESC,id DESC`;tenant/Site/actor 只来自启动时的仓库外控制台上下文,不接受客户端自报。
2. PostgreSQL 新增 append-only `rule_versions`、`event_rule_sweeps`、`rule_evaluations`、`alerts`、`alert_events`、`alert_transitions` 和 `alert_command_receipts`。Rule version、evaluation、Alert 身份、Event 关联、状态迁移与幂等收据均禁止 UPDATE/DELETE/TRUNCATE;当前状态由最新 transition 推导,不把 Event 和 Alert 合并。
3. 首版规则内核只支持精确 `event_kind`、最小严重度、可选 Site 范围和生效时间。每个稳定 `rule_key` 的最新生效版本决定 enabled/disabled;配置从仓库外绝对 JSON 文件加载并按 canonical hash 幂等发布,不提供公共规则编辑 API,也不实现任意表达式或场景 DSL。
4. 规则 worker 扫描尚无 sweep 的不可变 Event;在单个 PostgreSQL 事务内写 sweep、每条已考虑规则的 evaluation、匹配 Alert、初始 `open` transition 与 Event 关联。并发 worker 通过唯一键和事务级 advisory lock 收敛;无规则/未命中也写 durable sweep,避免无限重试。崩溃前未提交可重做,提交后不得生成重复 Alert。
5. Alert ID 由 Bell 生成 `alt_` ULID。首版每个命中 Rule/Event 生成一个 Alert,但关系表按多对多建模;Alert 保存命中时的 rule version、标题与严重度快照,后续规则变化不改写历史。
6. 状态机固定为 `open -> acknowledged -> closed`。`ack` 的首个成功事务固化 actor/时间;并发后到者返回 `409 already_acknowledged` 并带当前处置人和时间,不覆盖、不双成功。`close` 只允许从 acknowledged 进入 closed。写请求必须携带 8~128 字符 `Idempotency-Key`;同 key/同命令重放原响应,同 key/不同命令稳定冲突。
7. `bell-api` 以 `BELL_ALERTS_ENABLED=true` 显式启用规则 worker;规则文件必须是仓库外绝对路径。值班台另以 `BELL_ALERT_CONSOLE_ENABLED=true` 启用,只允许整个 Bell HTTP 监听地址为显式回环,并要求仓库外 token 文件、正整数 tenant/Site 与受限 actor ref。两项默认关闭,控制台 token 只进入页面内存和 `no-store` 响应,不记录。
8. `Bell/web/` 使用 Go `embed`、HTML/CSS/原生 JavaScript 实现,不增加前端依赖或 CDN。页面显示真实 Alert 队列、关联 Event、规则版本和 append-only 时间线;无证据时明确标注“证据切片尚未启用”,无升级/投递实现时不显示虚构倒计时或成功状态。ack/close 有 loading、成功、并发冲突、可重试错误与无权限反馈,颜色不是唯一状态表达;375px 与桌面均可操作。
9. 增加 Go 域/handler/store 测试、静态 OpenAPI/UI 契约测试,以及隔离 PostgreSQL migration replay、最小权限、规则幂等、并发 evaluation、并发 ack、幂等重放和重启后状态恢复验证。当前会话无 codebase-memory 图工具,代码发现降级为定向读取和 `rg` 并记录在执行证据。
## 不可变约束
- 阈值 / 数值边界:Alert 列表默认 16、最大 100;16 不是业务上限,站点仍允许 1~128 路。规则文件和控制台 token 均限制大小;actor/rule/idempotency 字段有显式长度与字符集边界。worker 必须可取消且空闲轮询有界,不按设备或 tenant 创建 goroutine。
- 判定式 / 状态转换:Rule 最新生效版本决定是否启用;严重度顺序固定为 `low < medium < high < critical`。每个 `(event_id, rule_version_id)` 最多一个 evaluation/Alert;`open -> acknowledged -> closed` 之外的迁移拒绝。首次 ack 获胜,后到者不得覆盖。Event、Rule version、Alert 事实和所有 transition 均 append-only。
- 安全边界:功能默认关闭;工程控制台只允许显式回环绑定。token、规则配置、DSN 与客户数据均在仓库外;页面/API/日志不得返回 Event 原始 payload、流 URI、凭据、内部 DSN 或 token。tenant/Site/actor 不来自请求体、查询参数或可修改浏览器存储。
- 既有契约:冻结 Event v0.1、T-019 ingress、Bell 事件 ULID/不可变性和 Sense/Bell schema 所有权保持不变。Alert 是独立实体并通过关系表关联 Event;本任务不修改 `docs/raw/contracts/event-v0.1.schema.json`。
## 验收要点
- 任务相关验证:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`、`python -m unittest discover -s tests -p "test_bell_alert_contract.py" -v`、`python -m unittest discover -s tests -p "test_postgres_contract.py" -v`、`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17`。
- 完整门禁:运行 `./init.ps1`、三项治理基线、`git diff --check`。PostgreSQL migration 必须从空库执行两遍;8 个并发 evaluator 对同 Event/Rule 只创建 1 个 Alert,8 个并发 ack 只有 1 个成功且其余观察同一处置人;同幂等 key 重放稳定,重启后状态/时间线不丢失;运行角色不能改写或删除任一规则/Alert 历史事实。
- 人工 / 设备验收:不需要摄像头、GPU、客户网络或通知供应商。页面沿用已由产品确认的 Bell 原型信息架构;任务责任人在本地以合成 Event 检查 375px/桌面、键盘、加载/空态/成功/冲突/错误和 reduced-motion。该工程验收不等于正式 Web/H5、JWT/RBAC、算法效果或生产 SLA 验收。
- 构建产物:Bell `bell-api`、嵌入式 `/bell-console/`、OpenAPI 和 PostgreSQL migration;不提交运行时规则文件、token、数据库或事件样本。
## 边界(不改什么)
不实现证据/pre-roll 切片、MinIO/S3、升级计时/重启续跑、联系人/排班、通知 provider、短信/语音/Webhook、本地声光、静默、交接班、误报反馈、正式公共 JWT/OIDC/RBAC、正式 Bell 前端框架、规则编辑器/试运行/回滚、生产模型或多路容量压测;不修改 Sense、Brain、MediaMTX、`_reference/` 或冻结 Event v0.1。
## 协作约束
- 责任 Agent:codex。
- 唯一写入者:codex。
- 委派:不启用。
- Gitea:任务定义先合入默认分支;创建唯一 Issue 并完成双向映射后,由 dispatcher 串行领取并回填 `context_ref`、claim / 工作分支。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-11 任务定义
- T-020 独立冻结规则扫描、Alert 身份/关系和首次 ack 竞争语义;证据、升级/投递和正式公共认证继续拆分,不用占位状态伪装完成。
- 复用已确认 Bell 原型及 US-003/US-005、IX-005~IX-008/IX-013;工程值班台不冻结最终前端框架。
- 当前会话未提供 codebase-memory MCP 图工具,按仓库规则降级为定向读取与 `rg`。任务定义前 `./init.ps1` 基线通过:77 项根测试、30 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
+2 -2
View File
@@ -10,9 +10,9 @@
$ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot
# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
$InstallCmd = "go -C Sense mod download; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell mod download"
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -c 'from importlib.metadata import version; assert version(`"numpy`") == `"1.26.4`"; assert version(`"opencv-python`") == `"4.9.0.80`"'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s Brain/tests -p 'test_*.py' -v; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m compileall -q Brain; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
$StartCmd = "go -C Sense run ./cmd/sense-api"
function Assert-Configured {
+2 -2
View File
@@ -12,9 +12,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
INSTALL_CMD=(bash -lc "go -C Sense mod download && go -C Bell mod download")
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...")
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && python3 -c 'from importlib.metadata import version; assert version(\"numpy\") == \"1.26.4\"; assert version(\"opencv-python\") == \"4.9.0.80\"' && python3 -m unittest discover -s Brain/tests -p 'test_*.py' -v && python3 -m compileall -q Brain && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...")
START_CMD=(go -C Sense run ./cmd/sense-api)
ensure_configured() {
+3 -1
View File
@@ -93,7 +93,9 @@ try {
'012_bell_events.sql',
'013_privileges_bell_events.sql',
'014_audit_relay.sql',
'015_privileges_audit_relay.sql'
'015_privileges_audit_relay.sql',
'016_event_ingress.sql',
'017_privileges_event_ingress.sql'
)) {
Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot "deploy\postgres\$name")
}
@@ -0,0 +1,70 @@
"""Cross-language invariants for Brain-to-Bell event ingress v1."""
from __future__ import annotations
import json
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTRACT = ROOT / "docs" / "contracts" / "brain-event-ingress-v1.openapi.json"
EVENT_SCHEMA = ROOT / "docs" / "raw" / "contracts" / "event-v0.1.schema.json"
class BrainEventIngressContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.document = json.loads(CONTRACT.read_text(encoding="utf-8"))
cls.event_schema = json.loads(EVENT_SCHEMA.read_text(encoding="utf-8"))
def test_candidate_is_exact_final_top_level_shape_minus_bell_id(self) -> None:
candidate = self.document["components"]["schemas"]["EventV01Candidate"]
expected = set(self.event_schema["required"]) - {"id"}
self.assertEqual(expected, set(candidate["required"]))
self.assertEqual(expected, set(candidate["properties"]))
self.assertFalse(candidate["additionalProperties"])
self.assertIn("id", self.event_schema["required"])
def test_transport_auth_and_statuses_are_frozen(self) -> None:
operation = self.document["paths"]["/internal/v1/event-candidates"]["post"]
self.assertEqual({"200", "201", "400", "401", "403", "409", "413", "422", "503"}, set(operation["responses"]))
header_names = {
self.document["components"]["parameters"][parameter["$ref"].split("/")[-1]]["name"]
for parameter in operation["parameters"]
}
self.assertEqual(
{"X-YoVision-Key-Id", "X-YoVision-Timestamp", "X-YoVision-Nonce", "X-YoVision-Signature"},
header_names,
)
def test_numeric_safety_bounds_match_both_implementations(self) -> None:
signature = self.document["x-yovision-signature"]
delivery = self.document["x-yovision-delivery"]
self.assertEqual((300, 600, 32), (signature["clock_skew_seconds"], signature["nonce_receipt_ttl_seconds"], signature["minimum_secret_bytes"]))
self.assertEqual((1048576, 10, 1, 300, 100, 10000), (
delivery["maximum_body_bytes"], delivery["deadline_seconds"], delivery["initial_retry_seconds"],
delivery["maximum_retry_seconds"], delivery["maximum_attempts"], delivery["maximum_queued"],
))
brain = (ROOT / "Brain" / "yovision_brain" / "ingress.py").read_text(encoding="utf-8")
bell = (ROOT / "Bell" / "internal" / "ingress" / "ingress.go").read_text(encoding="utf-8")
for marker in (
"/internal/v1/event-candidates", "X-YoVision-Key-Id", "X-YoVision-Timestamp",
"X-YoVision-Nonce", "X-YoVision-Signature",
):
self.assertIn(marker, brain)
self.assertIn(marker, bell)
self.assertIn("MAX_QUEUED = 10_000", brain)
self.assertIn("MAX_ATTEMPTS = 100", brain)
self.assertIn("300*time.Second", bell)
def test_event_key_is_bound_to_producer_and_separate_from_audit(self) -> None:
key_shape = self.document["x-yovision-signature"]["key_document_shape"]["keys"][0]
self.assertEqual({"key_id", "producer_id", "secret_base64url"}, set(key_shape))
bell_main = (ROOT / "Bell" / "cmd" / "bell-api" / "main.go").read_text(encoding="utf-8")
self.assertIn("BELL_EVENT_INGRESS_KEYS_FILE", bell_main)
self.assertIn("BELL_AUDIT_KEYS_FILE", bell_main)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -62,6 +62,8 @@ class PostgresContractTests(unittest.TestCase):
"013_privileges_bell_events.sql",
"014_audit_relay.sql",
"015_privileges_audit_relay.sql",
"016_event_ingress.sql",
"017_privileges_event_ingress.sql",
],
names,
)
@@ -146,6 +148,28 @@ class PostgresContractTests(unittest.TestCase):
self.assertIn("grant select, insert on table bell.audit_events to bell_runtime", privileges)
self.assertIn("revoke all on table bell.audit_events, bell.audit_relay_receipts from sense_app", privileges)
def test_event_ingress_has_permanent_source_receipts_and_minimal_privileges(self) -> None:
migration = normalized(migration_text("016_event_ingress.sql"))
privileges = normalized(migration_text("017_privileges_event_ingress.sql"))
for marker in (
"create table if not exists bell.event_ingress_bindings",
"create table if not exists bell.event_ingress_receipts",
"primary key (producer_id, source_event_id)",
"create table if not exists bell.event_ingress_nonces",
"expires_at >= received_at + interval '10 minutes'",
"insert into bell.schema_migrations(version) values (5)",
):
self.assertIn(marker, migration)
self.assertIn(
"grant select, insert on table bell.event_ingress_receipts to bell_runtime",
privileges,
)
self.assertIn(
"grant select, insert, delete on table bell.event_ingress_nonces to bell_runtime",
privileges,
)
self.assertNotIn("endpoint_ref", privileges.split("revoke all on table sense.devices")[1])
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "Sense" / "internal" / "console" / "assets"
class SenseConsoleContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = (ASSETS / "index.html").read_text(encoding="utf-8")
cls.css = (ASSETS / "app.css").read_text(encoding="utf-8")
cls.javascript = (ASSETS / "app.js").read_text(encoding="utf-8")
def test_console_is_self_contained_and_preserves_five_entry_ia(self):
self.assertIn('href="/sense-console/app.css"', self.html)
self.assertIn('src="/sense-console/app.js"', self.html)
self.assertNotRegex(self.html, r"https?://")
self.assertNotIn("<script>", self.html)
self.assertNotIn("<style>", self.html)
for view in ("overview", "monitor", "devices", "onboarding", "operations"):
self.assertGreaterEqual(self.html.count(f'data-view="{view}"'), 2)
self.assertIn(f'data-page="{view}"', self.html)
def test_capacity_and_preview_bounds_are_explicit(self):
self.assertIn('params.set("limit", String(state.config.page_size))', self.javascript)
self.assertIn("state.selected.size >= state.config.max_active_previews", self.javascript)
self.assertIn('state.config.page_size !== 16', self.javascript)
self.assertIn('state.config.max_active_previews !== 4', self.javascript)
self.assertIn('state.config.recording_and_playback !== false', self.javascript)
self.assertIn('$("#nextPage").disabled = loading || !state.nextCursor', self.javascript)
self.assertIn('$("#prevPage").disabled = loading || state.pageIndex === 0', self.javascript)
self.assertNotRegex(self.javascript, r"\.slice\(\s*0\s*,\s*16\s*\)")
def test_token_is_memory_only_and_sensitive_addresses_are_not_rendered(self):
for forbidden in ("local" + "Storage", "session" + "Storage", "document.cookie"):
self.assertNotIn(forbidden, self.javascript)
self.assertIn('state.token = ""', self.javascript)
self.assertIn('tokenInput.value = ""', self.javascript)
self.assertNotRegex(self.html, r"rtsp://|onvif://|/whep")
self.assertNotRegex(self.javascript, r"rtsp://|onvif://")
self.assertNotIn("innerHTML", self.javascript)
def test_accessibility_and_responsive_guards_are_present(self):
self.assertIn('href="#main-content"', self.html)
self.assertIn('aria-live="polite"', self.html)
self.assertIn('aria-current="page"', self.html)
self.assertIn("@media (max-width: 760px)", self.css)
self.assertIn("min-height: 44px", self.css)
self.assertIn("prefers-reduced-motion", self.css)
self.assertIn(":focus-visible", self.css)
ids = re.findall(r'\bid="([^"]+)"', self.html)
self.assertEqual(len(ids), len(set(ids)))
for attributes, content in re.findall(r"<button\b([^>]*)>(.*?)</button>", self.html, re.S):
visible_text = re.sub(r"<[^>]+>", "", content).strip()
self.assertTrue(
"aria-label=" in attributes or visible_text,
f"button lacks an accessible name: {attributes}",
)
if __name__ == "__main__":
unittest.main()