forked from erp-dev/erp
feat: task for auto upload plate_image to tencent each day 03:00
This commit is contained in:
@@ -10,6 +10,7 @@ SDK reference: https://github.com/TencentCloud/tencentcloud-sdk-python
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -24,6 +25,27 @@ def _truncate(s: str, max_len: int) -> str:
|
|||||||
return s[:max_len]
|
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 upload_image_url_to_tencent_tiia(*, image_url: str, entity_id: str) -> dict:
|
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.
|
Upload an image by URL to Tencent Cloud TIIA gallery using CreateImage.
|
||||||
@@ -157,7 +179,7 @@ def _extract_urls_from_plate_image(plate_image) -> list[str]:
|
|||||||
return deduped
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
def upload_plate_order_images_to_tencent_tiia(*, plate_order_id: int) -> list[dict]:
|
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) 中所有图片并上传到腾讯云图库。
|
包装函数:给定 plate_order_id,读取 PlateOrder.plate_image(JSONField) 中所有图片并上传到腾讯云图库。
|
||||||
|
|
||||||
@@ -205,6 +227,8 @@ def upload_plate_order_images_to_tencent_tiia(*, plate_order_id: int) -> list[di
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
try:
|
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)
|
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})
|
results.append({'image_url': url_str, 'ok': True, 'response': resp, 'error': None})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -212,5 +236,9 @@ def upload_plate_order_images_to_tencent_tiia(*, plate_order_id: int) -> list[di
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['upload_image_url_to_tencent_tiia', 'upload_plate_order_images_to_tencent_tiia']
|
__all__ = [
|
||||||
|
'SimpleRateLimiter',
|
||||||
|
'upload_image_url_to_tencent_tiia',
|
||||||
|
'upload_plate_order_images_to_tencent_tiia',
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ TENCENTCLOUD_TIIA_GROUP_ID = env('TENCENTCLOUD_TIIA_GROUP_ID', default='168')
|
|||||||
TENCENTCLOUD_TIIA_REGION = env('TENCENTCLOUD_TIIA_REGION', default='ap-guangzhou')
|
TENCENTCLOUD_TIIA_REGION = env('TENCENTCLOUD_TIIA_REGION', default='ap-guangzhou')
|
||||||
TENCENTCLOUD_TIIA_ENDPOINT = env('TENCENTCLOUD_TIIA_ENDPOINT', default='tiia.tencentcloudapi.com')
|
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')
|
TENCENTCLOUD_TIIA_PIC_NAME_PREFIX = env('TENCENTCLOUD_TIIA_PIC_NAME_PREFIX', default='plate_order')
|
||||||
|
TENCENTCLOUD_TIIA_QPS = env.int('TENCENTCLOUD_TIIA_QPS', default=10) # Tencent API 限速:每秒最多 10 次
|
||||||
|
|
||||||
|
|
||||||
# CORS 配置
|
# CORS 配置
|
||||||
@@ -473,6 +474,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
'filename_prefix': 'db-backup',
|
'filename_prefix': 'db-backup',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'daily_plate_order_tiia_image_upload': {
|
||||||
|
'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia',
|
||||||
|
'schedule': crontab(hour=3, minute=0),
|
||||||
|
},
|
||||||
'mdy_product_sync': {
|
'mdy_product_sync': {
|
||||||
'task': 'api_v1.tasks.sync_mdy_products',
|
'task': 'api_v1.tasks.sync_mdy_products',
|
||||||
'schedule': crontab(minute='*/10'),
|
'schedule': crontab(minute='*/10'),
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class PrintingOrderAdmin(admin.ModelAdmin):
|
|||||||
'craft',
|
'craft',
|
||||||
'description',
|
'description',
|
||||||
)
|
)
|
||||||
inlines = [PrintingJobInline]
|
|
||||||
|
|
||||||
def save_formset(self, request, form, formset, change):
|
def save_formset(self, request, form, formset, change):
|
||||||
"""保存 formset 后,为新创建的 PrintingJob 创建 BusinessObject"""
|
"""保存 formset 后,为新创建的 PrintingJob 创建 BusinessObject"""
|
||||||
@@ -468,3 +468,11 @@ class PrintingJobBatchAdvanceRecordAdmin(admin.ModelAdmin):
|
|||||||
@admin.display(description='涉及任务数')
|
@admin.display(description='涉及任务数')
|
||||||
def jobs_count(self, obj: models.PrintingJobBatchAdvanceRecord) -> int:
|
def jobs_count(self, obj: models.PrintingJobBatchAdvanceRecord) -> int:
|
||||||
return obj.printing_jobs.count()
|
return obj.printing_jobs.count()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.PlateOrderTiiaUploadFailure)
|
||||||
|
class PlateOrderTiiaUploadFailureAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'run_date', 'plate_order_id', 'attempts', 'last_attempt_at', 'created_at')
|
||||||
|
list_filter = ('run_date', 'created_at')
|
||||||
|
search_fields = ('plate_order_id', 'error')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|||||||
37
printing/migrations/0031_plateorder_tiia_upload_failure.py
Normal file
37
printing/migrations/0031_plateorder_tiia_upload_failure.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-01-16 19:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('printing', '0030_add_view_all_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PlateOrderTiiaUploadFailure',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('run_date', models.DateField(help_text='以任务处理的日期为准(通常为“昨天”)', verbose_name='任务日期')),
|
||||||
|
('plate_order_id', models.PositiveBigIntegerField(db_index=True, help_text='PlateOrder.id(不使用外键,避免历史数据/删除带来的约束问题)', verbose_name='开版订单ID')),
|
||||||
|
('error', models.TextField(blank=True, default='', verbose_name='错误摘要')),
|
||||||
|
('details', models.JSONField(blank=True, default=list, help_text='通常存储 upload_plate_order_images_to_tencent_tiia 返回的失败条目列表', verbose_name='错误详情')),
|
||||||
|
('attempts', models.PositiveIntegerField(default=0, verbose_name='尝试次数')),
|
||||||
|
('last_attempt_at', models.DateTimeField(blank=True, null=True, verbose_name='最后尝试时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '开版订单图片上传失败记录',
|
||||||
|
'verbose_name_plural': '开版订单图片上传失败记录',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='plateordertiiauploadfailure',
|
||||||
|
constraint=models.UniqueConstraint(fields=('run_date', 'plate_order_id'), name='uniq_plateorder_tiia_fail_run_date_po'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -255,6 +255,54 @@ class PlateOrder(ModelBase):
|
|||||||
return self.business_object.get_progress_percentage()
|
return self.business_object.get_progress_percentage()
|
||||||
|
|
||||||
|
|
||||||
|
class PlateOrderTiiaUploadFailure(ModelBase):
|
||||||
|
"""
|
||||||
|
PlateOrder 图片上传腾讯云图库失败记录(用于定时任务/人工重试追踪)。
|
||||||
|
|
||||||
|
设计目标:最小可用——记录“哪天跑的任务 + 哪个 plate_order_id + 失败详情/次数”。
|
||||||
|
"""
|
||||||
|
run_date = models.DateField(
|
||||||
|
verbose_name='任务日期',
|
||||||
|
help_text='以任务处理的日期为准(通常为“昨天”)',
|
||||||
|
)
|
||||||
|
plate_order_id = models.PositiveBigIntegerField(
|
||||||
|
verbose_name='开版订单ID',
|
||||||
|
db_index=True,
|
||||||
|
help_text='PlateOrder.id(不使用外键,避免历史数据/删除带来的约束问题)',
|
||||||
|
)
|
||||||
|
error = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
verbose_name='错误摘要',
|
||||||
|
)
|
||||||
|
details = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='错误详情',
|
||||||
|
help_text='通常存储 upload_plate_order_images_to_tencent_tiia 返回的失败条目列表',
|
||||||
|
)
|
||||||
|
attempts = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name='尝试次数',
|
||||||
|
)
|
||||||
|
last_attempt_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='最后尝试时间',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '开版订单图片上传失败记录'
|
||||||
|
verbose_name_plural = '开版订单图片上传失败记录'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['run_date', 'plate_order_id'],
|
||||||
|
name='uniq_plateorder_tiia_fail_run_date_po',
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PrintingOrder(ModelBase):
|
class PrintingOrder(ModelBase):
|
||||||
"""PrintingOrder model representing a printing order."""
|
"""PrintingOrder model representing a printing order."""
|
||||||
|
|
||||||
|
|||||||
118
printing/tasks.py
Normal file
118
printing/tasks.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from printing.models import PlateOrder, PlateOrderTiiaUploadFailure
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_day_range(day) -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Build [start, end) datetimes for a local date.
|
||||||
|
"""
|
||||||
|
tz = timezone.get_current_timezone()
|
||||||
|
start = datetime.combine(day, time.min)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
if timezone.is_naive(start):
|
||||||
|
start = timezone.make_aware(start, tz)
|
||||||
|
if timezone.is_naive(end):
|
||||||
|
end = timezone.make_aware(end, tz)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _record_failure(*, run_date, plate_order_id: int, error: str, details):
|
||||||
|
"""
|
||||||
|
Upsert failure record for (run_date, plate_order_id).
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
obj, created = PlateOrderTiiaUploadFailure.objects.select_for_update().get_or_create(
|
||||||
|
run_date=run_date,
|
||||||
|
plate_order_id=plate_order_id,
|
||||||
|
defaults={
|
||||||
|
'error': error or '',
|
||||||
|
'details': details or [],
|
||||||
|
'attempts': 1,
|
||||||
|
'last_attempt_at': now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
obj.error = error or obj.error or ''
|
||||||
|
obj.details = details or obj.details or []
|
||||||
|
obj.attempts = (obj.attempts or 0) + 1
|
||||||
|
obj.last_attempt_at = now
|
||||||
|
obj.save(update_fields=['error', 'details', 'attempts', 'last_attempt_at', 'updated_at'])
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def upload_yesterday_plate_order_images_to_tencent_tiia(self):
|
||||||
|
"""
|
||||||
|
每天凌晨 03:00 运行:
|
||||||
|
- 扫描“昨天创建”的 PlateOrder
|
||||||
|
- 将 plate_image(JSONField) 中的所有图片 URL 上传到腾讯云图库
|
||||||
|
- 遇错:记录失败的 plate_order_id(落库),继续处理下一个
|
||||||
|
|
||||||
|
注意:腾讯云调用限速 10 QPS(每秒 10 次)。
|
||||||
|
"""
|
||||||
|
from api_v1.utils.tencentcloud_tiia import (
|
||||||
|
SimpleRateLimiter,
|
||||||
|
upload_plate_order_images_to_tencent_tiia,
|
||||||
|
)
|
||||||
|
|
||||||
|
today = timezone.localdate()
|
||||||
|
run_date = today - timedelta(days=1)
|
||||||
|
start, end = _get_day_range(run_date)
|
||||||
|
|
||||||
|
qps = int(getattr(settings, 'TENCENTCLOUD_TIIA_QPS', 10) or 10)
|
||||||
|
limiter = SimpleRateLimiter(qps=float(qps))
|
||||||
|
|
||||||
|
qs = (
|
||||||
|
PlateOrder.objects.filter(created_at__gte=start, created_at__lt=end)
|
||||||
|
.only('id', 'plate_image')
|
||||||
|
.order_by('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
total_orders = 0
|
||||||
|
failed_ids: list[int] = []
|
||||||
|
processed_images = 0
|
||||||
|
|
||||||
|
for po in qs.iterator(chunk_size=200):
|
||||||
|
total_orders += 1
|
||||||
|
try:
|
||||||
|
results = upload_plate_order_images_to_tencent_tiia(
|
||||||
|
plate_order_id=po.id,
|
||||||
|
rate_limiter=limiter,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
failed_ids.append(po.id)
|
||||||
|
_record_failure(run_date=run_date, plate_order_id=po.id, error=str(e), details=[{'error': str(e)}])
|
||||||
|
logger.exception('[TIIA] PlateOrder %s 上传异常(任务继续)', po.id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_images += len(results)
|
||||||
|
bad = [r for r in results if not r.get('ok')]
|
||||||
|
if bad:
|
||||||
|
failed_ids.append(po.id)
|
||||||
|
summary = '; '.join([(b.get('error') or '') for b in bad][:5])
|
||||||
|
_record_failure(run_date=run_date, plate_order_id=po.id, error=summary, details=bad)
|
||||||
|
logger.warning('[TIIA] PlateOrder %s 部分图片上传失败: %s', po.id, summary)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'task_id': getattr(getattr(self, 'request', None), 'id', None),
|
||||||
|
'run_date': str(run_date),
|
||||||
|
'created_range': {'start': start.isoformat(), 'end': end.isoformat()},
|
||||||
|
'total_orders': total_orders,
|
||||||
|
'processed_images': processed_images,
|
||||||
|
'failed_count': len(failed_ids),
|
||||||
|
'failed_ids': failed_ids,
|
||||||
|
}
|
||||||
|
logger.info('[TIIA] 昨日 PlateOrder 图片上传任务完成: %s', payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
64
printing/test_tiia_upload_task.py
Normal file
64
printing/test_tiia_upload_task.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from printing import models as printing_models
|
||||||
|
|
||||||
|
|
||||||
|
class PlateOrderTiiaUploadTaskTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
self.customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='客户A',
|
||||||
|
mobile='13900000000',
|
||||||
|
area='A',
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('api_v1.utils.tencentcloud_tiia.time.sleep', lambda *_args, **_kwargs: None)
|
||||||
|
@patch('api_v1.utils.tencentcloud_tiia.upload_plate_order_images_to_tencent_tiia')
|
||||||
|
def test_task_records_failure_and_continues(self, mocked_upload_plate_order):
|
||||||
|
# 准备:创建 2 个 plate_order,并把 created_at 改到“昨天”
|
||||||
|
po_ok = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_image=[{'url': 'https://example.com/a.png'}],
|
||||||
|
)
|
||||||
|
po_bad = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_image=[{'url': 'https://example.com/b.png'}],
|
||||||
|
)
|
||||||
|
|
||||||
|
yesterday = timezone.localdate() - timedelta(days=1)
|
||||||
|
dt = timezone.make_aware(datetime.combine(yesterday, time.min))
|
||||||
|
printing_models.PlateOrder.objects.filter(id__in=[po_ok.id, po_bad.id]).update(created_at=dt)
|
||||||
|
|
||||||
|
def side_effect(*, plate_order_id: int, rate_limiter=None):
|
||||||
|
if plate_order_id == po_bad.id:
|
||||||
|
raise ValueError('boom')
|
||||||
|
return [{'image_url': 'https://example.com/a.png', 'ok': True, 'response': {'ok': 1}, 'error': None}]
|
||||||
|
|
||||||
|
mocked_upload_plate_order.side_effect = side_effect
|
||||||
|
|
||||||
|
from printing.tasks import upload_yesterday_plate_order_images_to_tencent_tiia
|
||||||
|
|
||||||
|
payload = upload_yesterday_plate_order_images_to_tencent_tiia()
|
||||||
|
|
||||||
|
self.assertEqual(payload['total_orders'], 2)
|
||||||
|
self.assertEqual(payload['failed_count'], 1)
|
||||||
|
self.assertIn(po_bad.id, payload['failed_ids'])
|
||||||
|
|
||||||
|
# failure 表应落库
|
||||||
|
from printing.models import PlateOrderTiiaUploadFailure
|
||||||
|
|
||||||
|
rec = PlateOrderTiiaUploadFailure.objects.filter(run_date=yesterday, plate_order_id=po_bad.id).first()
|
||||||
|
self.assertIsNotNone(rec)
|
||||||
|
self.assertGreaterEqual(rec.attempts, 1)
|
||||||
|
|
||||||
Reference in New Issue
Block a user