性能:看板数据合并为单次请求 + 摄像头名称缓存

- 新增 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:
2026-03-18 17:31:18 +08:00
parent 6d68e2d9c0
commit a3797e7508
3 changed files with 149 additions and 21 deletions

View File

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

View File

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

View File

@@ -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]:
""" """