fix(patrol-delete): 任务跑完不出现「下载结果」按钮——轮询在任务终态即停,而结果 Excel 由 Java 侧 MQ 异步生成,两者时间窗内前端本地的 fileReady 永远停在 false

现象:任务 27547(用户 996)15:06:31 创建、15:12:20 完成,结果文件 15:12:20 已生成并上传 OSS
(result/patrol_delete/.../巡店删除-27547.xlsx,4033 字节,直链与 /results/{id}/download 均 200),
但页面上始终没有下载入口,用户只能靠刷新页面才刷出来。

排查(生产日志时间线):结果文件任务 15:12:10 入队(MQ)、15:12:20 完成;前端最后一次拉取
history/progress 是 15:12:12,之后客户端再无任何请求——即前端是在 fileReady 尚为 false 时停的轮询。

根因:waitForTaskTerminal 只认 taskStatus(SUCCESS/FAILED)就 return,随后 clearActiveQueueTask()
令 hasQueueWork=false、stopHistoryPolling() 停掉历史轮询,refreshTaskViews() 也只跑这一轮。
taskStatus 是 Python 端回传完毕的信号,而 Excel 是 Java 收尾后(finalizeTaskWorkbook →
enqueueAssembleResult)异步生成的,中间有数秒到数十秒间隔;错过就再也不会刷新,本地 fileReady
永远为 false,canDownload() 恒假 → 历史记录里不渲染「下载结果」按钮。

修复:
① 新增 isResultFilePending():判定「确实有文件任务在生成」(fileJobId 存在或
   fileStatus=PENDING/RUNNING),文件已就绪(fileReady/downloadUrl)或已终态
   (SUCCESS/FAILED)则不再等待;全部店铺失败的任务本就不生成文件(fileStatus 为空),不会被误等。
② waitForTaskTerminal 在任务终态后继续轮询至文件就绪,上限 MAX_FILE_WAIT_ROUNDS=12(≈2 分钟),
   期间 queuePushResult 提示「正在生成结果文件(n/12)」,超时打 warn 后按终态返回,不阻塞队列。
③ 新增 settlePendingResultFiles():页面加载时若历史里存在「任务已完成、文件仍在生成」的记录
   (在生成窗口内刷新页面/重开客户端的场景),继续轮询收敛,避免同一个坑的另一个入口。
   仅在无未完成任务时执行,避免与 processQueue 重复轮询。

与同类页面对齐:BrandAppearancePatentTab 早已用 pendingFileTaskIds 等文件就绪再停轮询,
巡店删除页缺这一环。日志按规范用中文,关键分支(等待/超时/收敛完成)均有输出便于线上排查。
This commit is contained in:
2026-09-10 15:26:59 +08:00
parent 5af4e1c23e
commit 48ee76d60d
@@ -274,6 +274,8 @@ import {
} from "@/shared/country-options"; } from "@/shared/country-options";
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
/** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */
const MAX_FILE_WAIT_ROUNDS = 12;
const ziniaoVersion = useZiniaoVersion(); const ziniaoVersion = useZiniaoVersion();
const shopInput = ref(""); const shopInput = ref("");
@@ -624,6 +626,25 @@ function canDownload(item: PatrolDeleteHistoryItem) {
); );
} }
/**
* 结果文件是否仍在生成中。
*
* 任务终态(taskStatus=SUCCESS)只代表 Python 端回传完毕,结果 Excel 由 Java 侧
* 收尾后经 MQ 异步生成(finalizeTaskWorkbook → enqueueAssembleResult),两步之间
* 有几秒到几十秒的间隔。只按 taskStatus 停轮询会让前端本地的 fileReady 永远停在
* false,历史记录里就不会出现「下载结果」按钮(任务 27547 即此现象)。
*
* 判定只认「确实有文件任务」的信号(fileJobId / PENDING / RUNNING):全部店铺失败
* 的任务不会生成结果文件,其 fileStatus 为空,不应继续等待。
*/
function isResultFilePending(item?: PatrolDeleteHistoryItem) {
if (!item) return false;
if (item.fileReady || item.downloadUrl) return false;
const fileStatus = String(item.fileStatus || "").toUpperCase();
if (fileStatus === "SUCCESS" || fileStatus === "FAILED") return false;
return item.fileJobId != null || fileStatus === "PENDING" || fileStatus === "RUNNING";
}
function saveMatchedItems() { function saveMatchedItems() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.localStorage.setItem( window.localStorage.setItem(
@@ -965,6 +986,7 @@ function findHistoryItemByTaskId(taskId: number) {
async function waitForTaskTerminal(taskId: number) { async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0; let transientErrorCount = 0;
let fileWaitRounds = 0;
while (true) { while (true) {
if (disposed) return "STOPPED"; if (disposed) return "STOPPED";
if (activeTaskId.value !== taskId) return "DELETED"; if (activeTaskId.value !== taskId) return "DELETED";
@@ -982,6 +1004,22 @@ async function waitForTaskTerminal(taskId: number) {
const item = findHistoryItemByTaskId(taskId); const item = findHistoryItemByTaskId(taskId);
const status = item?.taskStatus || ""; const status = item?.taskStatus || "";
if (status === "SUCCESS" || status === "FAILED" || status === "COMPLETED") { if (status === "SUCCESS" || status === "FAILED" || status === "COMPLETED") {
// 任务终态后结果 Excel 还在异步生成,必须继续轮询到文件就绪再退出:
// 一旦退出,clearActiveQueueTask 会停掉历史轮询,fileReady 就再也刷不出来了
if (isResultFilePending(item) && fileWaitRounds < MAX_FILE_WAIT_ROUNDS) {
fileWaitRounds += 1;
console.log(
`[patrol-delete] 任务 ${taskId} 已终态(${status}),等待结果文件生成 ${fileWaitRounds}/${MAX_FILE_WAIT_ROUNDS}`,
);
queuePushResult.value = `任务 ${taskId} 已完成,正在生成结果文件(${fileWaitRounds}/${MAX_FILE_WAIT_ROUNDS}...`;
await sleep(getTaskPollIntervalMs());
continue;
}
if (isResultFilePending(item)) {
console.warn(
`[patrol-delete] 任务 ${taskId} 结果文件等待超时(${MAX_FILE_WAIT_ROUNDS} 轮),按任务终态返回,可刷新页面查看`,
);
}
return status; return status;
} }
} catch (error) { } catch (error) {
@@ -1000,6 +1038,36 @@ async function waitForTaskTerminal(taskId: number) {
} }
} }
/**
* 页面加载时收敛:历史里有「任务已完成但结果文件仍在生成」的任务时(例如正好在生成
* 窗口内刷新页面 / 重开客户端),继续轮询到文件就绪,避免下载按钮不出现。
*/
async function settlePendingResultFiles() {
for (let round = 1; round <= MAX_FILE_WAIT_ROUNDS; round += 1) {
const pendingIds = Array.from(new Set(
taskRecordItems.value
.filter((item) => isTaskTerminal(item.taskStatus) && isResultFilePending(item))
.map((item) => item.taskId)
.filter((taskId): taskId is number => typeof taskId === "number" && taskId > 0),
));
if (!pendingIds.length) {
if (round > 1) {
console.log(`[patrol-delete] 结果文件已就绪(第 ${round - 1} 轮收敛完成)`);
}
return;
}
console.log(
`[patrol-delete] 任务 ${pendingIds.join(",")} 结果文件仍在生成,等待中 ${round}/${MAX_FILE_WAIT_ROUNDS}`,
);
await sleep(getTaskPollIntervalMs());
if (disposed) return;
await refreshActiveTaskProgress(pendingIds);
}
console.warn(
`[patrol-delete] 结果文件等待超时(${MAX_FILE_WAIT_ROUNDS} 轮),可稍后刷新页面查看下载按钮`,
);
}
async function processQueue() { async function processQueue() {
if (disposed || queueWorkerRunning.value) return; if (disposed || queueWorkerRunning.value) return;
queueWorkerRunning.value = true; queueWorkerRunning.value = true;
@@ -1196,6 +1264,9 @@ onMounted(async () => {
? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成` ? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成`
: ""; : "";
void processQueue(); void processQueue();
} else {
// 未完成任务由 processQueue 负责等待;这里只收敛「任务已完成、结果文件还在生成」的历史任务
void settlePendingResultFiles();
} }
}); });