feat(server): 新增 MQTT 预警消息发布服务

This commit is contained in:
2026-06-12 13:57:41 +08:00
parent 40fd3089a7
commit 2fcaf57478
2 changed files with 515 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
"""预警发布器 (MVP-2 / D18)
将统一 ``AlertEvent`` 格式化为前端/外部系统可消费的 JSON 消息,
并通过 ``MQTTService`` 发布到对应主题。
主题命名规则::
{prefix}/{event_type}/{source_id} # 单流单类型
{prefix}/all # 全量订阅
{prefix}/{event_type} # 按事件类型订阅
消息格式 (与前端约定)::
{
"alert_id": "...",
"event_type": "fire",
"severity": "critical",
"confidence": 0.92,
"source_id": "cam-01",
"rule_name": "fire_critical",
"first_seen": 1781233417.12,
"last_seen": 1781233418.45,
"occurrence_count": 3,
"detections": [{...}],
"metadata": {...}
}
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, Iterable, List, Optional
from models.event_schemas import AlertEvent
from .mqtt_service import MQTTService
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# AlertPublisher
# ---------------------------------------------------------------------------
class AlertPublisher:
"""预警事件发布器。
Args:
mqtt_service: MQTT 服务实例
topic_prefix: 主题前缀
broadcast_all: 是否同时发布到 ``{prefix}/all`` 总订阅主题
qos: 发布 QoS 等级
retain: 是否保留最后一条预警 (用于新订阅者立即获取最近状态)
"""
def __init__(
self,
mqtt_service: MQTTService,
topic_prefix: str = "video/alerts",
broadcast_all: bool = True,
qos: int = 1,
retain: bool = False,
) -> None:
self._mqtt = mqtt_service
self.topic_prefix = topic_prefix.rstrip("/")
self.broadcast_all = broadcast_all
self.qos = qos
self.retain = retain
self._published_count = 0
self._failed_count = 0
# ------------------------------------------------------------------
# 发布
# ------------------------------------------------------------------
async def publish_alert(self, alert: AlertEvent) -> bool:
"""发布单条预警事件。
Returns:
True 表示至少有一条主题发布成功
"""
payload = self.format_alert(alert)
topics = self._build_topics(alert)
any_success = False
for topic in topics:
ok = await self._mqtt.publish(
topic, payload, qos=self.qos, retain=self.retain
)
if ok:
any_success = True
self._published_count += 1
logger.debug("AlertPublisher 已发布 topic=%s alert_id=%s", topic, alert.alert_id)
else:
self._failed_count += 1
logger.warning(
"AlertPublisher 发布失败 topic=%s alert_id=%s", topic, alert.alert_id
)
return any_success
async def publish_alerts(self, alerts: Iterable[AlertEvent]) -> int:
"""批量发布预警事件,返回成功条数。"""
success = 0
for alert in alerts:
if await self.publish_alert(alert):
success += 1
return success
# ------------------------------------------------------------------
# 格式化
# ------------------------------------------------------------------
@staticmethod
def format_alert(alert: AlertEvent) -> Dict[str, Any]:
"""将 AlertEvent 格式化为 dict (JSON 友好)。"""
return {
"alert_id": alert.alert_id,
"event_type": alert.event_type.value,
"severity": alert.severity.value,
"confidence": round(alert.confidence, 4),
"source_id": alert.source_id,
"rule_name": alert.rule_name,
"first_seen": alert.first_seen,
"last_seen": alert.last_seen,
"occurrence_count": alert.occurrence_count,
"detections": [
{
"detection_id": d.detection_id,
"track_id": d.track_id,
"class_name": d.class_name,
"label": d.label,
"confidence": round(d.confidence, 4),
"bbox": d.bbox.to_list(),
"source": d.source.value,
"model_id": d.model_id,
"timestamp": d.timestamp,
}
for d in alert.detections
],
"metadata": alert.metadata,
"published_at": time.time(),
}
# ------------------------------------------------------------------
# 主题构建
# ------------------------------------------------------------------
def _build_topics(self, alert: AlertEvent) -> List[str]:
topics: List[str] = []
event_type = alert.event_type.value
source_id = alert.source_id or "unknown"
# 细粒度主题
topics.append(f"{self.topic_prefix}/{event_type}/{source_id}")
# 事件类型订阅
topics.append(f"{self.topic_prefix}/{event_type}")
# 总广播
if self.broadcast_all:
topics.append(f"{self.topic_prefix}/all")
return topics
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
@property
def stats(self) -> Dict[str, Any]:
return {
"topic_prefix": self.topic_prefix,
"published_count": self._published_count,
"failed_count": self._failed_count,
"mqtt_connected": self._mqtt.is_connected,
}
__all__ = ["AlertPublisher"]