diff --git a/app/routers/yudao_aiot_alarm.py b/app/routers/yudao_aiot_alarm.py index dfb4ed4..e5d8425 100644 --- a/app/routers/yudao_aiot_alarm.py +++ b/app/routers/yudao_aiot_alarm.py @@ -279,6 +279,42 @@ async def delete_alert( return YudaoResponse.success(True) +@router.get("/alert/dashboard") +async def get_dashboard( + trendDays: int = Query(7, ge=1, le=90, description="趋势天数"), + service: AlarmEventService = Depends(get_alarm_event_service), + current_user: dict = Depends(get_current_user) +): + """看板数据(一次返回全部,减少请求数)""" + data = service.get_dashboard_data(trend_days=trendDays) + + # 批量查询摄像头名称(device-top + recent-alerts 共用) + camera_service = get_camera_name_service() + device_ids = list(set( + [d["deviceId"] for d in data["deviceTop"]] + + [a.get("device_id", "") for a in data["recentAlerts"]] + )) + camera_info_map = {} + try: + camera_info_map = await camera_service.get_camera_infos_batch(device_ids) + except Exception as e: + logger.warning(f"批量查询摄像头信息失败: {e}") + + # 填充设备名称 + for item in data["deviceTop"]: + did = item["deviceId"] + info = camera_info_map.get(did) + item["deviceName"] = camera_service.format_display_name(did, info) + + # 转换最近告警 + alarm_list = [] + for a in data["recentAlerts"]: + alarm_list.append(await _alarm_to_camel(a, camera_info_map, camera_service)) + data["recentAlerts"] = alarm_list + + return YudaoResponse.success(data) + + @router.get("/alert/statistics") async def get_statistics( service: AlarmEventService = Depends(get_alarm_event_service), diff --git a/app/services/alarm_event_service.py b/app/services/alarm_event_service.py index bfa51c0..f136e71 100644 --- a/app/services/alarm_event_service.py +++ b/app/services/alarm_event_service.py @@ -656,6 +656,102 @@ class AlarmEventService: finally: db.close() + def get_dashboard_data(self, trend_days: int = 7) -> Dict: + """一次查询返回看板所有数据(共用一个 DB session,减少连接开销)""" + db = get_session() + try: + now = beijing_now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + yesterday_start = today_start - timedelta(days=1) + trend_start = (now - timedelta(days=trend_days)).replace(hour=0, minute=0, second=0, microsecond=0) + week_start = (now - timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0) + + # === 统计概览 === + total = db.query(AlarmEvent).count() + today_count = db.query(AlarmEvent).filter(AlarmEvent.event_time >= today_start).count() + yesterday_count = db.query(AlarmEvent).filter( + AlarmEvent.event_time >= yesterday_start, AlarmEvent.event_time < today_start + ).count() + pending_count = db.query(AlarmEvent).filter(AlarmEvent.handle_status == "UNHANDLED").count() + handled_count = db.query(AlarmEvent).filter(AlarmEvent.handle_status.in_(["DONE", "IGNORED"])).count() + + from sqlalchemy.sql.expression import literal_column + avg_response = db.query( + func.avg(func.timestampdiff(literal_column("MINUTE"), AlarmEvent.event_time, AlarmEvent.handled_at)) + ).filter(AlarmEvent.handled_at.isnot(None), AlarmEvent.handled_at > AlarmEvent.event_time).scalar() + + by_type = {} + for r in db.query(AlarmEvent.alarm_type, func.count(AlarmEvent.alarm_id)).group_by(AlarmEvent.alarm_type).all(): + by_type[r[0]] = r[1] + + by_level = {} + for r in db.query(AlarmEvent.alarm_level, func.count(AlarmEvent.alarm_id)).group_by(AlarmEvent.alarm_level).all(): + by_level[r[0]] = r[1] + + # === 趋势 === + trend_rows = db.query( + func.date(AlarmEvent.event_time).label("date"), + AlarmEvent.alarm_type, + func.count(AlarmEvent.alarm_id).label("cnt"), + ).filter(AlarmEvent.event_time >= trend_start).group_by( + func.date(AlarmEvent.event_time), AlarmEvent.alarm_type + ).all() + + date_map: Dict[str, Dict[str, int]] = {} + for r in trend_rows: + d = str(r.date) + if d not in date_map: + date_map[d] = {} + date_map[d][r.alarm_type] = r.cnt + + trend = [] + for i in range(trend_days): + d = (trend_start + timedelta(days=i)).strftime("%Y-%m-%d") + tc = date_map.get(d, {}) + trend.append({"date": d, "total": sum(tc.values()), **tc}) + + # === 设备 Top10(近7天) === + device_rows = db.query( + AlarmEvent.device_id, func.count(AlarmEvent.alarm_id).label("cnt") + ).filter(AlarmEvent.event_time >= week_start).group_by( + AlarmEvent.device_id + ).order_by(func.count(AlarmEvent.alarm_id).desc()).limit(10).all() + + device_top = [{"deviceId": r.device_id, "count": r.cnt} for r in device_rows] + + # === 24h 分布(近7天) === + hour_rows = db.query( + func.hour(AlarmEvent.event_time).label("h"), + func.count(AlarmEvent.alarm_id).label("cnt"), + ).filter(AlarmEvent.event_time >= week_start).group_by( + func.hour(AlarmEvent.event_time) + ).all() + hour_map = {r.h: r.cnt for r in hour_rows} + hour_dist = [{"hour": h, "count": hour_map.get(h, 0)} for h in range(24)] + + # === 最近10条告警 === + recent = db.query(AlarmEvent).order_by(AlarmEvent.event_time.desc()).limit(10).all() + recent_list = [a.to_dict() for a in recent] + + return { + "statistics": { + "total": total, + "todayCount": today_count, + "yesterdayCount": yesterday_count, + "pendingCount": pending_count, + "handledCount": handled_count, + "avgResponseMinutes": round(float(avg_response), 1) if avg_response else None, + "byType": by_type, + "byLevel": by_level, + }, + "trend": trend, + "deviceTop": device_top, + "hourDistribution": hour_dist, + "recentAlerts": recent_list, + } + finally: + db.close() + def get_device_summary( self, page: int = 1, diff --git a/app/services/camera_name_service.py b/app/services/camera_name_service.py index a6cbc7e..7c29ec5 100644 --- a/app/services/camera_name_service.py +++ b/app/services/camera_name_service.py @@ -16,44 +16,40 @@ """ from typing import Optional, Dict, List +import time import httpx from app.config import CameraNameConfig from app.utils.logger import logger class CameraNameService: - """摄像头名称服务""" + """摄像头名称服务(带内存缓存)""" + + # 缓存 TTL(秒) + CACHE_TTL = 300 # 5 分钟 def __init__(self, config: CameraNameConfig): - """ - 初始化服务 - - Args: - config: 摄像头名称配置 - """ self.config = config + self._cache: Dict[str, tuple] = {} # {device_id: (info, expire_time)} async def get_camera_info(self, device_id: str) -> Optional[Dict]: - """ - 从 WVP 查询摄像头信息 + """从 WVP 查询摄像头信息(带缓存)""" + # 检查缓存 + cached = self._cache.get(device_id) + if cached and cached[1] > time.time(): + return cached[0] - Args: - device_id: 设备ID,支持两种格式: - - camera_code 格式:cam_xxxxxxxxxxxx - - app/stream 格式:大堂吧台3/012 - - Returns: - 摄像头信息字典,查询失败返回 None - """ + info = None # camera_code 格式(推荐) if device_id.startswith("cam_"): - return await self._query_by_camera_code(device_id) - + info = await self._query_by_camera_code(device_id) # app/stream 格式(遗留格式,直接解析) elif "/" in device_id: - return self._parse_app_stream_format(device_id) + info = self._parse_app_stream_format(device_id) - return None + # 写入缓存(包括 None 结果,避免反复查询不存在的设备) + self._cache[device_id] = (info, time.time() + self.CACHE_TTL) + return info def _parse_app_stream_format(self, device_id: str) -> Optional[Dict]: """