Files
tencent_face_recognition/tencent_cloud_client.py
T

1076 lines
44 KiB
Python
Raw Normal View History

2025-09-07 21:48:21 +08:00
"""腾讯云客户端"""
import base64
import io
import json
import logging
import os
import random
import re
import requests
import time
import uuid
from typing import Dict, Any, List, Optional, Union, Callable
from tencentcloud.common import credential
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.iai.v20200303 import iai_client, models
from .const import (
CONF_SECRET_ID,
CONF_SECRET_KEY,
CONF_REGION,
)
from .errors import (
handle_api_error,
ImageNotFoundError,
FaceNotDetectedError,
log_and_raise_error,
validate_config,
NetworkError,
AuthenticationError,
RateLimitError,
)
_LOGGER = logging.getLogger(__name__)
# 日志记录配置
ENABLE_REQUEST_LOGGING = True # 是否启用请求日志记录
ENABLE_RESPONSE_LOGGING = True # 是否启用响应日志记录
ENABLE_PERFORMANCE_LOGGING = True # 是否启用性能日志记录
LOG_REQUEST_PAYLOAD_MAX_SIZE = 1024 # 请求负载日志记录的最大大小(字节)
LOG_RESPONSE_PAYLOAD_MAX_SIZE = 1024 # 响应负载日志记录的最大大小(字节)
# API调用重试配置
MAX_RETRIES = 1
INITIAL_RETRY_DELAY = 1 # 秒
MAX_RETRY_DELAY = 30 # 秒
RETRY_BACKOFF_FACTOR = 2 # 指数退避因子
RETRY_JITTER = 0.1 # 抖动因子,避免多个客户端同时重试
# 错误码分类
NON_RETRYABLE_ERRORS = {
"AuthFailure", # 认证失败
"InvalidParameter", # 无效参数
"ResourceNotFound", # 资源未找到
"LimitExceeded", # 配额超限
"RequestLimitExceeded", # 请求频率超限
"RequestQuotaExceeded", # 请求配额超限
}
RATE_LIMIT_ERRORS = {
"RequestLimitExceeded",
"RequestQuotaExceeded",
"LimitExceeded",
}
NETWORK_ERRORS = {
"ResourceUnavailable.NetworkError",
"ResourceUnavailable.ServiceTimeout",
}
# 图片处理配置
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_IMAGE_DIMENSION = 4096 # 最大图片尺寸
SUPPORTED_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".bmp", ".gif"]
IMAGE_COMPRESSION_QUALITY = 85 # 图片压缩质量
MIN_FACE_SIZE = 34 # 最小人脸尺寸
class RetryConfig:
"""重试配置类"""
def __init__(
self,
max_retries: int = MAX_RETRIES,
initial_delay: float = INITIAL_RETRY_DELAY,
max_delay: float = MAX_RETRY_DELAY,
backoff_factor: float = RETRY_BACKOFF_FACTOR,
jitter: float = RETRY_JITTER
):
self.max_retries = max_retries
self.initial_delay = initial_delay
self.max_delay = max_delay
self.backoff_factor = backoff_factor
self.jitter = jitter
class TencentCloudClient:
"""腾讯云客户端"""
def __init__(
self,
secret_id: str,
secret_key: str,
region: str = "ap-shanghai",
retry_config: Optional[RetryConfig] = None
):
"""初始化腾讯云客户端"""
# 验证配置
config = {
"secret_id": secret_id,
"secret_key": secret_key,
"region": region
}
validate_config(config)
self.secret_id = secret_id
self.secret_key = secret_key
self.region = region
self.retry_config = retry_config or RetryConfig()
self.client = self._create_client()
def _create_client(self) -> iai_client.IaiClient:
"""创建腾讯云人脸识别客户端"""
try:
_LOGGER.debug("创建腾讯云客户端,区域: %s", self.region)
cred = credential.Credential(self.secret_id, self.secret_key)
client = iai_client.IaiClient(cred, self.region)
_LOGGER.debug("腾讯云客户端创建成功")
return client
except Exception as ex:
_LOGGER.error("创建腾讯云客户端失败: %s", ex, exc_info=True)
log_and_raise_error(
AuthenticationError,
f"创建腾讯云客户端失败: {str(ex)}",
{"region": self.region, "error_type": type(ex).__name__}
)
def _handle_error(self, ex: TencentCloudSDKException) -> None:
"""处理API错误"""
_LOGGER.error(
"腾讯云API调用失败: 错误码=%s, 错误消息=%s, 请求ID=%s",
ex.code, ex.message, getattr(ex, 'request_id', '未知')
)
handle_api_error(ex.code, ex.message)
def _get_image_base64(self, image_url: str = None, image_path: str = None, image_file: str = None) -> str:
"""获取图片的base64编码"""
_LOGGER.debug(
"获取图片 base64: image_url=%s, image_path=%s, image_file is not None: %s",
image_url, image_path, image_file is not None
)
# 验证参数
if not any([image_url, image_path, image_file]):
log_and_raise_error(
ValueError,
"必须提供image_url、image_path或image_file"
)
try:
if image_url:
# 如果是URL,下载图片并转换为base64
_LOGGER.debug("使用图片URL: %s", image_url)
return self._download_image_as_base64(image_url)
elif image_path:
# 如果是本地文件路径,读取文件并转换为base64
_LOGGER.debug("使用本地图片路径: %s", image_path)
return self._read_local_image_as_base64(image_path)
elif image_file:
# 如果是上传的文件,直接转换为base64
_LOGGER.debug("使用上传的图片文件")
return self._process_uploaded_image_as_base64(image_file)
except Exception as ex:
_LOGGER.error("处理图片失败: %s", ex, exc_info=True)
log_and_raise_error(
ImageNotFoundError,
f"处理图片失败: {str(ex)}",
{"image_url": image_url, "image_path": image_path, "error_type": type(ex).__name__}
)
def _download_image_as_base64(self, image_url: str) -> str:
"""下载图片并转换为base64编码"""
try:
_LOGGER.debug("开始下载图片: %s", image_url)
# 验证URL格式
if not self._is_valid_url(image_url):
log_and_raise_error(
ValueError,
f"无效的图片URL: {image_url}",
{"url": image_url}
)
# 设置请求超时和重试
session = requests.Session()
session.mount('http://', requests.adapters.HTTPAdapter(max_retries=2))
session.mount('https://', requests.adapters.HTTPAdapter(max_retries=2))
# 设置请求头,模拟浏览器请求
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
# 下载图片
response = session.get(image_url, timeout=10, headers=headers)
response.raise_for_status()
# 检查内容类型是否为图片
content_type = response.headers.get('content-type', '').lower()
if not content_type.startswith('image/'):
log_and_raise_error(
ValueError,
f"URL不是有效的图片: {image_url}, 内容类型: {content_type}",
{"url": image_url, "content_type": content_type}
)
# 处理图片数据
image_data = self._process_image_data(response.content)
encoded_image = base64.b64encode(image_data).decode("utf-8")
_LOGGER.debug("成功下载并编码图片,大小: %d 字节", len(encoded_image))
return encoded_image
except requests.exceptions.Timeout:
_LOGGER.error("下载图片超时: %s", image_url)
log_and_raise_error(
NetworkError,
f"下载图片超时: {image_url}",
{"url": image_url, "error": "timeout"}
)
except requests.exceptions.ConnectionError:
_LOGGER.error("下载图片连接错误: %s", image_url)
log_and_raise_error(
NetworkError,
f"下载图片连接错误: {image_url}",
{"url": image_url, "error": "connection_error"}
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("下载图片失败: %s, 错误: %s", image_url, ex)
log_and_raise_error(
NetworkError,
f"下载图片失败: {image_url}",
{"url": image_url, "error": str(ex)}
)
def _read_local_image_as_base64(self, image_path: str) -> str:
"""读取本地图片并转换为base64编码"""
try:
# 检查文件是否存在
if not os.path.exists(image_path):
_LOGGER.error("图片文件不存在: %s", image_path)
log_and_raise_error(
ImageNotFoundError,
f"图片文件不存在: {image_path}",
{"path": image_path}
)
# 检查是否为文件
if not os.path.isfile(image_path):
_LOGGER.error("路径不是文件: %s", image_path)
log_and_raise_error(
ImageNotFoundError,
f"路径不是文件: {image_path}",
{"path": image_path}
)
# 检查文件扩展名
file_ext = os.path.splitext(image_path)[1].lower()
if file_ext not in SUPPORTED_IMAGE_FORMATS:
_LOGGER.error("不支持的图片格式: %s", file_ext)
log_and_raise_error(
ImageNotFoundError,
f"不支持的图片格式: {file_ext}",
{"path": image_path, "supported_formats": SUPPORTED_IMAGE_FORMATS}
)
# 检查文件大小
file_size = os.path.getsize(image_path)
if file_size > MAX_IMAGE_SIZE:
_LOGGER.error("图片文件过大: %s, 大小: %d 字节", image_path, file_size)
log_and_raise_error(
ImageNotFoundError,
f"图片文件过大: {image_path}",
{"path": image_path, "size": file_size, "max_size": f"{MAX_IMAGE_SIZE // (1024*1024)}MB"}
)
# 读取文件内容
with open(image_path, "rb") as img_file:
image_data = img_file.read()
# 处理图片数据
processed_image = self._process_image_data(image_data)
encoded_image = base64.b64encode(processed_image).decode("utf-8")
_LOGGER.debug("成功读取图片文件,大小: %d 字节", len(encoded_image))
return encoded_image
except PermissionError:
_LOGGER.error("没有权限读取图片文件: %s", image_path)
log_and_raise_error(
ImageNotFoundError,
f"没有权限读取图片文件: {image_path}",
{"path": image_path, "error": "permission_denied"}
)
except Exception as ex:
_LOGGER.error("读取图片文件失败: %s, 错误: %s", image_path, ex)
log_and_raise_error(
ImageNotFoundError,
f"读取图片文件失败: {image_path}",
{"path": image_path, "error": str(ex)}
)
def _process_uploaded_image_as_base64(self, image_file: str) -> str:
"""处理上传的图片文件并转换为base64编码"""
try:
# image_file 应该是 base64 编码的字符串或文件对象
if isinstance(image_file, str):
# 检查是否已经是 base64 编码
if image_file.startswith('data:image'):
# 处理 data URL 格式
_LOGGER.debug("处理 data URL 格式图片")
try:
header, encoded = image_file.split(',', 1)
_LOGGER.debug("成功解析 data URL 格式图片,头部: %s", header)
# 解码base64并重新处理
image_data = base64.b64decode(encoded)
processed_image = self._process_image_data(image_data)
return base64.b64encode(processed_image).decode("utf-8")
except ValueError:
_LOGGER.error("无效的 data URL 格式")
log_and_raise_error(
ImageNotFoundError,
"无效的 data URL 格式",
{"data_url_length": len(image_file)}
)
else:
# 假设已经是 base64 编码
_LOGGER.debug("使用 base64 编码图片,长度: %d", len(image_file))
# 解码base64并重新处理
try:
image_data = base64.b64decode(image_file)
processed_image = self._process_image_data(image_data)
return base64.b64encode(processed_image).decode("utf-8")
except Exception as ex:
_LOGGER.error("base64解码失败: %s", ex)
log_and_raise_error(
ImageNotFoundError,
"base64解码失败",
{"error": str(ex), "data_length": len(image_file)}
)
else:
# 如果是文件对象,读取并转换为 base64
_LOGGER.debug("处理文件对象")
image_data = image_file.read()
# 处理图片数据
processed_image = self._process_image_data(image_data)
encoded_image = base64.b64encode(processed_image).decode("utf-8")
_LOGGER.debug("成功读取上传的图片文件,大小: %d 字节", len(encoded_image))
return encoded_image
except Exception as ex:
_LOGGER.error("处理上传的图片文件失败: %s", ex)
log_and_raise_error(
ImageNotFoundError,
f"处理上传的图片文件失败",
{"error": str(ex), "error_type": type(ex).__name__}
)
def search_faces(
self,
image_url: str = None,
image_path: str = None,
image_file: str = None,
group_ids: List[str] = None,
max_face_num: int = 1,
min_face_size: int = 34,
max_user_num: int = 5,
quality_control: int = 1,
need_rotate_check: int = 1,
face_match_threshold: float = 60.0
2025-09-07 23:21:24 +08:00
) -> Dict[str, Any]:
2025-09-07 21:48:21 +08:00
"""搜索人脸"""
_LOGGER.info(
"开始搜索人脸: image_url=%s, image_path=%s, image_file is not None: %s, max_face_num=%d, min_face_size=%d, max_user_num=%d, quality_control=%d, need_rotate_check=%d, face_match_threshold=%.2f",
image_url, image_path, image_file is not None, max_face_num, min_face_size, max_user_num, quality_control, need_rotate_check, face_match_threshold
)
# 验证参数
if not group_ids:
_LOGGER.warning("未提供人员库ID,搜索可能失败")
# 定义API调用函数
def api_call():
# 生成请求ID
request_id = str(uuid.uuid4())
start_time = time.time()
try:
# 获取图片数据
image = self._get_image_base64(image_url, image_path, image_file)
# 创建请求
req = models.SearchFacesRequest()
params = {
"NeedPersonInfo": 1,
"Image": image,
"MaxFaceNum": max_face_num,
"MinFaceSize": min_face_size,
"MaxPersonNum": max_user_num,
"QualityControl": quality_control,
"NeedRotateCheck": need_rotate_check,
"FaceMatchThreshold": face_match_threshold,
"GroupIds": group_ids
}
# 记录请求日志
self._log_request("人脸搜索", params)
req.from_json_string(json.dumps(params))
# 发送请求
_LOGGER.debug("发送人脸搜索请求")
resp = self.client.SearchFaces(req)
# 计算请求耗时
duration = time.time() - start_time
# 记录响应日志
self._log_response("人脸搜索", request_id, resp, duration)
# 解析响应
results = []
if getattr(resp, "Results", []):
_LOGGER.info("检测到 %d 个人脸", len(resp.Results))
for result in resp.Results:
face_result = {
"face_id": getattr(result, "FaceId", None),
"candidates": []
}
if result.Candidates:
face_id = getattr(result, "FaceId", "未知")
_LOGGER.debug("人脸 %s%d 个候选匹配", face_id, len(result.Candidates))
for candidate in result.Candidates:
candidate_info = {
"person_id": candidate.PersonId,
"person_name": candidate.PersonName,
"score": candidate.Score,
"person_tag": getattr(candidate, "PersonTag", None)
}
face_result["candidates"].append(candidate_info)
_LOGGER.debug(
"候选匹配: person_id=%s, person_name=%s, score=%.2f, person_tag=%s",
candidate.PersonId, candidate.PersonName, candidate.Score, getattr(candidate, "PersonTag", None)
)
results.append(face_result)
else:
_LOGGER.info("未检测到人脸")
2025-09-07 23:21:24 +08:00
return {
"success": True,
"faces": results,
"error": None,
"error_code": None,
"error_message": None
}
2025-09-07 21:48:21 +08:00
except Exception as ex:
# 计算请求耗时
duration = time.time() - start_time
# 记录错误日志
self._log_error("人脸搜索", request_id, ex, duration)
2025-09-07 23:21:24 +08:00
# 不抛出异常,而是返回包含错误信息的结构化结果
error_code = None
error_message = str(ex)
# 如果是腾讯云API异常,提取错误码和消息
if isinstance(ex, TencentCloudSDKException):
error_code = getattr(ex, "code", None)
error_message = getattr(ex, "message", str(ex))
return {
"success": False,
"faces": [],
"error": str(ex),
"error_code": error_code,
"error_message": error_message
}
2025-09-07 21:48:21 +08:00
# 执行带重试的API调用
return self._execute_with_retry(api_call, "人脸搜索")
def detect_faces(
self,
image_url: str = None,
image_path: str = None,
image_file: str = None,
max_face_num: int = 1,
min_face_size: int = 34,
need_rotate_check: int = 1
2025-09-07 23:21:24 +08:00
) -> Dict[str, Any]:
2025-09-07 21:48:21 +08:00
"""检测人脸"""
_LOGGER.info(
"开始检测人脸: max_face_num=%d, min_face_size=%d, need_rotate_check=%d",
max_face_num, min_face_size, need_rotate_check
)
# 定义API调用函数
def api_call():
# 生成请求ID
request_id = str(uuid.uuid4())
start_time = time.time()
try:
# 获取图片数据
image = self._get_image_base64(image_url, image_path, image_file)
# 创建请求
req = models.DetectFaceRequest()
params = {
"Image": image,
"MaxFaceNum": max_face_num,
"MinFaceSize": min_face_size,
"NeedRotateCheck": need_rotate_check
}
# 记录请求日志
self._log_request("人脸检测", params)
req.from_json_string(json.dumps(params))
# 发送请求
_LOGGER.debug("发送人脸检测请求")
resp = self.client.DetectFace(req)
# 计算请求耗时
duration = time.time() - start_time
# 记录响应日志
self._log_response("人脸检测", request_id, resp, duration)
# 解析响应
results = []
if getattr(resp, "FaceInfos", []):
_LOGGER.info("检测到 %d 个人脸", len(resp.FaceInfos))
for face_info in resp.FaceInfos:
face_result = {
"face_id": getattr(face_info, "FaceId", ""),
"x": getattr(face_info, "X", 0),
"y": getattr(face_info, "Y", 0),
"width": getattr(face_info, "Width", 0),
"height": getattr(face_info, "Height", 0),
"face_rect": getattr(face_info, "FaceRect", None).__dict__ if getattr(face_info, "FaceRect", None) else None
}
results.append(face_result)
else:
_LOGGER.info("未检测到人脸")
2025-09-07 23:21:24 +08:00
return {
"success": True,
"faces": results,
"error": None,
"error_code": None,
"error_message": None
}
2025-09-07 21:48:21 +08:00
except Exception as ex:
# 计算请求耗时
duration = time.time() - start_time
# 记录错误日志
self._log_error("人脸检测", request_id, ex, duration)
2025-09-07 23:21:24 +08:00
# 不抛出异常,而是返回包含错误信息的结构化结果
error_code = None
error_message = str(ex)
# 如果是腾讯云API异常,提取错误码和消息
if isinstance(ex, TencentCloudSDKException):
error_code = getattr(ex, "code", None)
error_message = getattr(ex, "message", str(ex))
return {
"success": False,
"faces": [],
"error": str(ex),
"error_code": error_code,
"error_message": error_message
}
2025-09-07 21:48:21 +08:00
# 执行带重试的API调用
return self._execute_with_retry(api_call, "人脸检测")
def get_face_attributes(
self,
image_url: str = None,
image_path: str = None,
image_file: str = None,
max_face_num: int = 1,
need_rotate_check: int = 1
2025-09-07 23:21:24 +08:00
) -> Dict[str, Any]:
2025-09-07 21:48:21 +08:00
"""获取人脸属性"""
_LOGGER.info(
"开始获取人脸属性: max_face_num=%d, need_rotate_check=%d",
max_face_num, need_rotate_check
)
# 定义API调用函数
def api_call():
# 生成请求ID
request_id = str(uuid.uuid4())
start_time = time.time()
try:
# 获取图片数据
image = self._get_image_base64(image_url, image_path, image_file)
# 创建请求
req = models.DetectFaceRequest()
params = {
"Image": image,
"MaxFaceNum": max_face_num,
"NeedRotateCheck": need_rotate_check
}
# 记录请求日志
self._log_request("获取人脸属性", params)
req.from_json_string(json.dumps(params))
# 发送请求
_LOGGER.debug("发送获取人脸属性请求")
resp = self.client.DetectFace(req)
# 计算请求耗时
duration = time.time() - start_time
# 记录响应日志
self._log_response("获取人脸属性", request_id, resp, duration)
# 解析响应
results = []
if getattr(resp, "FaceInfos", []):
_LOGGER.info("检测到 %d 个人脸", len(resp.FaceInfos))
for face_info in resp.FaceInfos:
face_result = {
"face_id": getattr(face_info, "FaceId", ""),
"x": getattr(face_info, "X", 0),
"y": getattr(face_info, "Y", 0),
"width": getattr(face_info, "Width", 0),
"height": getattr(face_info, "Height", 0),
"gender": getattr(face_info, "Gender", None),
"age": getattr(face_info, "Age", None),
"expression": getattr(face_info, "Expression", None),
"beauty": getattr(face_info, "Beauty", None),
"face_rect": getattr(face_info, "FaceRect", None).__dict__ if getattr(face_info, "FaceRect", None) else None
}
results.append(face_result)
else:
_LOGGER.info("未检测到人脸")
2025-09-07 23:21:24 +08:00
return {
"success": True,
"faces": results,
"error": None,
"error_code": None,
"error_message": None
}
2025-09-07 21:48:21 +08:00
except Exception as ex:
# 计算请求耗时
duration = time.time() - start_time
# 记录错误日志
self._log_error("获取人脸属性", request_id, ex, duration)
2025-09-07 23:21:24 +08:00
# 不抛出异常,而是返回包含错误信息的结构化结果
error_code = None
error_message = str(ex)
# 如果是腾讯云API异常,提取错误码和消息
if isinstance(ex, TencentCloudSDKException):
error_code = getattr(ex, "code", None)
error_message = getattr(ex, "message", str(ex))
return {
"success": False,
"faces": [],
"error": str(ex),
"error_code": error_code,
"error_message": error_message
}
2025-09-07 21:48:21 +08:00
# 执行带重试的API调用
return self._execute_with_retry(api_call, "获取人脸属性")
def get_group_info(self, group_id: str) -> Dict[str, Any]:
"""获取人员库信息"""
_LOGGER.info("开始获取人员库信息: group_id=%s", group_id)
def api_call():
request_id = str(uuid.uuid4())
start_time = time.time()
try:
req = models.GetGroupInfoRequest()
params = {"GroupId": group_id}
self._log_request("获取人员库信息", params)
req.from_json_string(json.dumps(params))
_LOGGER.debug("发送获取人员库信息请求")
resp = self.client.GetGroupInfo(req)
duration = time.time() - start_time
self._log_response("获取人员库信息", request_id, resp, duration)
return {
"group_name": getattr(resp, "GroupName", ""),
"group_tag": getattr(resp, "GroupTag", ""),
"face_model_version": getattr(resp, "FaceModelVersion", ""),
"creation_timestamp": getattr(resp, "CreationTimestamp", 0),
}
except Exception as ex:
duration = time.time() - start_time
self._log_error("获取人员库信息", request_id, ex, duration)
raise
return self._execute_with_retry(api_call, "获取人员库信息")
def _test_api_connection(self, group_id: str) -> bool:
"""测试API连接"""
try:
_LOGGER.info("开始测试API连接: group_id=%s", group_id)
self.get_group_info(group_id)
_LOGGER.info("API连接测试成功")
return True
except Exception as ex:
_LOGGER.error("API连接测试失败: %s", ex, exc_info=True)
return False
def get_person_list(self, group_id: str, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
"""获取人员列表"""
_LOGGER.info("开始获取人员列表: group_id=%s, limit=%d, offset=%d", group_id, limit, offset)
def api_call():
request_id = str(uuid.uuid4())
start_time = time.time()
try:
req = models.GetPersonListRequest()
params = {
"GroupId": group_id,
"Limit": limit,
"Offset": offset
}
self._log_request("获取人员列表", params)
req.from_json_string(json.dumps(params))
_LOGGER.debug("发送获取人员列表请求")
resp = self.client.GetPersonList(req)
duration = time.time() - start_time
self._log_response("获取人员列表", request_id, resp, duration)
persons = []
if getattr(resp, "PersonInfos", []):
for person_info in resp.PersonInfos:
persons.append({
"person_name": getattr(person_info, "PersonName", ""),
"person_id": getattr(person_info, "PersonId", ""),
"gender": getattr(person_info, "Gender", 0),
"face_ids": getattr(person_info, "FaceIds", []),
})
return persons
except Exception as ex:
duration = time.time() - start_time
self._log_error("获取人员列表", request_id, ex, duration)
raise
return self._execute_with_retry(api_call, "获取人员列表")
def _execute_with_retry(self, api_call: Callable, operation_name: str) -> Any:
"""执行带重试的API调用"""
last_exception = None
for attempt in range(self.retry_config.max_retries):
try:
if attempt > 0:
# 计算重试延迟
delay = self._calculate_retry_delay(attempt)
_LOGGER.info("重试第 %d%s,延迟 %.2f 秒", attempt + 1, operation_name, delay)
time.sleep(delay)
# 执行API调用
return api_call()
except TencentCloudSDKException as ex:
last_exception = ex
_LOGGER.warning("%s失败 (尝试 %d/%d): %s", operation_name, attempt + 1, self.retry_config.max_retries, ex)
# 检查是否应该重试
if not self._should_retry(ex):
_LOGGER.error("遇到不可重试的错误: %s", ex.code)
self._handle_error(ex)
break
except Exception as ex:
last_exception = ex
_LOGGER.warning("%s失败 (尝试 %d/%d): %s", operation_name, attempt + 1, self.retry_config.max_retries, ex)
# 所有重试都失败后,抛出最后一个异常
if last_exception:
if isinstance(last_exception, TencentCloudSDKException):
self._handle_error(last_exception)
else:
_LOGGER.error("%s最终失败: %s", operation_name, last_exception, exc_info=True)
log_and_raise_error(
RuntimeError,
f"{operation_name}失败: {str(last_exception)}",
{"error": str(last_exception), "error_type": type(last_exception).__name__}
)
def _calculate_retry_delay(self, attempt: int) -> float:
"""计算重试延迟时间"""
# 指数退避算法
delay = self.retry_config.initial_delay * (
self.retry_config.backoff_factor ** (attempt - 1)
)
# 限制最大延迟
delay = min(delay, self.retry_config.max_delay)
# 添加抖动,避免多个客户端同时重试
jitter = delay * self.retry_config.jitter
delay = delay + random.uniform(-jitter, jitter)
# 确保延迟不为负数
return max(0, delay)
def _should_retry(self, ex: TencentCloudSDKException) -> bool:
"""判断是否应该重试"""
# 如果错误码在不可重试的错误列表中,则不重试
if ex.code in NON_RETRYABLE_ERRORS:
return False
# 如果是速率限制错误,但已经是最后一次尝试,则不重试
if ex.code in RATE_LIMIT_ERRORS:
return True
# 如果是网络错误,则重试
if ex.code in NETWORK_ERRORS:
return True
# 默认情况下,其他错误都重试
return True
def _is_valid_url(self, url: str) -> bool:
"""验证URL格式是否有效"""
url_pattern = re.compile(
r'^https?://' # http:// 或 https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # 域名
r'localhost|' # localhost
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # IP地址
r'(?::\d+)?' # 可选端口
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
return url_pattern.match(url) is not None
def _process_image_data(self, image_data: bytes) -> bytes:
"""处理图片数据,包括压缩和格式转换"""
try:
# 检查图片大小
if len(image_data) > MAX_IMAGE_SIZE:
_LOGGER.warning("图片大小超过限制,尝试压缩: %d 字节", len(image_data))
image_data = self._compress_image(image_data)
# 尝试使用PIL处理图片
try:
from PIL import Image
# 将字节数据转换为PIL图像对象
img = Image.open(io.BytesIO(image_data))
# 检查图片尺寸
width, height = img.size
if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
_LOGGER.warning("图片尺寸过大,尝试缩放: %dx%d", width, height)
# 计算缩放比例
scale = min(MAX_IMAGE_DIMENSION / width, MAX_IMAGE_DIMENSION / height)
new_width = int(width * scale)
new_height = int(height * scale)
# 缩放图片
img = img.resize((new_width, new_height), Image.LANCZOS)
# 将处理后的图片转换回字节数据
output = io.BytesIO()
img.save(output, format='JPEG', quality=IMAGE_COMPRESSION_QUALITY)
image_data = output.getvalue()
_LOGGER.info("图片缩放完成: %dx%d -> %dx%d", width, height, new_width, new_height)
# 如果图片不是JPEG格式,转换为JPEG以减少大小
if img.format != 'JPEG':
_LOGGER.debug("转换图片格式为JPEG: %s", img.format)
output = io.BytesIO()
if img.mode == 'RGBA':
img = img.convert('RGB')
img.save(output, format='JPEG', quality=IMAGE_COMPRESSION_QUALITY)
image_data = output.getvalue()
except ImportError:
_LOGGER.warning("PIL库未安装,无法进行图片处理")
except Exception as ex:
_LOGGER.warning("图片处理失败: %s", ex)
return image_data
except Exception as ex:
_LOGGER.error("处理图片数据失败: %s", ex, exc_info=True)
# 如果处理失败,返回原始数据
return image_data
def _compress_image(self, image_data: bytes) -> bytes:
"""压缩图片数据"""
try:
from PIL import Image
# 将字节数据转换为PIL图像对象
img = Image.open(io.BytesIO(image_data))
# 计算压缩比例
original_size = len(image_data)
target_size = MAX_IMAGE_SIZE * 0.8 # 目标大小为最大限制的80%
quality = IMAGE_COMPRESSION_QUALITY
# 逐步降低质量直到满足大小要求
while quality > 30 and original_size > target_size:
output = io.BytesIO()
img.save(output, format='JPEG', quality=quality)
compressed_data = output.getvalue()
if len(compressed_data) <= target_size:
break
quality -= 5
original_size = len(compressed_data)
_LOGGER.info("图片压缩完成: %d -> %d 字节 (质量: %d)",
len(image_data), len(compressed_data), quality)
return compressed_data
except ImportError:
_LOGGER.warning("PIL库未安装,无法进行图片压缩")
return image_data
except Exception as ex:
_LOGGER.error("图片压缩失败: %s", ex, exc_info=True)
return image_data
def _log_request(self, operation: str, params: Dict[str, Any]) -> None:
"""记录API请求日志"""
if not ENABLE_REQUEST_LOGGING:
return
# 生成请求ID
request_id = str(uuid.uuid4())
# 记录请求基本信息
_LOGGER.info(
"API请求: operation=%s, request_id=%s, params_count=%d",
operation, request_id, len(params)
)
# 记录请求参数(截断过长的参数)
if ENABLE_PERFORMANCE_LOGGING:
safe_params = {}
for key, value in params.items():
if key == "Image":
# 图片参数只记录长度,不记录内容
safe_params[key] = f"<base64_image_{len(str(value))}_chars>"
else:
safe_params[key] = value
params_str = json.dumps(safe_params, ensure_ascii=False)
if len(params_str) > LOG_REQUEST_PAYLOAD_MAX_SIZE:
params_str = params_str[:LOG_REQUEST_PAYLOAD_MAX_SIZE] + "...[truncated]"
_LOGGER.debug(
"API请求详情: operation=%s, request_id=%s, params=%s",
operation, request_id, params_str
)
def _log_response(self, operation: str, request_id: str, response: Any, duration: float) -> None:
"""记录API响应日志"""
if not ENABLE_RESPONSE_LOGGING:
return
# 记录响应基本信息
_LOGGER.info(
"API响应: operation=%s, request_id=%s, duration=%.3fs, response_type=%s",
operation, request_id, duration, type(response).__name__
)
# 记录性能指标
if ENABLE_PERFORMANCE_LOGGING:
if duration > 5.0:
_LOGGER.warning(
"API响应缓慢: operation=%s, request_id=%s, duration=%.3fs",
operation, request_id, duration
)
elif duration > 2.0:
_LOGGER.info(
"API响应较慢: operation=%s, request_id=%s, duration=%.3fs",
operation, request_id, duration
)
# 记录响应详情(截断过长的响应)
try:
if hasattr(response, "__dict__"):
# 如果是对象,尝试转换为字典
response_dict = {}
for attr in dir(response):
if not attr.startswith("_") and not callable(getattr(response, attr)):
response_dict[attr] = getattr(response, attr)
response_str = json.dumps(response_dict, ensure_ascii=False, default=str)
else:
# 如果不是对象,直接转换为字符串
response_str = str(response)
if len(response_str) > LOG_RESPONSE_PAYLOAD_MAX_SIZE:
response_str = response_str[:LOG_RESPONSE_PAYLOAD_MAX_SIZE] + "...[truncated]"
_LOGGER.debug(
"API响应详情: operation=%s, request_id=%s, response=%s",
operation, request_id, response_str
)
except Exception as ex:
_LOGGER.debug(
"无法记录API响应详情: operation=%s, request_id=%s, error=%s",
operation, request_id, ex
)
def _log_error(self, operation: str, request_id: str, error: Exception, duration: float) -> None:
"""记录API错误日志"""
error_type = type(error).__name__
error_msg = str(error)
# 记录错误基本信息
_LOGGER.error(
"API错误: operation=%s, request_id=%s, duration=%.3fs, error_type=%s, error_msg=%s",
operation, request_id, duration, error_type, error_msg
)
# 记录错误详情
if isinstance(error, TencentCloudSDKException):
_LOGGER.error(
"腾讯云API错误详情: operation=%s, request_id=%s, code=%s, message=%s, request_id=%s",
operation, request_id, getattr(error, "code", "未知"),
getattr(error, "message", "未知"),
getattr(error, "request_id", "未知")
)
# 记录堆栈跟踪
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"API错误堆栈跟踪: operation=%s, request_id=%s",
operation, request_id, exc_info=True
)