fix: 告警列表500错误修复 + 摄像头查询容错 + COS认证重构

- 添加缺失的 import httpx(修复 _notify_ops_platform NameError)
- 摄像头批量查询加 try/except 容错,失败时降级使用 device_id
- 摄像头查询超时从 5 秒提升到 15 秒
- COS 存储重构:支持 CVM 角色认证 + 密钥认证双模式
- STS 接口改为返回提示(CVM 模式不支持 STS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 13:33:28 +08:00
parent 9cee0a2cac
commit 51dbad6794
4 changed files with 171 additions and 224 deletions

View File

@@ -16,15 +16,12 @@ class DatabaseConfig:
@dataclass @dataclass
class COSConfig: class COSConfig:
"""腾讯云 COS 存储配置""" """腾讯云 COS 存储配置(通过 CVM 角色认证,无需密钥)"""
secret_id: str = ""
secret_key: str = ""
region: str = "ap-beijing" region: str = "ap-beijing"
bucket: str = "" # 格式: bucketname-appid bucket: str = "" # 格式: bucketname-appid
upload_prefix: str = "alerts" # 对象 Key 前缀 upload_prefix: str = "alerts" # 对象 Key 前缀
presign_expire: int = 1800 # 预签名URL有效期默认30分钟 presign_expire: int = 1800 # 预签名URL有效期默认30分钟
sts_expire: int = 1800 # STS 临时凭证有效期(秒) enabled: bool = False # 是否启用 COS
enabled: bool = False # 是否启用 COSFalse 时使用本地存储)
@dataclass @dataclass
@@ -75,7 +72,7 @@ class CameraNameConfig:
name_field_priority: list = None name_field_priority: list = None
# 查询超时(秒) # 查询超时(秒)
query_timeout: int = 5 query_timeout: int = 15
def __post_init__(self): def __post_init__(self):
if self.name_field_priority is None: if self.name_field_priority is None:
@@ -103,13 +100,10 @@ def load_settings() -> Settings:
url=os.getenv("DATABASE_URL", "sqlite:///./data/alert_platform.db"), url=os.getenv("DATABASE_URL", "sqlite:///./data/alert_platform.db"),
), ),
cos=COSConfig( cos=COSConfig(
secret_id=os.getenv("COS_SECRET_ID", ""),
secret_key=os.getenv("COS_SECRET_KEY", ""),
region=os.getenv("COS_REGION", "ap-beijing"), region=os.getenv("COS_REGION", "ap-beijing"),
bucket=os.getenv("COS_BUCKET", ""), bucket=os.getenv("COS_BUCKET", ""),
upload_prefix=os.getenv("COS_UPLOAD_PREFIX", "alerts"), upload_prefix=os.getenv("COS_UPLOAD_PREFIX", "alerts"),
presign_expire=int(os.getenv("COS_PRESIGN_EXPIRE", "1800")), presign_expire=int(os.getenv("COS_PRESIGN_EXPIRE", "1800")),
sts_expire=int(os.getenv("COS_STS_EXPIRE", "1800")),
enabled=os.getenv("COS_ENABLED", "false").lower() == "true", enabled=os.getenv("COS_ENABLED", "false").lower() == "true",
), ),
app=AppConfig( app=AppConfig(
@@ -133,7 +127,7 @@ def load_settings() -> Settings:
camera_name=CameraNameConfig( camera_name=CameraNameConfig(
wvp_api_base=os.getenv("WVP_API_BASE", "http://localhost:18080"), wvp_api_base=os.getenv("WVP_API_BASE", "http://localhost:18080"),
display_format=os.getenv("CAMERA_NAME_FORMAT", "{name}"), display_format=os.getenv("CAMERA_NAME_FORMAT", "{name}"),
query_timeout=int(os.getenv("CAMERA_QUERY_TIMEOUT", "5")), query_timeout=int(os.getenv("CAMERA_QUERY_TIMEOUT", "15")),
), ),
) )

View File

@@ -16,6 +16,8 @@ from fastapi import APIRouter, Query, Depends, HTTPException
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
import asyncio import asyncio
import os
import httpx
from app.yudao_compat import YudaoResponse, get_current_user from app.yudao_compat import YudaoResponse, get_current_user
from app.services.alarm_event_service import get_alarm_event_service, AlarmEventService from app.services.alarm_event_service import get_alarm_event_service, AlarmEventService
@@ -41,8 +43,8 @@ async def _alarm_to_camel(alarm_dict: dict, camera_info_map: dict = None, camera
snapshot_url = alarm_dict.get("snapshot_url") snapshot_url = alarm_dict.get("snapshot_url")
if snapshot_url: if snapshot_url:
if snapshot_url.startswith("local:"): if snapshot_url.startswith("local:"):
# 本地截图(COS 未配置时的回退路径) # 边缘端 COS 未配置时的本地路径标记,云端无法访问
snapshot_url = "/captures/" + snapshot_url[6:] snapshot_url = ""
else: else:
snapshot_url = storage.get_url(snapshot_url) snapshot_url = storage.get_url(snapshot_url)
@@ -173,9 +175,13 @@ async def get_alert_page(
# 提取所有唯一的 device_id # 提取所有唯一的 device_id
device_ids = list(set(a.device_id for a in alarms if a.device_id)) device_ids = list(set(a.device_id for a in alarms if a.device_id))
# 批量查询摄像头信息(去重+并发优化) # 批量查询摄像头信息(去重+并发优化),失败不影响列表返回
camera_service = get_camera_name_service() camera_service = get_camera_name_service()
camera_info_map = await camera_service.get_camera_infos_batch(device_ids) camera_info_map = {}
try:
camera_info_map = await camera_service.get_camera_infos_batch(device_ids)
except Exception as e:
logger.warning(f"批量查询摄像头信息失败,将使用 device_id 作为名称: {e}")
# 转换为 camelCase 格式(使用摄像头信息映射) # 转换为 camelCase 格式(使用摄像头信息映射)
alarm_list = [await _alarm_to_camel(a.to_dict(), camera_info_map, camera_service) for a in alarms] alarm_list = [await _alarm_to_camel(a.to_dict(), camera_info_map, camera_service) for a in alarms]
@@ -203,13 +209,16 @@ async def get_alert(
if not alarm_dict: if not alarm_dict:
raise HTTPException(status_code=404, detail="告警不存在") raise HTTPException(status_code=404, detail="告警不存在")
# 查询单个摄像头信息 # 查询单个摄像头信息(失败不影响详情返回)
device_id = alarm_dict.get("device_id") device_id = alarm_dict.get("device_id")
camera_service = get_camera_name_service() camera_service = get_camera_name_service()
camera_info_map = {} camera_info_map = {}
if device_id: if device_id:
camera_info = await camera_service.get_camera_info(device_id) try:
camera_info_map[device_id] = camera_info camera_info = await camera_service.get_camera_info(device_id)
camera_info_map[device_id] = camera_info
except Exception as e:
logger.warning(f"查询摄像头信息失败: device_id={device_id}, error={e}")
return YudaoResponse.success(await _alarm_to_camel(alarm_dict, camera_info_map, camera_service)) return YudaoResponse.success(await _alarm_to_camel(alarm_dict, camera_info_map, camera_service))
@@ -296,8 +305,12 @@ async def get_device_summary_page(
for item in result.get("list", []): for item in result.get("list", []):
device_id = item.get("deviceId") device_id = item.get("deviceId")
# 批量查询摄像头信息 # 查询摄像头信息(失败不影响列表返回)
camera_info = await camera_service.get_camera_info(device_id) camera_info = None
try:
camera_info = await camera_service.get_camera_info(device_id)
except Exception as e:
logger.warning(f"查询摄像头信息失败: device_id={device_id}, error={e}")
# 提取摄像头名称 # 提取摄像头名称
device_name = camera_service.format_display_name(device_id, camera_info) device_name = camera_service.format_display_name(device_id, camera_info)
@@ -398,7 +411,7 @@ async def edge_alarm_resolve(
# ==================== 辅助函数 ==================== # ==================== 辅助函数 ====================
OPS_ALARM_URL = "http://192.168.0.104:48080/admin-api/ops/alarm/receive" OPS_ALARM_URL = os.getenv("OPS_ALARM_URL", "http://192.168.0.104:48080/admin-api/ops/alarm/receive")
async def _notify_ops_platform(data: dict): async def _notify_ops_platform(data: dict):

View File

@@ -117,21 +117,9 @@ async def get_sts_credential(
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
""" """
获取 STS 临时凭证 STS 凭证接口CVM 角色模式下不可用,请使用 /upload-url 预签名上传)
前端使用 COS JS SDK 直传时,先调用此接口获取临时密钥。
返回的 tmpSecretId / tmpSecretKey / sessionToken 用于初始化 SDK。
凭证仅允许向指定前缀上传,不可读取或删除其他路径。
""" """
storage = get_oss_storage() raise HTTPException(
status_code=400,
if not storage.is_cos_mode: detail="当前使用 CVM 角色认证,不支持 STS 签发。请使用 /admin-api/aiot/storage/upload-url 获取预签名上传 URL"
raise HTTPException(status_code=400, detail="COS 未启用,无法签发 STS 凭证") )
allow_prefix = f"{prefix}/*"
credential = storage.get_sts_credential(allow_prefix)
if not credential:
raise HTTPException(status_code=500, detail="STS 凭证签发失败,请检查 COS 配置或安装 qcloud-python-sts")
return YudaoResponse.success(credential)

View File

@@ -1,23 +1,22 @@
""" """
对象存储服务(腾讯云 COS + 本地回退 对象存储服务(腾讯云 COS
功能 支持两种认证模式
- COS_ENABLED=true 时使用腾讯云 COS 存储 1. CVM 角色认证通过元数据服务自动获取临时凭证推荐Docker 需配置 iptables 转发)
- COS_ENABLED=false 时回退到本地 uploads/ 目录 2. 密钥认证:通过 COS_SECRET_ID / COS_SECRET_KEY 环境变量(备选)
- 后端上传:服务端直接将图片/文件写入 COS
- 预签名下载:生成带鉴权的临时下载 URL
- STS 临时凭证:签发前端直传用的临时 AK/SK/Token
""" """
import os
import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Dict from typing import Optional
from pathlib import Path from pathlib import Path
from app.config import settings from app.config import settings
from app.utils.logger import logger from app.utils.logger import logger
# 按需导入 COS SDK,未安装时不影响本地模式 # 按需导入 COS SDK
_cos_client = None _cos_client = None
_cos_available = False _cos_available = False
@@ -28,6 +27,70 @@ except ImportError:
_cos_available = False _cos_available = False
class CosStorageError(Exception):
"""COS 存储操作异常"""
pass
class CvmRoleCredential:
"""CVM 角色凭证提供者,从元数据服务自动获取和刷新临时凭证"""
def __init__(self, role_name: str):
self._role_name = role_name
self._expired_time = 0
self._secret_id = ""
self._secret_key = ""
self._token = ""
self._refresh()
def _refresh(self):
"""从元数据服务获取临时凭证提前5分钟刷新"""
if time.time() < self._expired_time - 300:
return
import requests
url = f"http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/{self._role_name}"
resp = requests.get(url, timeout=5)
resp.raise_for_status()
data = resp.json()
if data.get("Code") != "Success":
raise CosStorageError(f"获取 CVM 角色凭证失败: {data}")
self._secret_id = data["TmpSecretId"]
self._secret_key = data["TmpSecretKey"]
self._token = data["Token"]
self._expired_time = data["ExpiredTime"]
logger.info(f"CVM 角色凭证已刷新,过期时间: {datetime.fromtimestamp(self._expired_time)}")
@property
def secret_id(self):
self._refresh()
return self._secret_id
@property
def secret_key(self):
self._refresh()
return self._secret_key
@property
def token(self):
self._refresh()
return self._token
def _discover_cvm_role() -> Optional[str]:
"""自动发现 CVM 绑定的角色名"""
import requests
try:
resp = requests.get(
"http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/",
timeout=3,
)
if resp.status_code == 200 and resp.text.strip():
return resp.text.strip()
except Exception:
pass
return None
def _get_cos_client(): def _get_cos_client():
"""懒加载 COS 客户端单例""" """懒加载 COS 客户端单例"""
global _cos_client global _cos_client
@@ -35,33 +98,55 @@ def _get_cos_client():
return _cos_client return _cos_client
if not _cos_available: if not _cos_available:
logger.warning("qcloud_cos 未安装,使用本地存储模式") raise CosStorageError("qcloud_cos 未安装,请运行: pip install cos-python-sdk-v5")
return None
cfg = settings.cos cfg = settings.cos
if not cfg.enabled or not cfg.secret_id or not cfg.bucket: if not cfg.enabled:
return None return None
if not cfg.bucket:
raise CosStorageError("COS_BUCKET 未配置")
secret_id = os.getenv("COS_SECRET_ID", "")
secret_key = os.getenv("COS_SECRET_KEY", "")
try: try:
cos_config = CosConfig( if secret_id and secret_key:
Region=cfg.region, # 模式1密钥认证
SecretId=cfg.secret_id, cos_config = CosConfig(
SecretKey=cfg.secret_key, Region=cfg.region,
Scheme="https", SecretId=secret_id,
) SecretKey=secret_key,
Scheme="https",
)
logger.info(f"COS 客户端初始化成功 (密钥模式): bucket={cfg.bucket}")
else:
# 模式2CVM 角色认证(通过元数据服务获取临时凭证)
role_name = _discover_cvm_role()
if not role_name:
raise CosStorageError(
"COS 认证失败: 未设置 COS_SECRET_ID/COS_SECRET_KEY"
"且无法访问 CVM 元数据服务。请检查 iptables 转发规则或配置密钥。"
)
credential = CvmRoleCredential(role_name)
cos_config = CosConfig(
Region=cfg.region,
CredentialInstance=credential,
Scheme="https",
)
logger.info(f"COS 客户端初始化成功 (CVM角色: {role_name}): bucket={cfg.bucket}")
_cos_client = CosS3Client(cos_config) _cos_client = CosS3Client(cos_config)
logger.info(f"COS 客户端初始化成功: bucket={cfg.bucket}, region={cfg.region}")
return _cos_client return _cos_client
except CosStorageError:
raise
except Exception as e: except Exception as e:
logger.error(f"COS 客户端初始化失败: {e}") raise CosStorageError(f"COS 客户端初始化失败: {e}")
return None
def _generate_object_key(prefix: str = "", ext: str = ".jpg") -> str: def _generate_object_key(prefix: str = "", ext: str = ".jpg") -> str:
""" """
生成对象存储 Key 生成对象存储 Key
格式: {prefix}/{YYYY}/{MM}/{DD}/{YYYYMMDDHHmmss}_{uuid8}{ext} 格式: {prefix}/{YYYY}/{MM}/{DD}/{YYYYMMDDHHmmss}_{uuid8}{ext}
示例: alerts/2026/02/09/20260209153000_A1B2C3D4.jpg
""" """
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
date_path = now.strftime("%Y/%m/%d") date_path = now.strftime("%Y/%m/%d")
@@ -73,47 +158,41 @@ def _generate_object_key(prefix: str = "", ext: str = ".jpg") -> str:
class COSStorage: class COSStorage:
""" """
对象存储统一接口 对象存储接口(纯 COS 模式)
- COS 模式:调用腾讯云 COS SDK COS_ENABLED=true: 使用腾讯云 COS上传失败直接报错
- 本地模式:写入 uploads/ 目录,返回相对路径 COS_ENABLED=false: 未启用,调用上传方法时报错
""" """
def __init__(self): def __init__(self):
self._client = None self._client = None
self._use_local = True self._enabled = False
self._init() self._init()
def _init(self): def _init(self):
"""初始化存储后""" """初始化 COS 客户"""
if settings.cos.enabled: if not settings.cos.enabled:
client = _get_cos_client() logger.info("COS 未启用 (COS_ENABLED=false)")
if client: return
self._client = client
self._use_local = False
return
logger.info("使用本地文件存储模式") self._client = _get_cos_client()
self._use_local = True if self._client:
self._enabled = True
@property @property
def is_cos_mode(self) -> bool: def is_cos_mode(self) -> bool:
return not self._use_local and self._client is not None return self._enabled and self._client is not None
def _require_cos(self):
"""检查 COS 是否可用,不可用则抛出异常"""
if not self._enabled or self._client is None:
raise CosStorageError("COS 未启用或初始化失败,请检查 COS_ENABLED 和 CVM 角色绑定")
# ======================== 上传 ======================== # ======================== 上传 ========================
def upload_image(self, image_data: bytes, filename: Optional[str] = None) -> str: def upload_image(self, image_data: bytes, filename: Optional[str] = None) -> str:
""" """上传图片到 COS返回 object_key"""
上传图片,返回 object_keyCOS 模式)或本地路径(本地模式) self._require_cos()
数据库中存储此返回值,下载时通过 get_presigned_url() 获取临时访问地址。
"""
if self._use_local:
return self._upload_local(image_data, filename)
return self._upload_cos(image_data, filename)
def _upload_cos(self, image_data: bytes, filename: Optional[str] = None) -> str:
"""上传到 COS"""
object_key = filename or _generate_object_key(ext=".jpg") object_key = filename or _generate_object_key(ext=".jpg")
try: try:
self._client.put_object( self._client.put_object(
@@ -125,13 +204,11 @@ class COSStorage:
logger.info(f"COS 上传成功: {object_key}") logger.info(f"COS 上传成功: {object_key}")
return object_key return object_key
except Exception as e: except Exception as e:
logger.error(f"COS 上传失败,回退本地: {e}") raise CosStorageError(f"COS 图片上传失败: {e}")
return self._upload_local(image_data, filename)
def upload_file(self, file_data: bytes, object_key: str, content_type: str = "application/octet-stream") -> str: def upload_file(self, file_data: bytes, object_key: str, content_type: str = "application/octet-stream") -> str:
"""上传任意文件到 COS""" """上传任意文件到 COS"""
if self._use_local: self._require_cos()
return self._upload_local(file_data, object_key)
try: try:
self._client.put_object( self._client.put_object(
Bucket=settings.cos.bucket, Bucket=settings.cos.bucket,
@@ -142,35 +219,16 @@ class COSStorage:
logger.info(f"COS 文件上传成功: {object_key}") logger.info(f"COS 文件上传成功: {object_key}")
return object_key return object_key
except Exception as e: except Exception as e:
logger.error(f"COS 文件上传失败: {e}") raise CosStorageError(f"COS 文件上传失败: {e}")
return self._upload_local(file_data, object_key)
def _upload_local(self, data: bytes, filename: Optional[str] = None) -> str:
"""本地存储回退"""
upload_dir = Path("uploads")
if filename is None:
filename = _generate_object_key(ext=".jpg")
file_path = upload_dir / filename
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "wb") as f:
f.write(data)
local_url = f"/uploads/{filename}"
logger.info(f"本地保存: {local_url}")
return local_url
# ======================== 下载(预签名 URL ======================== # ======================== 下载(预签名 URL ========================
def get_presigned_url(self, object_key: str, expire: Optional[int] = None) -> str: def get_presigned_url(self, object_key: str, expire: Optional[int] = None) -> str:
""" """获取预签名下载 URL"""
获取预签名下载 URL if object_key.startswith("http"):
return object_key
- COS 模式:生成带签名的临时 URL过期后失效 if not self._enabled:
- 本地模式:直接返回本地路径
"""
if self._use_local or object_key.startswith("/uploads/") or object_key.startswith("http"):
return object_key return object_key
expire = expire or settings.cos.presign_expire expire = expire or settings.cos.presign_expire
@@ -186,14 +244,8 @@ class COSStorage:
return object_key return object_key
def get_presigned_upload_url(self, object_key: str, expire: Optional[int] = None) -> str: def get_presigned_upload_url(self, object_key: str, expire: Optional[int] = None) -> str:
""" """获取预签名上传 URL供前端直传"""
获取预签名上传 URL供前端直传 self._require_cos()
前端拿到此 URL 后,直接 PUT 文件即可,无需经过后端中转。
"""
if self._use_local:
return ""
expire = expire or settings.cos.presign_expire expire = expire or settings.cos.presign_expire
try: try:
url = self._client.get_presigned_url( url = self._client.get_presigned_url(
@@ -204,111 +256,14 @@ class COSStorage:
) )
return url return url
except Exception as e: except Exception as e:
logger.error(f"生成预签名上传 URL 失败: {e}") raise CosStorageError(f"生成预签名上传 URL 失败: {e}")
return ""
# ======================== STS 临时凭证 ========================
def get_sts_credential(self, allow_prefix: Optional[str] = None) -> Optional[Dict]:
"""
获取 STS 临时凭证(供前端 SDK 直传)
需安装: pip install qcloud-python-sts
返回:
{
"credentials": {"tmpSecretId": ..., "tmpSecretKey": ..., "sessionToken": ...},
"expiredTime": ...,
"startTime": ...,
"bucket": ...,
"region": ...,
"allowPrefix": ...
}
"""
if self._use_local:
return None
try:
from sts.sts import Sts
cfg = settings.cos
prefix = allow_prefix or f"{cfg.upload_prefix}/*"
# 提取 appid from bucket (格式: name-appid)
parts = cfg.bucket.rsplit("-", 1)
appid = parts[1] if len(parts) == 2 else ""
sts_config = {
"duration_seconds": cfg.sts_expire,
"secret_id": cfg.secret_id,
"secret_key": cfg.secret_key,
"bucket": cfg.bucket,
"region": cfg.region,
"allow_prefix": prefix,
"allow_actions": [
"name/cos:PutObject",
"name/cos:PostObject",
"name/cos:InitiateMultipartUpload",
"name/cos:ListMultipartUploads",
"name/cos:ListParts",
"name/cos:UploadPart",
"name/cos:CompleteMultipartUpload",
],
"policy": {
"version": "2.0",
"statement": [
{
"action": [
"name/cos:PutObject",
"name/cos:PostObject",
"name/cos:InitiateMultipartUpload",
"name/cos:ListMultipartUploads",
"name/cos:ListParts",
"name/cos:UploadPart",
"name/cos:CompleteMultipartUpload",
],
"effect": "allow",
"resource": [
f"qcs::cos:{cfg.region}:uid/{appid}:{cfg.bucket}/{prefix}",
],
}
],
},
}
sts = Sts(sts_config)
response = sts.get_credential()
result = {
"credentials": response["credentials"],
"expiredTime": response["expiredTime"],
"startTime": response.get("startTime"),
"bucket": cfg.bucket,
"region": cfg.region,
"allowPrefix": prefix,
}
logger.info(f"STS 凭证签发成功, prefix={prefix}")
return result
except ImportError:
logger.error("qcloud-python-sts 未安装,无法签发 STS 凭证。请运行: pip install qcloud-python-sts")
return None
except Exception as e:
logger.error(f"STS 凭证签发失败: {e}")
return None
# ======================== 删除 ======================== # ======================== 删除 ========================
def delete_object(self, object_key: str) -> bool: def delete_object(self, object_key: str) -> bool:
"""删除对象""" """删除 COS 对象"""
if self._use_local: if not self._enabled:
local_path = Path(object_key.lstrip("/"))
if local_path.exists():
local_path.unlink()
return True
return False return False
try: try:
self._client.delete_object( self._client.delete_object(
Bucket=settings.cos.bucket, Bucket=settings.cos.bucket,
@@ -328,9 +283,6 @@ class COSStorage:
return "" return ""
if path.startswith("http"): if path.startswith("http"):
return path return path
if path.startswith("/uploads/"):
return path
# COS object_key → 预签名 URL
return self.get_presigned_url(path) return self.get_presigned_url(path)
@@ -339,7 +291,7 @@ _cos_storage: Optional[COSStorage] = None
def get_oss_storage() -> COSStorage: def get_oss_storage() -> COSStorage:
"""获取存储服务单例(保持旧函数名兼容)""" """获取存储服务单例"""
global _cos_storage global _cos_storage
if _cos_storage is None: if _cos_storage is None:
_cos_storage = COSStorage() _cos_storage = COSStorage()