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
@@ -164,6 +164,13 @@ import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
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'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -363,12 +370,23 @@ async function selectFiles() {
}
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
const files = await uploadAppearancePathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
try {
const files = await uploadAppearancePathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
uploadedFiles.value = []
selectedFileNames.value = []
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
}
}
async function selectFolder() {
@@ -385,6 +403,7 @@ async function selectFolder() {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
const files = await uploadAppearancePathsToJava(result.items)
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
@@ -393,6 +412,9 @@ async function selectFolder() {
queuePayloadText.value = ''
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
} catch (error) {
uploadedFiles.value = []
selectedFileNames.value = []
parseResult.value = null
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -414,6 +436,17 @@ async function parseFiles() {
relativePath: f.relativePath,
}))
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveLlmApiKey(), effectivePatentToken())
// 本模块按主 ID 分组执行,没有分组就没有可跑的批次,一并拦下
const guard = checkParseResult(res, {
requireGroups: true,
requiredColumnsHint: 'id / ASIN / 国家',
})
if (!(await passGuard(guard))) {
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
return
}
parseResult.value = res
queuedTaskSummary.value = null
queuePayloadText.value = ''
@@ -479,6 +512,13 @@ async function pushToPythonQueue() {
},
}
queuePayloadText.value = `任务 ${taskId} 已准备推送,共 ${groups.length}`
// 后端返回的 groups 为空时推送出去,Python 端没有可执行批次会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: 'appearance-patent-run',
requiredDataKeys: ['taskId', 'api_key'],
nonEmptyArrayKeys: ['groups'],
})
if (!(await passGuard(guard))) return
await activateAppearancePatentTask(taskId)
const result = await api.enqueue_json(payload)
if (!result?.success) {
@@ -219,6 +219,13 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
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 {
parseCollectData,
activateCollectDataTask,
@@ -396,12 +403,18 @@ 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
const files = await uploadPathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
lastTaskId.value = null
lastParseVo.value = null
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
uploadedFiles.value = []
selectedFileNames.value = []
lastTaskId.value = null
lastParseVo.value = null
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
}
}
@@ -420,6 +433,7 @@ async function selectFolder() {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
const files = await uploadPathsToJava(result.items)
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
@@ -427,6 +441,10 @@ async function selectFolder() {
lastParseVo.value = null
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
} catch (error) {
uploadedFiles.value = []
selectedFileNames.value = []
lastTaskId.value = null
lastParseVo.value = null
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -475,6 +493,14 @@ async function submitCollect() {
})
lastParseVo.value = vo
lastTaskId.value = vo.taskId ?? null
// 0 行落库的任务推给 Python 后拉不到任何明细,会一直停在执行中
const guard = checkParseResult(vo, { requiredColumnsHint: 'ASIN / 国家' })
if (!(await passGuard(guard))) {
lastParseVo.value = null
lastTaskId.value = null
await Promise.all([loadDashboard(), loadHistory()])
return
}
ElMessage.success(`提交成功:任务 ${vo.taskNo || vo.taskId},落库 ${vo.acceptedRows ?? 0}`)
await Promise.all([loadDashboard(), loadHistory()])
} catch (error) {
@@ -497,8 +523,6 @@ async function pushToPythonQueue() {
pushing.value = true
let activated = false
try {
await activateCollectDataTask(lastTaskId.value)
activated = true
const payload = {
type: 'collect-data-run',
ts: Date.now(),
@@ -511,6 +535,15 @@ async function pushToPythonQueue() {
filters: buildFiltersPayload(),
},
}
// 先校验再激活:拦下来时任务还是 PENDING,不需要回滚成 FAILED
const guard = checkQueuePayload(payload, {
expectedType: 'collect-data-run',
requiredDataKeys: ['taskId', 'pageSize'],
nonEmptyObjectKeys: ['filters'],
})
if (!(await passGuard(guard))) return
await activateCollectDataTask(lastTaskId.value)
activated = true
const result = await api.enqueue_json(payload)
if (!result?.success) {
throw new Error(result?.error || '入队失败')
@@ -190,6 +190,8 @@ import {
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
const convertSelectedPaths = ref<string[]>([])
const convertArchiveName = ref('')
@@ -272,8 +274,11 @@ async function selectConvertFiles() {
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待转换文件`)
} catch (error) {
convertSelectedPaths.value = []
convertUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -292,6 +297,7 @@ async function selectConvertFolder() {
const result = await expandBrandFolderRecursive(folder)
if (result.success && result.items?.length) {
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
convertSelectedPaths.value = result.items.map((item) => item.relativePath)
convertUploadedFiles.value = await uploadPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
@@ -300,6 +306,8 @@ async function selectConvertFolder() {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
convertSelectedPaths.value = []
convertUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -317,9 +325,25 @@ async function handleTemplateFileChange(event: Event) {
return
}
// 浏览器侧直接读内容的唯一入口:扩展名与内容都在这里拦,空模板传上去后
// 转换阶段每个文件都会失败,不如在上传时就说清楚。
if (!(await passGuard(checkSelectedFiles([file.name], { allowedExtensions: ['.txt'] })))) {
return
}
try {
templateUploading.value = true
const templateContent = await file.text()
if (!templateContent.trim()) {
await passGuard(
guardBlocked(
'模板内容为空',
`${file.name} 里没有任何内容。\n空模板无法用于格式转换,请填好列映射后重新上传。`,
'convert.empty-template',
),
)
return
}
const templateName = file.name.replace(/\.[^.]+$/, '') || file.name
const template = await importConvertTemplate({
@@ -436,6 +460,18 @@ async function submitConvertRun() {
convertSummary.value = result
convertResultItems.value = result.items || []
await loadConvertHistory()
if (result.total > 0 && result.successCount === 0) {
await passGuard(
guardBlocked(
'格式转换未成功',
`本次提交的 ${result.total} 个文件全部转换失败。\n` +
'常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n' +
'请在右侧结果列表查看每个文件的失败原因,或换一个模板后重试。',
'convert.all-failed',
),
)
return
}
ElMessage.success('格式转换完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
@@ -151,6 +151,8 @@ import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
const cleanAvailableColumns = ref<string[]>([])
const cleanSelectedColumns = ref<string[]>([])
@@ -231,8 +233,11 @@ async function selectCleanFiles() {
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待清洗文件`)
} catch (error) {
cleanSelectedPaths.value = []
cleanUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -251,6 +256,7 @@ async function selectCleanFolder() {
const result = await expandBrandFolderRecursive(folder)
if (result.success && result.items?.length) {
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
cleanSelectedPaths.value = result.items.map((item) => item.relativePath)
cleanUploadedFiles.value = await uploadPathsToJava(result.items)
if (cleanUploadedFiles.value.length > 0) {
@@ -262,6 +268,8 @@ async function selectCleanFolder() {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
cleanSelectedPaths.value = []
cleanUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -296,6 +304,19 @@ async function submitCleanRun() {
cleanSummary.value = result
cleanResultItems.value = result.items || []
await loadCleanHistory()
// 全部文件都失败时不该报「完成」,让用户看清是哪一批没处理成功
if (result.total > 0 && result.successCount === 0) {
await passGuard(
guardBlocked(
'去重未成功',
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
'常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' +
'请在右侧结果列表查看每个文件的失败原因后重试。',
'dedupe.all-failed',
),
)
return
}
ElMessage.success('数据去重完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重失败')
@@ -235,6 +235,13 @@ import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebvi
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_EXTENSIONS,
checkQueuePayload,
checkSelectedFiles,
guardBlocked,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
@@ -1054,10 +1061,14 @@ 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
selectedPaths.value = paths
uploadedFiles.value = await uploadPathsToJava(paths)
ElMessage.success(`已选择 ${paths.length} 个删除品牌文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -1077,10 +1088,13 @@ async function selectFolder() {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = await uploadPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
} catch (error) {
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -1100,6 +1114,21 @@ async function submitRun() {
})),
})
const normalizedItems = normalizeDeleteBrandItems(result.items || [])
// 一条都没解析出来说明文件根本没读懂,直接拦下,不写入会话与历史
if (!normalizedItems.length) {
await passGuard(
guardBlocked(
'解析结果无法执行',
'这批文件里没有解析出任何删除品牌记录。\n' +
'请确认文件名能对应到店铺名、表内有数据行且表头与模板一致,修正后重新上传。',
'delete-brand.empty-result',
),
)
queuePushResult.value = '解析完成,但没有解析出任何记录'
queuePayloadText.value = ''
await loadHistory()
return
}
const hasRunnableItems = normalizedItems.some((item) => isUsableMatchedItem(item))
const hasStaleMatchedItems = normalizedItems.some((item) => item.matchStatus === 'INDEX_STALE' && item.matched)
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
@@ -1322,6 +1351,16 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const guard = checkQueuePayload(payload, {
expectedType: 'delete-brand-run',
requiredDataKeys: ['taskId'],
nonEmptyArrayKeys: ['items'],
})
if (!(await passGuard(guard))) {
queuePushResult.value = `文件 ${item.sourceFilename || taskId} 数据校验未通过,已阻止推送`
saveSessionTask(taskId, sessionTask.items, queuePushResult.value, queuePayloadText.value)
return { success: false, retryable: false }
}
const pushResult = await api.enqueue_json(payload)
let qpr = ''
if (pushResult?.success) {
@@ -395,6 +395,8 @@ import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import { checkQueuePayload } from "@/shared/dispatch-guard";
import { passGuard } from "@/shared/dispatch-guard-ui";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
@@ -1119,6 +1121,31 @@ async function processQueue() {
const payload = buildQueuePayload(created.taskId, createdItems);
queuePayloadText.value = JSON.stringify(payload, null, 2);
// 删除条件为空时 Python 端没有可执行的判定规则,任务会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: "patrol-delete-run",
requiredDataKeys: ["taskId"],
nonEmptyArrayKeys: ["items", "delete_conditions"],
});
if (!(await passGuard(guard))) {
await submitPatrolDeleteTaskResult(created.taskId, {
shops: createdItems.map((createdItem) => ({
shopName: createdItem.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止推送`,
countrySections: createdItem.countrySections || [],
cartRatios: createdItem.cartRatios || [],
shopDone: true,
submissionId: createSubmissionId(created.taskId, createdItem.shopName),
chunkIndex: 1,
chunkTotal: 1,
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止推送`;
clearActiveQueueTask();
return;
}
const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) {
await submitPatrolDeleteTaskResult(created.taskId, {
@@ -272,6 +272,12 @@ import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/sha
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_CSV_EXTENSIONS,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import {
addPriceTrackCandidate,
@@ -527,6 +533,7 @@ async function selectAsinFile() {
multiple: true,
})
if (result?.paths?.length) {
if (!(await passGuard(checkSelectedFiles(result.paths, { allowedExtensions: EXCEL_CSV_EXTENSIONS })))) return
asinFiles.value = result.paths
asinUploadedFiles.value = await uploadAsinPathsToJava(result.paths)
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
@@ -536,6 +543,7 @@ async function selectAsinFile() {
if (api?.select_brand_xlsx_files) {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_CSV_EXTENSIONS })))) return
asinFiles.value = paths
asinUploadedFiles.value = await uploadAsinPathsToJava(paths)
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
@@ -558,10 +566,14 @@ async function selectAsinFolder() {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_CSV_EXTENSIONS })))) return
asinFiles.value = result.items.map((item) => item.relativePath || item.absolutePath)
asinUploadedFiles.value = await uploadAsinPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
asinFiles.value = []
asinUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -1072,6 +1084,22 @@ async function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrack
async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayload: unknown) {
if (!api.enqueue_json) throw new Error('当前环境未启用 pywebview enqueue_json')
// 三条推送路径(手动/串行队列/循环任务)都走这里,闸设在这一处即可全覆盖。
// 空 country_codes 会让 Python 端没有可遍历的站点,任务永远停在 RUNNING。
// asin_rows_by_country 只在 asin 模式下要求非空——status 模式下
// loadAsinRowsForAppClient 本就返回 {},那是正常的。
const mode = (queuePayload as { data?: { mode?: unknown } } | null)?.data?.mode
const guard = checkQueuePayload(queuePayload, {
expectedType: 'price-track-run',
requiredDataKeys: ['task_id', 'shop_name'],
nonEmptyArrayKeys: ['country_codes'],
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
})
if (!(await passGuard(guard))) {
const reason = `任务 ${taskId} 数据校验未通过,已阻止推送到 Python 队列`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
let pushResult: Awaited<ReturnType<NonNullable<PywebviewApi['enqueue_json']>>>
try {
pushResult = await api.enqueue_json(queuePayload)
@@ -224,6 +224,8 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { checkQueuePayload, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
@@ -1060,6 +1062,15 @@ function shouldBatchProductRiskQueue() {
return productRiskListingFilter.value === 'SearchSuppressed' || productRiskListingFilter.value === 'DetailPageRemoved'
}
/** 入队前的结构校验:店铺列表或国家列表为空、payload 里混进 NaN,都会让 Python 端空转 */
function productRiskPayloadGuard(payload: unknown) {
return checkQueuePayload(payload, {
expectedType: 'product-risk-resolve-run',
requiredDataKeys: ['taskId'],
nonEmptyArrayKeys: ['items', 'country_codes'],
})
}
async function processMatchedBatchQueue(toPush: ProductRiskShopQueueItem[]) {
const api = getPywebviewApi()
if (!api?.enqueue_json) {
@@ -1090,6 +1101,10 @@ async function processMatchedBatchQueue(toPush: ProductRiskShopQueueItem[]) {
},
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (!(await passGuard(productRiskPayloadGuard(payload)))) {
removePollingTask(taskId)
throw new Error('任务 ' + taskId + ' 数据校验未通过,已阻止推送')
}
const pushResult = await api.enqueue_json(payload)
if (!pushResult?.success) {
removePollingTask(taskId)
@@ -1122,6 +1137,17 @@ async function processMatchedQueue() {
ElMessage.warning('没有已匹配的店铺可推送')
return
}
// 一个国家都没选时 Python 端没有可遍历的站点,任务会停在执行中;在建任务之前就拦掉
if (!orderedCountryCodes.value.length) {
await passGuard(
guardBlocked(
'还没有选择国家',
'当前一个国家都没有勾选,推送后 Python 端没有可遍历的站点,任务会一直停在执行中。\n请先在左侧勾选至少 1 个国家。',
'product-risk.no-country',
),
)
return
}
queueWorkerRunning.value = true
pushing.value = true
queuePayloadText.value = ''
@@ -1168,6 +1194,10 @@ async function processMatchedQueue() {
},
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (!(await passGuard(productRiskPayloadGuard(payload)))) {
removePollingTask(taskId)
throw new Error('任务 ' + taskId + ' 数据校验未通过,已阻止推送')
}
const pushResult = await api.enqueue_json(payload)
if (!pushResult?.success) {
removePollingTask(taskId)
@@ -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
@@ -343,6 +343,8 @@ import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import { checkQueuePayload } from "@/shared/dispatch-guard";
import { passGuard } from "@/shared/dispatch-guard-ui";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
@@ -1050,6 +1052,32 @@ async function processQueue() {
const payload = buildQueuePayload(created.taskId, createdItem);
queuePayloadText.value = JSON.stringify(payload, null, 2);
// 模板里一个有效 ASIN 都没解析出来时,Python 端拿到空列表会空转,任务停在执行中
const guard = checkQueuePayload(payload, {
expectedType: "query-asin-run",
requiredDataKeys: ["taskId"],
nonEmptyArrayKeys: ["items", "query_asins"],
});
if (!(await passGuard(guard))) {
await submitQueryAsinTaskResult(created.taskId, {
shops: [
{
shopName: createdItem.shopName || nextItem.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止推送`,
shopDone: true,
submissionId: createSubmissionId(
created.taskId,
createdItem.shopName || nextItem.shopName,
),
},
],
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止推送并继续下一个任务`;
clearActiveQueueTask();
continue;
}
const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) {
await submitQueryAsinTaskResult(created.taskId, {
@@ -127,6 +127,8 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { checkQueuePayload } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { mergeHistoryItems } from '@/shared/merge-history-items'
import {
addShopDataCrawlCandidate,
@@ -429,6 +431,15 @@ async function dispatchActiveTask(api: NonNullable<ReturnType<typeof getPywebvie
country_codes: [...activeCountryCodes.value],
},
}
// country_codes 为空时 Python 端没有可遍历的站点,任务会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: 'shop-data-crawl-run',
requiredDataKeys: ['taskId'],
nonEmptyArrayKeys: ['items', 'country_codes'],
})
if (!(await passGuard(guard))) {
throw new Error(`任务 ${activeTaskId.value} 数据校验未通过,已阻止推送`)
}
const pushed = await api.enqueue_json(payload)
if (!pushed?.success) throw new Error(pushed?.error || `任务 ${activeTaskId.value} 入队失败`)
activeDispatched.value = true
@@ -213,6 +213,13 @@ import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { createAsinForceThrottle } from '@/shared/asin-force-throttle'
import { toParsePreview, type ParsePreviewOptions } from '@/shared/parse-preview'
import {
EXCEL_EXTENSIONS,
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -421,12 +428,23 @@ async function selectFiles() {
}
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
const files = await uploadAppearancePathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
try {
const files = await uploadAppearancePathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
uploadedFiles.value = []
selectedFileNames.value = []
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
}
}
async function selectFolder() {
@@ -443,6 +461,7 @@ async function selectFolder() {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
const files = await uploadAppearancePathsToJava(result.items)
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
@@ -451,6 +470,9 @@ async function selectFolder() {
queuePayloadText.value = ''
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
} catch (error) {
uploadedFiles.value = []
selectedFileNames.value = []
parseResult.value = null
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -473,6 +495,14 @@ async function parseFiles() {
relativePath: f.relativePath,
}))
const res = await parseSimilarAsin(files, effectiveLlmApiKey(), imgSwitch.value, categorySwitch.value)
// 0 有效行的任务推给 Python 只会一直停在 RUNNING,这里拦下来不写入 parseResult
const guard = checkParseResult(res, { requiredColumnsHint: 'id / ASIN / 国家' })
if (!(await passGuard(guard))) {
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
return
}
// 只保留摘要字段与有界预览行,避免数千行 items/groups 进入响应式对象
const previewOptions: ParsePreviewOptions = { previewRowLimit: 200 }
parseResult.value = toParsePreview(res, previewOptions) as unknown as SimilarAsinParseVo
@@ -551,6 +581,12 @@ async function pushToPythonQueue() {
},
}
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
// 入队前最后一道闸:缺字段或含 NaN 的 payload 到 Python 侧会变成 None,任务卡住
const guard = checkQueuePayload(payload, {
expectedType: 'similar-asin-run',
requiredDataKeys: ['taskId', 'api_key', 'aliprice_usename', 'aliprice_pwd'],
})
if (!(await passGuard(guard))) return
await activateSimilarAsinTask(taskId)
const result = await api.enqueue_json(payload)
if (!result?.success) {
@@ -170,6 +170,8 @@ import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
const splitSelectedPaths = ref<string[]>([])
const splitArchiveName = ref('')
@@ -239,8 +241,11 @@ async function selectSplitFiles() {
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待拆分文件`)
} catch (error) {
splitSelectedPaths.value = []
splitUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -259,6 +264,7 @@ async function selectSplitFolder() {
const result = await expandBrandFolderRecursive(folder)
if (result.success && result.items?.length) {
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
splitSelectedPaths.value = result.items.map((item) => item.relativePath)
splitUploadedFiles.value = await uploadPathsToJava(result.items)
if (splitUploadedFiles.value.length > 0) {
@@ -270,6 +276,8 @@ async function selectSplitFolder() {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
splitSelectedPaths.value = []
splitUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
@@ -308,6 +316,18 @@ async function submitSplitRun() {
splitSummary.value = result
splitResultItems.value = result.items || []
await loadSplitHistory()
if (result.total > 0 && result.successCount === 0) {
await passGuard(
guardBlocked(
'拆分未成功',
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
'常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' +
'请在右侧结果列表查看每个文件的失败原因后重试。',
'split.all-failed',
),
)
return
}
ElMessage.success('数据拆分完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
@@ -356,6 +356,8 @@ import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import { checkQueuePayload } from "@/shared/dispatch-guard";
import { passGuard } from "@/shared/dispatch-guard-ui";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
@@ -1161,6 +1163,27 @@ async function processQueue() {
const payload = buildQueuePayload(created.taskId, nextBatch);
queuePayloadText.value = JSON.stringify(payload, null, 2);
const guard = checkQueuePayload(payload, {
expectedType: "withdraw-run",
requiredDataKeys: ["task_id"],
nonEmptyArrayKeys: ["items"],
});
if (!(await passGuard(guard))) {
await submitWithdrawTaskResult(created.taskId, {
shops: nextBatch.items.map((item) => ({
shopName: item.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止推送`,
shopDone: true,
submissionId: createSubmissionId(created.taskId, item.shopName),
rows: [],
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止推送并继续下一个任务`;
clearActiveQueueTask();
continue;
}
const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) {
await submitWithdrawTaskResult(created.taskId, {