fix(采集数据): 品牌检测移出任务锁 + 失败任务仍产出可下载的部分结果

taskId 28599 事故根因:分片回传在任务锁内同步做品牌检测,上游 16890 卡死时
单次持锁 103.5 秒(costMs=103556),期间该任务的心跳与客户端重试全部撞
40902「任务正在处理」,客户端 5 次重试预算耗尽后中止了整个采集。

- submitResult:行归一化 / 去重过滤 / 品牌检测移到任务锁外,锁内只保留落库与终态
- BrandCheckClient:整批加 90 秒总耗时上限(超时按查询失败降级,首轮始终执行),
  单次回传时延从「无上界」收敛到约 90 秒 + 一次读超时
- 失败且已收到分片时照常组装部分结果;任务已 FAILED 则保留失败态与真实失败原因,
  只补结果文件(乐观写结果行 + 条件更新不匹配再补偿写回,避免崩溃时留下「成功但无下载」)
- productrisk / shopmatch 按 pricetrack 的 preserveFailure 样板补齐,含组装落地时
  不再把失败行「洗成成功」
- 前端:采集 / 跟价 / 产品风险 / 店铺匹配的下载按钮放宽到失败任务
This commit is contained in:
2026-09-18 15:53:15 +08:00
parent 986df86e89
commit 9d39705c77
12 changed files with 303 additions and 65 deletions
@@ -596,7 +596,9 @@ function taskKeywordProgress(item: CollectDataHistoryItem) {
function canDownload(item: CollectDataHistoryItem) {
const status = normalizeTaskStatus(item)
return Boolean(item.downloadUrl && (item.success || status === 'SUCCESS'))
// 失败任务只要已生成结果文件也允许下载:失败时后端按已收到的分片组装部分结果,
// 已采集的数据不应随任务失败一起不可用(taskId 28599 事故)。
return Boolean(item.downloadUrl && (item.success || status === 'SUCCESS' || status === 'FAILED'))
}
async function downloadResult(item: CollectDataHistoryItem) {
@@ -1658,7 +1658,8 @@ function statusClass(item: PriceTrackHistoryItem) {
function canDownload(item: PriceTrackHistoryItem) {
if (!item.resultId || (!item.fileReady && !item.downloadUrl)) return false
const st = resolvedTaskStatus(item)
return st === 'SUCCESS' || item.success === true
// 失败任务也可能有已跑出来的部分结果文件(后端按已收分片组装),有文件就允许下载
return st === 'SUCCESS' || item.success === true || st === 'FAILED'
}
async function downloadResult(item: PriceTrackHistoryItem) {
@@ -1018,7 +1018,8 @@ function statusClass(item: ProductRiskHistoryItem) {
function canDownload(item: ProductRiskHistoryItem) {
if (!item.resultId || (!item.fileReady && !item.downloadUrl)) return false
const st = resolvedTaskStatus(item)
return st === 'SUCCESS' || item.success === true
// 失败任务也可能有已跑出来的部分结果文件(后端按已收分片组装),有文件就允许下载
return st === 'SUCCESS' || item.success === true || st === 'FAILED'
}
async function downloadResult(item: ProductRiskHistoryItem) {
@@ -531,7 +531,8 @@ function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = tas
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatMonthDayTime(stage.scheduledAt) } if (item.scheduledAt) return formatMonthDayTime(item.scheduledAt); return '' }
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && (!!item.fileReady || !!item.downloadUrl) && (status === 'SUCCESS' || status === 'COMPLETED') }
// 失败任务也可能有已跑出来的部分结果文件(后端按已收分片组装),有文件就允许下载
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && (!!item.fileReady || !!item.downloadUrl) && (status === 'SUCCESS' || status === 'COMPLETED' || status === 'FAILED') }
async function downloadResult(item: ShopMatchHistoryItem) { if (!item.resultId) return; const url = getShopMatchResultDownloadUrl(item.resultId); const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`; const result = await saveUrlWithProgress(url, filename, `shop-match:${item.resultId}`); if (result.success) ElMessage.success(`已保存: ${result.path || filename}`); else if (result.error && result.error !== '用户取消') ElMessage.error(result.error) }
async function deleteTaskRecord(item: ShopMatchHistoryItem) {
const taskId = normalizeTaskId(item.taskId)