From 8878769ec58c5af7ed9f30ad7590a3bdf436c77e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 9 Jul 2026 14:46:09 +0800 Subject: [PATCH] feat: retry async image tasks --- .env.example | 2 + apps/api/admin.py | 4 +- apps/api/generation.py | 23 +- apps/api/image_tasks.py | 159 +++++++++++- .../management/commands/run_image_tasks.py | 20 +- ...generationtask_next_attempt_at_and_more.py | 26 ++ apps/api/models.py | 2 + apps/api/tests.py | 236 ++++++++++++++++++ config/settings.py | 5 + docs/04-architecture.md | 14 +- docs/06-tasks.md | 2 +- docs/api.md | 32 ++- docs/current-state.md | 20 +- docs/deployment.md | 8 +- docs/env.md | 4 +- progress.md | 35 +++ 16 files changed, 550 insertions(+), 42 deletions(-) create mode 100644 apps/api/migrations/0002_imagegenerationtask_next_attempt_at_and_more.py diff --git a/.env.example b/.env.example index 706e0ec..62a06ee 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,8 @@ IMAGE_TASK_RETENTION_HOURS=24 GENERATED_IMAGE_RETENTION_HOURS=72 IMAGE_TASK_REAPER_INTERVAL_SECONDS=60 IMAGE_TASK_LEASE_SECONDS=600 +IMAGE_TASK_MAX_RETRIES=2 +IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30 # API safety API_GENERATE_THROTTLE_RATE=60/min diff --git a/apps/api/admin.py b/apps/api/admin.py index 144a630..2c07a5e 100644 --- a/apps/api/admin.py +++ b/apps/api/admin.py @@ -10,11 +10,12 @@ class ImageGenerationTaskAdmin(admin.ModelAdmin): "user", "status", "attempt_count", + "next_attempt_at", "points_balance_after_charge", "created_at", "finished_at", ) - list_filter = ("status", "created_at", "finished_at") + list_filter = ("status", "created_at", "next_attempt_at", "finished_at") search_fields = ( "task_id", "user__username", @@ -37,6 +38,7 @@ class ImageGenerationTaskAdmin(admin.ModelAdmin): "started_at", "finished_at", "expires_at", + "next_attempt_at", "locked_at", "lease_expires_at", "heartbeat_at", diff --git a/apps/api/generation.py b/apps/api/generation.py index 6c49535..72fa41d 100644 --- a/apps/api/generation.py +++ b/apps/api/generation.py @@ -246,6 +246,7 @@ def execute_precharged_generation( precharged: PrechargedGeneration, *, image_url_builder: ImageUrlBuilder | None = None, + refund_on_failure: bool = True, ) -> GenerationResult: prepared = precharged.prepared try: @@ -259,22 +260,24 @@ def execute_precharged_generation( image_url_builder=image_url_builder, ) except AiCapabilityError as exc: - refund_call_points( - precharged.call_record, - error_message=str(exc), - reason=f"Provider rejected the {prepared.operation_type} request.", - ) + if refund_on_failure: + refund_call_points( + precharged.call_record, + error_message=str(exc), + reason=f"Provider rejected the {prepared.operation_type} request.", + ) raise ApiRequestError( "bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST, ) from exc except Exception as exc: - refund_call_points( - precharged.call_record, - error_message=str(exc), - reason=f"Upstream {prepared.operation_type} generation failed.", - ) + if refund_on_failure: + refund_call_points( + precharged.call_record, + error_message=str(exc), + reason=f"Upstream {prepared.operation_type} generation failed.", + ) raise upstream_error(exc) from exc return result diff --git a/apps/api/image_tasks.py b/apps/api/image_tasks.py index f38887c..e6cdc26 100644 --- a/apps/api/image_tasks.py +++ b/apps/api/image_tasks.py @@ -31,6 +31,7 @@ from .models import ImageGenerationTask IDEMPOTENCY_KEY_MAX_LENGTH = 128 TASK_FAILURE_MESSAGE = "图片生成失败,已退回点数" TASK_TIMEOUT_MESSAGE = "图片生成任务超时,已退回点数" +RETRYABLE_TASK_ERROR_CODES = {"upstream_timeout", "upstream_error"} def create_image_generation_task( @@ -207,6 +208,10 @@ def claim_next_image_task(worker_id: str | None = None) -> ImageGenerationTask | queryset = ( ImageGenerationTask.objects.select_related("user", "api_key", "call_record") .filter(status=ImageGenerationTask.Status.QUEUED) + .filter( + models_q("next_attempt_at__isnull", True) + | models_q("next_attempt_at__lte", now) + ) .order_by("created_at", "id") ) if connection.features.has_select_for_update_skip_locked: @@ -224,6 +229,7 @@ def claim_next_image_task(worker_id: str | None = None) -> ImageGenerationTask | task.lease_expires_at = lease_expires_at task.heartbeat_at = now task.started_at = task.started_at or now + task.next_attempt_at = None task.attempt_count += 1 task.save( update_fields=( @@ -233,6 +239,7 @@ def claim_next_image_task(worker_id: str | None = None) -> ImageGenerationTask | "lease_expires_at", "heartbeat_at", "started_at", + "next_attempt_at", "attempt_count", "updated_at", ) @@ -261,24 +268,56 @@ def run_image_generation_task( try: precharged = precharged_generation_for_task(task) - result = execute_precharged_generation( - precharged, - image_url_builder=media_public_url_builder, - ) except ApiRequestError as exc: refund_task_call(task, exc.message) return mark_task_failed_if_running(task.pk, exc.code, exc.message) + + try: + result = execute_precharged_generation( + precharged, + image_url_builder=media_public_url_builder, + refund_on_failure=False, + ) + except ApiRequestError as exc: + return handle_task_generation_failure( + task, + error_code=exc.code, + error_message=exc.message, + retryable=is_retryable_task_error(exc.code), + ) except Exception as exc: - refund_task_call(task, str(exc)) - return mark_task_failed_if_running( - task.pk, - "upstream_error", - TASK_FAILURE_MESSAGE, + return handle_task_generation_failure( + task, + error_code="upstream_error", + error_message=str(exc) or TASK_FAILURE_MESSAGE, + retryable=True, ) return mark_task_succeeded_if_running(task.pk, result.image_url) +def handle_task_generation_failure( + task: ImageGenerationTask, + *, + error_code: str, + error_message: str, + retryable: bool, +) -> ImageGenerationTask: + normalized_code = str(error_code or "upstream_error") + normalized_message = str(error_message or TASK_FAILURE_MESSAGE) + if retryable and task.attempt_count < image_task_max_attempts(): + return mark_task_retry_if_running( + task.pk, + normalized_code, + retry_error_message(normalized_code, normalized_message), + next_attempt_at=timezone.now() + + timedelta(seconds=image_task_retry_backoff_seconds(task.attempt_count)), + ) + + refund_task_call(task, normalized_message) + return mark_task_failed_if_running(task.pk, normalized_code, normalized_message) + + def precharged_generation_for_task(task: ImageGenerationTask) -> PrechargedGeneration: payload = dict(task.request_payload or {}) prepared = prepare_generation( @@ -344,6 +383,7 @@ def mark_task_succeeded_if_running( task.result_url = str(result_url or "") task.error_code = "" task.error_message = "" + task.next_attempt_at = None task.finished_at = now task.heartbeat_at = now task.save( @@ -352,6 +392,7 @@ def mark_task_succeeded_if_running( "result_url", "error_code", "error_message", + "next_attempt_at", "finished_at", "heartbeat_at", "updated_at", @@ -360,6 +401,41 @@ def mark_task_succeeded_if_running( return task +def mark_task_retry_if_running( + task_pk: int, + error_code: str, + error_message: str, + *, + next_attempt_at, +) -> ImageGenerationTask: + with transaction.atomic(): + task = ImageGenerationTask.objects.select_for_update().get(pk=task_pk) + if task.status != ImageGenerationTask.Status.RUNNING: + return task + task.status = ImageGenerationTask.Status.QUEUED + task.error_code = str(error_code or "upstream_error") + task.error_message = str(error_message or TASK_FAILURE_MESSAGE) + task.next_attempt_at = next_attempt_at + task.worker_id = "" + task.locked_at = None + task.lease_expires_at = None + task.heartbeat_at = None + task.save( + update_fields=( + "status", + "error_code", + "error_message", + "next_attempt_at", + "worker_id", + "locked_at", + "lease_expires_at", + "heartbeat_at", + "updated_at", + ) + ) + return task + + def mark_task_failed_if_running( task_pk: int, error_code: str, @@ -373,6 +449,7 @@ def mark_task_failed_if_running( task.status = ImageGenerationTask.Status.FAILED task.error_code = str(error_code or "upstream_error") task.error_message = str(error_message or TASK_FAILURE_MESSAGE) + task.next_attempt_at = None task.finished_at = now task.heartbeat_at = now task.save( @@ -380,6 +457,7 @@ def mark_task_failed_if_running( "status", "error_code", "error_message", + "next_attempt_at", "finished_at", "heartbeat_at", "updated_at", @@ -422,6 +500,7 @@ def reap_stale_image_task(task_pk: int, now) -> bool: task.result_url = task.call_record.result_ref task.error_code = "" task.error_message = "" + task.next_attempt_at = None task.finished_at = now task.save( update_fields=( @@ -429,6 +508,7 @@ def reap_stale_image_task(task_pk: int, now) -> bool: "result_url", "error_code", "error_message", + "next_attempt_at", "finished_at", "updated_at", ) @@ -446,14 +526,24 @@ def reap_stale_image_task(task_pk: int, now) -> bool: if task.call_record.status == CallRecord.Status.SUCCESS: task.status = ImageGenerationTask.Status.SUCCEEDED task.result_url = task.call_record.result_ref + task.next_attempt_at = None task.finished_at = now - task.save(update_fields=("status", "result_url", "finished_at", "updated_at")) + task.save( + update_fields=( + "status", + "result_url", + "next_attempt_at", + "finished_at", + "updated_at", + ) + ) return True raise task.status = ImageGenerationTask.Status.FAILED task.error_code = "task_timeout" task.error_message = TASK_TIMEOUT_MESSAGE + task.next_attempt_at = None task.finished_at = now task.heartbeat_at = now task.save( @@ -461,6 +551,7 @@ def reap_stale_image_task(task_pk: int, now) -> bool: "status", "error_code", "error_message", + "next_attempt_at", "finished_at", "heartbeat_at", "updated_at", @@ -510,6 +601,9 @@ def task_submit_response(task: ImageGenerationTask) -> dict[str, Any]: "call_id": call_record.id, "points_cost": call_record.points_cost, "points_balance": task.points_balance_after_charge, + "attempt_count": task.attempt_count, + "max_attempts": image_task_max_attempts(), + "next_attempt_at": task.next_attempt_at.isoformat() if task.next_attempt_at else None, "created_at": task.created_at.isoformat(), "expires_at": task.expires_at.isoformat() if task.expires_at else None, } @@ -522,6 +616,9 @@ def task_detail_response(task: ImageGenerationTask) -> dict[str, Any]: "status": task.status, "call_id": call_record.id, "points_cost": call_record.points_cost, + "attempt_count": task.attempt_count, + "max_attempts": image_task_max_attempts(), + "next_attempt_at": task.next_attempt_at.isoformat() if task.next_attempt_at else None, "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), "expires_at": task.expires_at.isoformat() if task.expires_at else None, @@ -553,6 +650,48 @@ def image_task_lease_seconds() -> int: return max(1, int(getattr(settings, "IMAGE_TASK_LEASE_SECONDS", 600))) +def image_task_max_retries() -> int: + return max(0, int(getattr(settings, "IMAGE_TASK_MAX_RETRIES", 2))) + + +def image_task_max_attempts() -> int: + return 1 + image_task_max_retries() + + +def image_task_retry_backoff_seconds(attempt_count: int) -> int: + values = image_task_retry_backoff_values() + if not values: + return 0 + index = max(0, int(attempt_count or 1) - 1) + return values[min(index, len(values) - 1)] + + +def image_task_retry_backoff_values() -> list[int]: + raw = str(getattr(settings, "IMAGE_TASK_RETRY_BACKOFF_SECONDS", "10,30") or "") + values: list[int] = [] + for part in raw.split(","): + item = part.strip() + if not item: + continue + try: + values.append(max(0, int(item))) + except ValueError: + continue + return values + + +def is_retryable_task_error(error_code: str) -> bool: + return str(error_code or "") in RETRYABLE_TASK_ERROR_CODES + + +def retry_error_message(error_code: str, fallback: str) -> str: + if error_code == "upstream_timeout": + return "上游 AI 调用超时,稍后自动重试" + if error_code == "upstream_error": + return "上游 AI 调用失败,稍后自动重试" + return fallback + + def normalize_worker_id(worker_id: str | None) -> str: normalized = str(worker_id or "").strip() if normalized: diff --git a/apps/api/management/commands/run_image_tasks.py b/apps/api/management/commands/run_image_tasks.py index d5ec8c7..9d33cbe 100644 --- a/apps/api/management/commands/run_image_tasks.py +++ b/apps/api/management/commands/run_image_tasks.py @@ -4,8 +4,12 @@ import uuid from django.conf import settings from django.core.management.base import BaseCommand +from apps.api.image_tasks import ( + image_task_max_attempts, + reap_stale_image_tasks, + run_one_image_task, +) from apps.api.models import ImageGenerationTask -from apps.api.image_tasks import reap_stale_image_tasks, run_one_image_task class Command(BaseCommand): @@ -70,13 +74,25 @@ class Command(BaseCommand): def format_task_log_line(task: ImageGenerationTask, *, started_at: float) -> str: duration_ms = max(0, int((time.monotonic() - started_at) * 1000)) alias = str((task.request_payload or {}).get("model") or "") + retrying = bool( + task.status == ImageGenerationTask.Status.QUEUED + and task.error_code + and task.next_attempt_at + ) fields = { "event": "image_task_processed", "task_id": str(task.task_id), "status": task.status, "alias": alias, + "attempt": str(task.attempt_count), + "max_attempts": str(image_task_max_attempts()), + "retrying": str(retrying).lower(), + "next_attempt_at": task.next_attempt_at.isoformat() if task.next_attempt_at else "", "duration_ms": str(duration_ms), } - if task.status in {ImageGenerationTask.Status.FAILED, ImageGenerationTask.Status.EXPIRED}: + if retrying or task.status in { + ImageGenerationTask.Status.FAILED, + ImageGenerationTask.Status.EXPIRED, + }: fields["error_code"] = task.error_code or "upstream_error" return " ".join(f"{key}={value}" for key, value in fields.items()) diff --git a/apps/api/migrations/0002_imagegenerationtask_next_attempt_at_and_more.py b/apps/api/migrations/0002_imagegenerationtask_next_attempt_at_and_more.py new file mode 100644 index 0000000..d17414b --- /dev/null +++ b/apps/api/migrations/0002_imagegenerationtask_next_attempt_at_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.15 on 2026-07-09 06:28 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ('billing', '0007_alter_pointsledger_change_type_signupbonusgrant'), + ('users', '0005_alter_apikey_created_at_alter_apikey_key_hash_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='imagegenerationtask', + name='next_attempt_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='下次重试时间'), + ), + migrations.AddIndex( + model_name='imagegenerationtask', + index=models.Index(fields=['status', 'next_attempt_at'], name='image_gener_status_e2e2f3_idx'), + ), + ] diff --git a/apps/api/models.py b/apps/api/models.py index ffeb27f..a7c64b8 100644 --- a/apps/api/models.py +++ b/apps/api/models.py @@ -62,6 +62,7 @@ class ImageGenerationTask(models.Model): started_at = models.DateTimeField("开始时间", null=True, blank=True) finished_at = models.DateTimeField("完成时间", null=True, blank=True) expires_at = models.DateTimeField("任务元数据过期时间", null=True, blank=True) + next_attempt_at = models.DateTimeField("下次重试时间", null=True, blank=True) locked_at = models.DateTimeField("锁定时间", null=True, blank=True) lease_expires_at = models.DateTimeField("租约过期时间", null=True, blank=True) heartbeat_at = models.DateTimeField("心跳时间", null=True, blank=True) @@ -89,6 +90,7 @@ class ImageGenerationTask(models.Model): models.Index(fields=("user", "created_at")), models.Index(fields=("api_key", "created_at")), models.Index(fields=("status", "created_at")), + models.Index(fields=("status", "next_attempt_at")), models.Index(fields=("status", "lease_expires_at")), models.Index(fields=("worker_id", "status")), models.Index(fields=("expires_at",)), diff --git a/apps/api/tests.py b/apps/api/tests.py index f7b2798..e1195e9 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -1507,6 +1507,9 @@ class GenerateApiTests(TestCase): self.assertEqual(response.status_code, 202) self.assertEqual(response.data["status"], ImageGenerationTask.Status.QUEUED) self.assertEqual(response.data["points_balance"], 90) + self.assertEqual(response.data["attempt_count"], 0) + self.assertEqual(response.data["max_attempts"], 3) + self.assertIsNone(response.data["next_attempt_at"]) self.assertEqual(self.provider.image_calls, []) with patch("apps.api.generation.get_provider", return_value=self.provider): @@ -1529,6 +1532,9 @@ class GenerateApiTests(TestCase): self.assertEqual(poll.status_code, 200) self.assertEqual(poll.data["status"], ImageGenerationTask.Status.SUCCEEDED) + self.assertEqual(poll.data["attempt_count"], 1) + self.assertEqual(poll.data["max_attempts"], 3) + self.assertIsNone(poll.data["next_attempt_at"]) self.assertEqual(poll.data["result"]["image_url"], task.result_url) self.assertEqual(repeat.data["result"]["image_url"], task.result_url) @@ -1552,6 +1558,7 @@ class GenerateApiTests(TestCase): self.assertEqual(denied.status_code, 404) self.assertEqual(denied.data["error"]["code"], "task_not_found") + @override_settings(IMAGE_TASK_MAX_RETRIES=0) def test_async_image_worker_failure_refunds_precharged_points(self): self.provider.image_error = requests.Timeout("image upstream deadline exceeded") response = self.post_with_provider( @@ -1586,7 +1593,199 @@ class GenerateApiTests(TestCase): ) self.assertEqual(poll.data["status"], ImageGenerationTask.Status.FAILED) self.assertEqual(poll.data["error"]["code"], "upstream_timeout") + self.assertEqual(poll.data["attempt_count"], 1) + self.assertEqual(poll.data["max_attempts"], 1) + self.assertIsNone(poll.data["next_attempt_at"]) + @override_settings(IMAGE_TASK_RETRY_BACKOFF_SECONDS="60,120") + def test_async_image_retryable_timeout_requeues_without_refund_and_respects_backoff(self): + self.provider.image_error = requests.Timeout("image upstream deadline exceeded") + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + task = run_image_generation_task( + claim_next_image_task("worker-retry"), + worker_id="worker-retry", + ) + + self.assertEqual(task.status, ImageGenerationTask.Status.QUEUED) + self.assertEqual(task.attempt_count, 1) + self.assertEqual(task.error_code, "upstream_timeout") + self.assertEqual(task.error_message, "上游 AI 调用超时,稍后自动重试") + self.assertIsNotNone(task.next_attempt_at) + self.assertGreater(task.next_attempt_at, timezone.now()) + self.assertIsNone(claim_next_image_task("worker-too-soon")) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 90) + + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.status, CallRecord.Status.PENDING) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 0, + ) + + poll = self.client.get( + f"/api/v1/generate/image/tasks/{response.data['task_id']}", + **self.auth_header(), + ) + self.assertEqual(poll.status_code, 200) + self.assertEqual(poll.data["status"], ImageGenerationTask.Status.QUEUED) + self.assertEqual(poll.data["attempt_count"], 1) + self.assertEqual(poll.data["max_attempts"], 3) + self.assertIsNotNone(poll.data["next_attempt_at"]) + + @override_settings( + MEDIA_PUBLIC_BASE_URL="https://cm.example.test", + IMAGE_TASK_RETRY_BACKOFF_SECONDS="0,0", + ) + def test_async_image_retryable_timeouts_then_success_charges_once(self): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + self.provider.image_error = requests.Timeout("first timeout") + first = run_image_generation_task( + claim_next_image_task("worker-retry-1"), + worker_id="worker-retry-1", + ) + ImageGenerationTask.objects.filter(pk=first.pk).update( + next_attempt_at=timezone.now() - timedelta(seconds=1) + ) + + self.provider.image_error = requests.Timeout("second timeout") + second = run_image_generation_task( + claim_next_image_task("worker-retry-2"), + worker_id="worker-retry-2", + ) + ImageGenerationTask.objects.filter(pk=second.pk).update( + next_attempt_at=timezone.now() - timedelta(seconds=1) + ) + + self.provider.image_error = None + succeeded = run_image_generation_task( + claim_next_image_task("worker-retry-3"), + worker_id="worker-retry-3", + ) + + self.assertEqual(first.status, ImageGenerationTask.Status.QUEUED) + self.assertEqual(second.status, ImageGenerationTask.Status.QUEUED) + self.assertEqual(succeeded.status, ImageGenerationTask.Status.SUCCEEDED) + self.assertEqual(succeeded.attempt_count, 3) + self.assertTrue(succeeded.result_url.startswith("https://cm.example.test/media/")) + self.assertEqual(len(self.provider.image_calls), 3) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 90) + + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.status, CallRecord.Status.SUCCESS) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.CONSUME, + ).count(), + 1, + ) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 0, + ) + + @override_settings(IMAGE_TASK_RETRY_BACKOFF_SECONDS="0,0") + def test_async_image_retryable_timeouts_final_failure_refunds_once(self): + self.provider.image_error = requests.Timeout("image upstream deadline exceeded") + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + first = run_image_generation_task( + claim_next_image_task("worker-final-1"), + worker_id="worker-final-1", + ) + ImageGenerationTask.objects.filter(pk=first.pk).update( + next_attempt_at=timezone.now() - timedelta(seconds=1) + ) + second = run_image_generation_task( + claim_next_image_task("worker-final-2"), + worker_id="worker-final-2", + ) + ImageGenerationTask.objects.filter(pk=second.pk).update( + next_attempt_at=timezone.now() - timedelta(seconds=1) + ) + failed = run_image_generation_task( + claim_next_image_task("worker-final-3"), + worker_id="worker-final-3", + ) + + self.assertEqual(failed.status, ImageGenerationTask.Status.FAILED) + self.assertEqual(failed.error_code, "upstream_timeout") + self.assertEqual(failed.attempt_count, 3) + self.assertIsNone(failed.next_attempt_at) + self.assertEqual(len(self.provider.image_calls), 3) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.status, CallRecord.Status.FAILED) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.CONSUME, + ).count(), + 1, + ) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 1, + ) + + def test_async_image_non_retryable_provider_error_fails_immediately_and_refunds(self): + self.provider.image_error = AiCapabilityError("input image is required") + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + failed = run_image_generation_task( + claim_next_image_task("worker-no-retry"), + worker_id="worker-no-retry", + ) + + self.assertEqual(failed.status, ImageGenerationTask.Status.FAILED) + self.assertEqual(failed.error_code, "bad_request") + self.assertEqual(failed.attempt_count, 1) + self.assertIsNone(failed.next_attempt_at) + self.assertEqual(len(self.provider.image_calls), 1) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.status, CallRecord.Status.FAILED) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 1, + ) + + @override_settings(IMAGE_TASK_MAX_RETRIES=0) def test_run_image_tasks_logs_failed_task_alias_error_and_duration(self): self.provider.image_error = requests.Timeout("image upstream deadline exceeded") response = self.post_with_provider( @@ -1611,10 +1810,47 @@ class GenerateApiTests(TestCase): self.assertIn(f"task_id={task.task_id}", output) self.assertIn(f"alias={self.image_alias}", output) self.assertIn("status=failed", output) + self.assertIn("attempt=1", output) + self.assertIn("max_attempts=1", output) + self.assertIn("retrying=false", output) self.assertIn("error_code=upstream_timeout", output) self.assertRegex(output, r"duration_ms=\d+") self.assertNotIn("生成图片", output) + @override_settings(IMAGE_TASK_RETRY_BACKOFF_SECONDS="60,120") + def test_run_image_tasks_logs_retrying_task_attempt_fields_without_sensitive_data(self): + self.provider.image_error = requests.Timeout("image upstream deadline exceeded") + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + out = io.StringIO() + + with patch("apps.api.generation.get_provider", return_value=self.provider): + call_command( + "run_image_tasks", + "--once", + "--worker-id", + "worker-log-retry", + stdout=out, + ) + + task = ImageGenerationTask.objects.get(task_id=response.data["task_id"]) + output = out.getvalue() + self.assertEqual(task.status, ImageGenerationTask.Status.QUEUED) + self.assertIn("event=image_task_processed", output) + self.assertIn(f"task_id={task.task_id}", output) + self.assertIn(f"alias={self.image_alias}", output) + self.assertIn("status=queued", output) + self.assertIn("attempt=1", output) + self.assertIn("max_attempts=3", output) + self.assertIn("retrying=true", output) + self.assertIn("next_attempt_at=", output) + self.assertIn("error_code=upstream_timeout", output) + self.assertRegex(output, r"duration_ms=\d+") + self.assertNotIn("生成图片", output) + self.assertNotIn(self.raw_key, output) + def test_async_image_reaper_fails_stale_running_task_and_refunds(self): response = self.post_with_provider( "/api/v1/generate/image/tasks", diff --git a/config/settings.py b/config/settings.py index 9e8deb1..92dcc1b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -123,6 +123,11 @@ IMAGE_TASK_RETENTION_HOURS = env_int("IMAGE_TASK_RETENTION_HOURS", 24) GENERATED_IMAGE_RETENTION_HOURS = env_int("GENERATED_IMAGE_RETENTION_HOURS", 72) IMAGE_TASK_REAPER_INTERVAL_SECONDS = env_int("IMAGE_TASK_REAPER_INTERVAL_SECONDS", 60) IMAGE_TASK_LEASE_SECONDS = env_int("IMAGE_TASK_LEASE_SECONDS", 600) +IMAGE_TASK_MAX_RETRIES = env_int("IMAGE_TASK_MAX_RETRIES", 2) +IMAGE_TASK_RETRY_BACKOFF_SECONDS = os.environ.get( + "IMAGE_TASK_RETRY_BACKOFF_SECONDS", + "10,30", +) RECHARGE_MAX_AMOUNT_CNY = env_decimal("RECHARGE_MAX_AMOUNT_CNY", "100000.00") MODERATION_ENABLED = env_bool("MODERATION_ENABLED", False) MODERATION_PROVIDER = os.environ.get("MODERATION_PROVIDER", "keyword").strip().lower() diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 835e91a..ed877a3 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -43,6 +43,8 @@ T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层 T-614 已实现 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:提交接口同步审核 prompt、解析图片输入和预扣点,创建 `ImageGenerationTask(status=queued)` 后立即返回公开 UUID `task_id`;后台 worker 通过 `select_for_update(skip_locked)` 抢任务,复用 T-613 `execute_precharged_generation()` 对已预扣 `CallRecord` 调上游、保存结果、成功确认或失败退点。任务表记录 worker 租约、心跳和尝试次数;reaper 识别僵尸 `running` 任务后默认判失败并幂等退点,不默认重排队。异步 worker 没有 DRF request,结果 URL 由 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成。当前实现中 worker 会基于任务快照再次运行 `prepare_generation()`,因此会复审 prompt、重解析别名 / Provider / 定价;账务仍使用已预扣 `CallRecord.points_cost`,不会重复扣点。这个取舍偏安全(排队期间敏感词库更新后仍能拦截并退款),但如果未来队列积压明显,应单独实现“提交时模型配置快照”,避免执行时别名映射变化导致按旧价预扣、按新模型执行。 +T-616 起异步生图 worker 对临时性上游失败增加自动重试:`upstream_timeout` / `upstream_error` 在未达到最大次数前回到 `queued` 并设置 `next_attempt_at`,`CallRecord` 保持 `pending` 且不退点;不可重试错误或最后一次失败才置 `failed` 并幂等退款。默认 `IMAGE_TASK_MAX_RETRIES=2`,因此 `attempt_count` 最多为 3。 + T-615 已给旧同步生图接口和新异步提交接口接入 `cmhub.api.generation_usage` 结构化日志,事件名为 `generation_route_usage`。日志字段只包含 `route_type(sync/async)`、`api_key_id`、`api_key_prefix`、`user_id`、`client_version`、`alias`、`status`、`latency_ms`、`error_code`、`http_status` 等白名单信息;不得记录 API Key 明文、prompt 全文、`image_base64`、provider raw 或上游密钥。第一版用日志查询完成用量观察,不新增报表表结构;如后续要在 admin 做统计报表,需单独评估数据量、索引和保留周期。 T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。 @@ -339,6 +341,7 @@ CREATE TABLE image_generation_task ( started_at DATETIME(6), finished_at DATETIME(6), expires_at DATETIME(6), + next_attempt_at DATETIME(6), locked_at DATETIME(6), lease_expires_at DATETIME(6), heartbeat_at DATETIME(6), @@ -353,13 +356,13 @@ CREATE TABLE image_generation_task ( 需要说明: - 主键自增;`api_key.key_hash`、`pricing_rule(operation_type, alias, resolution)`、`recharge_order.order_no`、`user.username` 唯一。 -- 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`、`image_generation_task(status, created_at)`、`image_generation_task(status, lease_expires_at)`、`image_generation_task(user_id, created_at)`。 +- 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`、`image_generation_task(status, created_at)`、`image_generation_task(status, next_attempt_at)`、`image_generation_task(status, lease_expires_at)`、`image_generation_task(user_id, created_at)`。 - 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。 - 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at` / `updated_at`。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 -- `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。 -- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-614 已在 `apps.api` 落地 `ImageGenerationTask` 与 `api.0001_initial` 迁移,使用 MySQL 兼容的 `(api_key, idempotency_key_hash)` 唯一约束处理幂等键,不使用条件唯一约束;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。 +- `call_record.status` 状态机为 `pending -> success / failed`。异步任务临时性失败等待重试时仍保持 `pending`;最终上游失败退点后才变为 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。 +- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-614 已在 `apps.api` 落地 `ImageGenerationTask` 与 `api.0001_initial` 迁移,使用 MySQL 兼容的 `(api_key, idempotency_key_hash)` 唯一约束处理幂等键,不使用条件唯一约束;T-616 已在 `ImageGenerationTask` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,用于 worker 避免立即反复抢占等待重试的任务;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。 ## 四、计费时序(核心,务必照此实现) @@ -398,7 +401,8 @@ CREATE TABLE image_generation_task ( 7. worker 抢 queued → running,写 worker_id / locked_at / lease_expires_at / heartbeat_at / attempt_count。 8. worker 复用已预扣 CallRecord 调上游: 成功 → mark_call_success,任务置 succeeded,写稳定 result_url。 - 失败 / 超时 → refund_call_points 幂等退点,任务置 failed。 + 可重试失败且 attempt_count < max_attempts → 任务回 queued,写 next_attempt_at,CallRecord 保持 pending,不退点。 + 不可重试失败或最终失败 → refund_call_points 幂等退点,任务置 failed。 9. reaper 扫描 lease/heartbeat 过期的 running: 默认置 failed + refund_call_points;不默认重排队。 若 call_record 已 success,则只把任务补成 succeeded,不退款。 @@ -409,6 +413,7 @@ CREATE TABLE image_generation_task ( - 提交即预扣,避免余额只够 1 张却排入大量任务;余额不足仍返回 `402 insufficient_points`。 - `Idempotency-Key` 按当前 API Key 去重,同 key 同 payload 返回同一任务且不重复扣点;同 key 不同 payload 返回 `409 idempotency_conflict`。 - 桌面端主链路推荐传 `image_base64`,submit 阶段只做解码和落盘,通常是毫秒级;`image_url` 输入会在 submit 阶段下载并可能阻塞,属于边缘路径,换来的是任务自包含和 worker 不再访问调用方外部 URL。 +- 默认只重试 `upstream_timeout` / `upstream_error` 这类临时性上游失败;敏感词、余额不足、未定价、别名 / 模型不可用、参数错误和 Provider 配置错误不重试。 - worker 是至少一次执行模型,但账务终态和结果终态必须 exactly-once:不得重复扣/退,不得覆盖已成功结果,也不得把 reaper 已失败退款的任务改回成功。 - 查询接口只按 `task_id` + 当前 API Key 所属用户查任务,防止 IDOR;成功任务重复查询返回同一 `result_url`。 @@ -469,6 +474,7 @@ CREATE TABLE image_generation_task ( | 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 | | 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 | | 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | T-612 后生图 Provider 读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`,撞硬截止走失败退点;T-614 已提供异步提交 / 轮询路径,新客户端优先使用异步任务,旧同步接口保留兼容 | +| 异步生图临时性上游失败 | 上游偶发 `Read timed out`、502/503/504 直接失败会降低批量任务成功率 | T-616 起异步 worker 对 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试等待用 `next_attempt_at` 防止立即打爆上游;最终失败才退款 | | 异步任务僵死 / 迟到 worker | worker 抢到任务后进程崩溃会留下 running;迟到 worker 可能在 reaper 退款后返回成功 | `ImageGenerationTask` 记录租约和心跳;reaper 按 `lease_expires_at` / `heartbeat_at` 幂等失败退款;成功/失败终态写入前重新锁任务,禁止覆盖已成功或已退款失败的任务 | | 异步执行时配置漂移 | worker 当前会复跑审核、别名解析和定价;账务用已预扣点数,不重复扣费,但排队期间后台改别名可能导致按提交时价格、执行时模型运行 | 当前接受该取舍并视为短队列安全优先;队列积压或多模型价差扩大后,单独实现提交时 `ai_model_id` / `model_used` 快照,worker 只复审 prompt、不重选模型 | | 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 971ade8..3d1b1e6 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -94,7 +94,7 @@ | T-613 | 抽生成核心 service(计费+审核+上游共享 core) | T-612, T-302, T-604 | 为异步化铺路,**重构现有 `apps/api/generation.py`,不是另写第二套生成逻辑**。把「别名解析+能力校验 → 审核(T-604) → 图片输入处理 → 计费计算 → 预扣(precharge) → 调上游 → 保存结果 → 成功确认/失败退点」整理为不依赖 DRF `Request` / `Response` 的核心 service,并为 T-614 拆出可复用的阶段:同步旧接口可一口气执行完整 pipeline,异步 worker 可复用“已预扣 call_record 的执行与确认 / 退点”阶段,**严禁复制第二套扣点/退点逻辑**。**HTTP 解耦**:核心 service 不直接构造 DRF `Response`,错误用领域异常表达;图片保存不能强依赖 `request.build_absolute_uri()`,需通过 URL 构建器或公开基础 URL 生成结果 URL。**兼容约束**:旧 `POST /api/v1/generate/title|image` 的字段、HTTP 状态码和错误语义保持不变;不要求 JSON 字段顺序逐字节一致。**验收**:T-302/T-604 既有断言不降低标准即通过;覆盖旧同步接口成功、上游失败退点、敏感词拦截不扣点、图片 URL 保存;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | DONE | | T-614 | 生图异步任务化接口(提交+轮询,新增不动旧接口) | T-613, T-203, T-301 | 新增任务化接口与旧同步接口**共存**(expand-contract 并行变更),新版桌面端走新路、老版本零感知。跨仓契约参考 cmshopee 侧设计(Obsidian「生图接口异步任务化-提交轮询方案」评审修订 v2),**落地口径以本任务 + `api.md` 为准**。**新增路由**:`POST /api/v1/generate/image/tasks`(提交后返回 `202` + `task_id`)、`GET /api/v1/generate/image/tasks/{task_id}`(轮询取状态和结果);本期不暴露 cancel 路由,若后续要做取消单独拆任务且只允许取消 `queued`。**任务模型**:新增 `ImageGenerationTask`,公开 `task_id` 用 UUID,不暴露自增 ID;字段至少包含 `user`、`api_key`、`call_record`、`status(queued/running/succeeded/failed/expired)`、`idempotency_key`、`request_hash`、必要的请求快照 / 输入引用、`result_url`、`error_code`、`error_message`、`started_at`、`finished_at`、`expires_at`、`locked_at`、`lease_expires_at`、`heartbeat_at`、`worker_id`、`attempt_count`。不得把 provider raw、密钥或超大 base64 原文长期存 DB;`image_base64` 应先解码后落临时文件 / 存储引用,`image_url` submit 阶段至少做协议与公网地址校验,实际下载可在 worker 内执行,失败走退点。**提交段**:审核(T-604)同步执行,命中 BLOCK 直接 `400 content_blocked`,不建 task、不扣点、不调上游;submit 时预扣(沿用现有 `precharge`),余额不足 `402`,堵住「余额只够 1 张却提交 100 个 task」;建任务后立即返回 `202`。**幂等**:支持 `Idempotency-Key`,按 `api_key + operation + key` 去重;同 key 同 payload 返回同一 `task_id` 且不重复预扣,同 key 不同 payload 返回 `409 idempotency_conflict`。**后台 worker**:优先用 DB 任务表 + management command worker + systemd 托管,MySQL 8.4 可用 `select_for_update(skip_locked)` 抢任务;暂不引入 Celery/Redis,`django-q`/`huey` 只有在 DB worker 不够时再单独评估。worker 必须复用 T-613 共享 core 和已预扣的 `CallRecord`,成功保留扣点并返回 cmhub 托管 URL,失败/撞硬截止必须退点。**结果 URL**:异步 worker 没有 request,必须新增 `PUBLIC_BASE_URL` 或 `MEDIA_PUBLIC_BASE_URL` 等配置来生成绝对 URL;不得透传上游临时链接。**查询段**:只读、短超时,必须校验 `task.user == api_key.user`(防 IDOR,跨用户返回 404 或 403);`succeeded` 幂等重取返回同一 URL;过期任务 / 图片 GC 口径写入文档。**保留窗口**:task_id 持久化的目的是扛客户端重启,窗口须覆盖桌面端现实停机(如关一晚),**任务元数据保留 ≥24h、结果图保留更久(如 24–72h,可配,别硬编码 6h)**,避免「点已扣、图被 GC」。**租约与僵任务回收(reaper,必做)**:worker 抢任务时写 `worker_id`、`locked_at`、`lease_expires_at`,运行中周期性更新 `heartbeat_at`;如果 worker 用 `select_for_update(skip_locked)` 抢任务后崩溃,行锁随连接释放但 `status` 会永远停在 `running`——**点数已预扣却永不退、客户端轮询到自己超时**。reaper 按 `lease_expires_at` / `heartbeat_at`(兜底 `started_at`)识别僵任务,默认把超时 `running` 判 `failed` + **退点(幂等)**,不默认重排队;只有能证明任务尚未调上游(如明确 `stage=not_started`)时才允许后续任务设计重排队。**worker 至少一次执行,账务和结果 exactly-once**:worker 可能重复执行同一任务,但「上游调用确认 / 退点 / 写结果」这些终态动作必须幂等;重复执行不得重复扣/退,不得覆盖已 `succeeded` 结果,也不得把 reaper 已判 `failed` 且已退点的任务改回 `succeeded`。**配置**:新增或同步 `IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS` 等环境变量口径。**文档**:同步 `api.md`(新契约、状态机、错误码、幂等)、`routes.md`、`04-architecture.md`(异步计费时序)、`deployment.md`(worker 进程托管)、`env.md`。**验收**:审核命中不建 task/不扣点/不调上游;预扣余额不足 402;同 Idempotency-Key 去重且 payload 冲突 409;worker 成功后重复 GET 同一 URL;跨用户 GET 被拒;失败/超时退点不超扣;**模拟 worker 崩溃留下 `running` 僵任务 → reaper 判失败并退点、不超扣、客户端下次 GET 得到 `failed`**;**worker 重复执行同一任务幂等(不重复扣/退、不覆盖已 `succeeded` 结果)**;**reaper 已把任务判 `failed` 并退点后,迟到 worker 返回成功也不能改回 `succeeded`、不能覆盖结果、不能再次改账**;旧生文与旧同步生图不回归;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | DONE | | T-615 | 旧同步生图接口用量遥测 + 弃用口径 | T-614 | 给旧同步接口装可观测、定弃用退出条件,避免永久双维护。**最小遥测**:旧 `POST /api/v1/generate/image` 和新 `/tasks` 路径都写结构化日志 / 计数,至少含 route_type(sync/async)、api_key 前缀或 ID、user_id、client version(若请求头提供,如 `X-Client-Version`)、alias、status、latency_ms、error_code;日志不得包含 API Key 明文、prompt 全文、图片 base64 或 provider raw。**查看方式**:先用日志查询即可;若要 admin 报表需单独评估数据量和索引。**弃用口径**:在 `deployment.md`/`04-architecture.md` 记录迁移计划:新版桌面端默认走异步接口 → 观察旧路调用量和错误率 → 旧路调用归零或低于阈值一段时间 → 宣布 deprecate → 另立任务下线;旧同步接口下线前必须保留成功响应兼容。**验收**:能按 client version / api_key 看到旧路与新路用量;弃用条件成文;不泄露敏感数据;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | DONE | -| T-616 | 生图失败自动重试 2 次 | T-614, T-615, T-203 | 给异步生图 worker 增加临时性上游失败自动重试,降低 `api.vectorengine.ai` 偶发 `Read timed out` 对用户的失败率。**业务口径**:“2 次重试”定义为第 1 次正常执行 + 失败后最多再重试 2 次,即最多 3 次上游调用;提交阶段仍只预扣一次。**只重试临时性错误**:`upstream_timeout`、网络连接错误、上游 502/503/504 等;不重试 `content_blocked`、`insufficient_points`、`no_pricing_rule`、模型/别名配置不可用、参数错误、Provider 配置错误等确定性错误。**数据结构**:复用现有 `attempt_count` 表示已开始执行次数;新增 `next_attempt_at`(允许下次抢占时间)和必要的 metadata-only admin 展示;如需记录最后一次错误,继续使用 `error_code` / `error_message`,不得保存 provider raw。**状态机**:`queued -> running(attempt_count+1)`;若成功则 `succeeded` 并确认已预扣调用;若可重试失败且 `attempt_count < 3`,任务回到 `queued`、设置 `next_attempt_at=now+backoff`、保留 `CallRecord.pending`、不退点;若不可重试或已到最终次数,才 `failed` 并调用计费层幂等退点。**worker 抢任务**:只抢 `status=queued` 且 `next_attempt_at IS NULL OR next_attempt_at <= now` 的任务;避免 66 worker 立即反复打爆上游。**配置**:新增 `IMAGE_TASK_MAX_RETRIES=2`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30`(或等价配置),同步 `.env.example`、`env.md`、`deployment.md`;生产要提醒最坏耗时约 `上游硬截止 * 3 + backoff`,桌面端轮询超时需覆盖。**接口兼容**:轮询响应可新增可选字段 `attempt_count`、`max_attempts`、`next_attempt_at`,但不得删除既有字段;旧客户端忽略新字段仍可工作。**日志**:扩展 `event=image_task_processed`,包含 `attempt`、`max_attempts`、`retrying`、`next_attempt_at`、`duration_ms`、`error_code`,不得记录 prompt、base64、provider raw 或密钥。**账务验收**:第 1/2 次 `upstream_timeout` 回到 queued 且不退点;第 3 次成功只扣一次并成功确认;连续 3 次失败只退一次;非重试错误立即失败并只退一次;幂等提交仍返回同一 task,不重复预扣;reaper 与迟到 worker 不得把已最终失败/已退款任务改回成功。**测试**:覆盖上述账务、状态机、backoff 抢占、轮询新增字段兼容、worker 日志字段和敏感信息不泄露;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | TODO | +| T-616 | 生图失败自动重试 2 次 | T-614, T-615, T-203 | 给异步生图 worker 增加临时性上游失败自动重试,降低 `api.vectorengine.ai` 偶发 `Read timed out` 对用户的失败率。**业务口径**:“2 次重试”定义为第 1 次正常执行 + 失败后最多再重试 2 次,即最多 3 次上游调用;提交阶段仍只预扣一次。**只重试临时性错误**:`upstream_timeout`、网络连接错误、上游 502/503/504 等;不重试 `content_blocked`、`insufficient_points`、`no_pricing_rule`、模型/别名配置不可用、参数错误、Provider 配置错误等确定性错误。**数据结构**:复用现有 `attempt_count` 表示已开始执行次数;新增 `next_attempt_at`(允许下次抢占时间)和必要的 metadata-only admin 展示;如需记录最后一次错误,继续使用 `error_code` / `error_message`,不得保存 provider raw。**状态机**:`queued -> running(attempt_count+1)`;若成功则 `succeeded` 并确认已预扣调用;若可重试失败且 `attempt_count < 3`,任务回到 `queued`、设置 `next_attempt_at=now+backoff`、保留 `CallRecord.pending`、不退点;若不可重试或已到最终次数,才 `failed` 并调用计费层幂等退点。**worker 抢任务**:只抢 `status=queued` 且 `next_attempt_at IS NULL OR next_attempt_at <= now` 的任务;避免 66 worker 立即反复打爆上游。**配置**:新增 `IMAGE_TASK_MAX_RETRIES=2`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30`(或等价配置),同步 `.env.example`、`env.md`、`deployment.md`;生产要提醒最坏耗时约 `上游硬截止 * 3 + backoff`,桌面端轮询超时需覆盖。**接口兼容**:轮询响应可新增可选字段 `attempt_count`、`max_attempts`、`next_attempt_at`,但不得删除既有字段;旧客户端忽略新字段仍可工作。**日志**:扩展 `event=image_task_processed`,包含 `attempt`、`max_attempts`、`retrying`、`next_attempt_at`、`duration_ms`、`error_code`,不得记录 prompt、base64、provider raw 或密钥。**账务验收**:第 1/2 次 `upstream_timeout` 回到 queued 且不退点;第 3 次成功只扣一次并成功确认;连续 3 次失败只退一次;非重试错误立即失败并只退一次;幂等提交仍返回同一 task,不重复预扣;reaper 与迟到 worker 不得把已最终失败/已退款任务改回成功。**测试**:覆盖上述账务、状态机、backoff 抢占、轮询新增字段兼容、worker 日志字段和敏感信息不泄露;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | DONE | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index c42ba6c..9745306 100644 --- a/docs/api.md +++ b/docs/api.md @@ -242,6 +242,9 @@ Content-Type: application/json "call_id": 12346, "points_cost": 10, "points_balance": 88, + "attempt_count": 0, + "max_attempts": 3, + "next_attempt_at": null, "created_at": "2026-07-08T21:30:00+08:00", "expires_at": "2026-07-09T21:30:00+08:00" } @@ -256,6 +259,7 @@ Content-Type: application/json - 桌面端主链路推荐传 `image_base64`。该路径在 submit 阶段只做解码和输入文件落盘,不发生外部网络请求,提交请求应保持短耗时。 - `image_url` 仍按同步接口的 SSRF 与大小规则处理,并在 submit 阶段下载成输入文件引用;失败不扣点。这个路径可能因远程图片下载变慢而让 submit 阻塞,适合作为边缘兼容能力,不建议桌面端批量生图主流程使用。 - worker 执行时会复审 prompt 并重解析当前别名 / Provider 配置,但账务使用提交阶段已预扣的 `CallRecord.points_cost`,不会重复扣点。若排队时间较长且后台切换别名,可能出现按提交时价格预扣、按执行时模型运行;未来如需强一致模型选择,应单独实现提交时模型配置快照。 +- T-616 起异步 worker 对临时性上游失败自动重试,默认最多 3 次上游调用(第 1 次执行 + 2 次重试)。提交阶段仍只预扣一次;重试等待期间任务状态回到 `queued`,点数暂不退回,最终失败才退款。 ### `GET /api/v1/generate/image/tasks/{task_id}` @@ -274,12 +278,32 @@ Authorization: Bearer sk_cmhub_xxx "status": "running", "call_id": 12346, "points_cost": 10, + "attempt_count": 1, + "max_attempts": 3, + "next_attempt_at": null, "created_at": "2026-07-08T21:30:00+08:00", "updated_at": "2026-07-08T21:30:05+08:00", "expires_at": "2026-07-09T21:30:00+08:00" } ``` +临时性上游失败等待重试时仍返回 `queued`: + +```json +{ + "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9", + "status": "queued", + "call_id": 12346, + "points_cost": 10, + "attempt_count": 1, + "max_attempts": 3, + "next_attempt_at": "2026-07-08T21:30:20+08:00", + "created_at": "2026-07-08T21:30:00+08:00", + "updated_at": "2026-07-08T21:30:10+08:00", + "expires_at": "2026-07-09T21:30:00+08:00" +} +``` + 成功: ```json @@ -288,6 +312,9 @@ Authorization: Bearer sk_cmhub_xxx "status": "succeeded", "call_id": 12346, "points_cost": 10, + "attempt_count": 3, + "max_attempts": 3, + "next_attempt_at": null, "created_at": "2026-07-08T21:30:00+08:00", "updated_at": "2026-07-08T21:31:40+08:00", "expires_at": "2026-07-09T21:30:00+08:00", @@ -305,6 +332,9 @@ Authorization: Bearer sk_cmhub_xxx "status": "failed", "call_id": 12346, "points_cost": 10, + "attempt_count": 3, + "max_attempts": 3, + "next_attempt_at": null, "created_at": "2026-07-08T21:30:00+08:00", "updated_at": "2026-07-08T21:33:00+08:00", "expires_at": "2026-07-09T21:30:00+08:00", @@ -315,7 +345,7 @@ Authorization: Bearer sk_cmhub_xxx } ``` -要点:查询只允许任务所属 `user` 与当前 API Key 所属 `user` 一致,跨用户返回 `404 task_not_found`;`succeeded` 重复查询必须返回同一个 `image_url`;`failed` 表示已退款或未扣款,不需要客户端再请求退款。任务元数据默认保留 `IMAGE_TASK_RETENTION_HOURS`(默认 24h),结果图片保留窗口按 `GENERATED_IMAGE_RETENTION_HOURS`(默认 72h)管理;本任务暂不暴露 cancel 路由。 +要点:查询只允许任务所属 `user` 与当前 API Key 所属 `user` 一致,跨用户返回 `404 task_not_found`;`succeeded` 重复查询必须返回同一个 `image_url`;`queued` 且 `next_attempt_at` 非空表示正在等待自动重试,客户端继续轮询即可;`failed` 表示最终失败且已退款或未扣款,不需要客户端再请求退款。任务元数据默认保留 `IMAGE_TASK_RETENTION_HOURS`(默认 24h),结果图片保留窗口按 `GENERATED_IMAGE_RETENTION_HOURS`(默认 72h)管理;本任务暂不暴露 cancel 路由。 ### `GET /api/v1/balance` diff --git a/docs/current-state.md b/docs/current-state.md index b8303b3..7afd134 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,14 +12,14 @@ ## 当前快照 - 日期:2026-07-09 -- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-603 django-admin 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本检查接口增加强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为虾皮圈、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口与 T-615 旧同步生图接口遥测 / 弃用口径已完成;T-616 生图失败自动重试 2 次已登记为下一个 TODO;后续仍需处理真实支付回调到账闭环、客户端下载包发布和生产侧旧同步接口用量观察 +- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-603 django-admin 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本检查接口增加强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为虾皮圈、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口遥测 / 弃用口径与 T-616 生图失败自动重试 2 次已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和生产侧旧同步接口用量观察 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` - 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 100 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 -- T-614 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL,失败 / 超时 / 僵任务走计费层幂等退款。 +- T-614/T-616 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL;T-616 已通过 `api.0002_imagegenerationtask_next_attempt_at_and_more` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,临时性 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试期间不退点,最终失败 / 僵任务才走计费层幂等退款。 - T-614 实现取舍补充:worker 执行前会基于任务快照复跑 `prepare_generation()`,即复审 prompt 并重解析别名 / Provider / 定价;账务使用已预扣 `CallRecord.points_cost`,不会重复扣点。短队列下这是偏安全取舍,词库变更后排队任务仍可被拦截并退款;若后续队列积压或频繁切换模型,应单独做提交时模型配置快照。异步 submit 对 `image_base64` 只解码并保存输入文件,桌面端主链路推荐继续使用;`image_url` 会在 submit 阶段完成 SSRF 校验、远程下载和大小限制,可能阻塞提交请求,属于边缘兼容路径。 - T-615 生产代码补充:已新增 `apps.api.telemetry`,旧同步 `POST /api/v1/generate/image` 与新异步提交 `POST /api/v1/generate/image/tasks` 都写 `cmhub.api.generation_usage` 结构化日志事件 `generation_route_usage`;字段白名单为 `route_type`、API Key ID/前缀、`user_id`、`X-Client-Version`、别名、状态、耗时、错误码和 HTTP 状态,不记录 API Key 明文、prompt、图片 base64 或 provider raw。 -- 异步生图 worker 排障补充:`run_image_tasks` 每处理一个任务会输出 `event=image_task_processed task_id=... status=... alias=... duration_ms=...`,失败 / 过期任务额外输出 `error_code`;日志不包含 prompt、`image_base64`、provider raw 或密钥。生产扩容 worker 应按 2、4、8、16 逐级观察队列长度、耗时、错误码、MySQL 连接数和上游失败率,不建议直接扩到 100。 -- 下一任务口径:T-616 已登记为「生图失败自动重试 2 次」。实现时最多 3 次上游调用,提交阶段只预扣一次;前两次临时性上游失败只重排队不退点,最终失败才退点;轮询响应可新增 `attempt_count` / `max_attempts` / `next_attempt_at`,但不得破坏既有字段。 +- 异步生图 worker 排障补充:`run_image_tasks` 每处理一个任务会输出 `event=image_task_processed task_id=... status=... alias=... attempt=... max_attempts=... retrying=... next_attempt_at=... duration_ms=...`,重试中 / 失败 / 过期任务额外输出 `error_code`;日志不包含 prompt、`image_base64`、provider raw 或密钥。生产扩容 worker 应按 2、4、8、16 逐级观察队列长度、耗时、错误码、MySQL 连接数和上游失败率,不建议直接扩到 100。 +- 最新验证:T-616 生图失败自动重试 2 次已验证 `py -3.12 -m py_compile apps\api\generation.py apps\api\image_tasks.py apps\api\management\commands\run_image_tasks.py apps\api\models.py apps\api\admin.py apps\api\tests.py config\settings.py` 通过;`py -3.12 manage.py makemigrations api` 生成 `api.0002_imagegenerationtask_next_attempt_at_and_more`;`py -3.12 manage.py migrate api --noinput` 已把 `api.0001` / `api.0002` 应用到当前开发库;`py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;T-616 新增 5 条目标测试通过,覆盖首次超时回队列不退点、两次超时后三次成功只扣一次、连续三次超时最终只退一次、非重试错误立即退款、worker 重试日志字段和敏感信息不泄露;异步生图旧行为回归 12 tests OK;`py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2` 通过,37 tests OK;`.\init.ps1` 通过;`git diff --check` 通过,仅 Windows CRLF 提示。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 最新验证:T-615 旧同步生图接口遥测已验证 `py -3.12 -m py_compile apps\api\telemetry.py apps\api\views.py apps\api\tests.py` 通过;新增 3 条目标测试通过,覆盖旧同步成功日志、新异步提交成功日志、异步余额不足错误日志,确认可按 client version / api_key / route_type 查询且不泄露 prompt、base64、完整 API Key 或 provider raw;`py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2` 通过,31 tests OK;`.\init.ps1` 通过;`git diff --check` 通过,仅 Windows CRLF 提示。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。 - 最新验证:T-614 生图异步任务化接口已验证 `py -3.12 manage.py makemigrations api` 生成 `api.0001_initial`;`py -3.12 manage.py test apps.api.tests.GenerateApiTests -v 2 --keepdb` 通过,28 tests OK,覆盖敏感词拦截不建任务/不扣点、余额不足 402、Idempotency-Key 去重和冲突、worker 成功轮询同一 URL、跨用户拒绝、失败/超时退款、reaper 僵任务退款、重复 worker 幂等和 reaper 退款后的迟到 worker 不可改回成功。首次不带 `--keepdb` 运行时因已有 `test_cmhub` 触发交互式删除确认导致 EOF 中断;重跑 `--keepdb` 通过。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 @@ -42,7 +42,7 @@ - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;新用户注册成功一次性赠送 100 点试用点数,必须经 billing 写 `signup_bonus` 流水,且通过 `SignupBonusGrant(user UNIQUE)` 防重复发放。详见 `04-architecture.md` 与 2026-07-08 的 `progress.md` 决策 -- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。用户端注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`:免邮箱验证、注册即可用、邮箱仍必填且唯一;`DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 仅用于后续密码找回、通知或恢复邮箱验证等邮件能力,不作为当前注册登录前置条件。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。T-604 起内容安全配置包含 `MODERATION_ENABLED`、`MODERATION_PROVIDER=keyword`、`MODERATION_FAIL_CLOSED`、`MODERATION_BLOCK_ON_REVIEW`、`MODERATION_CACHE_VERSION_KEY`;生产多 worker 下必须使用共享 cache 承载敏感词版本号。T-612 起生图同步接口止血配置包含 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,只作用于 `generate_image` 上游读取硬截止。T-614 起异步生图配置包含 `PUBLIC_BASE_URL`、`MEDIA_PUBLIC_BASE_URL`、`IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS`;生产必须运行 `run_image_tasks` worker。 +- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。用户端注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`:免邮箱验证、注册即可用、邮箱仍必填且唯一;`DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 仅用于后续密码找回、通知或恢复邮箱验证等邮件能力,不作为当前注册登录前置条件。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。T-604 起内容安全配置包含 `MODERATION_ENABLED`、`MODERATION_PROVIDER=keyword`、`MODERATION_FAIL_CLOSED`、`MODERATION_BLOCK_ON_REVIEW`、`MODERATION_CACHE_VERSION_KEY`;生产多 worker 下必须使用共享 cache 承载敏感词版本号。T-612 起生图同步接口止血配置包含 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,只作用于 `generate_image` 上游读取硬截止。T-614/T-616 起异步生图配置包含 `PUBLIC_BASE_URL`、`MEDIA_PUBLIC_BASE_URL`、`IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS`、`IMAGE_TASK_MAX_RETRIES`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS`;生产必须运行 `run_image_tasks` worker。 - 当前 blocker:微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。线上真实标题生成已跑通并验证扣点;图片同步旧接口已由 T-612 加上游硬截止止血,T-614 已提供异步任务化路径,T-615 已补旧同步 / 新异步提交遥测和旧路弃用口径。 ## 当前目录要点 @@ -56,7 +56,7 @@ | `requirements.txt` / `requirements-production.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管本地/基础运行依赖;`requirements-production.txt` 追加 Linux 生产 Gunicorn;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | | `apps/`(users/portal/billing/ai/api/moderation) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-608 已在 billing 落 `SignupBonusGrant`、`grant_signup_bonus()`、`signup_bonus` 流水类型、admin 检索和迁移;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501/T-608 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard,注册成功自动赠送 100 点;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503/T-608 已在 portal 落个人中心汇总、充值记录和点数记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页;T-601 已在 `apps/ai/catalog.py` 落公开模型目录,在 api 落 `/api/v1/models`,在 portal 落 `/models` 页面和导航入口;T-604 已在 moderation 落 `SensitiveWord`、keyword matcher、归一化、共享 cache 版本失效与 admin;T-606 已在 portal 落公开首页、`DownloadRelease`、下载版本 admin、`brand.css` 与首页模板;T-607/T-609 已在 api 落 `/api/v1/client/releases/latest` 公开版本检查接口,并在 portal `DownloadRelease` 落 `force_update` 字段与 admin 展示 | -| `apps/api` 异步生图 | 已有 | T-614 已落 `ImageGenerationTask`、`api.0001_initial`、异步提交/轮询路由、任务 service、worker lease / heartbeat / reaper、`run_image_tasks` 命令和 admin 只读排障;旧同步生图接口保留 | +| `apps/api` 异步生图 | 已有 | T-614 已落 `ImageGenerationTask`、`api.0001_initial`、异步提交/轮询路由、任务 service、worker lease / heartbeat / reaper、`run_image_tasks` 命令和 admin 只读排障;T-616 已落 `api.0002`、`next_attempt_at`、临时性失败自动重试和轮询响应新增重试字段;旧同步生图接口保留 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -64,11 +64,11 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送 100 点试用点数;T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径。 +- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送 100 点试用点数;T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次。 - 正在进行:无。 -- 待开始:T-616 生图失败自动重试 2 次。 -- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-615 并完成工程侧处理。 -- 下一个可领取任务:T-616 生图失败自动重试 2 次;实现后继续补真实支付回调到账闭环、客户端下载包发布等业务事项。 +- 待开始:无固定编号任务;后续按真实支付回调到账闭环、客户端发布和线上旧同步接口用量观察继续拆任务。 +- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 +- 下一个可领取任务:无固定编号任务;建议优先补真实支付回调到账闭环,其次处理客户端发布与生产旧同步接口用量观察。 ## 当前可运行内容 diff --git a/docs/deployment.md b/docs/deployment.md index 4d11008..a29165c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -120,6 +120,8 @@ IMAGE_TASK_RETENTION_HOURS=24 GENERATED_IMAGE_RETENTION_HOURS=72 IMAGE_TASK_REAPER_INTERVAL_SECONDS=60 IMAGE_TASK_LEASE_SECONDS=600 +IMAGE_TASK_MAX_RETRIES=2 +IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30 DJANGO_CACHE_BACKEND=django.core.cache.backends.db.DatabaseCache DJANGO_CACHE_LOCATION=cmhub_cache @@ -279,9 +281,11 @@ python3.12 manage.py run_image_tasks \ --sleep-seconds 1 ``` -建议单独托管为 `cmhub-image-worker.service`。该 worker 从数据库 `image_generation_task` 表抢 `queued` 任务,使用 MySQL `select_for_update(skip_locked)` 标记 `running`,执行成功后写 `succeeded` 和稳定 `result_url`;失败或上游超时会调用计费层退点并写 `failed`。worker 循环会按 `IMAGE_TASK_REAPER_INTERVAL_SECONDS` 扫描租约或心跳过期的 `running` 任务,默认判失败并幂等退点,不默认重排队。 +建议单独托管为 `cmhub-image-worker.service`。该 worker 从数据库 `image_generation_task` 表抢 `queued` 且 `next_attempt_at` 已到达的任务,使用 MySQL `select_for_update(skip_locked)` 标记 `running`,执行成功后写 `succeeded` 和稳定 `result_url`。T-616 起,`upstream_timeout` / `upstream_error` 这类临时性上游失败会先回到 `queued` 并写 `next_attempt_at`,默认最多重试 2 次;重试期间 `CallRecord` 仍为 `pending`,不退点。不可重试错误或最终失败才调用计费层幂等退点并写 `failed`。worker 循环会按 `IMAGE_TASK_REAPER_INTERVAL_SECONDS` 扫描租约或心跳过期的 `running` 任务,默认判失败并幂等退点,不默认重排队。 -worker 每处理一个任务会向 stdout 输出一行结构化日志,形如 `event=image_task_processed task_id=... status=failed alias=... duration_ms=... error_code=upstream_timeout`。失败日志必须用于区分「还在 queued 未提交给上游」和「已 running 但上游超时 / 失败」;日志不得包含 prompt、`image_base64`、provider raw 或密钥。 +worker 每处理一个任务会向 stdout 输出一行结构化日志,形如 `event=image_task_processed task_id=... status=queued alias=image-hd attempt=1 max_attempts=3 retrying=true next_attempt_at=2026-07-09T12:00:10+08:00 duration_ms=220015 error_code=upstream_timeout`。失败日志必须用于区分「还在 queued 未提交给上游」「queued 等待重试」和「已最终 failed」;日志不得包含 prompt、`image_base64`、provider raw 或密钥。 + +生产默认 `IMAGE_TASK_MAX_RETRIES=2`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30`,表示第 1 次正常执行失败后最多再重试 2 次。若 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=220`,单任务最坏耗时约 `220 * 3 + 10 + 30 = 700` 秒;Nginx / Gunicorn 的异步提交与轮询仍是短请求,但桌面端轮询总超时和任务保留窗口必须覆盖这个最坏耗时。 多 worker 可以并行运行同一命令,只要 `--worker-id` 不同即可;MySQL 8.4 会通过 `select_for_update(skip_locked)` 避免重复抢同一任务。生产扩容应按 2、4、8、16 逐级观察 `queued` 长度、`duration_ms`、`error_code`、MySQL 连接数、VPS CPU/内存/磁盘写入和上游失败率。不要直接扩到 100 个 worker:这会同时放大 MySQL 连接、上游请求、图片下载和本地写文件压力;如果上游已经频繁 `upstream_timeout`,100 并发通常只会把失败更快放大。 diff --git a/docs/env.md b/docs/env.md index 4322b94..81cdfb2 100644 --- a/docs/env.md +++ b/docs/env.md @@ -58,6 +58,8 @@ | `GENERATED_IMAGE_RETENTION_HOURS` | 否 | `72` | 生成图片文件保留窗口口径,默认 72 小时;实际清理任务后续单独实现时按此值执行 | | `IMAGE_TASK_REAPER_INTERVAL_SECONDS` | 否 | `60` | 异步生图 worker 循环中扫描僵尸 running 任务的间隔秒数 | | `IMAGE_TASK_LEASE_SECONDS` | 否 | `600` | 异步生图任务租约秒数;worker 认领任务后写 `lease_expires_at` / `heartbeat_at`,超时由 reaper 判失败并退点 | +| `IMAGE_TASK_MAX_RETRIES` | 否 | `2` | T-616 异步生图临时性上游失败最大重试次数;默认 2 表示最多 3 次上游调用 | +| `IMAGE_TASK_RETRY_BACKOFF_SECONDS` | 否 | `10,30` | T-616 异步生图重试退避秒数列表;第 1 次失败等 10 秒,第 2 次失败等 30 秒,列表不足时复用最后一个值 | `AI_KEY_ENCRYPTION_KEY` 必须是 `cryptography.fernet.Fernet.generate_key()` 生成的 base64 字符串,可用 `py -3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成。 @@ -65,7 +67,7 @@ AI 上游连接超时由 `AiModel.connect_timeout_seconds` 控制。文本读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。生图读取超时在此基础上再套 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止,避免慢 / 卡死图片上游长期占用生成池线程。 -T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和预扣点;worker 成功后返回 cmhub 托管媒体 URL。生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为 HTTPS 域名,否则 worker 只能返回相对 `/media/...` URL,不利于桌面端直接下载。 +T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和预扣点;worker 成功后返回 cmhub 托管媒体 URL。生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为 HTTPS 域名,否则 worker 只能返回相对 `/media/...` URL,不利于桌面端直接下载。T-616 起临时性上游失败会按 `IMAGE_TASK_MAX_RETRIES` 与 `IMAGE_TASK_RETRY_BACKOFF_SECONDS` 自动重试;若 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=220` 且默认重试 2 次,单个任务最坏耗时约为 `220 * 3 + 10 + 30 = 700` 秒,桌面端轮询总超时必须覆盖该窗口。 ## 五、对外 API 安全配置 diff --git a/progress.md b/progress.md index f2cf847..c82e05e 100644 --- a/progress.md +++ b/progress.md @@ -1805,3 +1805,38 @@ - 新增 `next_attempt_at` 与 `IMAGE_TASK_MAX_RETRIES` / `IMAGE_TASK_RETRY_BACKOFF_SECONDS` 等配置;worker 只抢到达重试时间的 queued 任务。 - 轮询响应可兼容性新增 `attempt_count`、`max_attempts`、`next_attempt_at`;日志新增 attempt / retrying / next_attempt_at 字段。 - 验证:仅文档更新;提交前执行 `git diff --check`。 + +## 2026-07-09 实施:T-616 生图失败自动重试 2 次 + +- 状态:DONE。 +- 背景:线上异步生图已出现 `api.vectorengine.ai` 偶发 `Read timed out`。原逻辑第一次 `upstream_timeout` 就把任务置 `failed` 并退点,批量生图成功率受上游短暂波动影响较大。 +- 代码变更: + - `apps/api/models.py` / `apps/api/migrations/0002_imagegenerationtask_next_attempt_at_and_more.py`:给 `ImageGenerationTask` 增加 `next_attempt_at`,并新增 `(status, next_attempt_at)` 索引,worker 只抢到达重试时间的 queued 任务。 + - `config/settings.py` / `.env.example`:新增 `IMAGE_TASK_MAX_RETRIES=2`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30`。 + - `apps/api/generation.py`:`execute_precharged_generation()` 增加 `refund_on_failure` 参数;旧同步接口默认行为不变,异步 worker 可在上游失败时先不退款,由任务状态机决定重试或最终退款。 + - `apps/api/image_tasks.py`:只对 `upstream_timeout` / `upstream_error` 自动重试;前两次临时性失败回到 `queued`、写 `next_attempt_at`、保留 `CallRecord.pending`、不退点;不可重试错误或最终失败才幂等退款。轮询响应新增 `attempt_count`、`max_attempts`、`next_attempt_at`。 + - `apps/api/management/commands/run_image_tasks.py`:worker 日志增加 `attempt`、`max_attempts`、`retrying`、`next_attempt_at`,重试中 / 失败 / 过期任务输出 `error_code`。 + - `apps/api/admin.py`:后台图片任务列表 / 过滤 / 只读字段展示 `next_attempt_at`。 + - `apps/api/tests.py`:新增 T-616 目标测试,覆盖首次超时回队列不退点、两次超时后三次成功只扣一次、连续三次超时最终只退一次、非重试错误立即退款、worker 重试日志字段不泄露敏感信息;旧失败退款测试用 `IMAGE_TASK_MAX_RETRIES=0` 保留原验收。 +- 文档变更: + - `docs/api.md`:异步提交 / 轮询响应示例新增 `attempt_count`、`max_attempts`、`next_attempt_at`,说明 `queued + next_attempt_at` 为等待自动重试。 + - `docs/04-architecture.md`:同步 `next_attempt_at` schema、索引、异步计费状态机和临时性失败重试语义。 + - `docs/env.md` / `docs/deployment.md`:同步新增环境变量、最坏耗时估算和 worker 日志排障口径。 + - `docs/current-state.md`:同步 T-616 已完成、最新验证和下一步。 + - `docs/06-tasks.md`:T-616 标记为 DONE。 +- 验证: + - `py -3.12 -m py_compile apps\api\generation.py apps\api\image_tasks.py apps\api\management\commands\run_image_tasks.py apps\api\models.py apps\api\admin.py apps\api\tests.py config\settings.py`:通过。 + - `py -3.12 manage.py makemigrations api`:生成 `api.0002_imagegenerationtask_next_attempt_at_and_more`。 + - `py -3.12 manage.py migrate api --noinput`:通过,已应用 `api.0001_initial` 与 `api.0002_imagegenerationtask_next_attempt_at_and_more` 到当前开发库。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_async_image_retryable_timeout_requeues_without_refund_and_respects_backoff apps.api.tests.GenerateApiTests.test_async_image_retryable_timeouts_then_success_charges_once apps.api.tests.GenerateApiTests.test_async_image_retryable_timeouts_final_failure_refunds_once apps.api.tests.GenerateApiTests.test_async_image_non_retryable_provider_error_fails_immediately_and_refunds apps.api.tests.GenerateApiTests.test_run_image_tasks_logs_retrying_task_attempt_fields_without_sensitive_data --keepdb --noinput --verbosity 2`:通过,5 tests OK。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_async_image_submit_usage_telemetry_logs_safe_success_event apps.api.tests.GenerateApiTests.test_async_image_submit_usage_telemetry_logs_error_code_without_sensitive_data apps.api.tests.GenerateApiTests.test_async_image_blocked_prompt_creates_no_task_or_charge apps.api.tests.GenerateApiTests.test_async_image_insufficient_points_returns_402_without_task apps.api.tests.GenerateApiTests.test_async_image_idempotency_reuses_task_and_rejects_conflict apps.api.tests.GenerateApiTests.test_async_image_worker_success_and_poll_are_idempotent apps.api.tests.GenerateApiTests.test_async_image_poll_rejects_cross_user_access apps.api.tests.GenerateApiTests.test_async_image_worker_failure_refunds_precharged_points apps.api.tests.GenerateApiTests.test_run_image_tasks_logs_failed_task_alias_error_and_duration apps.api.tests.GenerateApiTests.test_async_image_reaper_fails_stale_running_task_and_refunds apps.api.tests.GenerateApiTests.test_async_image_duplicate_worker_does_not_double_charge_or_refund apps.api.tests.GenerateApiTests.test_async_image_late_worker_after_reaper_cannot_flip_failed_task --keepdb --noinput --verbosity 2`:通过,12 tests OK。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2`:通过,37 tests OK。 + - `.\init.ps1`:通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 +- 测试环境现象: + - 测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。 +- 决策: + - T-616 第一版不重试敏感词、余额不足、未定价、模型 / 别名不可用、参数错误和 Provider 能力错误;这些属于确定性失败,立即失败并退款或在提交阶段不扣点。 + - 重试期间不展示 `error` 给轮询客户端,客户端只需看到 `queued + next_attempt_at` 后继续轮询;最终 `failed` 才表示已退款。