性能:看板数据合并为单次请求 + 摄像头名称缓存
- 新增 GET /alert/dashboard 聚合接口,一次返回全部看板数据 - 共用同一个 DB session 执行所有查询,减少连接开销 - 摄像头名称服务增加 5 分钟内存缓存,避免重复查询 WVP - 设备Top10 和最近告警共用一次批量摄像头名称查询 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -279,6 +279,42 @@ async def delete_alert(
|
|||||||
return YudaoResponse.success(True)
|
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")
|
@router.get("/alert/statistics")
|
||||||
async def get_statistics(
|
async def get_statistics(
|
||||||
service: AlarmEventService = Depends(get_alarm_event_service),
|
service: AlarmEventService = Depends(get_alarm_event_service),
|
||||||
|
|||||||
@@ -656,6 +656,102 @@ class AlarmEventService:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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(
|
def get_device_summary(
|
||||||
self,
|
self,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
|
|||||||
@@ -16,44 +16,40 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
from app.config import CameraNameConfig
|
from app.config import CameraNameConfig
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
class CameraNameService:
|
class CameraNameService:
|
||||||
"""摄像头名称服务"""
|
"""摄像头名称服务(带内存缓存)"""
|
||||||
|
|
||||||
|
# 缓存 TTL(秒)
|
||||||
|
CACHE_TTL = 300 # 5 分钟
|
||||||
|
|
||||||
def __init__(self, config: CameraNameConfig):
|
def __init__(self, config: CameraNameConfig):
|
||||||
"""
|
|
||||||
初始化服务
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: 摄像头名称配置
|
|
||||||
"""
|
|
||||||
self.config = 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]:
|
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:
|
info = None
|
||||||
device_id: 设备ID,支持两种格式:
|
|
||||||
- camera_code 格式:cam_xxxxxxxxxxxx
|
|
||||||
- app/stream 格式:大堂吧台3/012
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
摄像头信息字典,查询失败返回 None
|
|
||||||
"""
|
|
||||||
# camera_code 格式(推荐)
|
# camera_code 格式(推荐)
|
||||||
if device_id.startswith("cam_"):
|
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 格式(遗留格式,直接解析)
|
# app/stream 格式(遗留格式,直接解析)
|
||||||
elif "/" in device_id:
|
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]:
|
def _parse_app_stream_format(self, device_id: str) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user