From c170381383db302520378585f270800fe0560c92 Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 20 Jan 2026 15:26:20 +0800 Subject: [PATCH] feat: command for tiia(tencent) --- docs/2026-01-16_summary.md | 13 ++ .../tiia_upload_plate_order_images.py | 185 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 printing/management/commands/tiia_upload_plate_order_images.py diff --git a/docs/2026-01-16_summary.md b/docs/2026-01-16_summary.md index 0db1f94..6cb88c9 100644 --- a/docs/2026-01-16_summary.md +++ b/docs/2026-01-16_summary.md @@ -143,3 +143,16 @@ - `entity_id` 使用 `plate_order_id` - 通过 `SimpleRateLimiter(qps=10)` 遵守腾讯云限速 - 失败会写入 `PlateOrderTiiaUploadFailure`(便于后续排查与重试) + +补充:也提供了正式的 Django management command(推荐使用,避免手工粘贴脚本): + +- 全量跑批: + - `uv run python manage.py tiia_upload_plate_order_images --all` +- 跑“昨天创建”的订单(同定时任务口径): + - `uv run python manage.py tiia_upload_plate_order_images --yesterday` +- 跑指定日期(本地时区 YYYY-MM-DD): + - `uv run python manage.py tiia_upload_plate_order_images --date 2026-01-15` +- 跑单个订单: + - `uv run python manage.py tiia_upload_plate_order_images --plate-order-id 123` +- 小批量验证(最多处理 N 个): + - `uv run python manage.py tiia_upload_plate_order_images --all --limit 20` diff --git a/printing/management/commands/tiia_upload_plate_order_images.py b/printing/management/commands/tiia_upload_plate_order_images.py new file mode 100644 index 0000000..0fa5622 --- /dev/null +++ b/printing/management/commands/tiia_upload_plate_order_images.py @@ -0,0 +1,185 @@ +""" +TIIA manual uploader (management command). + +This command is intended for one-off backfill / verification: +- Iterate PlateOrder(s) +- Upload all image URLs in plate_image JSON field to Tencent Cloud TIIA gallery (CreateImage) +- Respect QPS limit +- Record failures into PlateOrderTiiaUploadFailure (and continue) +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from printing.models import PlateOrder, PlateOrderTiiaUploadFailure + + +def _get_day_range(day: date) -> tuple[datetime, datetime]: + 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: date, plate_order_id: int, error: str, details): + 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 + + +class Command(BaseCommand): + help = '手动上传 PlateOrder.plate_image 中的图片到腾讯云 TIIA 图库(CreateImage),带限速与失败落库' + + def add_arguments(self, parser): + parser.add_argument( + '--all', + action='store_true', + help='处理所有 PlateOrder(建议仅用于一次性跑批/补数据)', + ) + parser.add_argument( + '--yesterday', + action='store_true', + help='处理“昨天创建”的 PlateOrder(同定时任务筛选口径)', + ) + parser.add_argument( + '--date', + type=str, + help='处理某一天创建的 PlateOrder(YYYY-MM-DD,本地时区)', + ) + parser.add_argument( + '--plate-order-id', + type=int, + help='仅处理指定 plate_order_id', + ) + parser.add_argument( + '--qps', + type=float, + default=None, + help='覆盖 settings.TENCENTCLOUD_TIIA_QPS(默认 10)', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='仅展示将要处理的 PlateOrder 数量,不执行上传', + ) + parser.add_argument( + '--limit', + type=int, + default=None, + help='最多处理多少个 PlateOrder(按 id 升序),用于小批量验证', + ) + + def handle(self, *args, **options): + from api_v1.utils.tencentcloud_tiia import SimpleRateLimiter, upload_plate_order_images_to_tencent_tiia + + do_all = bool(options['all']) + yesterday = bool(options['yesterday']) + date_str = options.get('date') + plate_order_id = options.get('plate_order_id') + dry_run = bool(options['dry_run']) + limit = options.get('limit') + + chosen = sum([1 if do_all else 0, 1 if yesterday else 0, 1 if bool(date_str) else 0, 1 if plate_order_id else 0]) + if chosen != 1: + raise CommandError('必须且只能指定一种模式:--all / --yesterday / --date / --plate-order-id') + + qps_opt = options.get('qps') + qps = float(qps_opt) if qps_opt is not None else float(getattr(settings, 'TENCENTCLOUD_TIIA_QPS', 10) or 10) + limiter = SimpleRateLimiter(qps=qps) + + run_date = timezone.localdate() + + qs = PlateOrder.objects.all().only('id', 'plate_image').order_by('id') + mode = 'all' + if yesterday: + mode = 'yesterday' + day = timezone.localdate() - timedelta(days=1) + start, end = _get_day_range(day) + qs = qs.filter(created_at__gte=start, created_at__lt=end) + elif date_str: + mode = f'date={date_str}' + try: + day = date.fromisoformat(date_str) + except Exception as e: + raise CommandError('--date 必须为 YYYY-MM-DD') from e + start, end = _get_day_range(day) + qs = qs.filter(created_at__gte=start, created_at__lt=end) + elif plate_order_id: + mode = f'plate_order_id={plate_order_id}' + qs = qs.filter(id=int(plate_order_id)) + + if limit is not None: + qs = qs[: int(limit)] + + total_orders = qs.count() if dry_run else 0 + if dry_run: + self.stdout.write(f'[TIIA] dry-run: mode={mode} qps={qps} run_date={run_date} total_orders={total_orders}') + return + + total_orders = 0 + processed_images = 0 + failed_ids: list[int] = [] + + self.stdout.write(f'[TIIA] start: mode={mode} qps={qps} run_date={run_date}') + 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)}]) + self.stderr.write(f'[TIIA] PlateOrder {po.id} 上传异常(已记录,继续):{e}') + 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) + self.stderr.write(f'[TIIA] PlateOrder {po.id} 部分失败(已记录,继续):{summary}') + + self.stdout.write(self.style.SUCCESS('[TIIA] done')) + self.stdout.write( + str( + { + 'run_date': str(run_date), + 'mode': mode, + 'qps': qps, + 'total_orders': total_orders, + 'processed_images': processed_images, + 'failed_count': len(failed_ids), + 'failed_ids_sample': failed_ids[:50], + } + ) + ) +