""" Tencent Cloud TIIA helpers. We use Tencent Cloud "Image and Video AI" (TIIA) CreateImage API to upload an image referenced by URL into a gallery group. This module only provides a single upload function (not a scheduled task). SDK reference: https://github.com/TencentCloud/tencentcloud-sdk-python """ import json import time import uuid from urllib.parse import urlparse from django.conf import settings from api_v1.utils.media import build_public_media_url def _truncate(s: str, max_len: int) -> str: s = s or '' if len(s) <= max_len: return s return s[:max_len] class SimpleRateLimiter: """ Very small in-process rate limiter (token-bucket-ish) to keep API calls under QPS. This is used by the scheduled task to respect TencentCloud API limits (e.g. 10 req/s). """ def __init__(self, *, qps: float): if qps <= 0: raise ValueError('qps 必须 > 0') self._min_interval = 1.0 / float(qps) self._last_ts = 0.0 def wait(self) -> None: now = time.monotonic() elapsed = now - self._last_ts if self._last_ts > 0 and elapsed < self._min_interval: time.sleep(self._min_interval - elapsed) self._last_ts = time.monotonic() def _strip_data_url_base64_prefix(s: str) -> str: """ Accept both raw base64 and data URL format like: data:image/png;base64,AAAA... Return the base64 payload part. """ s = (s or '').strip() if not s: return s lower = s[:64].lower() if lower.startswith('data:') and 'base64,' in lower: return s.split('base64,', 1)[1].strip() return s def search_image_url_in_tencent_tiia( *, image_url: str | None = None, image_base64: str | None = None, limit: int | None = 10, offset: int | None = 0, match_threshold: int | None = None, rate_limiter: SimpleRateLimiter | None = None, ) -> dict: """ Search an image by URL in Tencent Cloud TIIA gallery using SearchImage. This is the counterpart of CreateImage (upload) and is typically used for "similar image search". Required settings: - TENCENTCLOUD_SECRET_ID - TENCENTCLOUD_SECRET_KEY - TENCENTCLOUD_TIIA_GROUP_ID Optional settings: - TENCENTCLOUD_TOKEN (STS token, if applicable) - TENCENTCLOUD_TIIA_REGION (default: ap-guangzhou) - TENCENTCLOUD_TIIA_ENDPOINT (default: tiia.tencentcloudapi.com) Args: image_url: Absolute URL to search. (preferred when both provided) image_base64: Base64-encoded image content. limit/offset/match_threshold: Optional SearchImage request parameters. rate_limiter: Optional in-process limiter to keep requests under QPS. Returns: Parsed JSON response dict from TencentCloud SDK. """ image_url = (image_url or '').strip() image_base64 = _strip_data_url_base64_prefix(image_base64 or '') if not image_url and not image_base64: raise ValueError('imageUrl 和 imageBase64 必须至少提供一个') if image_url: parsed = urlparse(image_url) if not (parsed.scheme and parsed.netloc): raise ValueError('imageUrl 必须为绝对 URL(包含 scheme 与 host)') group_id = (getattr(settings, 'TENCENTCLOUD_TIIA_GROUP_ID', '') or '').strip() if not group_id: raise ValueError('缺少 settings: TENCENTCLOUD_TIIA_GROUP_ID') secret_id = (getattr(settings, 'TENCENTCLOUD_SECRET_ID', '') or '').strip() secret_key = (getattr(settings, 'TENCENTCLOUD_SECRET_KEY', '') or '').strip() token = (getattr(settings, 'TENCENTCLOUD_TOKEN', '') or '').strip() or None if not secret_id or not secret_key: raise ValueError('缺少 settings: TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY') region = (getattr(settings, 'TENCENTCLOUD_TIIA_REGION', None) or 'ap-guangzhou').strip() endpoint = (getattr(settings, 'TENCENTCLOUD_TIIA_ENDPOINT', None) or 'tiia.tencentcloudapi.com').strip() try: from tencentcloud.common import credential from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile from tencentcloud.tiia.v20190529 import tiia_client, models except Exception as e: # pragma: no cover raise RuntimeError( "TencentCloud SDK 未安装或不可用,请先安装依赖:tencentcloud-sdk-python" ) from e if rate_limiter is not None: rate_limiter.wait() cred = credential.Credential(secret_id, secret_key, token) http_profile = HttpProfile(endpoint=endpoint, reqTimeout=30) client_profile = ClientProfile(httpProfile=http_profile) client = tiia_client.TiiaClient(cred, region, client_profile) req = models.SearchImageRequest() req.GroupId = str(group_id) # TencentCloud: ImageUrl + ImageBase64 can be both provided, but ImageUrl wins. if image_url: req.ImageUrl = str(image_url) else: req.ImageBase64 = str(image_base64) if limit is not None: req.Limit = int(limit) if offset is not None: req.Offset = int(offset) if match_threshold is not None: req.MatchThreshold = int(match_threshold) resp = client.SearchImage(req) return json.loads(resp.to_json_string()) def upload_image_url_to_tencent_tiia(*, image_url: str, entity_id: str) -> dict: """ Upload an image by URL to Tencent Cloud TIIA gallery using CreateImage. Only `image_url` and `entity_id` are required; other fields are derived from Django settings. Required settings: - TENCENTCLOUD_SECRET_ID - TENCENTCLOUD_SECRET_KEY - TENCENTCLOUD_TIIA_GROUP_ID Optional settings: - TENCENTCLOUD_TOKEN (STS token, if applicable) - TENCENTCLOUD_TIIA_REGION (default: ap-guangzhou) - TENCENTCLOUD_TIIA_ENDPOINT (default: tiia.tencentcloudapi.com) - TENCENTCLOUD_TIIA_PIC_NAME_PREFIX (default: plate_order) Returns: Parsed JSON response dict from TencentCloud SDK. """ image_url = (image_url or '').strip() if not image_url: raise ValueError('image_url 不能为空') entity_id = (entity_id or '').strip() if not entity_id: raise ValueError('entity_id 不能为空') parsed = urlparse(image_url) if not (parsed.scheme and parsed.netloc): raise ValueError('image_url 必须为绝对 URL(包含 scheme 与 host)') group_id = (getattr(settings, 'TENCENTCLOUD_TIIA_GROUP_ID', '') or '').strip() if not group_id: raise ValueError('缺少 settings: TENCENTCLOUD_TIIA_GROUP_ID') secret_id = (getattr(settings, 'TENCENTCLOUD_SECRET_ID', '') or '').strip() secret_key = (getattr(settings, 'TENCENTCLOUD_SECRET_KEY', '') or '').strip() token = (getattr(settings, 'TENCENTCLOUD_TOKEN', '') or '').strip() or None if not secret_id or not secret_key: raise ValueError('缺少 settings: TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY') region = (getattr(settings, 'TENCENTCLOUD_TIIA_REGION', None) or 'ap-guangzhou').strip() endpoint = (getattr(settings, 'TENCENTCLOUD_TIIA_ENDPOINT', None) or 'tiia.tencentcloudapi.com').strip() pic_prefix = (getattr(settings, 'TENCENTCLOUD_TIIA_PIC_NAME_PREFIX', None) or 'plate_order').strip() or 'plate_order' # CreateImage requires PicName; we auto-generate it. uid = uuid.uuid4().hex # NOTE: 云端对字段长度通常有限制,这里做一个保守截断,避免超长导致 400。 entity_id = _truncate(entity_id, 128) basename = (parsed.path.rsplit('/', 1)[-1] or '').strip() pic_name = f'{pic_prefix}_{basename}' if basename else f'{pic_prefix}_{uid}' pic_name = _truncate(pic_name, 128) try: from tencentcloud.common import credential from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile from tencentcloud.tiia.v20190529 import tiia_client, models except Exception as e: # pragma: no cover raise RuntimeError( "TencentCloud SDK 未安装或不可用,请先安装依赖:tencentcloud-sdk-python" ) from e cred = credential.Credential(secret_id, secret_key, token) http_profile = HttpProfile(endpoint=endpoint, reqTimeout=30) client_profile = ClientProfile(httpProfile=http_profile) client = tiia_client.TiiaClient(cred, region, client_profile) req = models.CreateImageRequest() req.GroupId = str(group_id) req.EntityId = str(entity_id) req.PicName = str(pic_name) req.ImageUrl = str(image_url) resp = client.CreateImage(req) return json.loads(resp.to_json_string()) def _extract_urls_from_plate_image(plate_image) -> list[str]: """ 从 PlateOrder.plate_image(JSON) 中提取所有可能的图片 URL/Path。 plate_image 在本项目中通常为 list[dict],dict 里常见字段: - url: 绝对 URL 或相对路径 - path: 相对路径(uploads/...) 也兼容: - imageUrl/ImageURL: 外部系统字段名 - 直接 string(url/path) """ if not plate_image: return [] if isinstance(plate_image, dict): items = [plate_image] elif isinstance(plate_image, list): items = plate_image elif isinstance(plate_image, str): items = [plate_image] else: return [] urls: list[str] = [] for it in items: raw = None if isinstance(it, str): raw = it elif isinstance(it, dict): raw = ( it.get('url') or it.get('imageUrl') or it.get('ImageUrl') or it.get('imageURL') or it.get('ImageURL') or it.get('path') ) if not raw: continue normalized = build_public_media_url(str(raw), request=None) if normalized: urls.append(normalized) # 去重(保序) seen = set() deduped: list[str] = [] for u in urls: if u in seen: continue seen.add(u) deduped.append(u) return deduped def upload_plate_order_images_to_tencent_tiia(*, plate_order_id: int, rate_limiter: SimpleRateLimiter | None = None) -> list[dict]: """ 包装函数:给定 plate_order_id,读取 PlateOrder.plate_image(JSONField) 中所有图片并上传到腾讯云图库。 - entity_id 使用 plate_order_id(字符串化) - 逐张上传;单张失败不会中断整体,会返回 errors 供人工排查 Returns: list[dict] 每张图片一条结果: - image_url: str - ok: bool - response: dict | None - error: str | None """ if plate_order_id is None: raise ValueError('plate_order_id 不能为空') try: plate_order_id_int = int(plate_order_id) except (TypeError, ValueError): raise ValueError('plate_order_id 必须为整数') from printing.models import PlateOrder try: po = PlateOrder.objects.get(id=plate_order_id_int) except PlateOrder.DoesNotExist: raise ValueError(f'PlateOrder {plate_order_id_int} 不存在') urls = _extract_urls_from_plate_image(getattr(po, 'plate_image', None)) if not urls: return [] entity_id = str(plate_order_id_int) results: list[dict] = [] for url in urls: url_str = (url or '').strip() if not url_str: continue parsed = urlparse(url_str) if not (parsed.scheme and parsed.netloc): results.append({ 'image_url': url_str, 'ok': False, 'response': None, 'error': '图片 URL 不是绝对地址(请确保存储了绝对 url,或正确配置 QINIU_BUCKET_DOMAIN)', }) continue try: if rate_limiter is not None: rate_limiter.wait() resp = upload_image_url_to_tencent_tiia(image_url=url_str, entity_id=entity_id) results.append({'image_url': url_str, 'ok': True, 'response': resp, 'error': None}) except Exception as e: results.append({'image_url': url_str, 'ok': False, 'response': None, 'error': str(e)}) return results __all__ = [ 'SimpleRateLimiter', 'upload_image_url_to_tencent_tiia', 'search_image_url_in_tencent_tiia', 'upload_plate_order_images_to_tencent_tiia', ]