2026-02-24 13:59:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
摄像头名称格式化服务
|
|
|
|
|
|
|
|
|
|
|
|
功能:
|
|
|
|
|
|
1. 从 WVP 查询摄像头信息
|
|
|
|
|
|
2. 根据配置提取摄像头名称
|
|
|
|
|
|
3. 按配置模板格式化显示名称
|
2026-02-24 14:08:36 +08:00
|
|
|
|
4. 批量查询和缓存优化
|
2026-02-24 13:59:13 +08:00
|
|
|
|
|
|
|
|
|
|
设计原则:
|
|
|
|
|
|
- 配置驱动:所有格式和字段映射通过配置文件控制
|
|
|
|
|
|
- 单一职责:只负责摄像头名称的查询和格式化
|
|
|
|
|
|
- 可扩展:新增格式只需修改配置,不需改代码
|
|
|
|
|
|
- 可测试:不依赖全局状态,便于单元测试
|
2026-02-24 14:08:36 +08:00
|
|
|
|
- 性能优化:批量查询、去重、请求内缓存
|
2026-02-24 13:59:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-02-24 14:08:36 +08:00
|
|
|
|
from typing import Optional, Dict, List
|
2026-02-24 13:59:13 +08:00
|
|
|
|
import httpx
|
|
|
|
|
|
from app.config import CameraNameConfig
|
|
|
|
|
|
from app.utils.logger import logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CameraNameService:
|
|
|
|
|
|
"""摄像头名称服务"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: CameraNameConfig):
|
|
|
|
|
|
"""
|
|
|
|
|
|
初始化服务
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
config: 摄像头名称配置
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.config = config
|
|
|
|
|
|
|
|
|
|
|
|
async def get_camera_info(self, device_id: str) -> Optional[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
从 WVP 查询摄像头信息
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
device_id: 设备ID,支持两种格式:
|
|
|
|
|
|
- camera_code 格式:cam_xxxxxxxxxxxx
|
|
|
|
|
|
- app/stream 格式:大堂吧台3/012
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
摄像头信息字典,查询失败返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
# camera_code 格式(推荐)
|
|
|
|
|
|
if device_id.startswith("cam_"):
|
|
|
|
|
|
return await self._query_by_camera_code(device_id)
|
|
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# app/stream 格式(遗留格式,直接解析)
|
2026-02-24 13:59:13 +08:00
|
|
|
|
elif "/" in device_id:
|
2026-02-24 14:26:44 +08:00
|
|
|
|
return self._parse_app_stream_format(device_id)
|
2026-02-24 13:59:13 +08:00
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 13:59:13 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-24 14:08:36 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 分类: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]
|
2026-02-24 14:08:36 +08:00
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 初始化结果映射
|
|
|
|
|
|
info_map = {}
|
2026-02-24 14:08:36 +08:00
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 并发查询 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)))
|
2026-02-24 14:08:36 +08:00
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 直接解析 app/stream 格式(无需查询)
|
|
|
|
|
|
for did in app_stream_ids:
|
|
|
|
|
|
info_map[did] = self._parse_app_stream_format(did)
|
2026-02-24 14:08:36 +08:00
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 补充其他格式(未识别的)
|
2026-02-24 14:08:36 +08:00
|
|
|
|
for did in unique_ids:
|
|
|
|
|
|
if did not in info_map:
|
|
|
|
|
|
info_map[did] = None
|
|
|
|
|
|
|
|
|
|
|
|
return info_map
|
|
|
|
|
|
|
2026-02-24 13:59:13 +08:00
|
|
|
|
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: 设备ID(fallback值)
|
|
|
|
|
|
camera_info: 摄像头信息字典(可选)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
格式化后的显示名称
|
|
|
|
|
|
|
|
|
|
|
|
示例:
|
|
|
|
|
|
配置模板:"{camera_code} {name}/{stream}"
|
|
|
|
|
|
camera_info: {cameraCode: "cam_123", gbName: "大堂/", stream: "012"}
|
|
|
|
|
|
返回: "cam_123 大堂/012"
|
|
|
|
|
|
|
|
|
|
|
|
配置模板:"{name}"
|
|
|
|
|
|
返回: "大堂"
|
2026-02-24 14:26:44 +08:00
|
|
|
|
|
|
|
|
|
|
app/stream格式:
|
|
|
|
|
|
device_id: "大堂吧台3/012"
|
|
|
|
|
|
camera_info: {app: "大堂吧台3", stream: "012", cameraCode: None}
|
|
|
|
|
|
返回: "大堂吧台3"(忽略模板,直接返回名称)
|
2026-02-24 13:59:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
# 如果没有摄像头信息,直接返回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)
|
|
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 如果没有提取到名称,返回device_id
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
logger.warning(f"无法提取摄像头名称: device_id={device_id}")
|
|
|
|
|
|
return device_id
|
2026-02-24 14:08:36 +08:00
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 对于 app/stream 格式(没有 camera_code),直接返回名称
|
|
|
|
|
|
if not camera_code:
|
2026-02-24 14:08:36 +08:00
|
|
|
|
return name
|
|
|
|
|
|
|
2026-02-24 14:26:44 +08:00
|
|
|
|
# 对于 camera_code 格式,检查模板需要的变量
|
|
|
|
|
|
# 如果模板只需要 {name},直接返回名称
|
|
|
|
|
|
if self.config.display_format == "{name}":
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
|
|
|
|
# 完整格式需要所有字段
|
|
|
|
|
|
if not stream:
|
2026-02-24 13:59:13 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"摄像头信息不完整: camera_code={camera_code}, "
|
|
|
|
|
|
f"name={name}, stream={stream}, 使用fallback"
|
|
|
|
|
|
)
|
2026-02-24 14:26:44 +08:00
|
|
|
|
return name # 至少返回名称
|
2026-02-24 13:59:13 +08:00
|
|
|
|
|
|
|
|
|
|
# 按模板格式化
|
|
|
|
|
|
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}")
|
2026-02-24 14:26:44 +08:00
|
|
|
|
return name # 出错时至少返回名称
|
2026-02-24 13:59:13 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-24 14:08:36 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 13:59:13 +08:00
|
|
|
|
|
|
|
|
|
|
# 全局单例(依赖注入)
|
|
|
|
|
|
_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
|