单图生成改为异步(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:
+20
-19
@@ -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 };
|
||||
}, "图片已生成");
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user