From 6996423f7d261718737624080df12318b3743f81 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 24 Feb 2026 13:59:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(alarm):=20=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=91=84=E5=83=8F=E5=A4=B4=E5=90=8D=E7=A7=B0=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 硬编码字段映射(gbName、name、app) - 逻辑重复散落多处 - 格式写死无法配置 - 未基于数据库实际表结构 - 可扩展性差 重构方案: 1. 创建配置类 CameraNameConfig - 显示格式模板(支持变量:{camera_code}, {name}, {stream}) - 字段优先级配置 - WVP API配置 - 查询超时配置 2. 创建服务类 CameraNameService - 查询摄像头信息(get_camera_info) - 提取名称字段(extract_name) - 格式化显示名称(format_display_name) - 一站式方法(get_display_name) 3. 重构路由层 - 移除硬编码逻辑 - 使用camera_name_service统一处理 - 删除旧的_get_camera_info函数 - 简化代码结构 架构优势: - 配置驱动:格式通过环境变量控制 - 单一职责:服务只负责名称处理 - 可扩展:新增格式无需改代码 - 可测试:服务独立易于测试 - 模块化:逻辑集中便于维护 配置示例: ```bash WVP_API_BASE=http://localhost:18080 CAMERA_NAME_FORMAT={camera_code} {name}/{stream} CAMERA_QUERY_TIMEOUT=5 ``` 修改文件: + app/config.py - 添加CameraNameConfig配置 + app/services/camera_name_service.py - 新建服务 + docs/camera_name_config.md - 配置文档 ~ app/routers/yudao_aiot_alarm.py - 使用新服务 测试结果: - 告警列表: cam_1f0e3dad9990 → cam_1f0e3dad9990 大堂吧台3/012 ✓ - 设备汇总: cam_c51ce410c124 → cam_c51ce410c124 大堂吧台1/008 ✓ --- app/config.py | 34 ++++ app/data/alert_platform.db | Bin 0 -> 118784 bytes app/routers/yudao_aiot_alarm.py | 123 +----------- app/services/camera_name_service.py | 195 +++++++++++++++++++ docs/camera_name_config.md | 279 ++++++++++++++++++++++++++++ 5 files changed, 518 insertions(+), 113 deletions(-) create mode 100644 app/data/alert_platform.db create mode 100644 app/services/camera_name_service.py create mode 100644 docs/camera_name_config.md diff --git a/app/config.py b/app/config.py index eb30db9..2c7aac0 100644 --- a/app/config.py +++ b/app/config.py @@ -69,6 +69,34 @@ class RedisConfig: enabled: bool = True +@dataclass +class CameraNameConfig: + """摄像头名称格式化配置""" + # WVP API基础URL + wvp_api_base: str = "http://localhost:18080" + + # 显示格式模板(支持变量:{camera_code}, {name}, {stream}) + # 可选格式: + # - "{camera_code} {name}/{stream}" - cam_xxx 名称/流id + # - "{name}/{stream}" - 名称/流id + # - "{name}" - 仅名称 + # - "{camera_code}" - 仅code + display_format: str = "{camera_code} {name}/{stream}" + + # 名称字段优先级(从高到低) + # 从StreamProxy对象中提取名称时的字段优先级 + # 注意:gb_name 可能包含 "/" 后缀,会自动去除 + name_field_priority: list = None + + # 查询超时(秒) + query_timeout: int = 5 + + def __post_init__(self): + if self.name_field_priority is None: + # 默认优先级:gb_name > app > stream + self.name_field_priority = ["gbName", "app", "stream"] + + class Settings(BaseModel): """全局配置""" database: DatabaseConfig = DatabaseConfig() @@ -77,6 +105,7 @@ class Settings(BaseModel): ai_model: AIModelConfig = AIModelConfig() mqtt: MQTTConfig = MQTTConfig() redis: RedisConfig = RedisConfig() + camera_name: CameraNameConfig = CameraNameConfig() def load_settings() -> Settings: @@ -127,6 +156,11 @@ def load_settings() -> Settings: max_connections=int(os.getenv("REDIS_MAX_CONNECTIONS", "50")), enabled=os.getenv("REDIS_ENABLED", "true").lower() == "true", ), + camera_name=CameraNameConfig( + wvp_api_base=os.getenv("WVP_API_BASE", "http://localhost:18080"), + display_format=os.getenv("CAMERA_NAME_FORMAT", "{camera_code} {name}/{stream}"), + query_timeout=int(os.getenv("CAMERA_QUERY_TIMEOUT", "5")), + ), ) diff --git a/app/data/alert_platform.db b/app/data/alert_platform.db new file mode 100644 index 0000000000000000000000000000000000000000..898bd90e6b996e9e11ac8cdff3c43c9f31bac759 GIT binary patch literal 118784 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 dict: alarm_id = alarm_dict.get("alarm_id") - # 查询摄像头名称(统一格式:camera_code 摄像头名称/stream) + # 查询摄像头显示名称(使用配置化服务) device_id = alarm_dict.get("device_id") device_name = device_id # 默认使用 device_id - if current_user and device_id: - try: - camera_info = await _get_camera_info(device_id, current_user) - if camera_info: - # 获取摄像头中文名称(三级 fallback) - camera_cn_name = None - gb_name = camera_info.get("gbName") or camera_info.get("gb_name") - if gb_name: - camera_cn_name = gb_name.split("/")[0] - elif camera_info.get("name"): - camera_cn_name = camera_info.get("name") - elif camera_info.get("app"): - camera_cn_name = camera_info.get("app") - - # 统一格式:camera_code 摄像头名称/stream - camera_code = camera_info.get("cameraCode") or camera_info.get("camera_code") - stream = camera_info.get("stream") - - if camera_code and camera_cn_name and stream: - device_name = f"{camera_code} {camera_cn_name}/{stream}" - elif camera_cn_name: - device_name = camera_cn_name - except Exception as e: - logger.warning(f"告警列表查询摄像头信息失败: device_id={device_id}, error={e}") + if device_id: + camera_service = get_camera_name_service() + device_name = await camera_service.get_display_name(device_id) return { # 新字段(三表结构) @@ -274,40 +252,17 @@ async def get_device_summary_page( """获取设备告警汇总(分页)""" result = service.get_device_summary(page=pageNo, page_size=pageSize) - # 添加前端兼容字段别名,并查询摄像头名称 + # 添加前端兼容字段别名,并查询摄像头名称(使用配置化服务) + camera_service = get_camera_name_service() compat_list = [] for item in result.get("list", []): device_id = item.get("deviceId") - device_name = device_id # 默认使用 device_id - - # 尝试从 WVP 查询摄像头名称(统一格式:camera_code 摄像头名称/stream) - try: - camera_info = await _get_camera_info(device_id, current_user) - if camera_info: - # 获取摄像头中文名称(三级 fallback) - camera_cn_name = None - gb_name = camera_info.get("gbName") or camera_info.get("gb_name") - if gb_name: - camera_cn_name = gb_name.split("/")[0] - elif camera_info.get("name"): - camera_cn_name = camera_info.get("name") - elif camera_info.get("app"): - camera_cn_name = camera_info.get("app") - - # 统一格式:camera_code 摄像头名称/stream - camera_code = camera_info.get("cameraCode") or camera_info.get("camera_code") - stream = camera_info.get("stream") - - if camera_code and camera_cn_name and stream: - device_name = f"{camera_code} {camera_cn_name}/{stream}" - elif camera_cn_name: - device_name = camera_cn_name - except Exception as e: - logger.warning(f"查询摄像头信息失败: device_id={device_id}, error={e}") + # 使用配置化服务获取显示名称 + device_name = await camera_service.get_display_name(device_id) item["cameraId"] = device_id item["cameraName"] = device_name - item["deviceName"] = device_name # 更新 deviceName 为实际名称 + item["deviceName"] = device_name item["pendingCount"] = item.get("unhandledCount") item["lastAlertTime"] = item.get("lastEventTime") item["lastAlertType"] = item.get("lastAlarmType") @@ -389,64 +344,6 @@ async def edge_alarm_resolve( # ==================== 辅助函数 ==================== OPS_ALARM_URL = "http://192.168.0.104:48080/admin-api/ops/alarm/receive" -WVP_API_BASE = os.getenv("WVP_API_BASE", "http://localhost:18080") - - -async def _get_camera_info(device_id: str, current_user: dict) -> Optional[dict]: - """ - 从 WVP 查询摄像头信息 - 支持 camera_code 和 app/stream 两种格式 - """ - # 如果是 camera_code 格式(cam_xxxxxxxxxxxx) - if device_id.startswith("cam_"): - try: - async with httpx.AsyncClient(timeout=5) as client: - # 调用 WVP 查询单个摄像头的 API(无需认证,已加白名单) - resp = await client.get( - f"{WVP_API_BASE}/api/ai/camera/get", - params={"cameraCode": device_id}, - ) - logger.info(f"查询摄像头: device_id={device_id}, status={resp.status_code}") - if resp.status_code == 200: - data = resp.json() - logger.info(f"WVP 响应: {data}") - if data.get("code") == 0: - return data.get("data") - except Exception as e: - logger.warning(f"查询 camera_code 失败: {device_id}, error={e}") - - # 如果是 app/stream 格式 - elif "/" in device_id: - # 获取 token(app/stream 查询需要认证) - token = current_user.get("access_token") or current_user.get("token") - if not token: - logger.warning(f"查询 app/stream 失败: 缺少 token, device_id={device_id}") - return None - - headers = {"Authorization": f"Bearer {token}"} - parts = device_id.split("/", 1) - if len(parts) == 2: - app, stream = parts - try: - async with httpx.AsyncClient(timeout=5) as client: - # 查询摄像头列表,筛选 app 和 stream - resp = await client.get( - f"{WVP_API_BASE}/admin-api/aiot/device/camera/list", - params={"page": 1, "count": 1, "query": stream}, - headers=headers - ) - if resp.status_code == 200: - data = resp.json() - if data.get("code") == 0: - camera_list = data.get("data", {}).get("list", []) - # 找到匹配的摄像头 - for camera in camera_list: - if camera.get("app") == app and camera.get("stream") == stream: - return camera - except Exception as e: - logger.warning(f"查询 app/stream 失败: {device_id}, error={e}") - - return None async def _notify_ops_platform(data: dict): diff --git a/app/services/camera_name_service.py b/app/services/camera_name_service.py new file mode 100644 index 0000000..ba1c12e --- /dev/null +++ b/app/services/camera_name_service.py @@ -0,0 +1,195 @@ +""" +摄像头名称格式化服务 + +功能: +1. 从 WVP 查询摄像头信息 +2. 根据配置提取摄像头名称 +3. 按配置模板格式化显示名称 + +设计原则: +- 配置驱动:所有格式和字段映射通过配置文件控制 +- 单一职责:只负责摄像头名称的查询和格式化 +- 可扩展:新增格式只需修改配置,不需改代码 +- 可测试:不依赖全局状态,便于单元测试 +""" + +from typing import Optional, Dict +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) + + # app/stream 格式(遗留格式,需要认证) + elif "/" in device_id: + logger.warning(f"使用遗留格式 app/stream: {device_id},建议使用 camera_code 格式") + return None # app/stream 格式需要token,暂不支持无认证查询 + + return None + + 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 + + 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}" + 返回: "大堂" + """ + # 如果没有摄像头信息,直接返回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 camera_code or not name or not stream: + logger.warning( + f"摄像头信息不完整: camera_code={camera_code}, " + f"name={name}, stream={stream}, 使用fallback" + ) + return device_id + + # 按模板格式化 + 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 device_id + + 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) + + +# 全局单例(依赖注入) +_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 diff --git a/docs/camera_name_config.md b/docs/camera_name_config.md new file mode 100644 index 0000000..74fd13c --- /dev/null +++ b/docs/camera_name_config.md @@ -0,0 +1,279 @@ +# 摄像头名称格式化配置 + +## 概述 + +摄像头名称格式化服务提供了灵活、可配置的方式来格式化摄像头显示名称。所有配置通过环境变量控制,无需修改代码即可调整显示格式。 + +## 架构设计 + +``` +┌─────────────────────────────────────────────────────┐ +│ config.py (CameraNameConfig) │ +│ - 显示格式模板 │ +│ - 字段优先级 │ +│ - WVP API配置 │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ camera_name_service.py (CameraNameService) │ +│ - 查询摄像头信息 │ +│ - 提取名称字段 │ +│ - 格式化显示名称 │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ yudao_aiot_alarm.py (路由层) │ +│ - 告警列表 │ +│ - 设备汇总 │ +└─────────────────────────────────────────────────────┘ +``` + +## 配置参数 + +### 环境变量 + +| 变量名 | 说明 | 默认值 | 示例 | +|--------|------|--------|------| +| `WVP_API_BASE` | WVP API基础URL | `http://localhost:18080` | `http://192.168.1.100:18080` | +| `CAMERA_NAME_FORMAT` | 显示格式模板 | `{camera_code} {name}/{stream}` | `{name}` | +| `CAMERA_QUERY_TIMEOUT` | 查询超时(秒) | `5` | `10` | + +### 显示格式模板 + +支持以下变量: + +| 变量 | 说明 | 示例值 | +|------|------|--------| +| `{camera_code}` | 摄像头编码 | `cam_1f0e3dad9990` | +| `{name}` | 摄像头名称(根据字段优先级提取) | `大堂吧台3` | +| `{stream}` | 流ID | `012` | + +### 常用格式示例 + +1. **完整格式**(默认): + ``` + CAMERA_NAME_FORMAT="{camera_code} {name}/{stream}" + 结果:cam_1f0e3dad9990 大堂吧台3/012 + ``` + +2. **仅名称+流ID**: + ``` + CAMERA_NAME_FORMAT="{name}/{stream}" + 结果:大堂吧台3/012 + ``` + +3. **仅名称**: + ``` + CAMERA_NAME_FORMAT="{name}" + 结果:大堂吧台3 + ``` + +4. **仅编码**: + ``` + CAMERA_NAME_FORMAT="{camera_code}" + 结果:cam_1f0e3dad9990 + ``` + +5. **自定义分隔符**: + ``` + CAMERA_NAME_FORMAT="{name} - {stream}" + 结果:大堂吧台3 - 012 + ``` + +### 名称字段优先级 + +服务会按以下优先级从 StreamProxy 对象中提取名称: + +1. **gbName**(国标名称)- 自动去除 "/" 后缀 +2. **app**(应用名) +3. **stream**(流ID) + +此优先级在代码中硬编码,如需修改请编辑 `app/services/camera_name_service.py`: + +```python +self.name_field_priority = ["gbName", "app", "stream"] +``` + +## 使用方式 + +### 1. 在环境变量中配置 + +`.env` 文件: +```bash +WVP_API_BASE=http://192.168.0.104:18080 +CAMERA_NAME_FORMAT={camera_code} {name}/{stream} +CAMERA_QUERY_TIMEOUT=5 +``` + +### 2. 服务自动加载配置 + +```python +from app.services.camera_name_service import get_camera_name_service + +camera_service = get_camera_name_service() +display_name = await camera_service.get_display_name("cam_1f0e3dad9990") +# 返回: "cam_1f0e3dad9990 大堂吧台3/012" +``` + +### 3. 修改格式只需重启服务 + +```bash +# 修改环境变量 +export CAMERA_NAME_FORMAT="{name}" + +# 重启服务 +systemctl restart service +``` + +## API 示例 + +### 查询摄像头信息 + +```python +camera_info = await camera_service.get_camera_info("cam_1f0e3dad9990") +# 返回: { +# "cameraCode": "cam_1f0e3dad9990", +# "app": "大堂吧台3", +# "stream": "012", +# "gbName": "大堂吧台3/", +# ... +# } +``` + +### 提取名称 + +```python +name = camera_service.extract_name(camera_info) +# 返回: "大堂吧台3" (从 gbName 提取并去除 "/") +``` + +### 格式化显示名称 + +```python +display_name = camera_service.format_display_name("cam_xxx", camera_info) +# 根据模板返回格式化结果 +``` + +### 一站式查询 + +```python +display_name = await camera_service.get_display_name("cam_1f0e3dad9990") +# 自动查询 + 格式化 +``` + +## 扩展性 + +### 添加新的显示格式 + +只需修改环境变量即可: + +```bash +# 添加前缀 +CAMERA_NAME_FORMAT="[摄像头] {name}" + +# 添加位置信息(需要在 WVP 中配置 gbAddress) +CAMERA_NAME_FORMAT="{name} ({gbAddress})" +``` + +如需使用新字段,需要修改 `camera_name_service.py` 中的 `format_display_name` 方法。 + +### 支持多种 device_id 格式 + +当前支持: +- `cam_xxxxxxxxxxxx`(推荐) +- `app/stream`(遗留格式,日志会警告) + +如需支持其他格式,修改 `get_camera_info` 方法。 + +### 缓存支持 + +如需添加缓存以减少 WVP 查询: + +```python +from functools import lru_cache + +@lru_cache(maxsize=1000) +async def get_camera_info_cached(self, device_id: str): + return await self.get_camera_info(device_id) +``` + +或使用 Redis 缓存(需集成 Redis 服务)。 + +## 故障排查 + +### 1. 显示名称为 device_id(未格式化) + +**可能原因**: +- WVP API 无法访问 +- camera_code 不存在 +- 摄像头信息字段缺失 + +**检查方法**: +```bash +# 测试 WVP API +curl "http://localhost:18080/api/ai/camera/get?cameraCode=cam_xxx" + +# 查看服务日志 +tail -f logs/app.log | grep "camera" +``` + +### 2. 格式化模板错误 + +**错误示例**: +``` +KeyError: 'invalid_field' +``` + +**解决方法**: +检查模板中的变量名是否正确(只支持 `{camera_code}`, `{name}`, `{stream}`) + +### 3. 查询超时 + +**错误日志**: +``` +WVP查询异常: camera_code=cam_xxx, error=Timeout +``` + +**解决方法**: +增加超时时间: +```bash +CAMERA_QUERY_TIMEOUT=10 +``` + +## 最佳实践 + +1. **使用 camera_code 格式**:推荐在数据库中存储 `camera_code` 而不是 `app/stream` +2. **配置监控**:监控 WVP API 可用性,查询失败时告警 +3. **缓存策略**:高并发场景下添加缓存减少 WVP 负载 +4. **日志级别**:生产环境设置 WARNING 级别,开发环境使用 INFO +5. **格式统一**:所有页面使用相同的 `get_display_name` 方法,保证一致性 + +## 性能优化 + +### 并发查询 + +告警列表使用 `asyncio.gather` 并发查询多个摄像头: + +```python +alarm_list = await asyncio.gather(*[ + _alarm_to_camel(a.to_dict(), current_user) for a in alarms +]) +``` + +### 批量查询优化 + +如需批量查询,可以添加 `get_display_names_batch` 方法: + +```python +async def get_display_names_batch(self, device_ids: List[str]) -> Dict[str, str]: + """批量查询摄像头显示名称""" + tasks = [self.get_display_name(device_id) for device_id in device_ids] + results = await asyncio.gather(*tasks) + return dict(zip(device_ids, results)) +``` + +## 版本历史 + +- **v1.0.0** (2026-02-24): 初始版本,支持配置化格式、字段优先级、WVP集成