refactor(edge): 截图不再保存本地,直接编码为base64上传COS
- ResultReporter: 截图通过 cv2.imencode 编码为 JPEG base64, 直接放入 Redis 消息,不再调用 ImageStorageManager 保存本地文件 - AlarmUploadWorker: 从 Redis 读取 base64 解码为字节流, 使用 put_object(Body=bytes) 直接上传 COS,移除 local: 回退逻辑 - 移除 AlarmInfo.snapshot_local_path,改为 snapshot_b64 - COS 未配置时返回 None 进入重试(不再静默回退本地路径) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,6 @@ Redis Key 设计:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -183,21 +182,16 @@ class AlarmUploadWorker:
|
|||||||
|
|
||||||
self._logger.info(f"开始处理告警: {alarm_id} (retry={retry_count})")
|
self._logger.info(f"开始处理告警: {alarm_id} (retry={retry_count})")
|
||||||
|
|
||||||
# Step 1: 上传截图到 COS
|
# Step 1: 上传截图到 COS(从 base64 解码后直接上传字节流)
|
||||||
snapshot_local_path = alarm_data.get("snapshot_local_path")
|
snapshot_b64 = alarm_data.get("snapshot_b64")
|
||||||
object_key = None
|
object_key = None
|
||||||
|
|
||||||
if snapshot_local_path:
|
if snapshot_b64:
|
||||||
# 截图是异步保存的,等待文件写入完成(最多 3 秒)
|
try:
|
||||||
if not os.path.exists(snapshot_local_path):
|
import base64
|
||||||
for _ in range(6):
|
image_bytes = base64.b64decode(snapshot_b64)
|
||||||
time.sleep(0.5)
|
|
||||||
if os.path.exists(snapshot_local_path):
|
|
||||||
break
|
|
||||||
|
|
||||||
if os.path.exists(snapshot_local_path):
|
|
||||||
object_key = self._upload_snapshot_to_cos(
|
object_key = self._upload_snapshot_to_cos(
|
||||||
snapshot_local_path,
|
image_bytes,
|
||||||
alarm_id,
|
alarm_id,
|
||||||
alarm_data.get("device_id", "unknown"),
|
alarm_data.get("device_id", "unknown"),
|
||||||
)
|
)
|
||||||
@@ -205,17 +199,12 @@ class AlarmUploadWorker:
|
|||||||
# COS 上传失败,进入重试
|
# COS 上传失败,进入重试
|
||||||
self._handle_retry(alarm_json, "COS 上传失败")
|
self._handle_retry(alarm_json, "COS 上传失败")
|
||||||
return
|
return
|
||||||
elif object_key == "":
|
except Exception as e:
|
||||||
# COS 未配置,使用本地截图路径作为回退
|
self._logger.error(f"截图解码/上传失败: {e}")
|
||||||
captures_base = os.path.join("data", "captures")
|
self._handle_retry(alarm_json, f"截图处理失败: {e}")
|
||||||
rel_path = os.path.relpath(snapshot_local_path, captures_base)
|
return
|
||||||
rel_path = rel_path.replace("\\", "/")
|
|
||||||
object_key = f"local:{rel_path}"
|
|
||||||
self._logger.info(f"使用本地截图路径: {object_key}")
|
|
||||||
else:
|
|
||||||
self._logger.warning(f"截图文件不存在: {snapshot_local_path}")
|
|
||||||
|
|
||||||
# Step 2: HTTP 上报告警元数据
|
# Step 2: HTTP 上报告警元数据(不含截图二进制数据)
|
||||||
report_data = {
|
report_data = {
|
||||||
"alarm_id": alarm_data.get("alarm_id"),
|
"alarm_id": alarm_data.get("alarm_id"),
|
||||||
"alarm_type": alarm_data.get("alarm_type"),
|
"alarm_type": alarm_data.get("alarm_type"),
|
||||||
@@ -234,14 +223,6 @@ class AlarmUploadWorker:
|
|||||||
if success:
|
if success:
|
||||||
self._stats["processed"] += 1
|
self._stats["processed"] += 1
|
||||||
self._logger.info(f"告警上报成功: {alarm_id}")
|
self._logger.info(f"告警上报成功: {alarm_id}")
|
||||||
|
|
||||||
# 仅在 COS 上传成功时删除本地截图;本地回退模式(local:)不删除
|
|
||||||
if snapshot_local_path and os.path.exists(snapshot_local_path) and object_key and not object_key.startswith("local:"):
|
|
||||||
try:
|
|
||||||
os.remove(snapshot_local_path)
|
|
||||||
self._logger.debug(f"已删除本地截图: {snapshot_local_path}")
|
|
||||||
except Exception as e:
|
|
||||||
self._logger.warning(f"删除本地截图失败: {e}")
|
|
||||||
else:
|
else:
|
||||||
# HTTP 上报失败,进入重试
|
# HTTP 上报失败,进入重试
|
||||||
self._handle_retry(alarm_json, "HTTP 上报失败")
|
self._handle_retry(alarm_json, "HTTP 上报失败")
|
||||||
@@ -277,13 +258,13 @@ class AlarmUploadWorker:
|
|||||||
self._logger.warning(f"告警结束上报异常: {e}")
|
self._logger.warning(f"告警结束上报异常: {e}")
|
||||||
|
|
||||||
def _upload_snapshot_to_cos(
|
def _upload_snapshot_to_cos(
|
||||||
self, local_path: str, alarm_id: str, device_id: str
|
self, image_bytes: bytes, alarm_id: str, device_id: str
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
上传截图到腾讯云 COS
|
上传截图到腾讯云 COS(直接从内存字节流上传)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
local_path: 本地截图路径
|
image_bytes: JPEG 图片字节
|
||||||
alarm_id: 告警ID
|
alarm_id: 告警ID
|
||||||
device_id: 设备ID
|
device_id: 设备ID
|
||||||
|
|
||||||
@@ -292,8 +273,8 @@ class AlarmUploadWorker:
|
|||||||
"""
|
"""
|
||||||
cos_cfg = self._settings.cos
|
cos_cfg = self._settings.cos
|
||||||
if not cos_cfg.secret_id or not cos_cfg.bucket:
|
if not cos_cfg.secret_id or not cos_cfg.bucket:
|
||||||
self._logger.warning("COS 未配置,跳过截图上传")
|
self._logger.error("COS 未配置(缺少 secret_id 或 bucket),无法上传截图")
|
||||||
return ""
|
return None
|
||||||
|
|
||||||
# 懒初始化 COS 客户端
|
# 懒初始化 COS 客户端
|
||||||
if self._cos_client is None:
|
if self._cos_client is None:
|
||||||
@@ -317,10 +298,11 @@ class AlarmUploadWorker:
|
|||||||
object_key = f"alarms/{device_id}/{date_str}/{alarm_id}.jpg"
|
object_key = f"alarms/{device_id}/{date_str}/{alarm_id}.jpg"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._cos_client.put_object_from_local_file(
|
self._cos_client.put_object(
|
||||||
Bucket=cos_cfg.bucket,
|
Bucket=cos_cfg.bucket,
|
||||||
LocalFilePath=local_path,
|
Body=image_bytes,
|
||||||
Key=object_key,
|
Key=object_key,
|
||||||
|
ContentType="image/jpeg",
|
||||||
)
|
)
|
||||||
self._stats["cos_uploaded"] += 1
|
self._stats["cos_uploaded"] += 1
|
||||||
self._logger.info(f"COS 上传成功: {object_key}")
|
self._logger.info(f"COS 上传成功: {object_key}")
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class AlarmInfo:
|
|||||||
scene_id: str
|
scene_id: str
|
||||||
event_time: str # ISO8601
|
event_time: str # ISO8601
|
||||||
alarm_level: int # 1-4
|
alarm_level: int # 1-4
|
||||||
snapshot_local_path: Optional[str] = None
|
snapshot_b64: Optional[str] = None # Base64 编码的 JPEG 截图
|
||||||
algorithm_code: Optional[str] = None
|
algorithm_code: Optional[str] = None
|
||||||
confidence_score: Optional[float] = None
|
confidence_score: Optional[float] = None
|
||||||
ext_data: Optional[Dict[str, Any]] = field(default_factory=dict)
|
ext_data: Optional[Dict[str, Any]] = field(default_factory=dict)
|
||||||
@@ -44,7 +44,7 @@ class AlarmInfo:
|
|||||||
"scene_id": self.scene_id,
|
"scene_id": self.scene_id,
|
||||||
"event_time": self.event_time,
|
"event_time": self.event_time,
|
||||||
"alarm_level": self.alarm_level,
|
"alarm_level": self.alarm_level,
|
||||||
"snapshot_local_path": self.snapshot_local_path,
|
"snapshot_b64": self.snapshot_b64,
|
||||||
"algorithm_code": self.algorithm_code,
|
"algorithm_code": self.algorithm_code,
|
||||||
"confidence_score": self.confidence_score,
|
"confidence_score": self.confidence_score,
|
||||||
"ext_data": self.ext_data,
|
"ext_data": self.ext_data,
|
||||||
@@ -85,21 +85,12 @@ class ResultReporter:
|
|||||||
"queue_failures": 0,
|
"queue_failures": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 图片存储(本地保存截图供 worker 读取上传)
|
|
||||||
self._image_storage = None
|
|
||||||
self._db_manager = None
|
self._db_manager = None
|
||||||
|
|
||||||
self._logger.info("ResultReporter 初始化完成(Redis 缓冲模式)")
|
self._logger.info("ResultReporter 初始化完成(Redis 缓冲模式)")
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""初始化 Redis 连接和本地存储"""
|
"""初始化 Redis 连接"""
|
||||||
# 初始化本地存储(截图保存)
|
|
||||||
try:
|
|
||||||
from core.storage_manager import get_image_storage
|
|
||||||
self._image_storage = get_image_storage()
|
|
||||||
except Exception as e:
|
|
||||||
self._logger.warning(f"本地图片存储初始化失败: {e}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from config.database import get_sqlite_manager
|
from config.database import get_sqlite_manager
|
||||||
self._db_manager = get_sqlite_manager()
|
self._db_manager = get_sqlite_manager()
|
||||||
@@ -138,19 +129,18 @@ class ResultReporter:
|
|||||||
"""
|
"""
|
||||||
self._performance_stats["alerts_generated"] += 1
|
self._performance_stats["alerts_generated"] += 1
|
||||||
|
|
||||||
# 保存截图到本地,获取本地路径
|
# 将截图编码为 JPEG base64,直接通过 Redis 传递给 Worker 上传 COS
|
||||||
if screenshot is not None and self._image_storage:
|
if screenshot is not None:
|
||||||
try:
|
try:
|
||||||
local_path = self._image_storage.save_capture(
|
import cv2
|
||||||
image=screenshot,
|
import base64
|
||||||
camera_id=alarm_info.device_id,
|
success, buffer = cv2.imencode('.jpg', screenshot, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
alert_id=alarm_info.alarm_id,
|
if success:
|
||||||
timestamp=datetime.now(),
|
alarm_info.snapshot_b64 = base64.b64encode(buffer.tobytes()).decode('ascii')
|
||||||
)
|
else:
|
||||||
if local_path:
|
self._logger.warning("截图 JPEG 编码失败")
|
||||||
alarm_info.snapshot_local_path = local_path
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.error(f"保存截图失败: {e}")
|
self._logger.error(f"编码截图失败: {e}")
|
||||||
|
|
||||||
# 写入 Redis 队列
|
# 写入 Redis 队列
|
||||||
if self._redis is None:
|
if self._redis is None:
|
||||||
@@ -209,12 +199,6 @@ class ResultReporter:
|
|||||||
"""关闭上报器"""
|
"""关闭上报器"""
|
||||||
self._logger.info("ResultReporter 资源清理")
|
self._logger.info("ResultReporter 资源清理")
|
||||||
|
|
||||||
if self._image_storage:
|
|
||||||
try:
|
|
||||||
self._image_storage.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self._db_manager:
|
if self._db_manager:
|
||||||
try:
|
try:
|
||||||
self._db_manager.close()
|
self._db_manager.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user