Files
iot-device-management-service/app/services/camera_name_service.py
16337 a3797e7508 性能:看板数据合并为单次请求 + 摄像头名称缓存
- 新增 GET /alert/dashboard 聚合接口,一次返回全部看板数据
- 共用同一个 DB session 执行所有查询,减少连接开销
- 摄像头名称服务增加 5 分钟内存缓存,避免重复查询 WVP
- 设备Top10 和最近告警共用一次批量摄像头名称查询

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:31:18 +08:00

295 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
摄像头名称格式化服务
功能:
1. 从 WVP 查询摄像头信息
2. 根据配置提取摄像头名称
3. 按配置模板格式化显示名称
4. 批量查询和缓存优化
设计原则:
- 配置驱动:所有格式和字段映射通过配置文件控制
- 单一职责:只负责摄像头名称的查询和格式化
- 可扩展:新增格式只需修改配置,不需改代码
- 可测试:不依赖全局状态,便于单元测试
- 性能优化:批量查询、去重、请求内缓存
"""
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):
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 查询摄像头信息(带缓存)"""
# 检查缓存
cached = self._cache.get(device_id)
if cached and cached[1] > time.time():
return cached[0]
info = None
# camera_code 格式(推荐)
if device_id.startswith("cam_"):
info = await self._query_by_camera_code(device_id)
# app/stream 格式(遗留格式,直接解析)
elif "/" in device_id:
info = self._parse_app_stream_format(device_id)
# 写入缓存(包括 None 结果,避免反复查询不存在的设备)
self._cache[device_id] = (info, time.time() + self.CACHE_TTL)
return info
def _parse_app_stream_format(self, device_id: str) -> Optional[Dict]:
"""
解析 app/stream 格式的 device_id
Args:
device_id: 格式如 "大堂吧台3/012"
Returns:
虚拟的摄像头信息字典
"""
parts = device_id.split("/", 1)
if len(parts) != 2:
return None
app, stream = parts
# 构造虚拟的摄像头信息(兼容统一格式化逻辑)
return {
"app": app, # 中文名称
"stream": stream, # 流ID
"gbName": app, # 兼容字段
"cameraCode": None, # app/stream 格式没有 camera_code
}
async def _query_by_camera_code(self, camera_code: str) -> Optional[Dict]:
"""
通过 camera_code 查询摄像头信息
Args:
camera_code: 摄像头编码格式cam_xxxxxxxxxxxx
Returns:
摄像头信息字典,查询失败返回 None
"""
try:
async with httpx.AsyncClient(timeout=self.config.query_timeout) as client:
resp = await client.get(
f"{self.config.wvp_api_base}/api/ai/camera/get",
params={"cameraCode": camera_code},
)
if resp.status_code == 200:
data = resp.json()
if data.get("code") == 0:
return data.get("data")
else:
logger.warning(
f"WVP查询摄像头失败: camera_code={camera_code}, "
f"status={resp.status_code}"
)
except Exception as e:
logger.error(f"WVP查询异常: camera_code={camera_code}, error={e}")
return None
async def get_camera_infos_batch(self, device_ids: List[str]) -> Dict[str, Optional[Dict]]:
"""
批量查询摄像头信息(并发+去重优化)
Args:
device_ids: 设备ID列表
Returns:
{device_id: camera_info} 字典
"""
# 去重
unique_ids = list(set(device_ids))
# 分类camera_code 和 app/stream 格式
camera_code_ids = [did for did in unique_ids if did.startswith("cam_")]
app_stream_ids = [did for did in unique_ids if "/" in did]
# 初始化结果映射
info_map = {}
# 并发查询 camera_code 格式
if camera_code_ids:
import asyncio
tasks = [self.get_camera_info(did) for did in camera_code_ids]
results = await asyncio.gather(*tasks)
info_map.update(dict(zip(camera_code_ids, results)))
# 直接解析 app/stream 格式(无需查询)
for did in app_stream_ids:
info_map[did] = self._parse_app_stream_format(did)
# 补充其他格式(未识别的)
for did in unique_ids:
if did not in info_map:
info_map[did] = None
return info_map
def extract_name(self, camera_info: Dict) -> Optional[str]:
"""
从摄像头信息中提取名称
根据配置的字段优先级提取名称:
1. 按优先级遍历字段
2. 如果是 gbName 字段,自动去除 "/" 后缀
3. 返回第一个非空值
Args:
camera_info: 摄像头信息字典
Returns:
提取的名称,失败返回 None
"""
for field in self.config.name_field_priority:
value = camera_info.get(field)
if value:
# gb_name 可能包含 "/" 后缀,需要去除
if field == "gbName" and "/" in value:
value = value.split("/")[0]
return value
return None
def format_display_name(
self,
device_id: str,
camera_info: Optional[Dict] = None
) -> str:
"""
格式化摄像头显示名称
Args:
device_id: 设备IDfallback值
camera_info: 摄像头信息字典(可选)
Returns:
格式化后的显示名称
示例:
配置模板:"{camera_code} {name}/{stream}"
camera_info: {cameraCode: "cam_123", gbName: "大堂/", stream: "012"}
返回: "cam_123 大堂/012"
配置模板:"{name}"
返回: "大堂"
app/stream格式
device_id: "大堂吧台3/012"
camera_info: {app: "大堂吧台3", stream: "012", cameraCode: None}
返回: "大堂吧台3"(忽略模板,直接返回名称)
"""
# 如果没有摄像头信息直接返回device_id
if not camera_info:
return device_id
# 提取变量
camera_code = camera_info.get("cameraCode") or camera_info.get("camera_code")
stream = camera_info.get("stream")
name = self.extract_name(camera_info)
# 如果没有提取到名称返回device_id
if not name:
logger.warning(f"无法提取摄像头名称: device_id={device_id}")
return device_id
# 对于 app/stream 格式(没有 camera_code直接返回名称
if not camera_code:
return name
# 对于 camera_code 格式,检查模板需要的变量
# 如果模板只需要 {name},直接返回名称
if self.config.display_format == "{name}":
return name
# 完整格式需要所有字段
if not stream:
logger.warning(
f"摄像头信息不完整: camera_code={camera_code}, "
f"name={name}, stream={stream}, 使用fallback"
)
return name # 至少返回名称
# 按模板格式化
try:
return self.config.display_format.format(
camera_code=camera_code,
name=name,
stream=stream
)
except KeyError as e:
logger.error(f"格式化模板变量错误: {e}, 模板={self.config.display_format}")
return name # 出错时至少返回名称
async def get_display_name(self, device_id: str) -> str:
"""
获取摄像头显示名称(一站式方法)
结合查询和格式化,返回最终显示名称
Args:
device_id: 设备ID
Returns:
格式化后的显示名称
"""
camera_info = await self.get_camera_info(device_id)
return self.format_display_name(device_id, camera_info)
async def get_display_names_batch(
self,
device_ids: List[str]
) -> Dict[str, str]:
"""
批量获取摄像头显示名称(性能优化版本)
Args:
device_ids: 设备ID列表
Returns:
{device_id: display_name} 字典
"""
# 批量查询摄像头信息(去重+并发)
info_map = await self.get_camera_infos_batch(device_ids)
# 格式化所有名称
return {
did: self.format_display_name(did, info_map.get(did))
for did in device_ids
}
# 全局单例(依赖注入)
_camera_name_service: Optional[CameraNameService] = None
def get_camera_name_service() -> CameraNameService:
"""
获取摄像头名称服务单例
Returns:
CameraNameService 实例
"""
global _camera_name_service
if _camera_name_service is None:
from app.config import settings
_camera_name_service = CameraNameService(settings.camera_name)
return _camera_name_service