单图生成改为异步(Celery+AITask+前端轮询),与视频同架构

把独立生图从「请求内同步出图」改成异步任务,彻底根治整站 502:
- 后端 enqueue_standalone_images:Web 请求只建任务+预留额度(秒级),慢 ARK 出图派发给
  Celery worker(generate_standalone_image_task)。Web 层不再被 ~30s 请求占住 worker,
  健康探针不会被饿死 → 整站 502 从根上消失。
- run_standalone_image_task:worker 内单张出图,成功落库扣费/失败退费,幂等(只处理 RESERVED,
  重复投递不二次扣费)。
- 提交成功后浏览器关闭/断网,worker 仍把图生成并落库,重开素材库即可见(异步天然抗断连)。
- 视图 POST 返回 202+任务列表,新增 GET ?ids= 轮询状态并带回成图;前端 submit+poll。
- count 上限 4→12,与 UI 一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 16:38:06 +08:00
co-authored by Claude Opus 4.8
parent 3472c3cc9c
commit a749be5837
5 changed files with 131 additions and 65 deletions
+34 -8
View File
@@ -9,14 +9,18 @@ from apps.common.celery_health import require_worker
from .models import AITask, ModelConfig
from .serializers import AITaskSerializer, ModelConfigSerializer
from .services import generate_standalone_image
from .services import enqueue_standalone_images
class GenerateImageView(APIView):
"""POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。"""
"""独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。
POST /api/ai/generate-image/ 提交生成,秒级返回 RESERVED 任务列表(慢出图交给 worker)。
GET /api/ai/generate-image/?ids=… 轮询这些任务的状态;成功的任务带回成图 asset。
"""
def post(self, request):
require_worker()
require_worker() # 异步出图依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
prompt = str(request.data.get("prompt") or "").strip()
if not prompt:
return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
@@ -27,12 +31,34 @@ class GenerateImageView(APIView):
count = 1
team = get_current_team(request.user)
try:
assets = generate_standalone_image(team=team, user=request.user, prompt=prompt, mode=mode, count=count)
except ValueError as exc:
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count)
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 生成失败已回滚额度,返回明确错误给前端
return Response({"detail": f"生成失败: {exc}"}, status=status.HTTP_502_BAD_GATEWAY)
return Response({"assets": AssetSerializer(assets, many=True).data}, status=status.HTTP_201_CREATED)
return Response(
{"tasks": [{"id": str(t.id), "status": t.status} for t in tasks]},
status=status.HTTP_202_ACCEPTED,
)
def get(self, request):
team = get_current_team(request.user)
ids = [s for s in str(request.query_params.get("ids") or "").split(",") if s]
if not ids:
return Response({"tasks": []})
tasks = AITask.objects.filter(team=team, id__in=ids).prefetch_related(
"generated_assets", "generated_assets__files"
)
data = [
{
"id": str(t.id),
"status": t.status,
"error_message": t.error_message,
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
).data,
}
for t in tasks
]
return Response({"tasks": data})
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):