- 新增 edge/report 端点接收边缘端HTTP告警上报 - alarm_event_service 新增 create_from_edge_report 幂等创建 - schemas 新增 EdgeAlarmReport 模型 - 移除 config_service/redis_service/yudao_aiot_config 配置中转 - MQTT 服务标记废弃,告警上报改为HTTP+COS - config 新增 COS/Redis 配置项 - requirements 新增 redis 依赖 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
567 lines
19 KiB
Python
567 lines
19 KiB
Python
"""
|
||
告警事件服务(新三表结构)
|
||
处理 alarm_event / alarm_event_ext / alarm_llm_analysis 的 CRUD
|
||
"""
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Optional, List, Dict, Any, Tuple
|
||
|
||
from sqlalchemy import func
|
||
|
||
from app.models import AlarmEvent, AlarmEventExt, AlarmLlmAnalysis, get_session
|
||
from app.services.oss_storage import get_oss_storage
|
||
from app.utils.logger import logger
|
||
|
||
|
||
def generate_alarm_id() -> str:
|
||
"""生成告警ID: ALM + YYYYMMDDHHmmss + 8位uuid"""
|
||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||
unique_id = uuid.uuid4().hex[:8].upper()
|
||
return f"ALM{timestamp}{unique_id}"
|
||
|
||
|
||
def _determine_alarm_level(alarm_type: str, confidence: float, duration_ms: Optional[int] = None) -> int:
|
||
"""
|
||
根据告警类型、置信度和持续时长确定告警级别
|
||
返回: 1提醒 2一般 3严重 4紧急
|
||
"""
|
||
if alarm_type == "intrusion":
|
||
return 3 # 严重
|
||
elif alarm_type == "leave_post":
|
||
if duration_ms and duration_ms > 30 * 60 * 1000:
|
||
return 3 # 严重
|
||
elif duration_ms and duration_ms > 10 * 60 * 1000:
|
||
return 2 # 一般
|
||
return 1 # 提醒
|
||
elif confidence and confidence > 0.9:
|
||
return 3 # 严重
|
||
elif confidence and confidence > 0.7:
|
||
return 2 # 一般
|
||
|
||
return 2 # 默认一般
|
||
|
||
|
||
class AlarmEventService:
|
||
"""告警事件服务"""
|
||
|
||
def __init__(self):
|
||
self.oss = get_oss_storage()
|
||
|
||
def create_from_mqtt(self, mqtt_data: Dict[str, Any]) -> Optional[AlarmEvent]:
|
||
"""从 MQTT 消息创建告警事件"""
|
||
db = get_session()
|
||
try:
|
||
alarm_id = generate_alarm_id()
|
||
|
||
# 解析时间
|
||
timestamp_str = mqtt_data.get("timestamp")
|
||
if timestamp_str:
|
||
try:
|
||
event_time = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
event_time = datetime.now(timezone.utc)
|
||
else:
|
||
event_time = datetime.now(timezone.utc)
|
||
|
||
# 置信度保持 float 0-1
|
||
confidence = mqtt_data.get("confidence")
|
||
if confidence is not None:
|
||
confidence = float(confidence)
|
||
# 如果传入的是 0-100 范围的值,转为 0-1
|
||
if confidence > 1:
|
||
confidence = confidence / 100.0
|
||
|
||
# duration_minutes → duration_ms
|
||
duration_minutes = mqtt_data.get("duration_minutes")
|
||
duration_ms = None
|
||
if duration_minutes is not None:
|
||
duration_ms = int(float(duration_minutes) * 60 * 1000)
|
||
|
||
alarm_type = mqtt_data.get("alert_type", "unknown")
|
||
alarm_level = _determine_alarm_level(alarm_type, confidence or 0, duration_ms)
|
||
|
||
alarm = AlarmEvent(
|
||
alarm_id=alarm_id,
|
||
alarm_type=alarm_type,
|
||
algorithm_code=mqtt_data.get("algorithm", "YOLO"),
|
||
device_id=mqtt_data.get("camera_id", "unknown"),
|
||
scene_id=mqtt_data.get("roi_id"),
|
||
event_time=event_time,
|
||
duration_ms=duration_ms,
|
||
alarm_level=alarm_level,
|
||
confidence_score=confidence,
|
||
alarm_status="NEW",
|
||
handle_status="UNHANDLED",
|
||
edge_node_id=mqtt_data.get("device_id"),
|
||
)
|
||
|
||
db.add(alarm)
|
||
|
||
# 写入扩展表
|
||
ext_data = {}
|
||
for key in ("bind_id", "target_class", "bbox", "message", "alert_id"):
|
||
val = mqtt_data.get(key)
|
||
if val is not None:
|
||
ext_key = "edge_alert_id" if key == "alert_id" else key
|
||
ext_data[ext_key] = val
|
||
|
||
if ext_data:
|
||
ext = AlarmEventExt(
|
||
alarm_id=alarm_id,
|
||
ext_type="EDGE",
|
||
ext_data=ext_data,
|
||
)
|
||
db.add(ext)
|
||
|
||
db.commit()
|
||
db.refresh(alarm)
|
||
|
||
logger.info(f"新告警事件创建: {alarm_id}, type={alarm_type}")
|
||
return alarm
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"创建告警事件失败: {e}")
|
||
return None
|
||
finally:
|
||
db.close()
|
||
|
||
def create_from_edge_report(self, data: Dict[str, Any]) -> Optional[AlarmEvent]:
|
||
"""
|
||
从边缘端 HTTP 上报创建告警事件
|
||
|
||
边缘端通过 POST /admin-api/aiot/alarm/edge/report 上报告警。
|
||
使用边缘端生成的 alarm_id,支持幂等(重复 alarm_id 跳过)。
|
||
|
||
Args:
|
||
data: 边缘端上报数据,字段与 alarm_event 表对齐
|
||
|
||
Returns:
|
||
AlarmEvent 或 None
|
||
"""
|
||
db = get_session()
|
||
try:
|
||
alarm_id = data.get("alarm_id")
|
||
if not alarm_id:
|
||
logger.error("边缘上报缺少 alarm_id")
|
||
return None
|
||
|
||
# 幂等校验:alarm_id 已存在则跳过
|
||
existing = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||
if existing:
|
||
logger.info(f"告警已存在,跳过: {alarm_id}")
|
||
return existing
|
||
|
||
# 解析时间
|
||
event_time_str = data.get("event_time")
|
||
if event_time_str:
|
||
try:
|
||
event_time = datetime.fromisoformat(event_time_str.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
event_time = datetime.now(timezone.utc)
|
||
else:
|
||
event_time = datetime.now(timezone.utc)
|
||
|
||
# 置信度
|
||
confidence = data.get("confidence_score")
|
||
if confidence is not None:
|
||
confidence = float(confidence)
|
||
if confidence > 1:
|
||
confidence = confidence / 100.0
|
||
|
||
alarm_type = data.get("alarm_type", "unknown")
|
||
alarm_level = data.get("alarm_level")
|
||
if alarm_level is None:
|
||
# 从 ext_data 取 duration_ms
|
||
ext_data = data.get("ext_data") or {}
|
||
duration_ms = ext_data.get("duration_ms")
|
||
alarm_level = _determine_alarm_level(alarm_type, confidence or 0, duration_ms)
|
||
|
||
alarm = AlarmEvent(
|
||
alarm_id=alarm_id,
|
||
alarm_type=alarm_type,
|
||
algorithm_code=data.get("algorithm_code"),
|
||
device_id=data.get("device_id", "unknown"),
|
||
scene_id=data.get("scene_id"),
|
||
event_time=event_time,
|
||
alarm_level=alarm_level,
|
||
confidence_score=confidence,
|
||
alarm_status="NEW",
|
||
handle_status="UNHANDLED",
|
||
snapshot_url=data.get("snapshot_url"), # COS object_key
|
||
edge_node_id=data.get("ext_data", {}).get("edge_node_id") if data.get("ext_data") else None,
|
||
)
|
||
|
||
db.add(alarm)
|
||
|
||
# 写入扩展表
|
||
ext_data = data.get("ext_data")
|
||
if ext_data:
|
||
ext = AlarmEventExt(
|
||
alarm_id=alarm_id,
|
||
ext_type="EDGE_HTTP",
|
||
ext_data=ext_data,
|
||
)
|
||
db.add(ext)
|
||
|
||
db.commit()
|
||
db.refresh(alarm)
|
||
|
||
logger.info(f"边缘端告警创建成功: {alarm_id}, type={alarm_type}, device={data.get('device_id')}")
|
||
return alarm
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"创建边缘端告警失败: {e}")
|
||
return None
|
||
finally:
|
||
db.close()
|
||
|
||
def create_from_http(self, data: Dict[str, Any], snapshot_data: Optional[bytes] = None) -> Optional[AlarmEvent]:
|
||
"""从 HTTP 请求创建告警事件"""
|
||
db = get_session()
|
||
try:
|
||
alarm_id = generate_alarm_id()
|
||
|
||
# 解析时间
|
||
trigger_time = data.get("trigger_time") or data.get("timestamp")
|
||
if isinstance(trigger_time, str):
|
||
try:
|
||
event_time = datetime.fromisoformat(trigger_time.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
event_time = datetime.now(timezone.utc)
|
||
elif isinstance(trigger_time, datetime):
|
||
event_time = trigger_time
|
||
else:
|
||
event_time = datetime.now(timezone.utc)
|
||
|
||
confidence = data.get("confidence")
|
||
if confidence is not None:
|
||
confidence = float(confidence)
|
||
if confidence > 1:
|
||
confidence = confidence / 100.0
|
||
|
||
duration_minutes = data.get("duration_minutes")
|
||
duration_ms = None
|
||
if duration_minutes is not None:
|
||
duration_ms = int(float(duration_minutes) * 60 * 1000)
|
||
|
||
alarm_type = data.get("alert_type", "unknown")
|
||
alarm_level = _determine_alarm_level(alarm_type, confidence or 0, duration_ms)
|
||
|
||
snapshot_url = None
|
||
if snapshot_data:
|
||
snapshot_url = self.oss.upload_image(snapshot_data)
|
||
|
||
alarm = AlarmEvent(
|
||
alarm_id=alarm_id,
|
||
alarm_type=alarm_type,
|
||
algorithm_code=data.get("algorithm"),
|
||
device_id=data.get("camera_id", "unknown"),
|
||
scene_id=data.get("roi_id"),
|
||
event_time=event_time,
|
||
duration_ms=duration_ms,
|
||
alarm_level=alarm_level,
|
||
confidence_score=confidence,
|
||
alarm_status="NEW",
|
||
handle_status="UNHANDLED",
|
||
snapshot_url=snapshot_url,
|
||
edge_node_id=data.get("device_id"),
|
||
)
|
||
|
||
db.add(alarm)
|
||
|
||
# 写入扩展表
|
||
ext_data = {}
|
||
for key in ("bind_id", "target_class", "bbox", "message"):
|
||
val = data.get(key)
|
||
if val is not None:
|
||
ext_data[key] = val
|
||
|
||
if ext_data:
|
||
ext = AlarmEventExt(
|
||
alarm_id=alarm_id,
|
||
ext_type="POST",
|
||
ext_data=ext_data,
|
||
)
|
||
db.add(ext)
|
||
|
||
db.commit()
|
||
db.refresh(alarm)
|
||
|
||
logger.info(f"HTTP告警事件创建: {alarm_id}")
|
||
return alarm
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"HTTP创建告警事件失败: {e}")
|
||
return None
|
||
finally:
|
||
db.close()
|
||
|
||
def get_alarm(self, alarm_id: str) -> Optional[Dict]:
|
||
"""获取告警详情(含扩展信息)"""
|
||
db = get_session()
|
||
try:
|
||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||
if not alarm:
|
||
return None
|
||
|
||
result = alarm.to_dict()
|
||
|
||
# 关联扩展
|
||
ext = db.query(AlarmEventExt).filter(AlarmEventExt.alarm_id == alarm_id).first()
|
||
if ext:
|
||
result["ext"] = ext.to_dict()
|
||
|
||
# 关联 LLM 分析
|
||
analyses = db.query(AlarmLlmAnalysis).filter(
|
||
AlarmLlmAnalysis.alarm_id == alarm_id
|
||
).order_by(AlarmLlmAnalysis.created_at.desc()).all()
|
||
if analyses:
|
||
result["llm_analyses"] = [a.to_dict() for a in analyses]
|
||
|
||
return result
|
||
finally:
|
||
db.close()
|
||
|
||
def get_alarms(
|
||
self,
|
||
device_id: Optional[str] = None,
|
||
alarm_type: Optional[str] = None,
|
||
alarm_status: Optional[str] = None,
|
||
alarm_level: Optional[int] = None,
|
||
start_time: Optional[datetime] = None,
|
||
end_time: Optional[datetime] = None,
|
||
edge_node_id: Optional[str] = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> Tuple[List[AlarmEvent], int]:
|
||
"""分页查询告警列表"""
|
||
db = get_session()
|
||
try:
|
||
query = db.query(AlarmEvent)
|
||
|
||
if device_id:
|
||
query = query.filter(AlarmEvent.device_id == device_id)
|
||
if alarm_type:
|
||
query = query.filter(AlarmEvent.alarm_type == alarm_type)
|
||
if alarm_status:
|
||
query = query.filter(AlarmEvent.alarm_status == alarm_status)
|
||
if alarm_level is not None:
|
||
query = query.filter(AlarmEvent.alarm_level == alarm_level)
|
||
if edge_node_id:
|
||
query = query.filter(AlarmEvent.edge_node_id == edge_node_id)
|
||
if start_time:
|
||
query = query.filter(AlarmEvent.event_time >= start_time)
|
||
if end_time:
|
||
query = query.filter(AlarmEvent.event_time <= end_time)
|
||
|
||
total = query.count()
|
||
alarms = (
|
||
query.order_by(AlarmEvent.event_time.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
|
||
return alarms, total
|
||
finally:
|
||
db.close()
|
||
|
||
def handle_alarm(
|
||
self,
|
||
alarm_id: str,
|
||
alarm_status: Optional[str] = None,
|
||
handle_status: Optional[str] = None,
|
||
remark: Optional[str] = None,
|
||
handler: Optional[str] = None,
|
||
) -> Optional[AlarmEvent]:
|
||
"""处理告警"""
|
||
db = get_session()
|
||
try:
|
||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||
if not alarm:
|
||
return None
|
||
|
||
if alarm_status:
|
||
alarm.alarm_status = alarm_status
|
||
if handle_status:
|
||
alarm.handle_status = handle_status
|
||
if remark is not None:
|
||
alarm.handle_remark = remark
|
||
if handler:
|
||
alarm.handler = handler
|
||
alarm.handled_at = datetime.now(timezone.utc)
|
||
alarm.updated_at = datetime.now(timezone.utc)
|
||
|
||
db.commit()
|
||
db.refresh(alarm)
|
||
|
||
logger.info(f"告警已处理: {alarm_id}, status={alarm_status}")
|
||
return alarm
|
||
finally:
|
||
db.close()
|
||
|
||
def delete_alarm(self, alarm_id: str) -> bool:
|
||
"""删除告警(含扩展和分析)"""
|
||
db = get_session()
|
||
try:
|
||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||
if not alarm:
|
||
return False
|
||
|
||
# 删除关联数据
|
||
db.query(AlarmEventExt).filter(AlarmEventExt.alarm_id == alarm_id).delete()
|
||
db.query(AlarmLlmAnalysis).filter(AlarmLlmAnalysis.alarm_id == alarm_id).delete()
|
||
db.delete(alarm)
|
||
db.commit()
|
||
|
||
logger.info(f"告警已删除: {alarm_id}")
|
||
return True
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"删除告警失败: {e}")
|
||
return False
|
||
finally:
|
||
db.close()
|
||
|
||
def get_statistics(self) -> Dict:
|
||
"""获取告警统计"""
|
||
db = get_session()
|
||
try:
|
||
total = db.query(AlarmEvent).count()
|
||
|
||
# 按 alarm_status 计数
|
||
by_status = {}
|
||
for row in db.query(
|
||
AlarmEvent.alarm_status, func.count(AlarmEvent.alarm_id)
|
||
).group_by(AlarmEvent.alarm_status).all():
|
||
by_status[row[0]] = row[1]
|
||
|
||
# 按 alarm_type 计数
|
||
by_type = {}
|
||
for row in db.query(
|
||
AlarmEvent.alarm_type, func.count(AlarmEvent.alarm_id)
|
||
).group_by(AlarmEvent.alarm_type).all():
|
||
by_type[row[0]] = row[1]
|
||
|
||
# 按 alarm_level 计数
|
||
by_level = {}
|
||
for row in db.query(
|
||
AlarmEvent.alarm_level, func.count(AlarmEvent.alarm_id)
|
||
).group_by(AlarmEvent.alarm_level).all():
|
||
by_level[row[0]] = row[1]
|
||
|
||
return {
|
||
"total": total,
|
||
"byStatus": by_status,
|
||
"byType": by_type,
|
||
"byLevel": by_level,
|
||
}
|
||
finally:
|
||
db.close()
|
||
|
||
def get_device_summary(
|
||
self,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
) -> Dict:
|
||
"""按设备(摄像头)分组统计告警汇总"""
|
||
db = get_session()
|
||
try:
|
||
query = db.query(
|
||
AlarmEvent.device_id,
|
||
func.count(AlarmEvent.alarm_id).label("total_count"),
|
||
func.max(AlarmEvent.event_time).label("last_event_time"),
|
||
).group_by(AlarmEvent.device_id)
|
||
|
||
total = query.count()
|
||
|
||
results = (
|
||
query.order_by(func.count(AlarmEvent.alarm_id).desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
|
||
summary_list = []
|
||
for row in results:
|
||
# 该设备待处理数量
|
||
unhandled_count = (
|
||
db.query(AlarmEvent)
|
||
.filter(AlarmEvent.device_id == row.device_id)
|
||
.filter(AlarmEvent.handle_status == "UNHANDLED")
|
||
.count()
|
||
)
|
||
|
||
# 最新一条告警
|
||
latest = (
|
||
db.query(AlarmEvent)
|
||
.filter(AlarmEvent.device_id == row.device_id)
|
||
.order_by(AlarmEvent.event_time.desc())
|
||
.first()
|
||
)
|
||
|
||
summary_list.append({
|
||
"deviceId": row.device_id,
|
||
"deviceName": row.device_id,
|
||
"totalCount": row.total_count,
|
||
"unhandledCount": unhandled_count,
|
||
"lastEventTime": row.last_event_time.isoformat() if row.last_event_time else None,
|
||
"lastAlarmType": latest.alarm_type if latest else None,
|
||
"lastAlarmTypeName": latest.alarm_type if latest else None,
|
||
})
|
||
|
||
return {
|
||
"list": summary_list,
|
||
"total": total,
|
||
}
|
||
finally:
|
||
db.close()
|
||
|
||
def save_llm_analysis(
|
||
self,
|
||
alarm_id: str,
|
||
llm_model: str,
|
||
analysis_type: str,
|
||
summary: Optional[str] = None,
|
||
is_false_alarm: Optional[bool] = None,
|
||
risk_score: Optional[int] = None,
|
||
confidence_score: Optional[float] = None,
|
||
suggestion: Optional[str] = None,
|
||
) -> Optional[AlarmLlmAnalysis]:
|
||
"""保存大模型分析结果"""
|
||
db = get_session()
|
||
try:
|
||
analysis = AlarmLlmAnalysis(
|
||
alarm_id=alarm_id,
|
||
llm_model=llm_model,
|
||
analysis_type=analysis_type,
|
||
summary=summary,
|
||
is_false_alarm=is_false_alarm,
|
||
risk_score=risk_score,
|
||
confidence_score=confidence_score,
|
||
suggestion=suggestion,
|
||
)
|
||
db.add(analysis)
|
||
db.commit()
|
||
db.refresh(analysis)
|
||
logger.info(f"LLM分析结果已保存: alarm={alarm_id}, model={llm_model}")
|
||
return analysis
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"保存LLM分析失败: {e}")
|
||
return None
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
# 全局单例
|
||
alarm_event_service = AlarmEventService()
|
||
|
||
|
||
def get_alarm_event_service() -> AlarmEventService:
|
||
"""获取告警事件服务单例"""
|
||
return alarm_event_service
|