feat(aiot): 本地截图回退访问 + 告警API前端兼容

- 挂载 Edge 截图目录为 /captures 静态文件(COS 不可用时回退)
- 挂载 /uploads 静态文件目录
- _alarm_to_camel 支持 local: 前缀转 /captures/ URL
- 告警分页/详情/处理/删除接口兼容前端旧字段名(id、cameraId、status 等)
- 设备告警汇总添加前端兼容别名(cameraId、pendingCount 等)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 09:56:15 +08:00
parent 9f4cea0810
commit 0f5e3ebce2
2 changed files with 103 additions and 19 deletions

View File

@@ -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

View File

@@ -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