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

@@ -1,32 +0,0 @@
# 忽略文件
.git
.gitignore
.env
.env.example
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
logs
*.log
*.sqlite
*.db
*.cache
.coverage
.pytest_cache
.mypy_cache
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
Thumbs.db
node_modules
static/node_modules

View File

@@ -2,18 +2,51 @@
摄像头管理器 摄像头管理器
处理摄像头配置和URL生成 处理摄像头配置和URL生成
""" """
import requests
import logging 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 from .config import BASE_URL, CAMERA_URL, CAMERAS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CameraManager: class CameraManager:
def __init__(self): """摄像头管理器"""
self.base_url = BASE_URL
self.camera_url = CAMERA_URL def __init__(self, cameras: Optional[List[Dict[str, Any]]] = None) -> None:
self.session = requests.Session() """
self.cameras = CAMERAS 初始化摄像头管理器
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({ self.session.headers.update({
@@ -24,8 +57,20 @@ class CameraManager:
'Connection': 'keep-alive', 'Connection': 'keep-alive',
}) })
def get_camera_url(self, camera_id, camera_number='mixed'): def get_camera_url(self, camera_id: int, camera_number: str = 'mixed') -> str:
"""根据摄像头ID和编号生成URL""" """
根据摄像头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) camera = next((c for c in self.cameras if c['id'] == camera_id), None)
if not camera: if not camera:
raise ValueError(f"摄像头ID {camera_id} 不存在") raise ValueError(f"摄像头ID {camera_id} 不存在")
@@ -36,19 +81,73 @@ class CameraManager:
else: else:
return f"{self.camera_url}?room={room}&camera=camera-{camera_number}" 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}") logger.info(f"刷新摄像头 {camera_id}")
return True return True
def get_all_cameras(self): def get_all_cameras(self) -> List[Dict[str, Any]]:
"""返回所有摄像头配置""" """
返回所有摄像头配置
Returns:
摄像头配置列表
"""
return self.cameras return self.cameras
def check_connection(self): def check_connection(self, timeout: int = 5) -> bool:
"""检查连接状态""" """
检查连接状态
Args:
timeout: 超时时间(秒)
Returns:
连接是否正常
"""
try: 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 return response.status_code == 200
except: except requests.exceptions.Timeout as e:
logger.warning(f"连接超时: {e}")
return False 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,20 +1,112 @@
""" """
配置文件 配置文件
支持YAML配置和.env环境变量
""" """
import os import os
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
import yaml
from dotenv import load_dotenv from dotenv import load_dotenv
# 加载环境变量 # 加载环境变量
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") BASE_URL = os.getenv("BASE_URL")
if BASE_URL is None: if BASE_URL is None and yaml_config:
raise ValueError("环境变量 BASE_URL 未设置") 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" CAMERA_URL = f"{BASE_URL}/adaops/blank-layout/camera-view"
# 摄像头配置可以从YAML/JSON加载这里先硬编码 # 摄像头配置
CAMERAS = [ 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, 'id': 1,
'room': 'cnfzhjyg-igv-251', 'room': 'cnfzhjyg-igv-251',
@@ -59,18 +151,20 @@ CAMERAS = [
} }
] ]
# Flask配置必须设置
DEBUG_STR = os.getenv("FLASK_DEBUG")
if DEBUG_STR is None:
raise ValueError("环境变量 FLASK_DEBUG 未设置")
DEBUG = DEBUG_STR.lower() == "true"
CAMERAS: List[Dict[str, Any]] = _load_cameras()
PORT_STR = os.getenv("PORT") # Flask配置
if PORT_STR is None: DEBUG: bool = _get_bool_value(yaml_config, "FLASK_DEBUG", ["app", "debug"], False)
raise ValueError("环境变量 PORT 未设置") PORT: int = _get_int_value(yaml_config, "PORT", ["app", "port"], 5000)
PORT = int(PORT_STR) HOST: str = _get_config_value(yaml_config, "HOST", ["app", "host"], "0.0.0.0")
HOST = os.getenv("HOST") # 验证HOST
if HOST is None: if not HOST:
raise ValueError("环境变量 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 import logging
from datetime import datetime 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 from app.camera_manager import CameraManager
logger = logging.getLogger(__name__) 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应用""" """注册主路由到Flask应用"""
@app.route('/') @app.route('/')
def index(): def index() -> str:
"""主页面 - 显示6个摄像头的网格布局""" """主页面 - 显示6个摄像头的网格布局"""
camera_manager = get_camera_manager()
cameras = camera_manager.get_all_cameras() cameras = camera_manager.get_all_cameras()
return render_template('index.html', return render_template(
'index.html',
cameras=cameras, cameras=cameras,
current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')) current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
)
@app.route('/api/cameras') @app.route('/api/cameras')
def get_cameras(): def get_cameras() -> Dict[str, List[Dict[str, Any]]]:
"""获取摄像头列表API""" """获取摄像头列表API"""
camera_manager = get_camera_manager()
cameras = camera_manager.get_all_cameras() cameras = camera_manager.get_all_cameras()
return jsonify(cameras) return jsonify(cameras)
@app.route('/api/refresh/<int:camera_id>', methods=['POST']) @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) success = camera_manager.refresh_camera(camera_id)
return jsonify({'success': success, 'camera_id': camera_id}) return jsonify({'success': success, 'camera_id': camera_id})
@app.route('/api/switch', methods=['POST']) @app.route('/api/switch', methods=['POST'])
def switch_camera(): def switch_camera() -> Dict[str, Any]:
"""切换摄像头编号""" """切换摄像头编号"""
camera_manager = get_camera_manager()
data = request.get_json() data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '无效的请求数据'}), 400
camera_id = data.get('camera_id') camera_id = data.get('camera_id')
camera_number = data.get('camera_number', 'mixed') camera_number = data.get('camera_number', 'mixed')
if camera_id is None:
return jsonify({'success': False, 'error': '缺少camera_id参数'}), 400
try: try:
url = camera_manager.get_camera_url(camera_id, camera_number) url = camera_manager.get_camera_url(camera_id, camera_number)
return jsonify({'success': True, 'url': url}) return jsonify({'success': True, 'url': url})
except Exception as e: except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400 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>
<div class="camera-controls-combined" id="controls-{{ camera.id }}"> <div class="camera-controls-combined" id="controls-{{ camera.id }}">
<button class="cam-btn" onclick="refreshCamera({{ camera.id }})">刷新</button> <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 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 }}, 0)">0</button>
<button class="selector-btn" onclick="switchCameraNumber(event, {{ camera.id }}, 1)">1</button> <button class="selector-btn" onclick="switchCameraNumber(event, {{ camera.id }}, 1)">1</button>

47
config.yaml Normal file
View File

@@ -0,0 +1,47 @@
# 多摄像头监控系统配置文件
# 基础配置
app:
host: "0.0.0.0"
port: 5000
debug: false
# 摄像头服务配置
camera:
base_url: "http://localhost:8080"
camera_path: "/adaops/blank-layout/camera-view"
# 摄像头列表配置
cameras:
- id: 1
room: "cnfzhjyg-igv-251"
camera: "mixed"
name: "1号车"
# url会自动生成: {camera_url}?room={room}&camera={camera}
- id: 2
room: "cnfzhjyg-igv-2"
camera: "mixed"
name: "2号车"
- id: 3
room: "cnfzhjyg-igv-3"
camera: "mixed"
name: "3号车"
- id: 4
room: "cnfzhjyg-igv-5"
camera: "mixed"
name: "5号车"
- id: 5
room: "cnfzhjyg-igv-6"
camera: "mixed"
name: "6号车"
- id: 6
room: "cnfzhjyg-igv-7"
camera: "mixed"
name: "7号车"
# 日志配置
logging:
level: "INFO"
file: "logs/multi_camera.log"
max_bytes: 10485760
backup_count: 10

View File

@@ -1,3 +1,17 @@
# Flask框架
Flask==2.3.3 Flask==2.3.3
# HTTP请求
requests==2.31.0 requests==2.31.0
# 环境变量
python-dotenv==1.0.0 python-dotenv==1.0.0
# YAML配置支持
PyYAML>=6.0
# 生产环境WSGI服务器
gunicorn>=21.0.0
# 异步支持(可选)
# gevent>=23.0.0

View File

@@ -64,6 +64,67 @@ body {
border-color: #4CAF50; border-color: #4CAF50;
z-index: 10; z-index: 10;
} }
/* 全屏模式下的样式 */
.camera-item:fullscreen {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #000;
border: none;
}
.camera-item:fullscreen .camera-frame-container {
flex: 1;
height: auto;
}
.camera-item:fullscreen .camera-controls-combined {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
padding: 10px 20px;
border-radius: 25px;
z-index: 9999;
}
.camera-item:fullscreen .camera-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
}
/* WebKit浏览器全屏支持 */
.camera-item:-webkit-full-screen {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #000;
border: none;
}
.camera-item:-webkit-full-screen .camera-frame-container {
flex: 1;
height: auto;
}
.camera-item:-webkit-full-screen .camera-controls-combined {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
padding: 10px 20px;
border-radius: 25px;
z-index: 9999;
}
.camera-item:-webkit-full-screen .camera-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
}
.camera-header { .camera-header {
background: #3a3a3a; background: #3a3a3a;
padding: 8px 12px; padding: 8px 12px;
@@ -124,6 +185,12 @@ body {
.cam-btn-fullscreen:hover { .cam-btn-fullscreen:hover {
background: #F57C00; background: #F57C00;
} }
.cam-btn-exit-fullscreen {
background: #f44336;
}
.cam-btn-exit-fullscreen:hover {
background: #d32f2f;
}
.camera-controls-combined { .camera-controls-combined {
background: #3a3a3a; background: #3a3a3a;
padding: 6px; padding: 6px;

View File

@@ -26,15 +26,16 @@ function refreshAllCameras() {
} }
function toggleFullscreen(cameraId) { function toggleFullscreen(cameraId) {
const frame = document.getElementById(`frame-${cameraId}`); const cameraItem = document.getElementById(`camera-${cameraId}`);
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
if (frame.requestFullscreen) { // 全屏相机容器而不是iframe
frame.requestFullscreen(); if (cameraItem.requestFullscreen) {
} else if (frame.webkitRequestFullscreen) { cameraItem.requestFullscreen();
frame.webkitRequestFullscreen(); } else if (cameraItem.webkitRequestFullscreen) {
} else if (frame.msRequestFullscreen) { cameraItem.webkitRequestFullscreen();
frame.msRequestFullscreen(); } else if (cameraItem.msRequestFullscreen) {
cameraItem.msRequestFullscreen();
} }
} else { } else {
if (document.exitFullscreen) { if (document.exitFullscreen) {
@@ -47,6 +48,41 @@ function toggleFullscreen(cameraId) {
} }
} }
// 监听全屏变化事件
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('msfullscreenchange', handleFullscreenChange);
function handleFullscreenChange() {
// 获取所有相机
for (let i = 1; i <= 6; i++) {
const fullscreenBtn = document.getElementById(`fullscreen-btn-${i}`);
const exitFullscreenBtn = document.getElementById(`exit-fullscreen-btn-${i}`);
if (fullscreenBtn && exitFullscreenBtn) {
if (document.fullscreenElement) {
// 进入全屏时,隐藏全屏按钮,显示退出按钮
fullscreenBtn.style.display = 'none';
exitFullscreenBtn.style.display = 'inline-block';
} else {
// 退出全屏时,显示全屏按钮,隐藏退出按钮
fullscreenBtn.style.display = 'inline-block';
exitFullscreenBtn.style.display = 'none';
}
}
}
}
function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function switchCameraNumber(event, cameraId, cameraNumber) { function switchCameraNumber(event, cameraId, cameraNumber) {
const frame = document.getElementById(`frame-${cameraId}`); const frame = document.getElementById(`frame-${cameraId}`);
const controls = document.getElementById(`controls-${cameraId}`); const controls = document.getElementById(`controls-${cameraId}`);