diff --git a/api_v1/test_tencentcloud_tiia_plate_order_upload.py b/api_v1/test_tencentcloud_tiia_plate_order_upload.py new file mode 100644 index 0000000..f1851a7 --- /dev/null +++ b/api_v1/test_tencentcloud_tiia_plate_order_upload.py @@ -0,0 +1,54 @@ +from unittest.mock import patch + +from django.test import TestCase + +from basic_info import models as basic_models +from printing import models as printing_models + + +class TencentTiiaPlateOrderUploadTestCase(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + # PlateOrder.customer 必填 + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='客户A', + mobile='13900000000', + area='A', + ) + + @patch('api_v1.utils.tencentcloud_tiia.upload_image_url_to_tencent_tiia') + def test_upload_plate_order_images_to_tencent_tiia_uploads_all_urls(self, mocked_upload): + mocked_upload.side_effect = lambda **kwargs: {'mocked': True, 'kwargs': kwargs} + + po = printing_models.PlateOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + plate_image=[ + {'url': 'https://example.com/a.png'}, + {'path': 'uploads/2026/01/16/b.png'}, # 将由 build_public_media_url 归一化 + 'https://example.com/c.png', + {'imageUrl': 'https://example.com/d.png'}, + ], + ) + + from api_v1.utils.tencentcloud_tiia import upload_plate_order_images_to_tencent_tiia + + results = upload_plate_order_images_to_tencent_tiia(plate_order_id=po.id) + + # 至少应尝试上传 3 张绝对 URL(example.com: a/c/d) + # uploads/... 会被转为基于 settings.QINIU_BUCKET_DOMAIN 的绝对/相对形式,这里不强耦合其结果, + # 只要 uploader 被调用次数 >= 3 即可。 + self.assertGreaterEqual(mocked_upload.call_count, 3) + + # entity_id 必须为 plate_order_id + for call in mocked_upload.call_args_list: + self.assertEqual(call.kwargs.get('entity_id'), str(po.id)) + + # 返回结构必须包含 ok/image_url 字段 + self.assertIsInstance(results, list) + self.assertTrue(all(('ok' in r and 'image_url' in r) for r in results)) + diff --git a/api_v1/utils/tencentcloud_tiia.py b/api_v1/utils/tencentcloud_tiia.py new file mode 100644 index 0000000..f57bc9d --- /dev/null +++ b/api_v1/utils/tencentcloud_tiia.py @@ -0,0 +1,216 @@ +""" +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 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] + + +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) -> 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: + 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__ = ['upload_image_url_to_tencent_tiia', 'upload_plate_order_images_to_tencent_tiia'] + diff --git a/flower/settings.py b/flower/settings.py index 8540e33..138504d 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -64,6 +64,17 @@ MDY_SYNC_PAGE_SIZE = env.int('MDY_SYNC_PAGE_SIZE', default=100) MDY_SYNC_MAX_PAGES = env.int('MDY_SYNC_MAX_PAGES', default=2) MDY_SYNC_MAX_RECORDS = env.int('MDY_SYNC_MAX_RECORDS', default=200) +# Tencent Cloud (TIIA) 配置:用于通过 ImageUrl 上传图片到图库(CreateImage) +# 注意:不要把 secret 写死在代码里,建议通过 .env / 环境变量注入。 +TENCENTCLOUD_SECRET_ID = env('TENCENTCLOUD_SECRET_ID', default='AKID2wtgIhYCcprkS0QfiZCbopetxpW1KSPE') +TENCENTCLOUD_SECRET_KEY = env('TENCENTCLOUD_SECRET_KEY', default='oSwGOJ0XzJRODrIXZWIrEPNAmHdppdHh') +TENCENTCLOUD_TOKEN = env('TENCENTCLOUD_TOKEN', default='') # STS 临时票据(可选) + +TENCENTCLOUD_TIIA_GROUP_ID = env('TENCENTCLOUD_TIIA_GROUP_ID', default='168') +TENCENTCLOUD_TIIA_REGION = env('TENCENTCLOUD_TIIA_REGION', default='ap-guangzhou') +TENCENTCLOUD_TIIA_ENDPOINT = env('TENCENTCLOUD_TIIA_ENDPOINT', default='tiia.tencentcloudapi.com') +TENCENTCLOUD_TIIA_PIC_NAME_PREFIX = env('TENCENTCLOUD_TIIA_PIC_NAME_PREFIX', default='plate_order') + # CORS 配置 CORS_ALLOW_ALL_ORIGINS = DEBUG # 开发环境允许所有源,生产环境需要配置白名单 diff --git a/pyproject.toml b/pyproject.toml index 930bc96..91d9021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "psycopg[binary]>=3.2.12", "redis>=5.0.0", "aiohttp>=3.13.2", + "tencentcloud-sdk-python>=3.0.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 4875899..6049ce5 100644 --- a/uv.lock +++ b/uv.lock @@ -427,6 +427,7 @@ dependencies = [ { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "redis" }, + { name = "tencentcloud-sdk-python" }, { name = "uvicorn" }, { name = "watchfiles" }, ] @@ -455,6 +456,7 @@ requires-dist = [ { name = "pillow", specifier = ">=12.0.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" }, { name = "redis", specifier = ">=5.0.0" }, + { name = "tencentcloud-sdk-python", specifier = ">=3.0.0" }, { name = "uvicorn", specifier = ">=0.38.0" }, { name = "watchfiles", specifier = ">=0.22.0" }, ] @@ -1000,6 +1002,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, ] +[[package]] +name = "tencentcloud-sdk-python" +version = "3.1.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/cd/192ac8f5853abf37c90cd1624a4031f5b5e4109f25abd0f2fec4728765c2/tencentcloud_sdk_python-3.1.32.tar.gz", hash = "sha256:0b2929687c91a6db54b1bdad7cc58b8ea6da425cfc645a5cd3dfb1d3b19f050d", size = 13810694, upload-time = "2026-01-14T21:06:32.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/62/4b17f841aca307aeef7905857bccb97a87e147a68fd8fdf2ceb49ce6c71d/tencentcloud_sdk_python-3.1.32-py2.py3-none-any.whl", hash = "sha256:53f8267ba444821fb38ced482ffa54fc37269a9e7ce67409689ca849e7c6eb06", size = 14689772, upload-time = "2026-01-14T21:06:19.374Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"