diff --git a/api_v1/urls.py b/api_v1/urls.py index b03ac7c..df616e8 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -9,6 +9,7 @@ from .views import ( stateflow, users, print_count, + tiia, ) from .views.business.purchase import views as purchase_views from .views.business.sales import views as sales_views @@ -100,6 +101,9 @@ urlpatterns = [ # 产品图片上传 API path('products//image/', product_image.ProductImageUploadView.as_view(), name='product_image_upload'), + + # Tencent Cloud (TIIA) API + path('tiia/search-image/', tiia.TiiaSearchImageView.as_view(), name='tiia_search_image'), # Stateflow API (使用 Router) path('stateflow/', include(stateflow_router.urls)), diff --git a/api_v1/utils/tencentcloud_tiia.py b/api_v1/utils/tencentcloud_tiia.py index 7394430..918e0fe 100644 --- a/api_v1/utils/tencentcloud_tiia.py +++ b/api_v1/utils/tencentcloud_tiia.py @@ -45,6 +45,90 @@ class SimpleRateLimiter: time.sleep(self._min_interval - elapsed) self._last_ts = time.monotonic() +def search_image_url_in_tencent_tiia( + *, + image_url: str, + 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. + 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() + if not image_url: + raise ValueError('image_url 不能为空') + + 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() + + 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) + req.ImageUrl = str(image_url) + + 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: """ @@ -239,6 +323,7 @@ def upload_plate_order_images_to_tencent_tiia(*, plate_order_id: int, rate_limit __all__ = [ 'SimpleRateLimiter', 'upload_image_url_to_tencent_tiia', + 'search_image_url_in_tencent_tiia', 'upload_plate_order_images_to_tencent_tiia', ] diff --git a/api_v1/views/test_tiia_search_image_api.py b/api_v1/views/test_tiia_search_image_api.py new file mode 100644 index 0000000..1fa8a3b --- /dev/null +++ b/api_v1/views/test_tiia_search_image_api.py @@ -0,0 +1,50 @@ +""" +TIIA SearchImage simplified API tests. +""" + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient +from unittest.mock import patch + + +User = get_user_model() + + +class TiiaSearchImageAPITestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username='test_tiia_search_user', + password='testpass123', + email='test_tiia_search@example.com', + ) + + def test_search_image_unauthenticated(self): + resp = self.client.post( + '/api/v1/tiia/search-image/', + data={'imageUrl': 'https://example.com/a.png'}, + format='json', + ) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + @patch('api_v1.views.tiia.search_image_url_in_tencent_tiia') + def test_search_image_success(self, mock_search): + self.client.force_authenticate(self.user) + mock_search.return_value = {'Candidates': [{'EntityId': '1', 'Score': 99}]} + + resp = self.client.post( + '/api/v1/tiia/search-image/', + data={'imageUrl': 'https://example.com/a.png'}, + format='json', + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.json(), {'Candidates': [{'EntityId': '1', 'Score': 99}]}) + + def test_search_image_missing_image_url(self): + self.client.force_authenticate(self.user) + resp = self.client.post('/api/v1/tiia/search-image/', data={}, format='json') + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', resp.json()) + diff --git a/api_v1/views/tiia.py b/api_v1/views/tiia.py new file mode 100644 index 0000000..058747d --- /dev/null +++ b/api_v1/views/tiia.py @@ -0,0 +1,57 @@ +""" +Tencent Cloud TIIA simplified APIs. + +Currently provides: +- SearchImage (by ImageUrl) with fixed groupId/region from settings. +""" + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from django.conf import settings + +from api_v1.utils.tencentcloud_tiia import SimpleRateLimiter, search_image_url_in_tencent_tiia + + +class TiiaSearchImageView(APIView): + """ + Tencent Cloud TIIA: SearchImage (simplified) + + POST /api/v1/tiia/search-image/ + + Body: + { + "imageUrl": "https://example.com/xxx.png" + } + + Notes: + - groupId/region/endpoint are configured via Django settings (TENCENTCLOUD_TIIA_*). + - Enforces QPS using settings.TENCENTCLOUD_TIIA_QPS (default 10). + """ + + permission_classes = [IsAuthenticated] + + def post(self, request): + image_url = ( + request.data.get('imageUrl') + or request.data.get('ImageUrl') + or request.data.get('image_url') + or request.data.get('ImageURL') + ) + try: + limiter = SimpleRateLimiter(qps=float(getattr(settings, 'TENCENTCLOUD_TIIA_QPS', 10))) + resp = search_image_url_in_tencent_tiia( + image_url=str(image_url or ''), + rate_limiter=limiter, + ) + return Response(resp, status=status.HTTP_200_OK) + except ValueError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response( + {'error': '腾讯云搜图失败', 'message': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + diff --git a/docs/2026-01-16_summary.md b/docs/2026-01-16_summary.md index 80a13be..0db1f94 100644 --- a/docs/2026-01-16_summary.md +++ b/docs/2026-01-16_summary.md @@ -13,6 +13,9 @@ - 为 `GET /api/v1/stateflow/business-objects/` 增加过滤条件:`state_id` + `param_key` + `param_value` - 增加 PostgreSQL JSONB 查询性能优化:为 `StateLogParameterRecord.parameters` 添加 GIN(`jsonb_path_ops`)索引 - 补齐/更新测试用例,验证过滤行为与撤销记录处理 +- 输出该查询能力的单独说明文档(用途/出入参/示例/性能与索引) +- Shipment 模块:为 `Shipment` 增加 `area` 字段并贯通所有相关 API(create/update/返回/文档/测试) +- Printing/定时任务:实现“PlateOrder 图片按 URL 上传到腾讯云图库”的能力(上传函数/按 plate_order_id 批量上传/每日 03:00 定时任务/限速/失败落库) --- @@ -49,3 +52,94 @@ 另外,为保证 `stateflow` 测试集可持续运行,恢复了 `stateflow.services.clone_business_object()` 的实现(此前曾临时禁用导致测试失败)。 +### 4) 单独说明文档(stateflow 查询能力) + +新增文档:`docs/stateflow_business_objects_query_by_completed_state_and_param.md`,包含: + +- 能力用途/背景(“已完成某节点 + 工艺参数 key/value”反查 `BusinessObject`) +- 接口定义与查询参数(`state_id/param_key/param_value` 与 `content_type_str` 的配套用法) +- 过滤语义(排除撤销记录、参数绑定范围) +- 返回结构(与 `BusinessObjectListSerializer` 对齐) +- 性能建议(GIN 索引与验证方式) + +### 5) Shipment:增加 area 字段并贯通 API(create/update/返回/文档/测试) + +新增字段: + +- `shipment.models.Shipment.area`:`CharField(max_length=30, blank=True, default='')` +- 迁移:`shipment/migrations/0008_add_area_to_shipment.py` + +接口/序列化器调整: + +- `api_v1/views/shipment/serializers.py` + - `ShipmentSerializer`:响应中新增 `area` + - `ShipmentCreateNormalSerializer` / `ShipmentCreateExternalSerializer`:支持写入 `area` + - 新增 `ShipmentUpdateSerializer`:用于更新入口(PATCH/PUT) +- `api_v1/views/shipment/views.py` + - 普通创建与 external 创建:将 `area` 透传并落库 + - `ShipmentDetailView`:新增 `PATCH/PUT /api/v1/shipment/shipments//`,支持更新 `area` 并回显(同时保持 merchant 隔离与 customer 校验) +- `shipment/services.py`:`create_shipment/create_external_shipment` 支持 `area` 并写入 + +测试与文档: + +- `api_v1/views/shipment/test_api.py`:补齐 create 回显 + PATCH 更新回显用例 +- `docs/shipment_api.md`:补齐 `area` 的请求/响应示例与字段说明 + +### 6) Printing:PlateOrder 图片上传腾讯云图库(函数 + 定时任务 + 限速 + 失败落库) + +依赖: + +- `pyproject.toml`:加入 `tencentcloud-sdk-python`(TIIA SDK) + +上传函数(基础能力): + +- `api_v1/utils/tencentcloud_tiia.py` + - `upload_image_url_to_tencent_tiia(image_url, entity_id)`:调用 TIIA `CreateImage`,通过 URL 上传图片到图库 + - 腾讯云凭证/配置改为从 `flower/settings.py` 读取(支持 `.env` 注入) + - `SimpleRateLimiter`:简单限速器,确保不超过 10 次/秒(可由 `TENCENTCLOUD_TIIA_QPS` 配置) + +按 PlateOrder 批量上传(包装函数): + +- `api_v1/utils/tencentcloud_tiia.py` + - `upload_plate_order_images_to_tencent_tiia(plate_order_id, rate_limiter=...)` + - 从 `PlateOrder.plate_image(JSONField)` 提取所有图片 URL/Path(兼容 `url/path/imageUrl/字符串`),逐张上传 + - `entity_id` 统一使用 `str(plate_order_id)` + +每日定时任务(03:00 跑“昨天创建的订单”): + +- `printing/tasks.py` + - `upload_yesterday_plate_order_images_to_tencent_tiia` + - 扫描昨天创建的 PlateOrder,逐单上传;遇错记录失败 ID 并继续 +- `flower/settings.py` + - `CELERY_BEAT_SCHEDULE` 新增 `daily_plate_order_tiia_image_upload`(03:00) + +失败记录表(用于可追踪/可重试): + +- `printing.models.PlateOrderTiiaUploadFailure` + - 字段:`run_date/plate_order_id/error/details/attempts/last_attempt_at` + - 唯一约束:`(run_date, plate_order_id)`,重复失败会累加 `attempts` + - 迁移:`printing/migrations/0031_plateorder_tiia_upload_failure.py` +- `printing/admin.py`:在 admin 中注册失败记录表,便于排查 + +测试: + +- `api_v1/test_tencentcloud_tiia_plate_order_upload.py`:mock uploader,验证包装函数能提取并上传多张图片、entity_id 绑定正确 +- `printing/test_tiia_upload_task.py`:mock uploader + sleep,验证“记录失败 ID 但不中断继续执行” + +补充配置项(settings): + +- `flower/settings.py` + - `TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY / TENCENTCLOUD_TOKEN` + - `TENCENTCLOUD_TIIA_GROUP_ID / TENCENTCLOUD_TIIA_REGION / TENCENTCLOUD_TIIA_ENDPOINT` + - `TENCENTCLOUD_TIIA_PIC_NAME_PREFIX` + - `TENCENTCLOUD_TIIA_QPS`(默认 10,用于任务限速) + +### 7) 临时跑批(一次性全量上传现有 PlateOrder) + +为支持上线前手工验证/补数据,提供了可直接在 `manage.py shell` 中执行的一次性跑批脚本(遍历全部 `PlateOrder`,逐单上传 `plate_image` 里的所有图片,遇错记录失败 ID 并继续)。 + +说明: + +- `entity_id` 使用 `plate_order_id` +- 通过 `SimpleRateLimiter(qps=10)` 遵守腾讯云限速 +- 失败会写入 `PlateOrderTiiaUploadFailure`(便于后续排查与重试)