task-101: 上传/解析/入队三道前端拦截,阻止坏数据卡死 Python 自动化
Build Backend JAR / build (push) Has been cancelled

新增 shared/dispatch-guard.ts(纯 TS 校验,可单测)与 dispatch-guard-ui.ts
(ElMessageBox 弹窗层),在数据流的三个位置设闸:

- 选文件:空选择、非 xlsx/xls/csv、空路径直接拦;重复文件弹确认
- 解析结果:taskId 非法、totalRows 为 0、整批行被丢弃、需要分组却无分组直接拦;
  部分行被丢弃弹确认后才允许推送
- 入队前:结构不符、必填字段缺失、items/groups/country_codes 为空数组、
  行数页数为 0、JSON 不安全值(NaN/Infinity/BigInt/循环引用/数组洞)

覆盖 11 个推 Python 队列的 Tab 与 dedupe/split/convert 三个纯 Java Tab。
闸位按 Tab 挑选以免留下孤儿后端状态:collect-data 校验前置到
activateCollectDataTask 之前(任务保持 PENDING 无需回滚)、product-risk 的国家
检查提到建任务之前;已建任务后才拦下的复用各 Tab 原有失败补偿路径。

两处边界:对象属性 undefined 放行(taskNo 这类可选字段是惯用写法),只拦数组
元素 undefined 与数组洞;price-track 的 asin_rows_by_country 仅在 mode=asin
时要求非空,status 模式下 loadAsinRowsForAppClient 本就返回 {}。

顺带修复 similar-asin / appearance-patent 的 selectFiles 缺少 try/catch,
上传失败会残留上一批文件与解析结果。

测试:tests/dispatch-guard.test.ts 新增 37 个 test_task_101_* 用例。
This commit is contained in:
2026-09-01 12:53:12 +08:00
parent e2607ab723
commit 759f0b15d8
18 changed files with 1603 additions and 14 deletions
@@ -227,6 +227,13 @@ import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_EXTENSIONS,
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version'
const COUNTRY_OPTIONS = [
@@ -463,12 +470,16 @@ async function selectFiles() {
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
uploading.value = true
const uploaded = await uploadPathsToJava(paths)
selectedPaths.value = paths
uploadedFiles.value = uploaded
ElMessage.success(`已上传 ${uploaded.length} 个待上架文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
} finally {
uploading.value = false
@@ -489,12 +500,15 @@ async function selectFolder() {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
uploading.value = true
const uploaded = await uploadPathsToJava(result.items)
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = uploaded
ElMessage.success(`已上传文件夹内 ${uploaded.length} 个 xlsx 文件`)
} catch (error) {
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
} finally {
uploading.value = false
@@ -532,6 +546,12 @@ async function submitRun() {
sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value),
})
if (!parsed.taskId) throw new Error('后端未返回有效任务标识')
// publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数
const guard = checkParseResult(
{ taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows },
{ requiredColumnsHint: '店铺名 / 商品行' },
)
if (!(await passGuard(guard))) return
const options: PublishDispatchOptions = {
publishCountry: publishCountry.value,
@@ -851,6 +871,15 @@ async function processQueue() {
await activatePublishFile(taskId, nextFileId)
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已派发到 Python 队列' })
const payload = buildQueuePayload(taskId, file)
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: 'publish-run',
requiredDataKeys: ['taskId', 'fileId', 'shopName', 'publish_country', 'paginationUrl'],
positiveNumberKeys: ['totalRows', 'totalPages'],
})
if (!(await passGuard(guard))) {
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
}
const result = await api.enqueue_json(payload)
if (!result?.success) throw new Error(result?.error || 'Python 队列拒绝接收任务')
queueMessage.value = pendingFileIds.value.length