Files
yovision/docs/raw/03-通用场景应用方案.md
T

1256 lines
67 KiB
Markdown
Raw Normal View History

# 🏗️ YoVision 智能视频事件平台 — 通用场景应用方案
> 版本 v0.4 · 2026-08-03
> 上游约束:《02-需求分析》§1 的 C1–C9
> 本文档描述**如何用一套底座支撑多个场景**,以及每个场景怎么落到配置上
---
# 1. 方案总纲
## 1.1 一句话
**一套通用底座(接入 → 流水线 → 能力 → 编排),场景差异全部收敛到「场景包」这一层配置里。**
新增一个场景 = 写一个场景包(规则模板 + 升级策略 + 报表口径 + 话术),**不改核心代码**。这是《02》M5 的验收标准。
## 1.2 分层落地
```mermaid
flowchart TD
subgraph L5["L5 场景包(配置)"]
SP1["居家养老包"]:::pack
SP2["校园包"]:::pack
SP3["社区包"]:::pack
end
subgraph L4["L4 业务编排(Go)"]
RE["规则引擎"] --> EV["事件生成器<br/>回捞 · 抓拍 · 去重"]
EV --> AL["预警状态机<br/>ack · 升级 · 回执"]
AL --> DL["投递适配器<br/>推送/短信/语音/Webhook"]
end
subgraph L3["L3 能力层(模型)"]
DET["检测+跟踪<br/>YOLO"] --> POSE["姿态<br/>YOLO-pose"]
DET --> ATTR["属性"]
DET --> REID["ReID 匿名同一性<br/>默认手段"]
DET -.旁路.-> FACE["人脸比对<br/>租户授权后"]
POSE --> TEMP["时序行为分析"]
end
subgraph L2["L2 流水线(Savant / 等价框架)"]
TRIG["触发器<br/>运动/ONVIF事件/传感器"] --> PIPE["解码·抽帧·推理调度"]
BUF["磁盘缓冲"] --> PIPE
end
subgraph L1["L1 接入(Go + mediamtx)"]
DEV["设备台账"] --> RECON["对账器"]
RECON --> MTX["媒体路由"]
TUN["WireGuard 隧道"] --> DEV
end
subgraph L0["L0 基座"]
MT["多租户 · RBAC"]
ST["对象存储"]
OBS["Prometheus + Grafana + 审计"]
end
L5 -.驱动.-> L4
L4 --> L3 --> L2 --> L1 --> L0
classDef pack fill:#e8f0fe,stroke:#4285f4
```
## 1.3 组件选型
| 层 | 组件 | 许可 | 角色 | 备注 |
| --- | --- | --- | --- | --- |
| L1 媒体路由 | **mediamtx** (bluenviron) | MIT | 协议互转、按需拉流、录制、鉴权回调、Prometheus | Go 单二进制 |
| L1 RTSP 库 | **gortsplib** | MIT | 自研 worker 拉流 + 帧级丢弃 | 同作者 |
| L1 ONVIF | **IOTechSystems/onvif** 或 use-go/onvif | Apache-2.0 | 设备控制 | 前者是 EdgeX device-onvif-camera 内部使用的增强版 |
| L1 API 客户端 | 自行用 **oapi-codegen** 从 mediamtx OpenAPI 生成 | — | 控制 mediamtx | ⚠️ 不依赖第三方 SDK 的更新节奏 |
| L1 边缘推流 | mediamtx 或 **go2rtc** | MIT | 边缘盒子 | 杂牌摄像头兼容性差时用 go2rtc 兜底 |
| L1 隧道 | **WireGuard** | GPL-2.0(内核态,不链接) | 控制面通道 | |
| L2 流水线 | **Savant** (insight-platform) | Apache-2.0 | 多路视频分析框架 | 基于 NVIDIA DeepStream;备选见 §6.2 |
| L3 检测 | **YOLO**(Ultralytics v8/v11) | AGPL-3.0 → **购 Enterprise License** | 目标检测(人、车、电动车、堆物多类) | 已决策沿用;授权采购见 §2.4 |
| L3 姿态 | **YOLO-pose**(COCO 17 点) | 同上 | 关键点 | 备选 RTMPose/RTMO(Apache-2.0,同为 COCO 17 点,可直接替换) |
| L3 跟踪 | ByteTrack / BoT-SORT | MIT | 多目标跟踪 | |
| L3 ReID | **ReID 模型(匿名同一性)** | 视模型,选 Apache/MIT 系 | 跨镜同一性,**默认手段** | 产出 `anon_id`,不涉及身份 |
| L3 人脸 | 人脸检测 + 特征提取(InsightFace 系等) | ⚠️ 权重许可需逐个核对 | 身份比对,**按租户授权开启** | 见 §2.8.3 |
| L3 向量检索 | pgvector 或 Milvus | 开源 | 底库比对 | 底库量小用 pgvector 即可 |
| L4 全部 | **自研 Go** | — | 规则、事件、预警、投递 | 业务定制度高,通用工具都要大改 |
| L4 投递 | Novu / Apprise / ntfy 或直连云厂商 | — | 只解决"怎么发" | 短信/语音**双供应商** |
| L0 数据库 | PostgreSQL | — | 唯一真相源 | |
| L0 存储 | MinIO / S3 兼容 | — | 证据文件 | |
| L0 可观测 | Prometheus + Grafana + Jaeger | — | 指标/看板/链路 | mediamtx 与 Savant 都出 Prometheus 指标,一个 Grafana 全收 |
| L0 运维告警 | Alertmanager | — | **仅运维**,不碰业务预警 | |
### 明确不用的东西
| 组件 | 不用的理由 |
| --- | --- |
| **Grafana OnCall (OSS)** | 已于 2026-03-24 归档,仓库只读仅修 CVSS 7.0+;**移动推送、短信、电话在归档当日停止工作**(依赖 Grafana Cloud 中转)。送不出去的告警系统等于没有 |
| **完整 MiBeeNvr** | 可作实验室兼容性测试台与选择性源码参考,**不整仓进入生产**。它已支持 RTMP/SRT push-in、远程访问等跨网络能力,但其单体媒体、录像、SQLite、设备管理和 UI 与本项目的 MediaMTX/Sense/Bell 边界重叠;未见公开的 128 路多分片验收数据,也没有本项目需要的多租户真相源与配额模型 |
| **EdgeX Foundry(本期)** | 白送的能力约值一两周开发量,代价是长期多背 4 个服务;且它假设设备在同一局域网,与 NAT 场景冲突。接入雷达/门磁/Modbus 设备时再评估切换(切换成本低:ONVIF 部分两边几乎一样,对账器不用改) |
## 1.4 NVR 开源项目选型结论(已定,2026-08-03)
本项目不 fork 一个完整 NVR 作为生产系统。当前默认由客户已有 NVR 承担常态录像,YoVision 负责统一接入、AI 分析和事件证据,因此选型拆成三类:**MediaMTX 是生产媒体数据面,MiBeeNvr 是 Sense 的主源码参考,Frigate 是事件录像与回看交互参考**。
| 项目 | 许可 / 技术栈 | 与本项目的匹配 | 决策 |
| --- | --- | --- | --- |
| [MediaMTX](https://github.com/bluenviron/mediamtx) | MIT / Go | 协议路由、按需拉流、fMP4/MPEG-TS 录像、按时间回放、HTTP/JWT 鉴权、Control API、Prometheus;官方提供横向扩展示例 | **直接部署为生产依赖,不复制源码**;Sense 通过自行生成的 API 客户端驱动 |
| [MiBeeNvr](https://github.com/Mi-Bee-Studio/MiBeeNvr) | MIT / Go + Svelte | ONVIF、设备发现、按序列号重新定位 IP、健康监控、退避重连、REST API 与现代 UI 最接近 Sense;但完整系统与 MediaMTX/Bell 重叠,内部以单机 NVR 为中心 | **首选源码参考,按白名单选择性重写/移植,不整仓 fork,不加入 go.mod** |
| [Frigate](https://github.com/blakeblackshear/frigate) | MIT / Python + FFmpeg + go2rtc | 主/子码流角色、低成本运动过滤、pre/post-capture、事件保留策略和 Review/History 时间轴成熟;自带推理进程会与 Brain/Savant 重复 | **只借鉴事件录像语义和 UI 工作流,不引入其推理与进程架构** |
| [go2rtc](https://github.com/AlexxIT/go2rtc) | MIT / Go | 特殊流格式、WebRTC/MSE、双向语音与 RTSP restream 兼容性强 | **仅作为边缘兼容插件**;不成为中心真相源,管理页面默认不暴露 |
| [Moonfire NVR](https://github.com/scottlamb/moonfire-nvr) | GPL-3.0-or-later / Rust | 不解码、不转码的低开销录像索引和任意时间段 MP4 导出值得研究;仍为 pre-1.0 | **只看设计,不复制代码**,避免 GPL 传染和存储格式兼容风险 |
| [ZoneMinder](https://github.com/ZoneMinder/zoneminder) | GPL-2.0 / C++、Perl、PHP | 传统集成式 VMS,功能完整但技术栈、部署模型和系统边界与 Sense/Brain/Bell 差异大 | **不作为实现基线** |
| [Shinobi](https://gitlab.com/Shinobi-Systems/Shinobi) | 许可证按采用版本单独核验 / Node.js | 同属完整 VMS,主仓已迁至 GitLab;引入后仍需拆除其媒体、用户和事件体系 | **不作为实现基线** |
核验资料:MediaMTX 官方[录像](https://mediamtx.org/docs/features/record)、[回放](https://mediamtx.org/docs/features/playback)、[横向扩展](https://mediamtx.org/docs/features/scalability)与 [Control API](https://mediamtx.org/docs/features/control-api);Frigate 官方[视频流水线](https://docs.frigate.video/frigate/video_pipeline/)与[录像保留策略](https://docs.frigate.video/configuration/record/)。MiBeeNvr 源码分析基线为本地 `_reference/mibeenvr` 的提交 `b8e7dd426d73d3b1910254c196d0937db9517a0f`(2026-08-01);实施前须重新核对上游版本、许可证与安全公告。
### MiBeeNvr 是否包含 MediaMTX 的功能
**部分包含,但不能等价替代。** MiBeeNvr 覆盖了单机 NVR 常用的摄像头接入、直播和录像功能;MediaMTX 的核心价值则是作为通用媒体路由器,为多个发布者/读者提供稳定 path、细粒度鉴权、会话控制和横向扩展。两者的功能交集很大,架构角色不同。
| 能力 | MiBeeNvr(当前分析基线) | MediaMTX | 对 YoVision 的影响 |
| --- | --- | --- | --- |
| ONVIF、设备发现、PTZ、序列号/IP 自愈 | **有,且更接近设备管理产品** | 不负责设备实体 | 借鉴 MiBeeNvr,落入 Sense |
| HLS/LL-HLS、WebRTC、浏览器直播 | 有,另含 HTTP-FLV/WebSocket | 有 HLS/WebRTC,侧重协议路由 | 初期界面演示可直接用 MiBeeNvr |
| RTMP/SRT 接入与 RTMP/RTSP 转推 | 有 | 有,且可在多种协议间发布、读取和代理 | 都能覆盖常见接入,但不能仅按协议名称判断可替换 |
| 通用 RTSP path 供 Brain、播放器、录像器共同读取 | 当前只确认摄像头 RTSP 输入与向外部目标转推,**未发现等价的通用 RTSP 读服务** | 核心能力:publish/read/proxy,多读者共享 | 含 Brain 的端到端版本应从 M1 引入 MediaMTX,否则 Brain 需要重复直连摄像头或再补一个媒体服务器 |
| 分段录像、保留、列表、时间轴与回放 | 有,MP4/SQLite/文件系统一体化 | 有 fMP4/MPEG-TS 录像与按时间回放,业务索引由上层负责 | 演示可用 MiBeeNvr;生产事件证据仍按 Sense/Bell 边界实现 |
| 鉴权 | 本地 BasicAuth、会话 token、API key | internal/HTTP/JWT,可按 path 与 publish/read/playback/api 等 action 控制 | MiBeeNvr 鉴权不能替代多租户 RBAC 与媒体动作授权 |
| 管理 API | 面向摄像头、录像、设置和 UI 的 NVR REST API | 面向 path、配置、发布者/读者和协议会话的 Control API,可查询/踢会话 | M1 对账器依赖 MediaMTX 的控制语义 |
| 横向分片与故障域 | 未见公开的多实例编排和 128 路分片验收 | 官方提供 origin/read-replica 与负载均衡方案 | 64/128 路不能以单体 MiBeeNvr 为扩展基线 |
以上“未发现”是基于指定提交的源码与文档审查,不代表上游永久不支持;升级前必须重新核验。
### 前期能否只用 MiBeeNvr
| 阶段/用途 | 是否允许只用 MiBeeNvr | 边界 |
| --- | --- | --- |
| **M0 实验室兼容性与 UI 演示**(3–5 款摄像头) | **可以,且优先直接运行而不是复制源码** | 只接自购测试设备;允许随 M0 结束丢弃,不形成生产数据 |
| 一次性内部 Alpha(最多 16 路) | 可以,但必须标记为 **disposable prototype** | 单站点、单用户、无生产 SLA;不承诺原地升级到 128 路 |
| **M1 首个可持续演进的生产骨架** | **不可以** | MediaMTX 必须进入;Sense 建立自有设备 ID、台账、API 客户端与对账器,Brain 以后只依赖稳定媒体地址 |
| M2/M3 默认 16 路真实试点 | 不可以 | MiBeeNvr 只保留为实验室测试台/源码参考;真实租户、配额、审计与事件契约不得落入其单机模型 |
| M4/M5 64/128 路 | 不可以 | 必须使用媒体/推理分片和故障隔离 |
> **决策规则**:如果“初版”指可丢弃的验证版,可以只用 MiBeeNvr;如果“初版”指以后原地升级的第一个正式版本,MediaMTX 必须从 M1 进入。若确需修改 MiBeeNvr,应保留为独立 fork/目录及上游 Git 历史,不把整仓源码粘贴进 Sense。
### MiBeeNvr 验证版安全红线
当前分析提交在 `internal/api/handler.go` 的 `registerAnonymousRoutes` 中将录像 `download`、`merged` 回放端点注册为匿名且不受该文件中的公共限流组保护。这对隔离实验室演示可以接受,**对居家老人、学校和工厂真实视频不可接受**。
验证版必须满足:
1. 仅监听隔离测试网或 localhost;若需远程访问,必须置于带认证的反向代理/VPN 后。
2. 不接入真实住户、学生、员工视频;不得用匿名录像 URL 作为生产分享机制。
3. 关闭不需要的 WebDAV、FTP、浏览器 AI、自动更新和公网管理端口,减少攻击面。
4. 若进入任何真实试点,先把录像下载/回放纳入 Bell 鉴权与审计,或直接切换到 M1 的 MediaMTX + Sense 生产骨架。
### MiBeeNvr 借鉴白名单
| 可借鉴内容 | 落入本项目 | 处理方式 |
| --- | --- | --- |
| `internal/onvif/` 的发现、WS-Security、Profile 选择、设备时间处理 | `Sense/internal/onvif/` | 按本项目接口重写;只保留必要协议能力和测试向量 |
| `internal/camera/rediscovery*` 的序列号识别与 IP 自愈 | `Sense/internal/device/`、`probe/` | 身份仍以序列号为准;适配 Postgres 设备台账和对账状态机 |
| `internal/health/`、退避与冻结检测 | `Sense/internal/probe/` | 统一成指标和状态事件,不直接驱动录像/告警业务 |
| 摄像头表单、直播卡片、录像列表的交互模式 | `Bell/web/` | 只借鉴交互;按多租户 RBAC、分页和 128 路虚拟列表重做 |
| ONVIF、录像异常与存储故障测试用例的组织方式 | Sense 测试目录 | 复用测试思想和公开协议样例;不得携带真实设备密码/视频 |
若逐行复制 MIT 源码,必须保留原版权和许可证声明,并在第三方清单记录文件、提交号和修改范围。优先采用“读懂后按 Sense 接口重写”,避免把 MiBeeNvr 的 `config/model/recorder/storage/event` 耦合链一起带入。
### 明确禁止复制的部分
- MiBeeNvr 的 `StreamHub`、HLS/WebRTC/RTMP/SRT 服务和录像主链:与 MediaMTX 重复。
- MiBeeNvr 的 SQLite 真相源、单机 `CameraManager` 生命周期:与 Postgres、多租户、16→128 路分片冲突。
- MiBeeNvr 的浏览器 AI 和 Frigate 的检测进程:AI 统一归 Brain/Savant。
- 任一 NVR 的 BasicAuth/本地用户模型:身份、租户、RBAC 与审计统一归 Bell。
- Frigate/Home Assistant/MQTT 的业务对象模型:只吸收事件录像与时间轴语义,不进入平台核心契约。
### 进入生产的验收门槛
1. 依赖固定版本与校验值,生成 SBOM 和第三方许可证清单;上游升级必须先过回归测试。
2. M0 完成 3–5 款摄像头的 ONVIF/RTSP 兼容测试;M3 建立 16 路基线,M4/M5 分别完成 64/128 路分片验收。
3. 分别测接入/录像带宽、同时解码、AI 推理和证据存储,不以“NVR 能录多少路”替代 AI 容量结论。
4. MediaMTX、go2rtc 的管理 API/UI 默认只监听内网或 localhost,经 Bell/Sense 授权代理后才可访问。
---
# 2. 通用能力底座
## 2.1 接入层:控制面/数据面分离
这是解决 NAT 的关键,也是三个场景可以用同一套接入的原因。
```mermaid
flowchart LR
subgraph SITE["站点(家庭/学校/小区)"]
CAM["IP 摄像头"]
SENSOR["传感器<br/>雷达/门磁/按钮"]
EDGE["边缘节点<br/>推流 + 本地缓存 + WG"]
CAM --> EDGE
SENSOR --> EDGE
end
subgraph CENTER["中心平台"]
MTX["mediamtx 集群<br/>(分片)"]
CTRL["控制面 Go 服务<br/>台账 · 对账 · 鉴权回调"]
PIPE["推理流水线"]
BIZ["业务编排"]
DB[("PostgreSQL")]
OBJ[("对象存储")]
end
EDGE -->|"数据面:主动推流<br/>RTSP announce / SRT"| MTX
CTRL -.->|"控制面:WireGuard 隧道<br/>ONVIF 配置与探活"| EDGE
MTX -->|鉴权回调| CTRL
MTX --> PIPE --> BIZ
CTRL --> DB
BIZ --> DB
BIZ --> OBJ
BIZ --> APP["App / 短信 / 语音 / Webhook / 大屏"]
```
| 平面 | 方向 | 通道 | 特点 |
| --- | --- | --- | --- |
| 控制面 | 中心 → 设备 | WireGuard 隧道 | 流量极小,仅配置与探活时产生 |
| 数据面 | 边缘 → 中心 | 主动推流 | 不走隧道,避免隧道成为带宽瓶颈 |
**控制面失联不影响视频推流**——这条解耦让隧道故障从 P0 降为 P2。
### IP 规划
每站点分配不冲突的内网段,设备地址确定化:
```
10.<站点高位>.<站点低位>.0/24
摄像头固定从 .10 起分配,传感器从 .100 起
```
设备地址可预测,不依赖自动发现,便于对账。**校园/社区场景若已有既定网段,在 `sites.subnet` 里登记实际值即可,逻辑不变。**
### mediamtx 配置基线
```yaml
api: yes
apiAddress: :9997
metrics: yes
metricsAddress: :9998
authMethod: http
authHTTPAddress: http://control-plane:8080/mtx-auth
pathDefaults:
sourceOnDemand: yes # 有 reader 才拉流
sourceOnDemandStartTimeout: 10s
sourceOnDemandCloseAfter: 30s
rtspTransport: tcp # 家庭/无线网络下 UDP 丢包严重
record: no # 按场景包覆盖
recordDeleteAfter: 7d
runOnOnline: curl -s -X POST http://control-plane:8080/hooks/online?path=$MTX_PATH
runOnOffline: curl -s -X POST http://control-plane:8080/hooks/offline?path=$MTX_PATH
paths:
"~^t(\\d+)-s(\\d+)-d(\\d+)$": # tenant-site-device,一条正则兜底
source: publisher
```
> 用一条正则 path 兜底,**不要写 N 条静态配置**。
>
> ⚠️ v1.19.x 的钩子命名为 `runOnAvailable` / `runOnUnavailable` / `runOnOnline` / `runOnOffline`,与旧版 `runOnReady` / `runOnNotReady` 不同。部署前核对所用版本的配置参考。
### 必须遵守的接入约定(血泪清单)
| 约定 | 原因 |
| --- | --- |
| 设备身份用序列号,不用 IP | DHCP / Wi-Fi 漫游会换 IP |
| 建 path 前先查是否存在 | 盲目 add 会踢掉正在推流的 publisher |
| 改配置前比对哈希,没变绝不调 API | 任何 path 配置变更都会触发 reload,断开正在推流的 source,**即使改动与它无关** |
| 重试用指数退避 | 无事务,只能靠幂等重试 |
| 并发限流(每系统信号量 8) | 避免打爆 API;每系统独立预算,不共享 |
| 设备状态含 `pending_activation` | 批量开通必然有凭据待补的中间态 |
| API 改的配置不落盘 | mediamtx 重启后 API 添加的 path 全部消失,服务启动时必须从 DB replay |
| 探活用独立 ONVIF 调用 | 「设备离线」与「无人订阅」在 mediamtx 侧表现相同 |
| 开通时 `SetSystemDateAndTime`,定期校时 | 时钟漂移会让事件时间戳全乱,审计失效 |
### 停用的四种粒度(副作用从轻到重)
| 需求 | 做法 | 效果 |
| --- | --- | --- |
| 暂停分析,流保留 | **卸载推理 source** | 配合 sourceOnDemand 自动停止拉流,随时恢复 |
| 立即断开,允许重连 | `POST /v3/rtspsessions/kick/{id}` | 断一次,客户端会重连 |
| 永久拒绝 | 鉴权回调返回 401 + kick | 推不上来,无需改 mediamtx 配置 |
| 彻底移除 | `DELETE /v3/config/paths/delete/{name}` | path 消失,重启后靠自有库恢复 |
标准组合:**先 kick 断开、再让鉴权拒绝** = 立刻生效且不会重连。
> ⚠️ **关键耦合**:`sourceOnDemand: yes` 的语义是「有 reader 才拉流」,而**推理 source 挂上去就是一个 reader**。因此「暂停分析」必须通过**卸载 source** 实现,不能只在推理插件里 `return`——后者会让 mediamtx 持续拉流、带宽白烧,而监控上看起来一切正常。
## 2.2 对账器:多系统一致性
数据库是唯一真相源,mediamtx / 推理流水线 / 存储都是被驱动的从属状态。
**水平触发(level-triggered)而非边沿触发**:对账器不处理"事件",只不断比对期望状态与实际状态并向期望收敛。这是处理部分失败的唯一可行方案。
```go
func (r *Reconciler) Sync(ctx context.Context) error {
desired := r.db.EnabledBindings() // 真相源
if len(desired) == 0 && r.lastDesiredCount > 0 {
return ErrSuspiciousEmptyDesired // 安全闸:期望集合意外为空
}
mtxActual, err1 := r.mtx.ListPaths(ctx) // 分片内所有实例
infActual, err2 := r.inference.ListSources(ctx)
if err1 != nil || err2 != nil {
return errors.Join(err1, err2) // 拿不到实际状态就不动作
}
plan := buildPlan(desired, mtxActual, infActual)
// plan 内部已按顺序约束排序:
// setup: mtx 先 → inference 后 (流必须先存在)
// teardown: inference 先 → mtx 后 (先停消费再断流)
// ⚠️ 建立与拆除是逆序的,很容易被后来人改错
for _, step := range plan {
if !step.DueForRetry(now) { continue } // 退避窗口未到
if step.DesiredHash == step.AppliedHash { continue } // 没变,绝不调 API
sem[step.System].Acquire(ctx, 1) // 每系统独立限流
go func() {
defer sem[step.System].Release(1)
if err := step.Apply(ctx); err != nil {
r.db.MarkFailed(step, err) // 退避,不回滚
return
}
r.db.MarkApplied(step) // applied_hash = desired_hash
}()
}
return nil
}
```
### 部分失败:不回滚,只收敛
典型场景:mediamtx path 建好了,推理 source 挂载失败。
- ❌ **错误做法**:回滚删掉 path。回滚本身也会失败,那时你需要回滚的回滚,无限递归。
- ✅ **正确做法**:保留已成功的部分,把失败那行 `sync_state` 标为未收敛 + 退避重试。
代价是会有一个无人消费的 path 短暂存在——但因为 `sourceOnDemand`,没有 reader 就不拉流,**成本接近零**。这是 sourceOnDemand 给多系统对账带来的额外好处:**中间态是安全的**。
### 重试预算与安全闸
| 参数 | 值 |
| --- | --- |
| 初始退避 | 5s |
| 退避倍率 | 2×,上限 5min |
| 告警阈值 | `attempt > 5` 且持续 > 15min → 告警 |
| 放弃阈值 | **不放弃**。持续重试,靠告警引入人工 |
| 循环周期 | 正常 30s;有未收敛项降到 5s;每 10min 全量对账(含孤儿扫描) |
| 孤儿清理安全闸 | 单轮删除量 > 总量 10% 时**停止并告警**,不执行 |
> 不设放弃阈值是故意的:对账器放弃了,设备就静默地不工作了,这在监护场景是不可接受的失败模式。
>
> 安全闸防的是数据库连接异常或查询 bug 导致的"期望集合意外为空"——那会把全部路数拆掉。
### 孤儿资源三类
| 类型 | 成因 | 处理 |
| --- | --- | --- |
| mediamtx 有 path,DB 无 | 人工手改、旧版本残留 | 删除(先记日志,不静默) |
| 推理有 source,DB 无 | 同上 | 卸载 |
| DB 有 binding,两边都无且长期未收敛 | 开通失败后无人处理 | 告警,**不自动删 DB** |
## 2.3 流水线层:故障隔离与触发式推理
### 为什么用框架而非完全自研
开源社区没有成品的「批量摄像头行为检测」(算法是差异化,不会有人开源),但有成熟的**多路视频分析流水线框架**。**算法是自己的,工程化用现成的。**
选定 **Savant**,三个决定性理由:
1. **故障隔离** —— 原生 DeepStream 中数据流与 pipeline 紧耦合,任一源崩溃则整个 pipeline 崩溃,重启加载模型要数秒且期间丢数据。Savant 的 adapter 通过 ZeroMQ 与框架通信,**adapter 挂掉不影响 pipeline**。家庭网络掉线是常态,这是规模化的生死线。
2. **可配置降级** —— 实时模式(算力不足时主动丢帧)或高吞吐模式(保证处理全部数据)。**本系统选实时模式,丢帧优于积压崩溃。**
3. **Replay(按需与非线性处理)** —— REST 控制的录制/回放/转推服务,直接对应触发式二级确认与 pre-roll 回捞。
同一套代码可跑在 Jetson 和数据中心 GPU 上,与边缘/中心混合部署契合。
**代价**:必须 NVIDIA 硬件,与 DeepStream 版本强绑定,学习曲线陡,无企业支持。
### Pipeline 架构
```
边缘推流 → mediamtx(汇聚,按需拉流)
↓ RTSP
RTSP source adapter(多路复用 + 故障隔离)
↓ ZeroMQ / Protobuf
Buffer Adapter(磁盘持久化缓冲,抗突发) ← 必须开启
↓
推理 module
├─ 检测 + 跟踪(YOLO + ByteTrack)
├─ 姿态(YOLO-pose,COCO 17 点)
├─ 属性 / ReID
├─ 人脸旁路(授权租户,异步不阻塞)
└─ Python 插件:时序窗口 + 行为判定(**自研部分**)
↓ ZeroMQ / Protobuf
Go 业务编排(规则引擎 → 事件 → 预警)
```
**需要自己写的只有中间那个 Python 插件,前后两段是现成的。**
### 关键配置决策
| 决策 | 说明 |
| --- | --- |
| **必须开启 Buffer Adapter** | adapter 与 module 之间的持久化事务性磁盘缓冲,抗流量突发,条目数与大小导出到 Prometheus。本场景天然有突发(夜里安静、早上集中活动、上下学时段)。**一开始就加上,别等出问题再补** |
| **AO-RTSP Sink 仅用于调试** | ⚠️ GeForce 系列显卡**同时硬件编码流数上限为 3**。给多路挂可视化回推会直接卡在 3 路,需 Quadro/Tesla/L4 类专业卡。**不要做成常态功能** |
| **开发期开 Development Server** | 改代码动态重载不重启 pipeline,可从 IDE 远程开发跑在 Jetson/服务器上的代码。原生 DeepStream 改一行要重启等几十秒 |
| **实时模式** | 算力不足时主动丢帧,不积压 |
### 与 Go 控制面的对接契约
| 需求 | 通道 | 语言无关 |
| --- | --- | --- |
| 接收检测结果 | 订阅 sink 的 ZeroMQ 端点,解 protobuf | ✅ |
| 动态增删流 | 动态挂载/卸载 source 与 sink,无需重载 pipeline | ✅ |
| 回放/回捞(pre-roll) | Replay 服务的 REST 接口 | ✅ |
| 程序化灌数据/调试 | Client SDK(**仅 Python**) | ❌ |
> **设计约束:优先走 ZeroMQ 和 REST 两条语言无关通道,Go 服务不得依赖 Python 中间层。** Client SDK 仅用于算法调试和测试。
>
> 因为两边都是 API 驱动,**对账器可以同时驱动 mediamtx 和推理流水线**:开通 = 建 path + 挂 source;停用 = kick + 卸 source。逻辑写在同一个 reconciler 里。
### 触发式推理(成本的数量级优化)
常态全量推理不可持续。三种触发源,按场景选:
```mermaid
flowchart LR
T1["运动侦测<br/>(软件,零成本)"] --> GATE{"触发门"}
T2["ONVIF 事件订阅<br/>(摄像头侧)"] --> GATE
T3["传感器<br/>雷达/门磁/按钮"] --> GATE
GATE -->|唤醒| INF["推理流水线<br/>+ Replay 回捞前 N 秒"]
GATE -->|静默| IDLE["不解码 不推理"]
```
| 触发源 | 成本 | 可靠性 | 推荐场景 |
| --- | --- | --- | --- |
| 运动侦测(参考 Frigate 思路) | 零 | 中(光照/树叶会误触) | 通用兜底、无雷达时的过渡方案 |
| ONVIF 事件订阅 | 零 | 中 | 支持的型号,节省中心解码 |
| 毫米波雷达 | 硬件成本 | 高 | 家庭卧室/卫生间(**同时解决隐私**) |
| 常态全量 | 高 | 最高 | 仅高价值点位(校园危险区域、社区出入口) |
> **重要澄清**:Savant 这类框架主要解决多路调度、复用与故障隔离,不会自动降低单帧推理成本。触发式推理可以显著降低平均占空比,但实际容量必须按模型、分辨率、FPS、batch、解码方式和硬件实测。
### 架构预留:触发入口独立
```go
// 所有触发源汇入同一个入口,接新传感器时不改动流控制逻辑
type TriggerSource interface {
Name() string
Subscribe(ctx context.Context) (<-chan TriggerEvent, error)
}
type TriggerEvent struct {
DeviceID int64
SiteID int64
Kind string // motion | onvif_event | radar | door | button
At time.Time
PreRoll time.Duration // 需要回捞多久
Payload json.RawMessage
}
```
## 2.4 能力层:检测什么
### 能力清单与场景映射
| 能力 | 模型/方法 | 输出 | S1 | S2 | S3 |
| --- | --- | --- | :-: | :-: | :-: |
| 人体检测 + 跟踪 | **YOLO** + ByteTrack | bbox, track_id | ✅ | ✅ | ✅ |
| 姿态关键点 | **YOLO-pose** (COCO 17) | 17 点坐标 + 置信度 | ✅ | ✅ | ✅ |
| 跌倒/倒地 | 姿态 + 时序窗口 | 7 分类动作 | ✅ | ✅ | ✅ |
| 区域入侵 / 越线 | 几何判定(Zone) | 进入/离开/越线方向 | ○ | ✅ | ✅ |
| 停留 / 徘徊 | track 时长统计 | 停留秒数 | ✅ | ✅ | ✅ |
| 静止 / 无活动 | 位移统计 | 无位移时长 | ✅ | ○ | ✅ |
| 人群聚集 / 密度 | 检测框计数 + 空间聚类 | 人数、密度 | — | ✅ | ○ |
| 剧烈动作 / 打架 | 姿态时序 + 光流 | 分类置信度 | — | ✅ | ○ |
| 攀爬 / 翻越 | 姿态 + 高度变化 + Zone | 分类 | — | ✅ | ✅ |
| 非人目标 | 检测模型多类 | e_bike / obstacle | — | ○ | ✅ |
| **ReID 跨镜(匿名)** | ReID 模型 | `anon_id` | ○ | ✅ | ✅ |
| 身份比对 | 人脸底库(§2.8.3) | `identity{ref_id, group, score}` | 🔐 | 🔐 | 🔐 |
✅ 核心 ○ 可选 — 不需要 🔐 需租户授权后启用
> **能力选择原则**:「同一性」问题走 **ReID**,「是谁」问题才走**人脸**。徘徊、尾随、跟随、跨镜追踪一律用 ReID 实现;人脸只用于白名单放行、关注名单、访客核验这类必须知道身份的场景。
### 算法分层(模型只输出观测,不做主观判定)
| 层 | 内容 | 位置 |
| --- | --- | --- |
| 姿态/检测提取 | YOLO / YOLO-pose | 推理单元(TensorRT) |
| 单帧启发式 | 包围框宽高比、重心高度、躯干角度 | Python 插件 |
| 时序判定 | 30 帧滑窗,静态位置 + 动态速度特征 | Python 插件 |
| **规则判定** | **主体过滤 × 时空 × 条件组合** | **Go 规则引擎(不在插件里)** |
| 事件后处理 | 去重、防抖、多源融合 | Go 业务编排 |
> **这条边界是《02》C4 的落地**:插件输出「有个人、姿态是躺倒、在多边形 A 内停留 12 秒」这类**客观观测**;规则引擎决定「这是否需要报警」。阈值、时段、联系人**一律不进插件**。
### 参考实现与经验
| 项目 | 借鉴点 | 注意 |
| --- | --- | --- |
| `GajuuzZ/Human-Falling-Detect-Tracks` | ST-GCN 每 30 帧预测动作,**7 分类**(站立/行走/坐下/躺下/起身/坐下动作/跌倒) | **七分类而非二分类是降误报的关键** |
| `kelsonbatista/fall-detection-ai-project` | 逐帧抽 128 维特征(静态位置 + 动态速度),30 帧窗口喂 LSTM | ⚠️ GPL-3.0,只读思路不抄代码 |
| `Y-B-Class-Projects/Human-Fall-Detection` | **负面经验**:约 500 张网络图片训 LSTM 完全学不会,最终退回 if-else 规则 | **小数据量下时序模型学不动,先解决数据再上模型** |
| Frigate | 运动侦测过滤后才跑推理 | 无雷达时的触发式过渡方案 |
### 模型许可(已决策:继续用 YOLO)
**主路径:使用 Ultralytics YOLO + 购买 Enterprise License。**
| # | 必办事项 | 时限 |
| --- | --- | --- |
| 1 | 采购 Ultralytics Enterprise License | **M3 前完成** |
| 2 | 书面确认授权覆盖:私有化交付的每个客户实例、边缘盒子内的模型文件、基于官方权重二次训练的产出 | 采购时 |
| 3 | 人脸/ReID 模型的**权重许可**单独核对(常与代码许可不一致,部分权重仅限非商业用途) | 启用前 |
**逃生通道(授权未获批时启用)**:
| 环节 | 替代 | 迁移代价 |
| --- | --- | --- |
| 检测 | **YOLOX**(Megvii,Apache-2.0,仍属 YOLO 家族) | 低,接口相近 |
| 姿态 | **RTMPose / RTMO**(MMPose,Apache-2.0) | **极低,关键点定义同为 COCO 17 点,判定算法不用改** |
> 为让逃生通道随时可用,**推理封装必须与具体模型解耦**:定义统一的 `Detector` / `PoseEstimator` 接口,模型只作为可替换实现。这条约束成本很低,但决定了授权谈判失败时是"换个实现"还是"重写一遍"。
>
> 附带好处:RTMPose 通过 MMDeploy 支持 ONNXRuntime、TensorRT、ncnn、OpenVINO、**RKNN** 多后端。家庭场景的 RK3588 盒子若跑不动 YOLO,可在边缘侧单独换成 RTMPose 而不影响中心侧。
## 2.5 编排层:规则引擎
### 规则 DSL
```yaml
# 规则的完整形态。场景包提供模板,租户/站点/设备逐级覆盖。
rule:
id: R-SCH-003
code: danger_zone_intrusion
name: 危险区域非授权进入
scene_pack: campus@1.2
enabled: true
dry_run: false # 试运行:命中但不通知,仅记录
scope: # 时空:哪些设备、哪些时段
devices: [tag:lab_area] # 支持标签选择,不写死设备 ID
zones: [danger_zone_1] # 画面区域,仅设备级可定义
schedule:
calendar: school_term # 引用日历(学期/课表/节假日)
windows: ["!class_time"] # 非上课时段(! 为取反)
subject: # 主体:检测什么人/物
class: person
filters:
- attr: age_group
in: [child, teen]
- identity: # 身份类,identity 为 null 时按 unknown 处理
not_in_group: authorized_staff
on_unavailable: treat_as_unknown # ⚠️ 身份服务不可用时不得漏报
condition: # 条件:客观观测量的组合
all:
- type: zone_enter
zone: danger_zone_1
- type: dwell
gte: 3s
- type: not_accompanied_by
role: teacher
radius_px: 200
suppression: # 抑制
cooldown: 300s
aggregate_by: [site_id, zone]
respect_silence_window: true
evidence: # 证据规格
pre_roll: 10s
post_roll: 15s
snapshots: 3
include_observations: true # 结构化观测序列,供后续训练
action:
severity: high
event_kind: danger_zone_intrusion
notify:
contact_chain: campus_security # 引用站点的升级链
start_level: 0
```
### 三级覆盖语义
```
场景包默认 ──继承──> 租户 ──覆盖──> 站点 ──覆盖──> 设备
```
| 字段类型 | 覆盖语义 | 理由 |
| --- | --- | --- |
| 标量(阈值、时长、severity) | 就近覆盖 | 直觉一致 |
| 列表(联系人、通道、devices) | **整体替换,不合并** | 合并会意外扩大通知面 |
| 开关 enabled | 就近覆盖,下级可关闭上级规则 | 单点位豁免是常见需求 |
| Zone 坐标 | **只能设备级定义**,上级不可下发 | 像素坐标与具体机位绑定 |
> 覆盖语义必须在文档和 UI 上明示(显示"此值继承自站点/租户"),否则运维会被"为什么这条没生效"折磨死。
### 规则生命周期
| 能力 | 说明 |
| --- | --- |
| 热生效 | 规则变更不重启 pipeline(一次重启影响同机全部路数) |
| 版本化 | `rule_versions` 表,记录变更人与时间,事故复盘用 |
| **试运行(dry_run)** | 命中但不通知,仅落库。新规则上线前评估误报量 |
| 质量指标 | 每条规则的命中量、误报率可查询 |
| 灰度 | 按站点比例放量 |
> **试运行模式是被低估的需求。** 没有它,每条新规则上线都是拿真实用户做实验。
## 2.6 事件实例
### 定义
「命中规则的画面组成事件实例」的具体产物:
```json
{
"id": "evt_01J8X...",
"tenant_id": 1001,
"site_id": 20301,
"device_id": 5012,
"rule": { "id": "R-SCH-003", "version": 7, "code": "danger_zone_intrusion" },
"kind": "danger_zone_intrusion",
"severity": "high",
"confidence": 0.87,
"occurred_at": "2026-08-01T14:23:07.412Z",
"detected_at": "2026-08-01T14:23:08.905Z",
"subject": {
"class": "person",
"track_id": "trk_88213",
"attributes": { "age_group": "teen" },
"anon_id": "pid_7f3a91",
"identity": null,
"identity_status": "not_enabled"
},
"observation": {
"zone": "danger_zone_1",
"dwell_sec": 3.4,
"bbox_seq_uri": "s3://.../evt_01J8X/obs.jsonl",
"keypoint_seq_uri": "s3://.../evt_01J8X/pose.jsonl"
},
"evidence": {
"clip_uri": "s3://.../evt_01J8X/clip.mp4",
"clip_range": ["2026-08-01T14:22:57Z", "2026-08-01T14:23:22Z"],
"snapshot_uris": ["s3://.../s0.jpg", "s3://.../s1.jpg", "s3://.../s2.jpg"]
},
"dedup_key": "20301:danger_zone_1:danger_zone_intrusion:2026-08-01T14:20",
"aggregated_into": null,
"outcome": "unknown",
"outcome_reason": null
}
```
### 三条硬约束
| 约束 | 理由 |
| --- | --- |
| **事件不可变** | 误判只能通过 `outcome` 标记,不能删改。合规与举证要求 |
| **`occurred_at` 与 `detected_at` 都要存** | 前者是事发时刻(决定证据回捞窗口),后者是判定时刻(决定 SLA 计算) |
| **必须含结构化观测序列** | 只有视频无法直接用于训练;`bbox_seq` 与 `keypoint_seq` 是《02》C9 数据闭环的原料 |
| **`anon_id` 与 `identity` 分开存** | 前者是 ReID 匿名标识(会话内有效),后者是人脸身份;不可互相顶替 |
| **`identity_status` 必须显式** | 取 `not_enabled` / `pending` / `matched` / `below_threshold` / `no_candidate` / `timeout`。只写 `null` 无法区分"没开这功能"和"比对失败",后者是需要排查的故障 |
### 证据回捞(pre-roll)
事件是"事后才知道"的——判定成立时,事发画面已经过去了。三种回捞方式:
| 方式 | 实现 | 适用 |
| --- | --- | --- |
| 边缘环形缓冲 | 边缘节点持续录最近 N 秒到本地环 | 家庭(不上云,隐私最优) |
| 中心持续录制 | mediamtx `record: yes` + `recordDeleteAfter` | 校园/社区(允许常态录像) |
| Replay 服务回捞 | 推理框架的 Replay REST 接口 | 通用 |
> ⚠️ pre-roll 的实际起点受 **GOP 长度**限制——只能从关键帧切。这是《01》§6.1 把"支持修改 GOP 长度"列为加分项的原因。GOP 2s 时,请求 10s pre-roll 实际可能拿到 10–12s。
### 去重与聚合
| 规则 | 参数 | 场景 |
| --- | --- | --- |
| 同设备冷却期 | 默认 5min | 同一事件多次触发 |
| 同站点聚合 | `aggregate_by` 键相同则合并 | 两个机位拍到同一次跌倒 |
| 已处置抑制 | 已 ack 期间不再新建同类 | 人已到现场 |
| 静默窗口 | 用户主动静默 | **强制限时(≤4h),到期前提醒,无"永久"选项** |
> **永久静默是这类系统最常见的事故成因。**
## 2.7 预警投递
### 状态机
```
[触发] ─→ [投递中] ─→ [已送达] ─→ [已确认 ack] ─→ [处置中] ─→ [关闭]
│ │ │ │
│ │ └─超时未 ack─→ [升级] ─→ 下一级投递 │
│ └─投递失败─→ [升级] │
└─防抖命中─→ [抑制] [标记误报] ←───┘
```
**硬约束:告警先落库再投递。** 进程崩溃重启后扫描所有非终态告警并接续升级链——与对账器的水平触发是同一思路。
### 升级链配置(场景差异的主要载体)
```yaml
contact_chain:
id: home_elderly_default
levels:
- level: 0
timeout_sec: 30
contacts: [{ role: family_primary, channels: [push] }]
- level: 1
timeout_sec: 60
contacts: [{ role: family_primary, channels: [sms, voice] }]
- level: 2
timeout_sec: 90
contacts: [{ role: backup, channels: [sms, voice] }] # 邻居/物业/护理员
- level: 3
timeout_sec: null
contacts: [{ role: call_center, channels: [voice, ticket] }]
time_policy:
- window: "08:00-22:00"
override: { level0_role: family_primary }
- window: "22:00-08:00"
override: { level0_role: cohabitant } # 夜间找同住者
```
### 通道选型与冗余
**必须至少两条独立路径,且电话通道要能绕过互联网。** 推送依赖 APNs/FCM,短信依赖运营商,任何单一通道都可能整体故障。
| 层 | 选型 | 说明 |
| --- | --- | --- |
| 状态机、升级链、ack、审计 | **自研 Go** | 业务定制度高,通用工具都要大改 |
| 多通道投递 | Novu / Apprise / ntfy 或直连云厂商 | 只解决"怎么发" |
| 短信 + 语音 | 云厂商,**双供应商** | `provider` 字段就是为切换准备的 |
### 三个不同的事实
| 事实 | 含义 | 字段 |
| --- | --- | --- |
| 发出去了 | 调用了上游 API | `sent_at` |
| 送达了 | 上游回执确认到达终端 | `receipt_at` |
| 被看到了 | 用户主动 ack | `alerts.acked_at` |
> **没有回执就当失败并升级**,不做乐观假设。推送可能被系统杀掉、短信可能延迟、电话可能占线。
### 业务预警 vs 运维告警
| | 运维告警 | 业务预警 |
| --- | --- | --- |
| 方案 | Prometheus + Alertmanager | 自研状态机 + 复用投递通道 |
| 收件人 | 运维团队 | 家属 / 值班员 / 呼叫中心 |
| 延迟容忍 | 分钟级 | 秒级 |
> **两套系统不得共用投递通道和值班配置**,避免运维噪声淡化业务预警。
## 2.8 同一性与身份:ReID 与人脸识别
### 2.8.1 两条路径的分工
```mermaid
flowchart LR
TRK["跟踪目标 Track"] --> Q{"需要回答什么"}
Q -->|"是不是同一个人"| REID["ReID 匿名同一性<br/>默认开启"]
Q -->|"他是谁"| FACE["人脸比对<br/>租户授权后开启"]
REID --> AID["anon_id<br/>会话内有效"]
FACE --> IDT["identity{ref_id, group, score}"]
AID --> RULE["规则引擎"]
IDT --> RULE
```
| 能力 | 产出 | 生命周期 | 合规负担 | 典型用途 |
| --- | --- | --- | --- | --- |
| **ReID** | `anon_id` | 会话/时间窗内有效,**不跨天持久化为人员画像** | 中 | 徘徊、尾随、跟随、跨镜追踪、同一人多次出现计数 |
| **人脸** | `identity` | 持久(底库) | **高** | 白名单放行、关注名单、访客核验 |
> **默认走 ReID。** 一条规则若想用 `identity`,需在评审中回答"为什么 anon_id 不够"。
### 2.8.2 ReID 实现要点
| 要点 | 做法 |
| --- | --- |
| 特征时效 | `anon_id` 绑定滑动时间窗(默认 30min),超窗即失效重编号,避免变相形成长期追踪 |
| 跨镜匹配范围 | 限定在同一站点内,不跨租户、不跨站点 |
| 存储 | 特征向量仅驻内存/短期缓存,**不入长期库** |
| 事件中的表现 | 只写 `anon_id` 字符串,不写特征向量 |
> 第 1 条和第 3 条是有意的自我约束:ReID 之所以合规负担低于人脸,正是因为它不建立可长期关联到自然人的档案。若把 ReID 特征长期入库并跨天关联,它在实质上就变成了无同意的身份追踪——合规优势荡然无存。
### 2.8.3 人脸识别子系统
#### 架构:异步旁路,不在主链路上
```mermaid
flowchart TD
subgraph MAIN["主链路(实时,不可阻塞)"]
D["检测+跟踪"] --> P["姿态/行为"] --> R["规则引擎"] --> E["事件生成"]
end
subgraph SIDE["旁路(可延迟、可失败)"]
FD["人脸检测"] --> QC["质量筛选<br/>角度·清晰度·遮挡·尺寸"]
QC --> FE["特征提取"] --> M["底库比对<br/>向量检索"]
end
D -.抽取人脸 ROI.-> FD
M -.时间窗内回填.-> E
M --> LOG[("face_match_logs<br/>只追加审计")]
GATE{"租户授权开关"} -.控制.-> SIDE
```
**为什么必须旁路**:把比对放进主链路,一次底库慢查询会拖垮整条 pipeline 的实时性;而跌倒、打架这类人身安全事件根本不需要知道是谁。
| 参数 | 值 |
| --- | --- |
| 回填时间窗 | 默认 2s,超窗事件以 `identity: null` 定稿 |
| 质量筛选 | 人脸像素 ≥ 64×64、偏航角 ≤ 30°、遮挡比 < 30%、清晰度阈值 |
| 比对失败/超时 | 不阻塞事件生成,记 `decision = no_candidate / timeout` |
#### 授权是租户级主开关
```yaml
tenant_features:
tenant_id: 1001
feature: face_recognition
enabled: true
authorized_by: "客户法务签署 · 2026-08-01"
expires_at: "2027-08-01" # 授权有到期日,到期需续签
doc_uri: "s3://compliance/auth/1001-face.pdf"
scope:
sites: [20301] # 可进一步限定站点
purposes: [whitelist, visitor] # 未含 watchlist 则关注名单功能不可用
```
| 层级 | 语义 |
| --- | --- |
| 平台级 | 能力是否部署(私有化交付可整体不装) |
| **租户级** | 主开关。未授权租户在 **API 与 UI 层均不可见**,不是"看得到点不动" |
| 站点级 | 已授权租户内的生效范围(如只在校门口,不在教室) |
| 规则级 | 单条规则是否使用 `identity` 过滤 |
#### 底库管理
| 要求 | 实现 |
| --- | --- |
| 独立 schema,独立加密密钥 | 与业务库分离,泄露面隔离 |
| **默认只存特征向量,不存原图** | 需展示原图的场景(访客核验)单独授权,原图分库存储 |
| **人员必须有有效期** | `valid_until` 非空约束;学生按学年、住户按合同、访客按当日 |
| 到期自动失效 | 定时任务将过期人员移出检索集合,不依赖人工清理 |
| 三类库分开 | `whitelist` / `watchlist` / `visitor`,阈值与处置策略各不相同 |
| 整体导出与彻底删除 | 客户终止服务时的义务,需留删除证明 |
| 每次比对与每次查询均审计 | `face_match_logs` 只追加,含操作人/设备/分数/决策 |
#### 阈值分级(误识别后果不对称)
| 库类型 | 假阳性后果 | 阈值 | 附加控制 |
| --- | --- | --- | --- |
| `whitelist` 白名单放行 | 陌生人被当成住户放行 → 安防失效 | 高 | — |
| `watchlist` 关注名单 | **无辜者被当成关注对象 → 对个人的实质伤害** | **最高** | **强制人工复核** |
| `visitor` 访客核验 | 核验错误 | 中 | 人工在场 |
> **产品红线(L4 编排层硬约束)**:`watchlist` 类规则**不得自动触发对人的处置动作**(自动锁门、自动通报、自动广播),只能生成待人工确认的事件。这条写进代码,不靠流程约束。
#### 规则中的降级语义
```yaml
subject:
class: person
filters:
- identity:
library: campus_students
not_in_group: authorized_staff
min_score: 0.75
on_unavailable: treat_as_unknown # 默认;另一取值 skip
```
| 取值 | 语义 | 适用 |
| --- | --- | --- |
| **`treat_as_unknown`(默认)** | 比对不可用/未命中时按"未知人员"处理,**倾向报警** | 监护与安防场景 |
| `skip` | 比对不可用时不产生事件,**倾向不报** | 仅用于低风险规则,需显式写明并有审批记录 |
> 默认必须是 `treat_as_unknown`:人脸角度不佳、服务抖动、底库超时都会产生 null,若默认跳过,系统会在最需要报警的时候安静下来。
---
# 3. 场景包
## 3.1 场景包是什么
一个可版本化的配置集合,**不含代码**:
```
scene_packs/campus@1.2/
├── manifest.yaml # 名称、版本、依赖的能力与最低平台版本
├── rules/ # 规则模板(DSL)
│ ├── fight.yaml
│ ├── climb_fence.yaml
│ ├── danger_zone.yaml
│ └── ...
├── zones/ # 区域模板(相对坐标 + 引导文案,实施时按机位调)
├── chains/ # 升级链模板
├── calendars/ # 日历/时段模板(学期、课表、作息)
├── copy/ # 通知话术模板(多语言)
├── dashboards/ # Grafana 看板 JSON + 报表口径
└── onboarding/ # 实施checklist:机位建议、安装高度、俯角、光照要求
```
## 3.2 居家养老包(S1)
### 规则集
| 规则 | 条件 | 严重度 | 备注 |
| --- | --- | --- | --- |
| 跌倒 | 姿态 7 分类命中 fall,持续 ≥ 2s | critical | 核心 |
| 长时间静止 | 无位移 ≥ 30min(白天)/ 卫生间 ≥ 20min | high | 阈值按户可调 |
| 夜间离床未归 | 22:00–06:00 离开卧室 ≥ 20min 未返回 | high | 需雷达或门磁 |
| 陌生人入室 | 非住户人员出现 | medium | 优先 ReID;家属若要"认出保姆/子女"可授权开人脸白名单库 |
| 活动量异常 | 日活动量较基线下降 > 50% | low | 跨天统计,非实时 |
### 场景特有约束
| 约束 | 实现 |
| --- | --- |
2026-08-04 10:05:56 +08:00
| 卧室、卫生间**禁装摄像头** | `areas.capture_policy = non_imaging_only` 时,Sense 在设备新增/启用写路径拒绝具有 `captures_image` 能力的设备(代码级硬约束);策略读取失败时拒绝新变更并告警,不静默切断已有链路 |
| 平时不解码不上传 | 触发式推理 + `record: no` + 边缘环形缓冲 |
| 家属只能看自己家的告警关联片段 | RBAC:`family` 角色的证据访问范围 = `site_id` ∩ `alert_events` |
| 3 分钟内必有真人接手 | 4 级升级链(30s/60s/90s → 呼叫中心) |
### 时间线
| 时刻 | 发生什么 |
| --- | --- |
| T+0s | 事件发生,一级触发源捕获(雷达/运动) |
| T+3s | 唤醒推理,回捞前 30s 画面 |
| T+10s | 判定成立,事件落库,首次投递(App 推送) |
| T+40s | 未 ack → 短信 + 语音电话 |
| T+100s | 未 ack → 备用联系人(邻居/物业/护理员) |
| T+190s | 未 ack → 呼叫中心人工介入 |
### 部署形态
```mermaid
flowchart LR
subgraph HOME["用户家中"]
CAM["客厅 IP 摄像头"]
RADAR["毫米波雷达<br/>卧室 / 卫生间"]
BOX["边缘盒子 RK3588<br/>录像环缓冲 + 推流 + WG"]
CAM --> BOX
RADAR --> BOX
end
subgraph CLOUD["云端平台"]
MTX["mediamtx"] --> INF["推理"]
INF --> BIZ["业务编排"]
BIZ --> DB[("事件与审计库")]
end
BOX -->|"仅上传事件片段"| MTX
BIZ --> FAM["家属 App / 短信 / 语音"]
BIZ --> CC["呼叫中心"]
```
**一户一盒子,互不影响。** 断网时本地缓存,恢复后补传;断网本身触发运维告警。
## 3.3 校园包(S2)
### 规则集
| 规则 | 条件 | 严重度 | 时段 |
| --- | --- | --- | --- |
| 打架/推搡 | 剧烈动作分类 + 人数 ≥ 2 + 持续 ≥ 3s | critical | 全天 |
| 翻越围墙 | 攀爬姿态 + 周界 Zone + 高度变化 | critical | 全天 |
| 危险区域闯入 | Zone 进入 + 停留 ≥ 3s + 非授权角色 | high | 非开放时段 |
| 尾随进校 | 门禁事件 1 次 + 检测到人数 ≥ 2 | medium | 上下学时段 |
| 上课时段徘徊 | 楼道 Zone 停留 ≥ 5min | low | 课表内 |
| 宿舍晚归 | 22:30 后进入宿舍楼 | low | 夜间 |
| 学生跌倒/晕倒 | 复用 S1 跌倒能力 | high | 全天 |
| 人群聚集 | 单 Zone 人数 ≥ N 且持续 ≥ 30s | medium | 可配 |
### 场景特有
| 特点 | 方案 |
| --- | --- |
| **未成年人合规** | 人脸能力即使租户已授权,**校园场景仍需专项合规意见方可开启**(独立阻塞项);证据留存期限从严;查看权限按角色最小化;采集告示牌 |
| 危险区域的"授权人员"判定 | 优先用**角色标签 + 工牌/工服属性**;确需人脸时用 `whitelist` 库(教职工),学生不入库 |
| 尾随进校 | **ReID + 门禁事件融合**,不依赖人脸 |
| 课表/校历驱动 | `calendars/school_term.yaml` + 课表导入,规则用 `!class_time` 这类语义引用 |
| 值班室 7×24 有人 | 升级链只需 2 级(值班室 → 校领导),超时 2min/10min |
| 批量事件处理 | **需要看板与工单**(S1 不需要):列表、批量 ack、按区域筛选、当班交接 |
| 大屏联动 | Webhook 推到值班室大屏 + 声光 |
| 带宽充足 | 可中心部署,可用常态推理覆盖高价值点位 |
### 部署形态
校内边缘节点(Jetson Orin NX 或 x86+GPU)承担推理,事件与元数据上中心平台;视频不出校(若客户要求)。
## 3.4 社区/物业包(S3)
### 规则集
| 规则 | 条件 | 严重度 | 备注 |
| --- | --- | --- | --- |
| 周界入侵 | 越线(方向性)或 Zone 进入 | high | 夜间加权 |
| 电动车进电梯 | 检测 class = e_bike + 电梯 Zone | high | **非人目标** |
| 消防通道占用 | 静态变化检测 + 持续 ≥ 10min | medium | 需持续态表达 |
| 陌生人徘徊 | ReID 会话在单元门 Zone 停留 ≥ 5min | medium | **用 ReID 不用人脸** |
| 独居老人异常 | 连续 N 天未在出入口出现 | medium | 跨天统计 |
| 高空抛物 | — | — | ⚠️ 需专用仰拍机位与独立算法,**不在本包,建议独立立项** |
### 场景特有
| 特点 | 方案 |
| --- | --- |
| 持续态事件(消防通道堆物) | 事件带 `state: ongoing`,整改后关闭;**不是每帧生成新事件** |
| 夜间可能无人值班 | 升级链 3 级:值班 → 主管 → 业主群/物业总值班 |
| 非人目标检测 | 检测模型需含 e_bike / obstacle 类,与人体检测共用 backbone |
| 陌生人判定 | **优先 ReID(匿名)**,避免人脸底库的合规负担 |
## 3.5 场景包对比速查
| 维度 | 居家养老 | 校园 | 社区 |
| --- | --- | --- | --- |
| 单站点路数 | 1–3 | 默认 16,单站点 ≤128 | 默认 16,单站点 ≤128 |
| 部署 | 边缘盒子(必须) | 校内边缘/中心 | 混合 |
| 推理模式 | 触发式 | 混合(重点常态) | 触发式为主 |
| 常态上云录像 | ❌ 禁止 | ✅ | ✅ |
| 升级链级数 | 4 | 2 | 3 |
| 首级超时 | 30s | 2min | 1min |
| 接收端 | App(个人) | 值班大屏 + 工单 | App + 值班 |
| 同一性/身份 | ReID 为主;人脸可授权开白名单库 | ReID 为主;**人脸需专项合规意见**(未成年人) | ReID 为主;人脸可用于访客核验 |
| 误报容忍 | 极低 | 中 | 中 |
| 主要成本 | 硬件(一户一盒) | GPU | GPU + 存储 |
---
# 4. 部署形态
## 4.1 三种形态
| 形态 | 推理位置 | 适用 | 优点 | 代价 |
| --- | --- | --- | --- | --- |
| **边缘为主** | 站点内 | 家庭、隐私敏感、带宽受限 | 视频不出户,带宽省 | 硬件成本×站点数,运维难 |
| **中心为主** | 中心机房 | 校园、社区(有专网) | 算力集中利用率高,好运维 | 带宽压力,视频出站点 |
| **混合** | 一级边缘 + 二级中心 | 大多数实际项目 | 成本与隐私平衡 | 架构复杂度 |
**推荐默认:混合。** 边缘做触发与初筛(运动侦测、轻量检测),中心做重推理与二次确认。
## 4.2 容量与分片
容量规格分为 16 / 32 / 64 / 128 四档。默认交付 16 路,单站点本阶段上限 128 路;16 是默认配额,不是数据库或代码硬限制。
| 项 | 默认 16 路 | 128 路上限 | 设计要求 |
| --- | ---: | ---: | --- |
| 子码流 640×360 @ 512 kbps | ≈ 8 Mbps | ≈ 64 Mbps | 推理优先使用子码流 |
| 主码流按 4–6 Mbps | ≈ 64–96 Mbps | ≈ 512–768 Mbps | 录像与证据回捞预算;不默认重复保存客户全量录像 |
| 抽帧 5 fps | 80 帧/秒 | 640 帧/秒 | 解码与推理均需实测 |
| mediamtx | 1 个分片 | 建议 4 个 32 路分片起步 | 若需缩小故障域,可改为 8 个 16 路分片 |
| 推理 | 1 个 16 路逻辑分片 | 最多 8 个 16 路逻辑分片 | 多个逻辑分片可落在同一 Worker,实际比例由压测决定 |
推荐配置:
```yaml
capacity:
site_default_video_channels: 16
site_max_video_channels: 128
media_shard_max_streams: 32
inference_profile_max_sources: 16
```
`stream_bindings` 同时记录 `mtx_instance` 与 `inference_shard`。默认按 `site_id + device_id` 稳定分配;扩容时允许迁移,但同一设备任一时刻只能归属一个有效媒体分片和一个推理分片。
`sites.max_video_channels` 的唯一真相源在 Bell;Sense 在设备新增/启用写路径通过版本化内部 API 或只读投影执行配额校验,不跨 schema 直接写入。配额依赖暂时不可用时只拒绝新的容量变更,已有视频链路继续运行。
> **不要单实例硬扛 128 路**:扩容必须通过增加分片与 Worker 完成。单分片故障只允许影响该分片,不能拖垮整个站点。GPU 型号与数量不在架构文档写死,M1 用 5 路打通,M3 建立 16 路基线,M4/M5 再验证 64/128 路。
## 4.3 硬件选型
| 平台 | 用途 | 注意 |
| --- | --- | --- |
| RK3588 | 家庭边缘盒 | 6 TOPS NPU,无风扇低噪;模型需 MMDeploy 转 RKNN |
| Jetson Orin Nano/NX/AGX | 校园/社区边缘 | 与 Savant/DeepStream 原生契合,同一套代码 |
| x86 + L4 / A2 / Quadro | 中心推理 | ⚠️ **GeForce 卡同时硬件编码流数上限 3**,做可视化回推必须用专业卡 |
---
# 5. 可观测与运维
## 5.1 一个 Grafana 全收
mediamtx 与 Savant 都原生导出 Prometheus 指标,**前期不用写一行前端代码**就有运营看板。
| 类别 | 关键指标 |
| --- | --- |
| 接入 | 设备在线率、path 数、推流断连次数、探活失败率 |
| **对账** | `reconciler_unconverged_bindings{system}`(持续 > 0 即异常)、`reconciler_orphans_detected{type}`、`reconciler_safety_brake_triggered`(**应当直接呼人**)、`reconciler_loop_duration_seconds`(超时 = 该分片了) |
| 流水线 | 每路人体检出率、推理延迟 P50/P99、丢帧率、buffer adapter 条目数与大小 |
| 业务 | 事件数(按规则/站点/类型)、**误报率(按规则)**、预警投递成功率、ack 时长 P50/P95、升级到各级的比例 |
| SLA | 事件 → 首次投递延迟、投递 → 回执延迟、全链路无响应比例 |
> **把每路的检出率、事件候选数、推理延迟声明成自定义指标** → Grafana 上就是现成的运营看板。
## 5.2 追踪
OpenTelemetry → Jaeger,排查单帧处理延迟与端到端事件链路。
## 5.3 管理界面
生产组合(mediamtx + 自研服务)**没有现成的管理界面**,三个选择:
1. **前期不做完整 UI** —— REST API + 脚本撑过接入验证期;即使默认只有 16 路,也不应依赖逐路手工配置。正式管理端从第一版起按 128 路设计分页、虚拟列表、筛选与批量操作。
2. 临时用 go2rtc 的 1984 页面看流状态 —— ⚠️ **默认无密码,局域网内任何人可访问**,必须在配置里关闭或用防火墙隔离。
3. **正式做时长在自研服务上** —— 因为要展示的是「租户 → 站点 → 设备 → 流状态」的业务视图;第三方面板展示的是「path 列表」,不是一回事。
> 社区面板(mediamtx-dashboard、lgcshy/mediamtx-ui、nabaco/mediamtx-ui、bcanfield/mediamtx-connect)均为独立进程通过 API 对接,可按需临时使用。mediamtx 作者明确表示精力有限、优先保证核心稳定。
## 5.4 管理系统与 App 的功能边界
| 端 | 核心功能 |
| --- | --- |
| **管理系统(Web)** | 租户/站点/设备管理、批量开通、规则配置(含试运行)、Zone 画图、升级链配置、事件看板与筛选、事件详情(视频+观测回放)、处置与工单、误报标记、统计报表、审计查询、RBAC |
| **App(家属/值班员)** | 预警接收与 ack、事件详情与视频回看、一键呼叫、误报反馈、限时静默、设备状态、(家属)多老人切换 |
| **大屏/值班台** | 实时事件流、地图/平面图点位、声光联动、当班交接 |
| **Webhook/开放 API** | 对接客户既有平台、门禁、消防、工单系统 |
---
# 6. 演进路径与备选
## 6.1 里程碑
| 阶段 | 内容 | 出口标准 |
| --- | --- | --- |
| **M0** | 摄像头兼容性验证 + 需求定稿 | 可直接运行 MiBeeNvr 作为隔离实验室测试台;3–5 款型号跑通 GetProfiles/GetStreamUri/SetSystemDateAndTime,采购白名单归档;M0 代码不得作为生产基线 |
| **M1** | 接入骨架 + mediamtx | **MediaMTX 正式进入且不得由完整 MiBeeNvr 替代**;5 路自动建 path、探活、断线重建 |
| **M2** | 对账器 + 多租户 + 隧道 | 10 站点试点,至少一个站点完成 16 路开通/停用全流程,`unconverged = 0` 稳定 |
| **M3** | 推理 + 规则引擎 + 事件预警 | **默认 16 路**端到端稳定;跑通首个场景三类规则;拿到现场误报基线,数据闭环已收数据 |
| **M4** | 64 路分片 + 灰度 + 管理系统 | 64 路稳定运行;单分片故障不扩散;看板、批量操作与处置流可用 |
| **M5** | 128 路容量验收 + 第二、三场景包 + 触发式推理 + **人脸识别子系统** | **128 路横向扩展不改业务代码**;场景包为纯配置交付;人脸在一个已授权租户跑通(底库有效期、异步回填、降级、审计) |
| **M6** | 异构传感器 + 两级判定 | 误报率显著下降,视频常态采集下降 |
### 代码归属:三个系统
本方案的实现拆为三个可独立开发的系统,**完整职责与目录结构见《08-三系统职责划分》**:
| 系统 | 中文 | 层 | 语言 | 状态 | 首次交付 |
| --- | --- | --- | --- | --- | --- |
| **Sense** | 感知系统 | L1 接入 | Go | 有(设备台账) | M1 |
| **Brain** | 推理系统 | L2 流水线 + L3 算法 | Python / CUDA | **无状态** | M3 |
| **Bell** | 管理系统 | L4 业务 + L0 基座 | Go + 前端 | 有(事件、告警) | M3 → M4 |
三者之间只有一条契约——**事件实例 JSON**(§2.6,规范本体见 `contracts/event-v0.1.schema.json`)。Sense 向 Brain 供流与信号,Brain 产出事件,Bell 消费事件。这条边界让推理侧换模型、换框架(Savant → Pipeless、YOLO → YOLOX)时,另两个系统一行不改。
> mediamtx 是独立二进制,不属于任何一个系统。Sense 管它的配置与生命周期。
#### M1 骨架(约 200 行起步,全部是生产代码,**只动 Sense**)
```
Sense/
cmd/sense-api/main.go
internal/
onvif/ 连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
store/ 先 SQLite,schema 与生产 Postgres 保持一致
mtx/ oapi-codegen 生成的客户端 + 薄封装
reconcile/ 最小对账循环(幂等 + 退避)
probe/ 探活 goroutine
```
跑通之后就已经站在生产架构上了,后面是往上加(Postgres、多租户、隧道、分片),**不是推倒重来**。Brain 与 Bell 到 M3 才起步——那也是事件契约第一次被真实使用的时刻。
### 演示与验证阶段的使用红线
| 允许 | 禁止 |
| --- | --- |
| 实验室接自购测试摄像头做兼容性验证 | 接入真实住户/学校的摄像头 |
| M0 直接运行现成 NVR(如 MiBeeNvr)给干系人做效果演示 | 把它作为可持续演进的生产系统第一版上线;将匿名录像端点暴露到真实网络 |
| 按 §1.4 白名单阅读/移植 ONVIF、IP 自愈、健康检测和 UI 交互 | 复制媒体内核、SQLite 真相源、AI 或本地用户体系;作为长期依赖引入 go.mod |
> 理由:现成 NVR 的目标通常是单机一体化交付,而本项目是多租户控制面 + 可分片媒体面 + 独立推理面。许可证宽松不等于架构适配或已通过 128 路压测;生产采用范围必须由本项目自己的兼容性、容量、安全和故障隔离测试决定。
## 6.2 备选与逃生通道
| 主选 | 备选 | 触发切换的条件 |
| --- | --- | --- |
| Savant (DeepStream) | **Pipeless**(Rust,不绑 NVIDIA,支持 ONNX Runtime/OpenVINO/TensorRT,HTTP 动态增删流) | 硬件选型变更、规模缩小、需信创环境 |
| **YOLO + Ultralytics Enterprise License** | YOLOX(检测)+ RTMPose(姿态),均 Apache-2.0 | 授权采购未获批;或边缘 NPU 跑不动 YOLO |
| 人脸识别(授权租户) | 纯 ReID + 角色标签/工牌属性 | 合规意见未通过,或客户撤回授权 |
| 自研设备管理 | EdgeX Foundry 最小服务集 | 确定接入雷达/门磁/Modbus 等异构设备时 |
| 中心推理 | 边缘推理 | 隐私要求提升或带宽成本超预期 |
> **推理代码必须与框架解耦**——模型推理、时序判定、特征工程写成独立模块,框架只负责调度。这是切换成本能否控制的关键。
### EdgeX 最小服务集(若切换)
```
core-metadata 设备注册表
core-command 命令下发入口
device-onvif-camera ONVIF <-> REST
redis 消息总线
(consul 可用 -cp=none 省掉)
```
砍掉 core-data、app-service-*、security 全套。⚠️ discovery 在本场景基本不可用:netscan 对大量 /24 段是数万次探测,应使用预定义 deviceList 或 REST 批量录入,但保留 `EnableStatusCheck` / `CheckStatusInterval` 做探活。另注意 EdgeX 3.x 起 API 路径是 `/api/v3`,网上大量 2.x 文档写的是 `/api/v2`。
---
# 7. 落地检查清单
开工前逐项确认:
## 硬件与网络
- [ ] 3–5 款候选摄像头完成 §2.1 必过项实测,白名单归档
- [ ] 各场景站点的网络出口带宽、是否有公网 IP 已摸底
- [ ] 边缘硬件选型确定(RK3588 / Jetson / x86+GPU)
- [ ] 中心 GPU 型号确认(若需可视化回推,**必须专业卡**)
## 许可与合规
- [ ] **Ultralytics Enterprise License 已采购**,覆盖私有化实例 / 边缘模型 / 二次训练权重
- [ ] 人脸与 ReID 模型的**权重许可**已逐个核对(可能与代码许可不一致)
- [ ] 视频证据留存期限已由法务确认
- [ ] 各角色查看范围的法定边界已确认
- [ ] 被拍摄者知情同意的取得方式与留档流程已确定
## 人脸能力启用前(逐租户执行)
- [ ] 租户授权书已签署并录入 `tenant_features`(含用途、范围、**到期日**)
- [ ] 人脸信息的**单独同意**取得方式已确定(通常不能与一般隐私政策打包)
- [ ] 底库来源合法性由客户书面承诺
- [ ] 底库人员的知情/查询/删除权响应流程与时限已定
- [ ] **未成年人场景:专项合规意见已出**(S2 独立阻塞项)
- [ ] 人脸数据留存期限已单独配置(独立于视频证据)
- [ ] 终止服务时的底库彻底删除义务与证明方式已约定
## 架构
2026-08-04 10:05:56 +08:00
- [ ] `devices` 表而非 `cameras`,含 `modality + capabilities`;Area 持有 `capture_policy`
- [ ] 设备身份用序列号
- [ ] 触发入口独立成 handler
- [ ] `sync_state` 每系统一行
- [ ] 对账器安全闸已实现并有指标
- [ ] 隐私区域拒绝 camera 类型是**代码级**约束
- [ ] 所有查询强制带 tenant_id,在数据访问层统一拦截
- [ ] 推理封装与具体模型解耦(`Detector` / `PoseEstimator` 接口),逃生通道随时可用
- [ ] 人脸比对为**异步旁路**,超时不阻塞事件生成
- [ ] 依赖 identity 的规则默认 `on_unavailable: treat_as_unknown`
- [ ] `watchlist` 类规则**不得自动触发对人的处置动作**(代码级)
- [ ] 未授权租户在 API 与 UI 层均不可见人脸能力
- [ ] `persons.valid_until` 非空约束 + 到期自动失效任务
- [ ] 告警先落库再投递,重启可续跑升级链
- [ ] 静默功能无"永久"选项
## 数据闭环
- [ ] 事件证据含结构化观测序列(bbox_seq / keypoint_seq)
- [ ] 误报标记含归因分类
- [ ] 标注队列从 M3 第一天就在收数据
- [ ] 每条规则的命中量与误报率可查
---
> 本方案的所有技术决策均可追溯到《02-需求分析》的 C1–C9。若实施中发现某条决策不成立,应先回到《02》修改分析结论,再同步修订本文档——**不要在实现里悄悄绕过**。