"""腾讯云客户端""" import base64 import io import json import logging import os import random import re import requests import time import uuid from functools import lru_cache 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__) _LOGGER_POLLING = _LOGGER.getChild("polling") _LOGGER_POLLING.setLevel(logging.DEBUG) ENABLE_REQUEST_LOGGING = False ENABLE_RESPONSE_LOGGING = False ENABLE_PERFORMANCE_LOGGING = True LOG_REQUEST_PAYLOAD_MAX_SIZE = 1024 LOG_RESPONSE_PAYLOAD_MAX_SIZE = 1024 MAX_ATTEMPTS = 3 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 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_attempts: int = MAX_ATTEMPTS, initial_delay: float = INITIAL_RETRY_DELAY, max_delay: float = MAX_RETRY_DELAY, backoff_factor: float = RETRY_BACKOFF_FACTOR, jitter: float = RETRY_JITTER ): self.max_attempts = max_attempts 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() self._session = requests.Session() self._session.mount('http://', requests.adapters.HTTPAdapter(max_retries=2)) self._session.mount('https://', requests.adapters.HTTPAdapter(max_retries=2)) def close(self) -> None: """释放资源""" if self._session: self._session.close() 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", self._sanitize_error(str(ex))) log_and_raise_error( AuthenticationError, "创建腾讯云客户端失败", {"region": self.region, "error_type": type(ex).__name__} ) def verify_credentials(self) -> bool: """验证凭据是否有效,通过调用 GetGroupList 测试""" try: req = models.GetGroupListRequest() params = {"Limit": 1, "Offset": 0} req.from_json_string(json.dumps(params)) self.client.GetGroupList(req) _LOGGER.info("凭据验证成功") return True except TencentCloudSDKException as ex: _LOGGER.error("凭据验证失败: %s", ex.code) handle_api_error(ex.code, ex.message) def _handle_error(self, ex: TencentCloudSDKException) -> None: """处理API错误""" _LOGGER.error( "腾讯云API调用失败: 错误码=%s, 请求ID=%s", ex.code, getattr(ex, 'request_id', '未知') ) handle_api_error(ex.code, ex.message) def _sanitize_error(self, error_msg: str) -> str: """脱敏错误信息中的凭据""" if self.secret_id and self.secret_id in error_msg: error_msg = error_msg.replace(self.secret_id, f"{self.secret_id[:8]}***") if self.secret_key and self.secret_key in error_msg: error_msg = error_msg.replace(self.secret_key, "***") return error_msg @lru_cache(maxsize=32) def _cached_download_image_as_base64(self, image_url: str) -> str: return self._download_image_as_base64_uncached(image_url) @lru_cache(maxsize=32) def _cached_read_local_image_as_base64(self, image_path: str) -> str: return self._read_local_image_as_base64_uncached(image_path) 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: _LOGGER.debug("使用图片URL: %s", image_url) return self._cached_download_image_as_base64(image_url) elif image_path: _LOGGER.debug("使用本地图片路径: %s", image_path) return self._cached_read_local_image_as_base64(image_path) elif image_file: _LOGGER.debug("使用上传的图片文件") return self._process_uploaded_image_as_base64(image_file) except Exception as ex: _LOGGER.error("处理图片失败: %s", ex) 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_uncached(self, image_url: str) -> str: """下载图片并转换为base64编码""" try: _LOGGER.debug("开始下载图片: %s", image_url) if not self._is_valid_url(image_url): log_and_raise_error( ValueError, f"无效的图片URL: {image_url}", {"url": image_url} ) session = self._session headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/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", image_url) log_and_raise_error( NetworkError, f"下载图片失败: {image_url}", {"url": image_url, "error": str(ex)} ) def _read_local_image_as_base64_uncached(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 ImageNotFoundError: raise except Exception as ex: _LOGGER.error("读取图片文件失败: %s", image_path) 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: if isinstance(image_file, str): if image_file.startswith('data:image'): _LOGGER.debug("处理 data URL 格式图片") try: header, encoded = image_file.split(',', 1) image_data = base64.b64decode(encoded) processed_image = self._process_image_data(image_data) return base64.b64encode(processed_image).decode("utf-8") except ValueError: log_and_raise_error( ImageNotFoundError, "无效的 data URL 格式", {"data_url_length": len(image_file)} ) else: _LOGGER.debug("使用 base64 编码图片,长度: %d", len(image_file)) try: image_data = base64.b64decode(image_file) if len(image_data) <= MAX_IMAGE_SIZE: try: from PIL import Image img = Image.open(io.BytesIO(image_data)) if (img.size[0] <= MAX_IMAGE_DIMENSION and img.size[1] <= MAX_IMAGE_DIMENSION and img.format == 'JPEG'): return image_file except ImportError: pass except Exception: pass 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: _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 ImageNotFoundError: raise except Exception as ex: _LOGGER.error("处理上传的图片文件失败: %s", ex) log_and_raise_error( ImageNotFoundError, "处理上传的图片文件失败", {"error": str(ex), "error_type": type(ex).__name__} ) def search_faces( self, image_url: str = None, image_path: str = None, image_file: str = None, image_base64: 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 ) -> Dict[str, Any]: """搜索人脸""" _LOGGER.info( "开始搜索人脸: max_face_num=%d, min_face_size=%d, max_user_num=%d, quality_control=%d, need_rotate_check=%d, face_match_threshold=%.2f", max_face_num, min_face_size, max_user_num, quality_control, need_rotate_check, face_match_threshold ) if not group_ids: _LOGGER.warning("未提供人员库ID,搜索可能失败") if image_base64 is None: image_base64 = self._get_image_base64(image_url, image_path, image_file) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.SearchFacesRequest() params = { "NeedPersonInfo": 1, "Image": image_base64, "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, request_id) 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: 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) results.append(face_result) else: _LOGGER.info("未检测到人脸") return { "success": True, "faces": results, "error": None, "error_code": None, "error_message": None } except Exception as ex: duration = time.time() - start_time self._log_error("人脸搜索", request_id, ex, duration) error_code = None error_message = str(ex) 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 } return self._execute_with_retry(api_call, "人脸搜索") def detect_faces( self, image_url: str = None, image_path: str = None, image_file: str = None, image_base64: str = None, max_face_num: int = 1, min_face_size: int = 34, need_rotate_check: int = 1 ) -> Dict[str, Any]: """检测人脸""" _LOGGER.info( "开始检测人脸: max_face_num=%d, min_face_size=%d, need_rotate_check=%d", max_face_num, min_face_size, need_rotate_check ) if image_base64 is None: image_base64 = self._get_image_base64(image_url, image_path, image_file) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.DetectFaceRequest() params = { "Image": image_base64, "MaxFaceNum": max_face_num, "MinFaceSize": min_face_size, "NeedRotateCheck": need_rotate_check } self._log_request("人脸检测", params, request_id) 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("未检测到人脸") return { "success": True, "faces": results, "error": None, "error_code": None, "error_message": None } except Exception as ex: duration = time.time() - start_time self._log_error("人脸检测", request_id, ex, duration) error_code = None error_message = str(ex) 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 } return self._execute_with_retry(api_call, "人脸检测") def get_face_attributes( self, image_url: str = None, image_path: str = None, image_file: str = None, image_base64: str = None, max_face_num: int = 1, need_rotate_check: int = 1 ) -> Dict[str, Any]: """获取人脸属性""" _LOGGER.info( "开始获取人脸属性: max_face_num=%d, need_rotate_check=%d", max_face_num, need_rotate_check ) if image_base64 is None: image_base64 = self._get_image_base64(image_url, image_path, image_file) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.DetectFaceRequest() params = { "Image": image_base64, "MaxFaceNum": max_face_num, "NeedRotateCheck": need_rotate_check, "NeedFaceAttributes": 1, "NeedQualityDetection": 1, } self._log_request("获取人脸属性", params, request_id) 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("未检测到人脸") return { "success": True, "faces": results, "error": None, "error_code": None, "error_message": None } except Exception as ex: duration = time.time() - start_time self._log_error("获取人脸属性", request_id, ex, duration) error_code = None error_message = str(ex) 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 } return self._execute_with_retry(api_call, "获取人脸属性") def create_person( self, person_id: str, person_name: str, group_id: str, image_url: str = None, image_path: str = None, image_file: str = None, image_base64: str = None, gender: int = None, person_tag: str = None, quality_control: int = 1, need_rotate_check: int = 1, ) -> Dict[str, Any]: """创建人员""" _LOGGER.info("开始创建人员: person_id=%s, group_id=%s", person_id, group_id) if image_base64 is None and any([image_url, image_path, image_file]): image_base64 = self._get_image_base64(image_url, image_path, image_file) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.CreatePersonRequest() params = { "PersonId": person_id, "PersonName": person_name, "GroupId": group_id, "QualityControl": quality_control, "NeedRotateCheck": need_rotate_check, } if image_base64: params["Image"] = image_base64 if gender is not None: params["Gender"] = gender if person_tag is not None: params["PersonTag"] = person_tag self._log_request("创建人员", params, request_id) req.from_json_string(json.dumps(params)) _LOGGER.debug("发送创建人员请求") resp = self.client.CreatePerson(req) duration = time.time() - start_time self._log_response("创建人员", request_id, resp, duration) return { "success": True, "person_id": getattr(resp, "PersonId", person_id), "face_id": getattr(resp, "FaceId", ""), "face_rect": str(getattr(resp, "FaceRect", "")), } except Exception as ex: duration = time.time() - start_time self._log_error("创建人员", request_id, ex, duration) error_code = None error_message = str(ex) if isinstance(ex, TencentCloudSDKException): error_code = getattr(ex, "code", None) error_message = getattr(ex, "message", str(ex)) return { "success": False, "person_id": person_id, "face_id": "", "face_rect": "", "error": str(ex), "error_code": error_code, "error_message": error_message } return self._execute_with_retry(api_call, "创建人员") def delete_person(self, person_id: str) -> Dict[str, Any]: """删除人员""" _LOGGER.info("开始删除人员: person_id=%s", person_id) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.DeletePersonRequest() params = {"PersonId": person_id} self._log_request("删除人员", params, request_id) req.from_json_string(json.dumps(params)) _LOGGER.debug("发送删除人员请求") resp = self.client.DeletePerson(req) duration = time.time() - start_time self._log_response("删除人员", request_id, resp, duration) return {"success": True, "person_id": person_id} except Exception as ex: duration = time.time() - start_time self._log_error("删除人员", request_id, ex, duration) error_code = None error_message = str(ex) if isinstance(ex, TencentCloudSDKException): error_code = getattr(ex, "code", None) error_message = getattr(ex, "message", str(ex)) return { "success": False, "person_id": person_id, "error": str(ex), "error_code": error_code, "error_message": error_message } return self._execute_with_retry(api_call, "删除人员") def create_face( self, person_id: str, image_url: str = None, image_path: str = None, image_file: str = None, image_base64: str = None, quality_control: int = 1, need_rotate_check: int = 1, ) -> Dict[str, Any]: """为人员注册人脸""" _LOGGER.info("开始注册人脸: person_id=%s", person_id) if image_base64 is None: image_base64 = self._get_image_base64(image_url, image_path, image_file) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.CreateFaceRequest() params = { "PersonId": person_id, "Image": image_base64, "QualityControl": quality_control, "NeedRotateCheck": need_rotate_check, } self._log_request("注册人脸", params, request_id) req.from_json_string(json.dumps(params)) _LOGGER.debug("发送注册人脸请求") resp = self.client.CreateFace(req) duration = time.time() - start_time self._log_response("注册人脸", request_id, resp, duration) return { "success": True, "person_id": person_id, "face_ids": getattr(resp, "FaceIds", []), "face_rects": [str(r) for r in getattr(resp, "FaceRects", [])], } except Exception as ex: duration = time.time() - start_time self._log_error("注册人脸", request_id, ex, duration) error_code = None error_message = str(ex) if isinstance(ex, TencentCloudSDKException): error_code = getattr(ex, "code", None) error_message = getattr(ex, "message", str(ex)) return { "success": False, "person_id": person_id, "face_ids": [], "face_rects": [], "error": str(ex), "error_code": error_code, "error_message": error_message } return self._execute_with_retry(api_call, "注册人脸") def delete_face(self, person_id: str, face_id: str) -> Dict[str, Any]: """删除人脸""" _LOGGER.info("开始删除人脸: person_id=%s, face_id=%s", person_id, face_id) def api_call(): request_id = str(uuid.uuid4()) start_time = time.time() try: req = models.DeleteFaceRequest() params = { "PersonId": person_id, "FaceId": face_id, } self._log_request("删除人脸", params, request_id) req.from_json_string(json.dumps(params)) _LOGGER.debug("发送删除人脸请求") resp = self.client.DeleteFace(req) duration = time.time() - start_time self._log_response("删除人脸", request_id, resp, duration) return {"success": True, "person_id": person_id, "face_id": face_id} except Exception as ex: duration = time.time() - start_time self._log_error("删除人脸", request_id, ex, duration) error_code = None error_message = str(ex) if isinstance(ex, TencentCloudSDKException): error_code = getattr(ex, "code", None) error_message = getattr(ex, "message", str(ex)) return { "success": False, "person_id": person_id, "face_id": face_id, "error": str(ex), "error_code": error_code, "error_message": error_message } 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, request_id) 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 { "success": True, "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) error_code = None error_message = str(ex) if isinstance(ex, TencentCloudSDKException): error_code = getattr(ex, "code", None) error_message = getattr(ex, "message", str(ex)) return { "success": False, "group_name": "", "group_tag": "", "face_model_version": "", "creation_timestamp": 0, "error": str(ex), "error_code": error_code, "error_message": error_message } return self._execute_with_retry(api_call, "获取人员库信息") def verify_credentials_lightweight(self) -> bool: """测试API连接,使用 GetGroupList 轻量验证凭据""" try: _LOGGER.info("开始测试API连接(轻量验证)") req = models.GetGroupListRequest() params = {"Limit": 1, "Offset": 0} req.from_json_string(json.dumps(params)) self.client.GetGroupList(req) _LOGGER.info("API连接测试成功") return True except Exception as ex: _LOGGER.error("API连接测试失败: %s", self._sanitize_error(str(ex))) return False 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", self._sanitize_error(str(ex))) return False def get_person_list(self, group_id: str, limit: int = 100, offset: int = 0) -> 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, request_id) 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 { "success": True, "persons": persons, "error": None, "error_code": None, "error_message": None } except Exception as ex: duration = time.time() - start_time self._log_error("获取人员列表", request_id, ex, duration) error_code = None error_message = str(ex) if isinstance(ex, TencentCloudSDKException): error_code = getattr(ex, "code", None) error_message = getattr(ex, "message", str(ex)) return { "success": False, "persons": [], "error": str(ex), "error_code": error_code, "error_message": error_message } return self._execute_with_retry(api_call, "获取人员列表") def get_person_list_all(self, group_id: str) -> Dict[str, Any]: """获取人员列表(自动分页获取全部)""" all_persons = [] offset = 0 limit = 100 while True: result = self.get_person_list(group_id, limit=limit, offset=offset) if not result.get("success", False): return result persons = result.get("persons", []) all_persons.extend(persons) if len(persons) < limit: break offset += limit return { "success": True, "persons": all_persons, "error": None, "error_code": None, "error_message": None } def _execute_with_retry(self, api_call: Callable, operation_name: str) -> Any: """执行带重试的API调用,max_attempts 为总尝试次数(1次初始 + N次重试)""" last_exception = None for attempt in range(self.retry_config.max_attempts): try: if attempt > 0: delay = self._calculate_retry_delay(attempt) _LOGGER.info("重试第 %d 次%s,延迟 %.2f 秒", attempt, operation_name, delay) time.sleep(delay) return api_call() except TencentCloudSDKException as ex: last_exception = ex _LOGGER.warning("%s失败 (尝试 %d/%d): %s", operation_name, attempt + 1, self.retry_config.max_attempts, ex.code) 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_attempts, ex) if last_exception: if isinstance(last_exception, TencentCloudSDKException): self._handle_error(last_exception) else: _LOGGER.error("%s最终失败: %s", operation_name, last_exception) 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?://' r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' r'localhost|' r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' 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) try: from PIL import Image 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) 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库(Pillow)未安装,无法进行图片处理。建议安装: pip install Pillow>=10.0.0") except Exception as ex: _LOGGER.warning("图片处理失败: %s", ex) return image_data except Exception as ex: _LOGGER.error("处理图片数据失败: %s", ex) return image_data def _compress_image(self, image_data: bytes) -> bytes: """压缩图片数据""" try: from PIL import Image img = Image.open(io.BytesIO(image_data)) original_size = len(image_data) target_size = MAX_IMAGE_SIZE * 0.8 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库(Pillow)未安装,无法进行图片压缩。建议安装: pip install Pillow>=10.0.0") return image_data except Exception as ex: _LOGGER.error("图片压缩失败: %s", ex) return image_data def _log_request(self, operation: str, params: Dict[str, Any], request_id: str) -> None: """记录API请求日志""" if not ENABLE_REQUEST_LOGGING: return _LOGGER.debug( "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"" 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.debug( "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 = self._sanitize_error(str(error)) _LOGGER.error( "API错误: operation=%s, request_id=%s, duration=%.3fs, error_type=%s", operation, request_id, duration, error_type ) if isinstance(error, TencentCloudSDKException): _LOGGER.error( "腾讯云API错误详情: operation=%s, request_id=%s, code=%s", operation, request_id, getattr(error, "code", "未知") ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "API错误堆栈跟踪: operation=%s, request_id=%s", operation, request_id, exc_info=True )