From 0f5e3ebce2209f0d2ab95b7c962a505a019283d4 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Wed, 11 Feb 2026 09:56:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(aiot):=20=E6=9C=AC=E5=9C=B0=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E5=9B=9E=E9=80=80=E8=AE=BF=E9=97=AE=20+=20=E5=91=8A?= =?UTF-8?q?=E8=AD=A6API=E5=89=8D=E7=AB=AF=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 挂载 Edge 截图目录为 /captures 静态文件(COS 不可用时回退) - 挂载 /uploads 静态文件目录 - _alarm_to_camel 支持 local: 前缀转 /captures/ URL - 告警分页/详情/处理/删除接口兼容前端旧字段名(id、cameraId、status 等) - 设备告警汇总添加前端兼容别名(cameraId、pendingCount 等) Co-Authored-By: Claude Opus 4.6 --- app/main.py | 11 ++++ app/routers/yudao_aiot_alarm.py | 111 ++++++++++++++++++++++++++------ 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/app/main.py b/app/main.py index 80a5e4e..c265a22 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from typing import Optional from datetime import datetime from pathlib import Path @@ -85,6 +86,16 @@ app.include_router(yudao_aiot_storage_router) # 注册芋道格式异常处理器 app.add_exception_handler(HTTPException, yudao_exception_handler) +# ==================== 静态文件(本地截图) ==================== +_uploads_dir = Path("uploads") +_uploads_dir.mkdir(parents=True, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=str(_uploads_dir)), name="uploads") + +# Edge 本地截图目录(COS 未配置时的回退访问路径) +_edge_captures_dir = Path(r"C:\Users\16337\PycharmProjects\ai_edge\data\captures") +if _edge_captures_dir.exists(): + app.mount("/captures", StaticFiles(directory=str(_edge_captures_dir)), name="captures") + def get_alert_svc(): return alert_service diff --git a/app/routers/yudao_aiot_alarm.py b/app/routers/yudao_aiot_alarm.py index 0fbd211..e73ed55 100644 --- a/app/routers/yudao_aiot_alarm.py +++ b/app/routers/yudao_aiot_alarm.py @@ -26,15 +26,40 @@ 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 + """将 alarm_event 字典转换为前端 camelCase 格式(兼容前端旧字段名)""" + # snapshot_url: 根据存储方式转为可访问 URL storage = get_oss_storage() snapshot_url = alarm_dict.get("snapshot_url") if snapshot_url: - snapshot_url = storage.get_url(snapshot_url) + if snapshot_url.startswith("local:"): + # 本地截图(COS 未配置时的回退路径) + snapshot_url = "/captures/" + snapshot_url[6:] + else: + snapshot_url = storage.get_url(snapshot_url) + + # alarm_level int → 文本映射 + alarm_level = alarm_dict.get("alarm_level") + level_map = {1: "low", 2: "medium", 3: "high", 4: "critical"} + level_str = level_map.get(alarm_level, "medium") if alarm_level else "medium" + + # alarm_status → 前端 status 映射 + alarm_status = alarm_dict.get("alarm_status", "NEW") + status_map = {"NEW": "pending", "CONFIRMED": "handled", "FALSE": "ignored", "CLOSED": "handled"} + status_str = status_map.get(alarm_status, "pending") + + # confidence_score 0-1 → 百分比 + confidence_score = alarm_dict.get("confidence_score") + confidence_pct = round(confidence_score * 100) if confidence_score is not None else None + + # duration_ms → 分钟 + duration_ms = alarm_dict.get("duration_ms") + duration_minutes = round(duration_ms / 60000, 1) if duration_ms else None + + alarm_id = alarm_dict.get("alarm_id") return { - "alarmId": alarm_dict.get("alarm_id"), + # 新字段(三表结构) + "alarmId": alarm_id, "alarmType": alarm_dict.get("alarm_type"), "alarmTypeName": _get_alarm_type_name(alarm_dict.get("alarm_type")), "algorithmCode": alarm_dict.get("algorithm_code"), @@ -44,10 +69,10 @@ def _alarm_to_camel(alarm_dict: dict) -> dict: "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"), + "durationMs": duration_ms, + "alarmLevel": alarm_level, + "confidenceScore": confidence_score, + "alarmStatus": alarm_status, "handleStatus": alarm_dict.get("handle_status"), "snapshotUrl": snapshot_url, "videoUrl": alarm_dict.get("video_url"), @@ -57,9 +82,22 @@ def _alarm_to_camel(alarm_dict: dict) -> dict: "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"), + # 兼容前端旧字段名 + "id": alarm_id, + "alertNo": alarm_id, + "cameraId": alarm_dict.get("device_id"), + "alertType": alarm_dict.get("alarm_type"), + "alertTypeName": _get_alarm_type_name(alarm_dict.get("alarm_type")), + "confidence": confidence_pct, + "durationMinutes": duration_minutes, + "status": status_str, + "level": level_str, + "triggerTime": alarm_dict.get("event_time"), + "ossUrl": snapshot_url, + "message": None, + "bbox": (alarm_dict.get("ext") or {}).get("ext_data", {}).get("bbox") if isinstance(alarm_dict.get("ext"), dict) else None, } @@ -70,9 +108,10 @@ async def get_alert_page( pageNo: int = Query(1, ge=1, description="页码"), pageSize: int = Query(20, ge=1, le=100, description="每页大小"), deviceId: Optional[str] = Query(None, description="摄像头/设备ID"), + cameraId: 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"), + alarmType: Optional[str] = Query(None, alias="alertType", description="告警类型"), + alarmStatus: Optional[str] = Query(None, alias="status", description="告警状态"), alarmLevel: Optional[int] = Query(None, description="告警级别: 1提醒/2一般/3严重/4紧急"), startTime: Optional[datetime] = Query(None, description="开始时间"), endTime: Optional[datetime] = Query(None, description="结束时间"), @@ -80,8 +119,13 @@ async def get_alert_page( current_user: dict = Depends(get_current_user) ): """分页查询告警列表""" + # 兼容旧接口的 status 文本转换 + if alarmStatus and alarmStatus in ("pending", "handled", "ignored"): + status_convert = {"pending": "NEW", "handled": "CONFIRMED", "ignored": "FALSE"} + alarmStatus = status_convert.get(alarmStatus, alarmStatus) + alarms, total = service.get_alarms( - device_id=deviceId, + device_id=deviceId or cameraId, alarm_type=alarmType, alarm_status=alarmStatus, alarm_level=alarmLevel, @@ -104,12 +148,16 @@ async def get_alert_page( @router.get("/alert/get") async def get_alert( - alarmId: str = Query(..., description="告警ID"), + alarmId: Optional[str] = Query(None, description="告警ID"), + id: Optional[str] = Query(None, description="告警ID(兼容旧接口)"), service: AlarmEventService = Depends(get_alarm_event_service), current_user: dict = Depends(get_current_user) ): """获取告警详情""" - alarm_dict = service.get_alarm(alarmId) + alarm_id = alarmId or id + if not alarm_id: + raise HTTPException(status_code=400, detail="缺少 alarmId 或 id 参数") + alarm_dict = service.get_alarm(alarm_id) if not alarm_dict: raise HTTPException(status_code=404, detail="告警不存在") @@ -118,18 +166,28 @@ async def get_alert( @router.put("/alert/handle") async def handle_alert( - alarmId: str = Query(..., description="告警ID"), + alarmId: Optional[str] = Query(None, description="告警ID"), + id: Optional[str] = Query(None, description="告警ID(兼容)"), alarmStatus: Optional[str] = Query(None, description="告警状态: CONFIRMED/FALSE/CLOSED"), handleStatus: Optional[str] = Query(None, description="处理状态: HANDLING/DONE"), + status: Optional[str] = Query(None, description="处理状态(兼容旧接口)"), remark: Optional[str] = Query(None, description="处理备注"), service: AlarmEventService = Depends(get_alarm_event_service), current_user: dict = Depends(get_current_user) ): """处理告警""" + alarm_id = alarmId or id + if not alarm_id: + raise HTTPException(status_code=400, detail="缺少 alarmId 或 id 参数") handler = current_user.get("username", "admin") + # 兼容旧接口: status=handled → alarmStatus=CONFIRMED, status=ignored → alarmStatus=FALSE + if not alarmStatus and status: + status_convert = {"handled": "CONFIRMED", "ignored": "FALSE", "resolved": "CLOSED"} + alarmStatus = status_convert.get(status, status.upper()) + alarm = service.handle_alarm( - alarm_id=alarmId, + alarm_id=alarm_id, alarm_status=alarmStatus, handle_status=handleStatus, remark=remark, @@ -143,12 +201,16 @@ async def handle_alert( @router.delete("/alert/delete") async def delete_alert( - alarmId: str = Query(..., description="告警ID"), + alarmId: Optional[str] = Query(None, description="告警ID"), + id: Optional[str] = Query(None, description="告警ID(兼容)"), service: AlarmEventService = Depends(get_alarm_event_service), current_user: dict = Depends(get_current_user) ): """删除告警""" - success = service.delete_alarm(alarmId) + alarm_id = alarmId or id + if not alarm_id: + raise HTTPException(status_code=400, detail="缺少 alarmId 或 id 参数") + success = service.delete_alarm(alarm_id) if not success: raise HTTPException(status_code=404, detail="告警不存在") @@ -178,8 +240,19 @@ async def get_device_summary_page( """获取设备告警汇总(分页)""" result = service.get_device_summary(page=pageNo, page_size=pageSize) + # 添加前端兼容字段别名 + compat_list = [] + for item in result.get("list", []): + item["cameraId"] = item.get("deviceId") + item["cameraName"] = item.get("deviceName") + item["pendingCount"] = item.get("unhandledCount") + item["lastAlertTime"] = item.get("lastEventTime") + item["lastAlertType"] = item.get("lastAlarmType") + item["lastAlertTypeName"] = _get_alarm_type_name(item.get("lastAlarmType")) + compat_list.append(item) + return YudaoResponse.page( - list_data=result.get("list", []), + list_data=compat_list, total=result.get("total", 0), page_no=pageNo, page_size=pageSize