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:
2026-02-09 17:47:35 +08:00
parent b4fa6901f3
commit 6cf1524013
12 changed files with 1377 additions and 222 deletions

View File

@@ -1,5 +1,5 @@
"""
AIoT 告警路由 - 芋道规范
AIoT 告警路由 - 芋道规范(新三表结构)
统一到 /admin-api/aiot/alarm 命名空间,与 aiot 平台架构对齐。
@@ -9,7 +9,7 @@ API 路径规范:
- /admin-api/aiot/alarm/alert/handle - 处理告警
- /admin-api/aiot/alarm/alert/delete - 删除告警
- /admin-api/aiot/alarm/alert/statistics - 获取统计
- /admin-api/aiot/alarm/camera-summary/page - 摄像头汇总
- /admin-api/aiot/alarm/device-summary/page - 设备告警汇总
"""
from fastapi import APIRouter, Query, Depends, HTTPException
@@ -17,75 +17,83 @@ from typing import Optional
from datetime import datetime
from app.yudao_compat import YudaoResponse, get_current_user
from app.services.alert_service import get_alert_service, AlertService
from app.schemas import AlertHandleRequest
from app.services.alarm_event_service import get_alarm_event_service, AlarmEventService
from app.services.oss_storage import get_oss_storage
router = APIRouter(prefix="/admin-api/aiot/alarm", tags=["AIoT-告警"])
def _alarm_to_camel(alarm_dict: dict) -> dict:
"""将 alarm_event 字典转换为前端 camelCase 格式"""
# snapshot_url: 如果是 COS object_key转为预签名 URL
storage = get_oss_storage()
snapshot_url = alarm_dict.get("snapshot_url")
if snapshot_url:
snapshot_url = storage.get_url(snapshot_url)
return {
"alarmId": alarm_dict.get("alarm_id"),
"alarmType": alarm_dict.get("alarm_type"),
"alarmTypeName": _get_alarm_type_name(alarm_dict.get("alarm_type")),
"algorithmCode": alarm_dict.get("algorithm_code"),
"deviceId": alarm_dict.get("device_id"),
"deviceName": alarm_dict.get("device_id"),
"sceneId": alarm_dict.get("scene_id"),
"eventTime": alarm_dict.get("event_time"),
"firstFrameTime": alarm_dict.get("first_frame_time"),
"lastFrameTime": alarm_dict.get("last_frame_time"),
"durationMs": alarm_dict.get("duration_ms"),
"alarmLevel": alarm_dict.get("alarm_level"),
"confidenceScore": alarm_dict.get("confidence_score"),
"alarmStatus": alarm_dict.get("alarm_status"),
"handleStatus": alarm_dict.get("handle_status"),
"snapshotUrl": snapshot_url,
"videoUrl": alarm_dict.get("video_url"),
"edgeNodeId": alarm_dict.get("edge_node_id"),
"handler": alarm_dict.get("handler"),
"handleRemark": alarm_dict.get("handle_remark"),
"handledAt": alarm_dict.get("handled_at"),
"createdAt": alarm_dict.get("created_at"),
"updatedAt": alarm_dict.get("updated_at"),
# 扩展数据(详情时可能包含)
"ext": alarm_dict.get("ext"),
"llmAnalyses": alarm_dict.get("llm_analyses"),
}
# ==================== 告警管理 ====================
@router.get("/alert/page")
async def get_alert_page(
pageNo: int = Query(1, ge=1, description="页码"),
pageSize: int = Query(20, ge=1, le=100, description="每页大小"),
cameraId: Optional[str] = Query(None, description="摄像头ID"),
deviceId: Optional[str] = Query(None, description="设备ID"),
alertType: Optional[str] = Query(None, description="告警类型"),
status: Optional[str] = Query(None, description="状态: pending/confirmed/ignored/resolved/dispatched"),
level: Optional[str] = Query(None, description="级别: low/medium/high/critical"),
deviceId: Optional[str] = Query(None, description="摄像头/设备ID"),
edgeNodeId: Optional[str] = Query(None, description="边缘节点ID"),
alarmType: Optional[str] = Query(None, description="告警类型"),
alarmStatus: Optional[str] = Query(None, description="告警状态: NEW/CONFIRMED/FALSE/CLOSED"),
alarmLevel: Optional[int] = Query(None, description="告警级别: 1提醒/2一般/3严重/4紧急"),
startTime: Optional[datetime] = Query(None, description="开始时间"),
endTime: Optional[datetime] = Query(None, description="结束时间"),
service: AlertService = Depends(get_alert_service),
service: AlarmEventService = Depends(get_alarm_event_service),
current_user: dict = Depends(get_current_user)
):
"""分页查询告警列表"""
alerts, total = service.get_alerts(
camera_id=cameraId,
alarms, total = service.get_alarms(
device_id=deviceId,
alert_type=alertType,
status=status,
level=level,
alarm_type=alarmType,
alarm_status=alarmStatus,
alarm_level=alarmLevel,
edge_node_id=edgeNodeId,
start_time=startTime,
end_time=endTime,
page=pageNo,
page_size=pageSize,
)
alert_list = []
for alert in alerts:
alert_dict = alert.to_dict()
alert_list.append({
"id": alert_dict.get("id"),
"alertNo": alert_dict.get("alert_no"),
"cameraId": alert_dict.get("camera_id"),
"cameraName": alert_dict.get("camera_id"),
"roiId": alert_dict.get("roi_id"),
"bindId": alert_dict.get("bind_id"),
"deviceId": alert_dict.get("device_id"),
"alertType": alert_dict.get("alert_type"),
"alertTypeName": _get_alert_type_name(alert_dict.get("alert_type")),
"algorithm": alert_dict.get("algorithm"),
"confidence": alert_dict.get("confidence"),
"durationMinutes": alert_dict.get("duration_minutes"),
"triggerTime": alert_dict.get("trigger_time"),
"message": alert_dict.get("message"),
"bbox": alert_dict.get("bbox"),
"snapshotUrl": alert_dict.get("snapshot_url"),
"ossUrl": alert_dict.get("oss_url"),
"status": alert_dict.get("status"),
"level": alert_dict.get("level"),
"handleRemark": alert_dict.get("handle_remark"),
"handledBy": alert_dict.get("handled_by"),
"handledAt": alert_dict.get("handled_at"),
"workOrderId": alert_dict.get("work_order_id"),
"aiAnalysis": alert_dict.get("ai_analysis"),
"createdAt": alert_dict.get("created_at"),
"updatedAt": alert_dict.get("updated_at"),
})
alarm_list = [_alarm_to_camel(a.to_dict()) for a in alarms]
return YudaoResponse.page(
list_data=alert_list,
list_data=alarm_list,
total=total,
page_no=pageNo,
page_size=pageSize
@@ -94,60 +102,38 @@ async def get_alert_page(
@router.get("/alert/get")
async def get_alert(
id: int = Query(..., description="告警ID"),
service: AlertService = Depends(get_alert_service),
alarmId: str = Query(..., description="告警ID"),
service: AlarmEventService = Depends(get_alarm_event_service),
current_user: dict = Depends(get_current_user)
):
"""获取告警详情"""
alert = service.get_alert(id)
if not alert:
alarm_dict = service.get_alarm(alarmId)
if not alarm_dict:
raise HTTPException(status_code=404, detail="告警不存在")
alert_dict = alert.to_dict()
return YudaoResponse.success({
"id": alert_dict.get("id"),
"alertNo": alert_dict.get("alert_no"),
"cameraId": alert_dict.get("camera_id"),
"cameraName": alert_dict.get("camera_id"),
"roiId": alert_dict.get("roi_id"),
"bindId": alert_dict.get("bind_id"),
"deviceId": alert_dict.get("device_id"),
"alertType": alert_dict.get("alert_type"),
"alertTypeName": _get_alert_type_name(alert_dict.get("alert_type")),
"algorithm": alert_dict.get("algorithm"),
"confidence": alert_dict.get("confidence"),
"durationMinutes": alert_dict.get("duration_minutes"),
"triggerTime": alert_dict.get("trigger_time"),
"message": alert_dict.get("message"),
"bbox": alert_dict.get("bbox"),
"snapshotUrl": alert_dict.get("snapshot_url"),
"ossUrl": alert_dict.get("oss_url"),
"status": alert_dict.get("status"),
"level": alert_dict.get("level"),
"handleRemark": alert_dict.get("handle_remark"),
"handledBy": alert_dict.get("handled_by"),
"handledAt": alert_dict.get("handled_at"),
"workOrderId": alert_dict.get("work_order_id"),
"aiAnalysis": alert_dict.get("ai_analysis"),
"createdAt": alert_dict.get("created_at"),
"updatedAt": alert_dict.get("updated_at"),
})
return YudaoResponse.success(_alarm_to_camel(alarm_dict))
@router.put("/alert/handle")
async def handle_alert(
id: int = Query(..., description="告警ID"),
status: str = Query(..., description="处理状态: confirmed/ignored/resolved"),
alarmId: str = Query(..., description="告警ID"),
alarmStatus: Optional[str] = Query(None, description="告警状态: CONFIRMED/FALSE/CLOSED"),
handleStatus: Optional[str] = Query(None, description="处理状态: HANDLING/DONE"),
remark: Optional[str] = Query(None, description="处理备注"),
service: AlertService = Depends(get_alert_service),
service: AlarmEventService = Depends(get_alarm_event_service),
current_user: dict = Depends(get_current_user)
):
"""处理告警"""
handle_data = AlertHandleRequest(status=status, remark=remark)
handled_by = current_user.get("username", "admin")
handler = current_user.get("username", "admin")
alert = service.handle_alert(id, handle_data, handled_by)
if not alert:
alarm = service.handle_alarm(
alarm_id=alarmId,
alarm_status=alarmStatus,
handle_status=handleStatus,
remark=remark,
handler=handler,
)
if not alarm:
raise HTTPException(status_code=404, detail="告警不存在")
return YudaoResponse.success(True)
@@ -155,12 +141,12 @@ async def handle_alert(
@router.delete("/alert/delete")
async def delete_alert(
id: int = Query(..., description="告警ID"),
service: AlertService = Depends(get_alert_service),
alarmId: str = Query(..., description="告警ID"),
service: AlarmEventService = Depends(get_alarm_event_service),
current_user: dict = Depends(get_current_user)
):
"""删除告警"""
success = service.delete_alert(id)
success = service.delete_alarm(alarmId)
if not success:
raise HTTPException(status_code=404, detail="告警不存在")
@@ -169,35 +155,26 @@ async def delete_alert(
@router.get("/alert/statistics")
async def get_statistics(
service: AlertService = Depends(get_alert_service),
service: AlarmEventService = Depends(get_alarm_event_service),
current_user: dict = Depends(get_current_user)
):
"""获取告警统计"""
stats = service.get_statistics()
return YudaoResponse.success({
"total": stats.get("total", 0),
"pending": stats.get("pending", 0),
"confirmed": stats.get("confirmed", 0),
"ignored": stats.get("ignored", 0),
"resolved": stats.get("resolved", 0),
"dispatched": stats.get("dispatched", 0),
"byType": stats.get("by_type", {}),
"byLevel": stats.get("by_level", {}),
})
return YudaoResponse.success(stats)
# ==================== 摄像头告警汇总 ====================
# ==================== 设备告警汇总 ====================
@router.get("/camera-summary/page")
async def get_camera_summary_page(
@router.get("/device-summary/page")
async def get_device_summary_page(
pageNo: int = Query(1, ge=1, description="页码"),
pageSize: int = Query(20, ge=1, le=100, description="每页大小"),
service: AlertService = Depends(get_alert_service),
service: AlarmEventService = Depends(get_alarm_event_service),
current_user: dict = Depends(get_current_user)
):
"""获取摄像头告警汇总(分页)"""
result = service.get_camera_alert_summary(page=pageNo, page_size=pageSize)
"""获取设备告警汇总(分页)"""
result = service.get_device_summary(page=pageNo, page_size=pageSize)
return YudaoResponse.page(
list_data=result.get("list", []),
@@ -209,7 +186,7 @@ async def get_camera_summary_page(
# ==================== 辅助函数 ====================
def _get_alert_type_name(alert_type: Optional[str]) -> str:
def _get_alarm_type_name(alarm_type: Optional[str]) -> str:
"""获取告警类型名称"""
type_names = {
"leave_post": "离岗检测",
@@ -221,4 +198,4 @@ def _get_alert_type_name(alert_type: Optional[str]) -> str:
"helmet": "安全帽检测",
"unknown": "未知类型",
}
return type_names.get(alert_type, alert_type or "未知类型")
return type_names.get(alarm_type, alarm_type or "未知类型")