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:
co-authored by
Claude Opus 4.8
parent
71af0ae166
commit
c8cb6d3d51
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user