fix(extract): 实体提取改异步(worker+轮询),根治 502 + 刷新丢 loading + 重复扣费

问题:进资产页点"提取人物/场景"——同步死等豆包思考模型(数十秒)→ 撞网关超时
502;"提取中"loading 是纯前端内存态,刷新即丢;且无幂等,刷新后重点会重复扣费。

改动(对齐全站异步三件套:出图/三视图/故事板同款):
- 后端 submit_extract_entities:Web 只建 RESERVED 任务 + 预留额度秒回(不再 502);
  已有在途提取则复用,绝不二次预扣(防刷新后重点 / 并发重复扣费)。
- 后端 run_extract_entities_task(Celery worker):流式调豆包 + 解析 + 落库 + 扣费;
  失败退费并把可读错误记进 task.error_message。
- 新增 AITask.Type.entity_extraction(迁移 0011)+ extract-status 端点(轮询进度/成败)。
- 前端 runExtract 改提交+轮询;进资产页自动认领在途提取,刷新后 loading 自己回来。

验收:完整 Django 套件 149/149 全绿(含新增 4 测:异步落库+计费一次 / content空
回退 reasoning / 在途防重复扣费 / 状态端点成败透出);tsc + vite build 全绿;
makemigrations --check 无遗漏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-22 23:15:11 +08:00
co-authored by Claude Opus 4.8
parent 71af0ae166
commit c8cb6d3d51
8 changed files with 304 additions and 86 deletions
+10 -7
View File
@@ -327,16 +327,19 @@ export const api = {
body: JSON.stringify({ script_version_id })
});
},
// 独立实体提取:读已定稿脚本 → AI 识别角色/场景 + 每镜 refs,落库覆盖 metadata。商品不提(参考图层用真实主图)。
// 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。
// 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。
extractEntities(projectId: string) {
return request<{ task_id: string; status: string }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
},
// 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。
extractStatus(projectId: string) {
return request<{
running: boolean;
status: string;
error: string;
entities: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }>;
segments: Array<{ index: number; entity_refs: string[] }>;
cast: string[];
cast_prompts: Record<string, string>;
scenes: string[];
scene_prompts: Record<string, string>;
}>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
}>(`/api/projects/${projectId}/extract-status/`);
},
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
+71 -17
View File
@@ -680,6 +680,53 @@ export function PipelinePage(props: {
const [extractState, setExtractState] = useState<"idle" | "running">("idle");
const [extractMsg, setExtractMsg] = useState("");
const [extractErr, setExtractErr] = useState("");
const extractPollRef = useRef(0); // 提取轮询定时器句柄
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/三视图/场景图)
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
for (const e of entities) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") {
if (mode === "full") await genPersonWithTri(e.visual_prompt, e.name, bk); // 立绘 + 三视图
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
} else {
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
}
}
await onRefreshProject();
}
// 轮询提取进度直到结束:成功→刷新(可选继续出图)→idle;失败→显错→idle。
// mode=null 表示「刷新后认领」(只恢复 loading,不自动出图 —— 出图态由 pending-assets 单独认领)。
function pollExtractUntilDone(mode: "only" | "gen" | "full" | null) {
window.clearInterval(extractPollRef.current);
extractStartedRef.current = true;
const finish = () => { window.clearInterval(extractPollRef.current); extractStartedRef.current = false; };
const tick = async () => {
try {
const s = await api.extractStatus(project.id);
if (s.running) return; // 还在跑,下一轮再看
finish();
if (s.status === "succeeded") {
await onRefreshProject(); // 拉回 metadata(cast/scenes/script_entities)+ 回填的每镜 refs
if ((mode === "gen" || mode === "full") && (s.entities?.length ?? 0) > 0) {
await runGenForEntities(s.entities, mode);
}
setExtractState("idle"); setExtractMsg(""); // 闸门据 entities_extracted 已落库 → 渲染层自动隐藏
} else if (s.status === "failed") {
setExtractState("idle"); setExtractMsg("");
setExtractErr(s.error || "提取失败,请重试");
} else {
setExtractState("idle"); setExtractMsg(""); // 既不在途也无成败(理论不至于):收尾,别把 loading 永久挂住
}
} catch {
/* 网络抖动:忽略,下一轮再试 */
}
};
extractPollRef.current = window.setInterval(() => { void tick(); }, 3000);
void tick();
}
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+全套(立绘+三视图+场景图)
async function runExtract(mode: "only" | "gen" | "full") {
if (extractState === "running") return;
@@ -687,23 +734,8 @@ export function PipelinePage(props: {
setExtractState("running");
setExtractMsg("正在从剧本认出角色 / 场景…");
try {
const res = await api.extractEntities(project.id);
await onRefreshProject(); // 拉回 metadata(cast/scenes/script_entities)+ 回填的每镜 refs
if (mode !== "only") {
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
for (const e of res.entities || []) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") {
if (mode === "full") await genPersonWithTri(e.visual_prompt, e.name, bk); // 立绘 + 三视图
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
} else {
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
}
}
await onRefreshProject();
}
setExtractState("idle"); // 闸门据 metadata.script_entities 已落库 → 渲染层自动隐藏
setExtractMsg("");
await api.extractEntities(project.id); // 异步提交(已有在途则后端复用,不重复扣费),慢活在 worker 跑
pollExtractUntilDone(mode); // 轮询直到 worker 跑完
} catch (err) {
setExtractState("idle");
setExtractMsg("");
@@ -2171,6 +2203,28 @@ export function PipelinePage(props: {
return () => { stopped = true; window.clearTimeout(timer); };
}, [activeDot, viewStage, project.id, onRefreshProject]);
// 流程步骤4 · 刷新后认领在途「提取」:内存态 extractState 一刷新就丢,据后端 extract-status 重建「提取中」loading
// (和上面出图占位卡同思路)。已有轮询在跑则不重复起。进资产趴时检查一次。
useEffect(() => {
if (activeDot !== 2 && viewStage !== 2) return;
let cancelled = false;
(async () => {
try {
const s = await api.extractStatus(project.id);
if (cancelled || !s.running || extractStartedRef.current) return;
setExtractState("running");
setExtractMsg("正在从剧本认出角色 / 场景…");
pollExtractUntilDone(null); // 认领:mode 未知,只恢复 loading,完成后刷新即见卡片
} catch {
/* 忽略,无在途就不重建 */
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDot, viewStage, project.id]);
// 卸载/离开页面:停掉提取轮询,别泄漏定时器
useEffect(() => () => window.clearInterval(extractPollRef.current), []);
function goStage(n: number) {
setViewStage(n);
setNavigated(true);