forked from erp-dev/erp
feat: tencent tiia upload image support
This commit is contained in:
54
api_v1/test_tencentcloud_tiia_plate_order_upload.py
Normal file
54
api_v1/test_tencentcloud_tiia_plate_order_upload.py
Normal file
@@ -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))
|
||||||
|
|
||||||
216
api_v1/utils/tencentcloud_tiia.py
Normal file
216
api_v1/utils/tencentcloud_tiia.py
Normal file
@@ -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']
|
||||||
|
|
||||||
@@ -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_PAGES = env.int('MDY_SYNC_MAX_PAGES', default=2)
|
||||||
MDY_SYNC_MAX_RECORDS = env.int('MDY_SYNC_MAX_RECORDS', default=200)
|
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 配置
|
||||||
CORS_ALLOW_ALL_ORIGINS = DEBUG # 开发环境允许所有源,生产环境需要配置白名单
|
CORS_ALLOW_ALL_ORIGINS = DEBUG # 开发环境允许所有源,生产环境需要配置白名单
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dependencies = [
|
|||||||
"psycopg[binary]>=3.2.12",
|
"psycopg[binary]>=3.2.12",
|
||||||
"redis>=5.0.0",
|
"redis>=5.0.0",
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
|
"tencentcloud-sdk-python>=3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
14
uv.lock
generated
14
uv.lock
generated
@@ -427,6 +427,7 @@ dependencies = [
|
|||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
|
{ name = "tencentcloud-sdk-python" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
]
|
]
|
||||||
@@ -455,6 +456,7 @@ requires-dist = [
|
|||||||
{ name = "pillow", specifier = ">=12.0.0" },
|
{ name = "pillow", specifier = ">=12.0.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
|
||||||
{ name = "redis", specifier = ">=5.0.0" },
|
{ name = "redis", specifier = ">=5.0.0" },
|
||||||
|
{ name = "tencentcloud-sdk-python", specifier = ">=3.0.0" },
|
||||||
{ name = "uvicorn", specifier = ">=0.38.0" },
|
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||||
{ name = "watchfiles", specifier = ">=0.22.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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user