单图生成改为异步(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
+20 -19
View File
@@ -355,29 +355,30 @@ export function App() {
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
// 每张图一条独立 HTTP 请求(不再让一条请求串行出 N 张)——单张约 30s 不会触发后端请求超时。
// 但不全量并行:同时最多 CONCURRENCY 条,避免 N 条一起砸向单个后端 pod 致内存峰值翻倍 OOM→502。
// 任一张失败只丢那一张(各自独立扣费/回滚),其余正常返回
const n = Math.max(1, Math.min(Number(payload.count) || 1, 12));
const CONCURRENCY = 2;
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见
return action(async () => {
const results: Array<{ ok: true; assets: Asset[] } | { ok: false; reason: unknown }> = new Array(n);
let next = 0;
async function worker() {
for (let i = next++; i < n; i = next++) {
try {
const res = await api.generateImage({ prompt: payload.prompt, mode: payload.mode, count: 1 });
results[i] = { ok: true, assets: res.assets };
} catch (reason) {
results[i] = { ok: false, reason };
}
const { tasks } = await api.submitGenerateImage(payload);
const pending = new Set(tasks.map((t) => t.id));
if (pending.size === 0) throw new Error("未能提交生成任务");
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assets: Asset[] = [];
let lastErr = "";
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没全好就停止轮询(图仍会在后台出完)
while (pending.size > 0 && Date.now() < deadline) {
await sleep(2500);
const res = await api.generateImageStatus([...pending]);
for (const t of res.tasks) {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) lastErr = t.error_message;
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, n) }, worker));
const assets = results.flatMap((r) => (r.ok ? r.assets : []));
if (assets.length === 0) {
const firstErr = results.find((r) => !r.ok) as { ok: false; reason: unknown } | undefined;
throw new Error(firstErr ? String((firstErr.reason as Error)?.message || firstErr.reason || "生成失败") : "未生成任何图片");
throw new Error(lastErr || (pending.size > 0 ? "生成超时,图片仍在后台生成,稍后可在素材库查看" : "未生成任何图片"));
}
return { assets };
}, "图片已生成");
+6 -2
View File
@@ -296,8 +296,12 @@ export const api = {
aiTasks() {
return request<Paginated<AITask>>("/api/ai/tasks/");
},
generateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return request<{ assets: Asset[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });