feat(aiot): 告警三表结构升级 + 腾讯云COS对象存储集成
1. 新增三表结构: alarm_event(主表), alarm_event_ext(算法扩展), alarm_llm_analysis(大模型分析) 2. 新增 AlarmEventService 服务,支持 MQTT/HTTP 双路创建告警 3. MQTT handler 双写新旧表,平滑过渡 4. 重写 yudao_aiot_alarm 路由,对接新告警服务 5. 集成腾讯云 COS 对象存储:上传、预签名URL、STS临时凭证 6. 新增 storage 路由:upload/presign/upload-url/sts 四个接口 7. COS 未启用时自动降级本地 uploads/ 目录存储 8. 新增数据迁移脚本 migrate_to_alarm_event.py 9. 删除根目录 main.py(非项目入口) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
475
app/services/alarm_event_service.py
Normal file
475
app/services/alarm_event_service.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
告警事件服务(新三表结构)
|
||||
处理 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_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
|
||||
Reference in New Issue
Block a user