""" 告警事件服务(新三表结构) 处理 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": # 告警触发时 duration_ms 为 None,设置为一般级别 if duration_ms is None: return 2 # 一般级别(刚触发,持续时长未知) # 根据持续时长判断级别 if duration_ms > 30 * 60 * 1000: return 3 # 严重 elif 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) # 解析 first_frame_time(告警首次触发时间) first_frame_str = mqtt_data.get("first_frame_time") first_frame_time = None if first_frame_str: try: first_frame_time = datetime.fromisoformat(first_frame_str.replace("Z", "+00:00")) except ValueError: pass # 置信度保持 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 alarm_type = mqtt_data.get("alert_type", "unknown") # 告警创建时不传递 duration_ms,传递 None alarm_level = _determine_alarm_level(alarm_type, confidence or 0, None) 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, first_frame_time=first_frame_time, duration_ms=None, last_frame_time=None, 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") ext_data = data.get("ext_data") or {} if alarm_level is None: # 从 ext_data 取 duration_ms duration_ms = ext_data.get("duration_ms") alarm_level = _determine_alarm_level(alarm_type, confidence or 0, duration_ms) # 解析 first_frame_time(离岗开始时间,由 Edge 在 ext_data 中传递) first_frame_time = None first_frame_time_str = ext_data.get("first_frame_time") if first_frame_time_str: try: first_frame_time = datetime.fromisoformat(first_frame_time_str.replace("Z", "+00:00")) except ValueError: first_frame_time = None 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, first_frame_time=first_frame_time, duration_ms=ext_data.get("duration_ms"), alarm_level=alarm_level, confidence_score=confidence, alarm_status="NEW", handle_status="UNHANDLED", snapshot_url=data.get("snapshot_url"), edge_node_id=ext_data.get("edge_node_id"), ) 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 resolve_alarm(self, alarm_id: str, duration_ms: int, last_frame_time: str, resolve_type: str) -> bool: """更新告警的持续时长和结束时间""" db = get_session() try: alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first() if not alarm: return False alarm.duration_ms = duration_ms # 解析 last_frame_time,去除微秒保持格式一致 try: parsed_time = datetime.fromisoformat(last_frame_time.replace("Z", "+00:00")) alarm.last_frame_time = parsed_time.replace(microsecond=0) except Exception: alarm.last_frame_time = datetime.now(timezone.utc).replace(microsecond=0) # 如果是人员回岗,标记为自动关闭 if resolve_type == "person_returned": alarm.alarm_status = "CLOSED" alarm.handle_status = "DONE" alarm.handle_remark = "人员回岗自动关闭" alarm.handled_at = datetime.now(timezone.utc) elif resolve_type == "non_work_time": alarm.alarm_status = "CLOSED" alarm.handle_status = "DONE" alarm.handle_remark = "非工作时间自动关闭" alarm.handled_at = datetime.now(timezone.utc) alarm.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"告警已更新结束信息: {alarm_id}, duration={duration_ms}ms, type={resolve_type}") return True except Exception as e: db.rollback() logger.error(f"更新告警结束信息失败: {e}") return False 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