refactor: 优化配置管理和异常处理

- 添加YAML配置文件支持
- 改进camera_manager异常处理
- 添加类型提示和URL验证
- 完善依赖注入支持测试
- 新增健康检查API端点
This commit is contained in:
qichi.liang
2026-01-02 06:25:36 +08:00
parent 3e9a840576
commit 6903ee6f0b
9 changed files with 503 additions and 132 deletions

View File

@@ -2,18 +2,51 @@
摄像头管理器
处理摄像头配置和URL生成
"""
import requests
import logging
from typing import Any, Dict, List, Optional
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .config import BASE_URL, CAMERA_URL, CAMERAS
logger = logging.getLogger(__name__)
class CameraManager:
def __init__(self):
self.base_url = BASE_URL
self.camera_url = CAMERA_URL
self.session = requests.Session()
self.cameras = CAMERAS
"""摄像头管理器"""
def __init__(self, cameras: Optional[List[Dict[str, Any]]] = None) -> None:
"""
初始化摄像头管理器
Args:
cameras: 摄像头配置列表如果为None则使用全局配置
"""
self.base_url: str = BASE_URL
self.camera_url: str = CAMERA_URL
self.cameras: List[Dict[str, Any]] = cameras if cameras is not None else CAMERAS.copy()
# 初始化请求会话,配置连接池和重试策略
self.session: requests.Session = requests.Session()
# 配置重试策略
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS"]
)
# 配置适配器
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=10,
pool_maxsize=10
)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
# 配置请求头
self.session.headers.update({
@@ -24,8 +57,20 @@ class CameraManager:
'Connection': 'keep-alive',
})
def get_camera_url(self, camera_id, camera_number='mixed'):
"""根据摄像头ID和编号生成URL"""
def get_camera_url(self, camera_id: int, camera_number: str = 'mixed') -> str:
"""
根据摄像头ID和编号生成URL
Args:
camera_id: 摄像头ID
camera_number: 摄像头编号,默认为'mixed'
Returns:
摄像头URL
Raises:
ValueError: 摄像头ID不存在
"""
camera = next((c for c in self.cameras if c['id'] == camera_id), None)
if not camera:
raise ValueError(f"摄像头ID {camera_id} 不存在")
@@ -36,19 +81,73 @@ class CameraManager:
else:
return f"{self.camera_url}?room={room}&camera=camera-{camera_number}"
def refresh_camera(self, camera_id):
"""刷新指定摄像头(模拟操作)"""
def refresh_camera(self, camera_id: int) -> bool:
"""
刷新指定摄像头(模拟操作)
Args:
camera_id: 摄像头ID
Returns:
是否成功
"""
logger.info(f"刷新摄像头 {camera_id}")
return True
def get_all_cameras(self):
"""返回所有摄像头配置"""
def get_all_cameras(self) -> List[Dict[str, Any]]:
"""
返回所有摄像头配置
Returns:
摄像头配置列表
"""
return self.cameras
def check_connection(self):
"""检查连接状态"""
def check_connection(self, timeout: int = 5) -> bool:
"""
检查连接状态
Args:
timeout: 超时时间(秒)
Returns:
连接是否正常
"""
try:
response = self.session.get(self.base_url, timeout=5)
response = self.session.get(
self.base_url,
timeout=timeout,
allow_redirects=True
)
response.raise_for_status()
return response.status_code == 200
except:
return False
except requests.exceptions.Timeout as e:
logger.warning(f"连接超时: {e}")
return False
except requests.exceptions.ConnectionError as e:
logger.warning(f"连接错误: {e}")
return False
except requests.exceptions.HTTPError as e:
logger.warning(f"HTTP错误: {e}")
return False
except requests.exceptions.RequestException as e:
logger.error(f"请求异常: {e}")
return False
def get_camera_by_id(self, camera_id: int) -> Optional[Dict[str, Any]]:
"""
根据ID获取摄像头配置
Args:
camera_id: 摄像头ID
Returns:
摄像头配置如果不存在返回None
"""
return next((c for c in self.cameras if c['id'] == camera_id), None)
def close(self) -> None:
"""
关闭会话
"""
self.session.close()

View File

@@ -1,76 +1,170 @@
"""
配置文件
支持YAML配置和.env环境变量
"""
import os
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
import yaml
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
# 基础URL必须设置
# 配置文件路径
CONFIG_FILE = os.getenv("CONFIG_FILE", "config.yaml")
def _validate_url(url: str, field_name: str) -> None:
"""验证URL格式"""
if not url:
raise ValueError(f"环境变量 {field_name} 未设置")
parsed = urlparse(url)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError(f"{field_name} 格式不正确: {url}")
def _load_yaml_config() -> Optional[Dict[str, Any]]:
"""加载YAML配置文件"""
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
return None
def _get_config_value(
yaml_config: Optional[Dict[str, Any]],
env_key: str,
yaml_path: List[str],
default: Any = None,
validator: Optional[callable] = None
) -> Any:
"""获取配置值,优先级:环境变量 > YAML配置 > 默认值"""
# 1. 优先从环境变量获取
env_value = os.getenv(env_key)
if env_value is not None:
return env_value
# 2. 其次从YAML配置获取
if yaml_config:
value = yaml_config
for key in yaml_path:
if isinstance(value, dict) and key in value:
value = value[key]
else:
value = None
break
if value is not None:
if validator:
validator(value)
return value
# 3. 使用默认值
return default
def _get_int_value(yaml_config: Optional[Dict[str, Any]], env_key: str, yaml_path: List[str], default: int) -> int:
"""获取整数配置值"""
value = _get_config_value(yaml_config, env_key, yaml_path, str(default))
try:
return int(value)
except (ValueError, TypeError):
return default
def _get_bool_value(yaml_config: Optional[Dict[str, Any]], env_key: str, yaml_path: List[str], default: bool) -> bool:
"""获取布尔配置值"""
value = _get_config_value(yaml_config, env_key, yaml_path, str(default).lower())
return value.lower() == "true"
# 加载YAML配置
yaml_config = _load_yaml_config()
# 验证并获取BASE_URL
BASE_URL = os.getenv("BASE_URL")
if BASE_URL is None:
raise ValueError("环境变量 BASE_URL 未设置")
if BASE_URL is None and yaml_config:
BASE_URL = _get_config_value(yaml_config, "BASE_URL", ["camera", "base_url"])
_validate_url(BASE_URL, "BASE_URL")
CAMERA_URL = f"{BASE_URL}/adaops/blank-layout/camera-view"
# 摄像头配置可以从YAML/JSON加载这里先硬编码
CAMERAS = [
{
'id': 1,
'room': 'cnfzhjyg-igv-251',
'camera': 'mixed',
'name': '1号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-251&camera=mixed"
},
{
'id': 2,
'room': 'cnfzhjyg-igv-2',
'camera': 'mixed',
'name': '2号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-2&camera=mixed"
},
{
'id': 3,
'room': 'cnfzhjyg-igv-3',
'camera': 'mixed',
'name': '3号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-3&camera=mixed"
},
{
'id': 4,
'room': 'cnfzhjyg-igv-5',
'camera': 'mixed',
'name': '5号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-5&camera=mixed"
},
{
'id': 5,
'room': 'cnfzhjyg-igv-6',
'camera': 'mixed',
'name': '6号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-6&camera=mixed"
},
{
'id': 6,
'room': 'cnfzhjyg-igv-7',
'camera': 'mixed',
'name': '7号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-7&camera=mixed"
}
]
# Flask配置必须设置
DEBUG_STR = os.getenv("FLASK_DEBUG")
if DEBUG_STR is None:
raise ValueError("环境变量 FLASK_DEBUG 未设置")
DEBUG = DEBUG_STR.lower() == "true"
# 摄像头配置
def _load_cameras() -> List[Dict[str, Any]]:
"""加载摄像头配置"""
if yaml_config and "cameras" in yaml_config:
cameras = yaml_config["cameras"]
# 如果cameras中没有url字段自动生成
for camera in cameras:
if 'url' not in camera:
room = camera.get('room', '')
cam = camera.get('camera', 'mixed')
camera['url'] = f"{CAMERA_URL}?room={room}&camera={cam}"
return cameras
# 硬编码后备配置(不推荐使用)
return [
{
'id': 1,
'room': 'cnfzhjyg-igv-251',
'camera': 'mixed',
'name': '1号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-251&camera=mixed"
},
{
'id': 2,
'room': 'cnfzhjyg-igv-2',
'camera': 'mixed',
'name': '2号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-2&camera=mixed"
},
{
'id': 3,
'room': 'cnfzhjyg-igv-3',
'camera': 'mixed',
'name': '3号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-3&camera=mixed"
},
{
'id': 4,
'room': 'cnfzhjyg-igv-5',
'camera': 'mixed',
'name': '5号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-5&camera=mixed"
},
{
'id': 5,
'room': 'cnfzhjyg-igv-6',
'camera': 'mixed',
'name': '6号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-6&camera=mixed"
},
{
'id': 6,
'room': 'cnfzhjyg-igv-7',
'camera': 'mixed',
'name': '7号车',
'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-7&camera=mixed"
}
]
PORT_STR = os.getenv("PORT")
if PORT_STR is None:
raise ValueError("环境变量 PORT 未设置")
PORT = int(PORT_STR)
CAMERAS: List[Dict[str, Any]] = _load_cameras()
HOST = os.getenv("HOST")
if HOST is None:
raise ValueError("环境变量 HOST 未设置")
# Flask配置
DEBUG: bool = _get_bool_value(yaml_config, "FLASK_DEBUG", ["app", "debug"], False)
PORT: int = _get_int_value(yaml_config, "PORT", ["app", "port"], 5000)
HOST: str = _get_config_value(yaml_config, "HOST", ["app", "host"], "0.0.0.0")
# 验证HOST
if not HOST:
raise ValueError("环境变量 HOST 未设置")
# 日志配置
LOG_LEVEL: str = _get_config_value(yaml_config, "LOG_LEVEL", ["logging", "level"], "INFO")
LOG_FILE: str = _get_config_value(yaml_config, "LOG_FILE", ["logging", "file"], "logs/multi_camera.log")
LOG_MAX_BYTES: int = _get_int_value(yaml_config, "LOG_MAX_BYTES", ["logging", "max_bytes"], 10485760)
LOG_BACKUP_COUNT: int = _get_int_value(yaml_config, "LOG_BACKUP_COUNT", ["logging", "backup_count"], 10)

View File

@@ -3,46 +3,91 @@
"""
import logging
from datetime import datetime
from flask import render_template, jsonify, request
from typing import Dict, Any, List
from flask import Flask, render_template, jsonify, request
from app.camera_manager import CameraManager
logger = logging.getLogger(__name__)
# 创建摄像头管理器实例(全局
camera_manager = CameraManager()
# 创建摄像头管理器实例(可通过依赖注入覆盖
_camera_manager: CameraManager = None
def register_main_routes(app):
def get_camera_manager() -> CameraManager:
"""获取摄像头管理器实例(支持依赖注入)"""
global _camera_manager
if _camera_manager is None:
_camera_manager = CameraManager()
return _camera_manager
def set_camera_manager(manager: CameraManager) -> None:
"""设置摄像头管理器实例(用于测试)"""
global _camera_manager
_camera_manager = manager
def register_main_routes(app: Flask) -> None:
"""注册主路由到Flask应用"""
@app.route('/')
def index():
def index() -> str:
"""主页面 - 显示6个摄像头的网格布局"""
camera_manager = get_camera_manager()
cameras = camera_manager.get_all_cameras()
return render_template('index.html',
cameras=cameras,
current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
return render_template(
'index.html',
cameras=cameras,
current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
)
@app.route('/api/cameras')
def get_cameras():
def get_cameras() -> Dict[str, List[Dict[str, Any]]]:
"""获取摄像头列表API"""
camera_manager = get_camera_manager()
cameras = camera_manager.get_all_cameras()
return jsonify(cameras)
@app.route('/api/refresh/<int:camera_id>', methods=['POST'])
def refresh_camera(camera_id):
def refresh_camera(camera_id: int) -> Dict[str, Any]:
"""刷新指定摄像头"""
camera_manager = get_camera_manager()
success = camera_manager.refresh_camera(camera_id)
return jsonify({'success': success, 'camera_id': camera_id})
@app.route('/api/switch', methods=['POST'])
def switch_camera():
def switch_camera() -> Dict[str, Any]:
"""切换摄像头编号"""
camera_manager = get_camera_manager()
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '无效的请求数据'}), 400
camera_id = data.get('camera_id')
camera_number = data.get('camera_number', 'mixed')
if camera_id is None:
return jsonify({'success': False, 'error': '缺少camera_id参数'}), 400
try:
url = camera_manager.get_camera_url(camera_id, camera_number)
return jsonify({'success': True, 'url': url})
except Exception as e:
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
logger.error(f"切换摄像头失败: {e}")
return jsonify({'success': False, 'error': '内部服务器错误'}), 500
@app.route('/api/health')
def health_check() -> Dict[str, Any]:
"""健康检查API"""
camera_manager = get_camera_manager()
is_connected = camera_manager.check_connection()
return jsonify({
'status': 'healthy' if is_connected else 'degraded',
'connection': is_connected,
'timestamp': datetime.now().isoformat()
})

View File

@@ -33,7 +33,8 @@
</div>
<div class="camera-controls-combined" id="controls-{{ camera.id }}">
<button class="cam-btn" onclick="refreshCamera({{ camera.id }})">刷新</button>
<button class="cam-btn cam-btn-fullscreen" onclick="toggleFullscreen({{ camera.id }})">全屏</button>
<button class="cam-btn cam-btn-fullscreen" id="fullscreen-btn-{{ camera.id }}" onclick="toggleFullscreen({{ camera.id }})">全屏</button>
<button class="cam-btn cam-btn-exit-fullscreen" id="exit-fullscreen-btn-{{ camera.id }}" onclick="toggleFullscreen({{ camera.id }})" style="display: none;">退出全屏</button>
<button class="selector-btn active" onclick="switchCameraNumber(event, {{ camera.id }}, 'mixed')">混合</button>
<button class="selector-btn" onclick="switchCameraNumber(event, {{ camera.id }}, 0)">0</button>
<button class="selector-btn" onclick="switchCameraNumber(event, {{ camera.id }}, 1)">1</button>