From 759f0b15d8ef24afab79a1c6d84ddbcbb69b4dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Tue, 1 Sep 2026 12:53:12 +0800 Subject: [PATCH] =?UTF-8?q?task-101:=20=E4=B8=8A=E4=BC=A0/=E8=A7=A3?= =?UTF-8?q?=E6=9E=90/=E5=85=A5=E9=98=9F=E4=B8=89=E9=81=93=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=8B=A6=E6=88=AA=EF=BC=8C=E9=98=BB=E6=AD=A2=E5=9D=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8D=A1=E6=AD=BB=20Python=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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_* 用例。 --- .../components/BrandAppearancePatentTab.vue | 52 +- .../brand/components/BrandCollectDataTab.vue | 37 +- .../brand/components/BrandConvertTab.vue | 36 + .../pages/brand/components/BrandDedupeTab.vue | 21 + .../brand/components/BrandDeleteBrandTab.vue | 39 ++ .../brand/components/BrandPatrolDeleteTab.vue | 27 + .../brand/components/BrandPriceTrackTab.vue | 28 + .../brand/components/BrandProductRiskTab.vue | 30 + .../brand/components/BrandPublishTab.vue | 29 + .../brand/components/BrandQueryAsinTab.vue | 28 + .../components/BrandShopDataCrawlTab.vue | 11 + .../brand/components/BrandSimilarAsinTab.vue | 48 +- .../pages/brand/components/BrandSplitTab.vue | 20 + .../brand/components/BrandWithdrawTab.vue | 23 + frontend-vue/src/shared/dispatch-guard-ui.ts | 67 ++ frontend-vue/src/shared/dispatch-guard.ts | 661 ++++++++++++++++++ frontend-vue/src/styles/main.css | 18 + frontend-vue/tests/dispatch-guard.test.ts | 442 ++++++++++++ 18 files changed, 1603 insertions(+), 14 deletions(-) create mode 100644 frontend-vue/src/shared/dispatch-guard-ui.ts create mode 100644 frontend-vue/src/shared/dispatch-guard.ts create mode 100644 frontend-vue/tests/dispatch-guard.test.ts diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index e6088716..35b9e335 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -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([]) const uploadedFiles = ref([]) @@ -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) { diff --git a/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue b/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue index d084010c..3e24c2cc 100644 --- a/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue @@ -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 || '入队失败') diff --git a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue index 42899600..2a7a879b 100644 --- a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue @@ -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([]) 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 : '格式转换失败') diff --git a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue index 2bf044a8..b36faff1 100644 --- a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue @@ -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([]) const cleanSelectedColumns = ref([]) @@ -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 : '去重失败') diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 1d54a9c7..8ecdc1b8 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -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) { diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index ceb504e4..e0bda516 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -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, { diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue index 5c8c5cef..43d23aae 100644 --- a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -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>> try { pushResult = await api.enqueue_json(queuePayload) diff --git a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue index e18d6266..ca05c3fd 100644 --- a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue @@ -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) diff --git a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue index 752aac98..a449ff3b 100644 --- a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue @@ -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 diff --git a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue index 8d3deb1f..9274bd93 100644 --- a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue @@ -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, { diff --git a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue index bad94ea0..67e4ad0a 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue @@ -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([]) const uploadedFiles = ref([]) @@ -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) { diff --git a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue index 26ba0edb..8605702a 100644 --- a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue @@ -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([]) 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 : '数据拆分失败') diff --git a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue index 35e724cc..be03664a 100644 --- a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue @@ -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, { diff --git a/frontend-vue/src/shared/dispatch-guard-ui.ts b/frontend-vue/src/shared/dispatch-guard-ui.ts new file mode 100644 index 00000000..35ecac8c --- /dev/null +++ b/frontend-vue/src/shared/dispatch-guard-ui.ts @@ -0,0 +1,67 @@ +/** + * dispatch-guard 的界面层(Task 101)。 + * + * 把 GuardResult 渲染成模态框:block 级用 alert(红色、只有「知道了」, + * 必须点掉),confirm 级用 confirm(「仍然继续」/「取消」)。之所以不用 + * ElMessage toast,是因为这些提示直接决定要不要把任务推给 Python 自动化, + * 3 秒自动消失的 toast 很容易被划过去。 + * + * 与 dispatch-guard.ts 分开放,是为了让核心校验保持纯 TS、可以用 + * `node --test` 直接跑,不必把 Element Plus 拉进测试环境。 + */ +import { ElMessageBox } from 'element-plus' +import type { GuardResult } from './dispatch-guard.ts' + +/** 配套样式见 src/styles/main.css 的 .dispatch-guard-box(保留换行、限制宽度) */ +const GUARD_BOX_CLASS = 'dispatch-guard-box' + +async function alertBlocked(result: GuardResult) { + try { + await ElMessageBox.alert(result.message, result.title, { + type: 'error', + confirmButtonText: '知道了', + customClass: GUARD_BOX_CLASS, + dangerouslyUseHTMLString: false, + }) + } catch { + // 用户按 ESC / 点遮罩关闭,同样视为已知悉;拦截结果不受影响 + } +} + +async function confirmSuspicious(result: GuardResult) { + try { + await ElMessageBox.confirm(result.message, result.title, { + type: 'warning', + confirmButtonText: '仍然继续', + cancelButtonText: '取消', + customClass: GUARD_BOX_CLASS, + dangerouslyUseHTMLString: false, + }) + return true + } catch { + return false + } +} + +/** + * 按顺序处理若干校验结果,返回是否放行。 + * + * - 任一结果含 block 级问题:弹 alert 并返回 false(调用方直接 return)。 + * - 只含 confirm 级问题:弹 confirm,用户点「仍然继续」返回 true,否则 false。 + * - 全部通过:不弹任何东西,返回 true。 + * + * 用法: + * if (!(await passGuard(checkParseResult(vo)))) return + */ +export async function passGuard(...results: GuardResult[]): Promise { + const blocked = results.find((result) => !result.ok) + if (blocked) { + await alertBlocked(blocked) + return false + } + for (const result of results) { + if (!result.needsConfirm) continue + if (!(await confirmSuspicious(result))) return false + } + return true +} diff --git a/frontend-vue/src/shared/dispatch-guard.ts b/frontend-vue/src/shared/dispatch-guard.ts new file mode 100644 index 00000000..e18cebef --- /dev/null +++ b/frontend-vue/src/shared/dispatch-guard.ts @@ -0,0 +1,661 @@ +/** + * 上传 / 解析 / 入队三道闸(Task 101)。 + * + * 现状:各 Tab 选完文件直接 upload_file_to_java,解析成功就把 taskId 交给 + * pushToPythonQueue,enqueue_json 之前只判断 taskId 是否为真值。于是 + * 「表头对不上导致 0 条有效行」「country_codes 为空」「payload 里混进 NaN」 + * 这类问题都会被原样推给 Python 自动化——Python 端拿到空任务后既不报错也 + * 没得可做,任务永远停在 RUNNING,前端就一直轮询,表现为「卡住」。 + * + * 本模块把这三处收敛成统一校验,全部在界面侧拦截: + * - checkSelectedFiles:文件选完、上传前,挡掉空选择 / 非法扩展名 / 超量。 + * - checkParseResult:Java 解析返回后,挡掉无效 taskId 与 0 有效行; + * 部分行被丢弃降级为 confirm,由用户决定是否继续。 + * - checkQueuePayload:enqueue_json 前的最后一道闸,挡掉缺字段、空数组, + * 以及 JSON.stringify 会静默改写或直接抛错的值(NaN/Infinity → null、 + * BigInt → 抛错、循环引用 → 抛错),这些到了 Python 侧就是 None。 + * + * 纯 TS 无副作用模块:不引入 Vue / Element Plus,输入对象永不修改,任何输入 + * 都返回 GuardResult 而不抛错(弹窗文案由 dispatch-guard-ui 负责渲染)。 + */ + +/** block:必须修数据,禁止继续;confirm:可疑但可继续,需用户确认 */ +export type GuardSeverity = 'block' | 'confirm' + +export interface GuardIssue { + /** 稳定的问题码,便于日志与测试断言 */ + code: string + /** 面向用户的中文说明 */ + message: string + severity: GuardSeverity +} + +export interface GuardResult { + /** 没有任何 block 级问题 */ + ok: boolean + /** 存在 confirm 级问题,需要用户点确认才能继续 */ + needsConfirm: boolean + /** 模态框标题 */ + title: string + /** 汇总文案,多条问题按换行拼接 */ + message: string + issues: GuardIssue[] +} + +/** 常用扩展名白名单 */ +export const EXCEL_EXTENSIONS = ['.xlsx', '.xls'] as const +export const EXCEL_CSV_EXTENSIONS = ['.xlsx', '.xls', '.csv'] as const + +/** JSON 嵌套深度上限,超过基本可以断定是环或异常结构 */ +const MAX_JSON_DEPTH = 64 +/** 单次扫描的节点数上限,避免超大 payload 卡住 UI 线程 */ +const MAX_JSON_NODES = 200_000 + +function buildResult( + blockTitle: string, + confirmTitle: string, + issues: GuardIssue[], +): GuardResult { + const blocking = issues.filter((issue) => issue.severity === 'block') + const confirming = issues.filter((issue) => issue.severity === 'confirm') + // 有 block 时只展示 block,避免把「可继续」的提示和「不可继续」的混在一个弹窗里 + const shown = blocking.length ? blocking : confirming + return { + ok: blocking.length === 0, + needsConfirm: blocking.length === 0 && confirming.length > 0, + title: blocking.length ? blockTitle : confirmTitle, + message: shown.map((issue) => issue.message).join('\n'), + issues, + } +} + +/** 一个恒定放行的结果,供调用方在无需校验时占位 */ +export function guardPassed(): GuardResult { + return buildResult('', '', []) +} + +/** + * 手工构造一个 block 级结果,用于那些不属于文件/解析/payload 三类、 + * 但同样会让 Python 端空转的前置条件(例如「一个国家都没选」)。 + */ +export function guardBlocked( + title: string, + message: string, + code = 'precondition.failed', +): GuardResult { + return buildResult(title, title, [{ code, severity: 'block', message }]) +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) return false + if (Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0 +} + +function isPositiveInteger(value: unknown): value is number { + return isNonNegativeInteger(value) && value > 0 +} + +/** 从本地路径里取出文件名,同时兼容 Windows 反斜杠与 POSIX 斜杠 */ +export function basenameOf(path: string): string { + const segments = String(path).split(/[/\\]/) + for (let i = segments.length - 1; i >= 0; i -= 1) { + if (segments[i]) return segments[i] + } + return '' +} + +/** 取小写扩展名(含点);无扩展名返回空串 */ +export function extensionOf(path: string): string { + const name = basenameOf(path) + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot).toLowerCase() +} + +export interface SelectedFilesOptions { + /** 允许的扩展名(小写含点);不传表示不限制 */ + allowedExtensions?: readonly string[] + /** 单次允许选择的最大文件数 */ + maxFiles?: number + /** 弹窗标题 */ + title?: string +} + +/** 选择结果里的一项:本地绝对路径字符串,或 expandBrandFolderRecursive 返回的条目 */ +export type SelectedFileInput = + | string + | { + absolutePath?: string + relativePath?: string + } + +function resolveSelectedPath(item: SelectedFileInput): string { + if (typeof item === 'string') return item.trim() + if (typeof item === 'object' && item !== null) { + const record = item as { absolutePath?: unknown; relativePath?: unknown } + if (typeof record.absolutePath === 'string' && record.absolutePath.trim()) { + return record.absolutePath.trim() + } + if (typeof record.relativePath === 'string' && record.relativePath.trim()) { + return record.relativePath.trim() + } + } + return '' +} + +/** + * 文件选择校验:在 upload_file_to_java 之前跑,挡掉根本不该上传的输入。 + * 扩展名不匹配一律 block —— 把 .txt 改名成 .xlsx 交给 Java 解析只会得到 + * 一个语焉不详的后端错误,不如在选择时就说清楚。 + */ +export function checkSelectedFiles( + paths: unknown, + options: SelectedFilesOptions = {}, +): GuardResult { + const blockTitle = options.title || '文件选择有问题' + const confirmTitle = '选择的文件需要确认' + const issues: GuardIssue[] = [] + + if (!Array.isArray(paths)) { + issues.push({ + code: 'files.not-array', + severity: 'block', + message: '没有拿到文件列表,请重新选择文件。', + }) + return buildResult(blockTitle, confirmTitle, issues) + } + if (!paths.length) { + issues.push({ + code: 'files.empty', + severity: 'block', + message: '没有选择任何文件,请先选择要处理的文件。', + }) + return buildResult(blockTitle, confirmTitle, issues) + } + + const maxFiles = options.maxFiles + if (maxFiles != null && maxFiles > 0 && paths.length > maxFiles) { + issues.push({ + code: 'files.too-many', + severity: 'block', + message: `一次最多选择 ${maxFiles} 个文件,当前选了 ${paths.length} 个,请分批处理。`, + }) + } + + const allowed = options.allowedExtensions + ? options.allowedExtensions.map((ext) => ext.toLowerCase()) + : null + const invalidNames: string[] = [] + let blankCount = 0 + const seen = new Set() + const duplicated: string[] = [] + + for (const item of paths as SelectedFileInput[]) { + const resolved = resolveSelectedPath(item) + if (!resolved) { + blankCount += 1 + continue + } + const key = resolved.toLowerCase() + if (seen.has(key)) { + const name = basenameOf(resolved) + if (!duplicated.includes(name)) duplicated.push(name) + } else { + seen.add(key) + } + if (allowed) { + const ext = extensionOf(resolved) + if (!allowed.includes(ext)) { + const name = basenameOf(resolved) || resolved + if (!invalidNames.includes(name)) invalidNames.push(name) + } + } + } + + if (blankCount) { + issues.push({ + code: 'files.blank-path', + severity: 'block', + message: `有 ${blankCount} 个文件路径为空,无法上传,请重新选择。`, + }) + } + if (invalidNames.length && allowed) { + issues.push({ + code: 'files.bad-extension', + severity: 'block', + message: + `以下文件类型不支持:${formatNameList(invalidNames)}\n` + + `当前只接受 ${allowed.join(' / ')} 格式,请转换后重新选择。`, + }) + } + if (duplicated.length) { + issues.push({ + code: 'files.duplicated', + severity: 'confirm', + message: `以下文件被重复选择,会被处理多次:${formatNameList(duplicated)}\n确认继续吗?`, + }) + } + + return buildResult(blockTitle, confirmTitle, issues) +} + +function formatNameList(names: string[], limit = 5): string { + if (names.length <= limit) return names.join('、') + return `${names.slice(0, limit).join('、')} 等 ${names.length} 个` +} + +/** 各模块 parse 接口返回的公共统计字段 */ +export interface ParseOutcome { + taskId?: unknown + totalRows?: unknown + acceptedRows?: unknown + droppedRows?: unknown + groupCount?: unknown + sourceFilename?: unknown +} + +export interface ParseResultOptions { + /** 该模块必须解析出分组才能执行(如 appearance-patent 按主 ID 分组) */ + requireGroups?: boolean + /** 必须解析出有效行;默认 true */ + requireRows?: boolean + /** 必要字段名,用于拼「缺什么」的提示,如 'ASIN / 国家' */ + requiredColumnsHint?: string + title?: string +} + +/** + * 解析结果校验:Java 返回后、把 taskId 交给推送按钮之前跑。 + * + * 0 有效行是最危险的情况——任务在库里建好了,推给 Python 后没有任何明细可 + * 执行,任务会一直停在 RUNNING。这里一律 block。 + * 部分行被丢弃则降级成 confirm:日常确实存在「几行没 ASIN 也照跑」的用法, + * 但要让用户明确知道少了多少行。 + */ +export function checkParseResult( + result: unknown, + options: ParseResultOptions = {}, +): GuardResult { + const blockTitle = options.title || '解析结果无法执行' + const confirmTitle = '解析结果需要确认' + const issues: GuardIssue[] = [] + + if (!isPlainObject(result)) { + issues.push({ + code: 'parse.not-object', + severity: 'block', + message: '后端没有返回解析结果,请重新解析。', + }) + return buildResult(blockTitle, confirmTitle, issues) + } + + const vo = result as ParseOutcome + if (!isPositiveInteger(vo.taskId)) { + issues.push({ + code: 'parse.bad-task-id', + severity: 'block', + message: `后端未返回有效任务标识(taskId=${String(vo.taskId)}),请重新解析。`, + }) + // taskId 都不对,后面的行数统计没有讨论价值 + return buildResult(blockTitle, confirmTitle, issues) + } + + const totalRows = normalizeCount(vo.totalRows) + const acceptedRows = normalizeCount(vo.acceptedRows) + const droppedRows = normalizeCount(vo.droppedRows) + const groupCount = normalizeCount(vo.groupCount) + + for (const [field, raw, parsed] of [ + ['totalRows', vo.totalRows, totalRows], + ['acceptedRows', vo.acceptedRows, acceptedRows], + ['droppedRows', vo.droppedRows, droppedRows], + ['groupCount', vo.groupCount, groupCount], + ] as const) { + if (raw != null && parsed == null) { + issues.push({ + code: 'parse.bad-count', + severity: 'block', + message: `解析结果字段 ${field} 异常(${String(raw)}),无法判断数据量,请重新解析。`, + }) + } + } + if (issues.length) return buildResult(blockTitle, confirmTitle, issues) + + const requireRows = options.requireRows !== false + const columnsHint = options.requiredColumnsHint + ? `(必要字段:${options.requiredColumnsHint})` + : '' + + if (requireRows && acceptedRows === 0) { + if (!totalRows) { + issues.push({ + code: 'parse.empty-file', + severity: 'block', + message: + '文件里没有读到任何数据行。\n' + + '请确认文件不是空表、数据不在隐藏的其他 Sheet 里,然后重新上传。', + }) + } else { + issues.push({ + code: 'parse.all-dropped', + severity: 'block', + message: + `共读取 ${totalRows} 行,但没有一行包含完整的必要字段${columnsHint},全部被丢弃。\n` + + '推送这样的任务会让 Python 端拿不到任何可执行明细、任务一直卡在执行中。\n' + + '请检查表头列名是否与模板一致,修正后重新上传解析。', + }) + } + return buildResult(blockTitle, confirmTitle, issues) + } + + if (options.requireGroups && groupCount === 0) { + issues.push({ + code: 'parse.no-group', + severity: 'block', + message: + '解析出了数据行,但没有生成任何分组,Python 端会没有可执行的批次。\n' + + '请检查主 ID 列是否填写正确,修正后重新解析。', + }) + return buildResult(blockTitle, confirmTitle, issues) + } + + if (droppedRows && droppedRows > 0) { + const accepted = acceptedRows ?? 0 + const total = totalRows || accepted + droppedRows + issues.push({ + code: 'parse.partial-dropped', + severity: 'confirm', + message: + `共 ${total} 行,其中 ${droppedRows} 行因缺少必要字段${columnsHint}被丢弃,` + + `只有 ${accepted} 行会被执行。\n` + + `确认按这 ${accepted} 行继续吗?`, + }) + } + + return buildResult(blockTitle, confirmTitle, issues) +} + +function normalizeCount(value: unknown): number | null { + if (value == null) return null + if (isNonNegativeInteger(value)) return value + return null +} + +export interface QueuePayloadOptions { + /** data 下必须存在且非空的字段名 */ + requiredDataKeys?: readonly string[] + /** data 下必须是非空数组的字段名 */ + nonEmptyArrayKeys?: readonly string[] + /** data 下必须是非空对象(或非空数组)的字段名 */ + nonEmptyObjectKeys?: readonly string[] + /** + * data 下必须是正数的字段名。用于「行数 / 页数」这类为 0 就等于没活干的字段—— + * 它们不是 null,requiredDataKeys 拦不住,但 0 会让 Python 端翻 0 页后空转。 + */ + positiveNumberKeys?: readonly string[] + /** 期望的 type 值;不匹配时 block */ + expectedType?: string + title?: string +} + +/** + * 入队 payload 校验:api.enqueue_json(payload) 之前的最后一道闸。 + * + * 除了业务字段,这里重点扫 JSON 安全性。pywebview 桥接会对 payload 做 + * JSON 序列化,而 JSON.stringify 对几类值的处理是「静默改写」而不是报错: + * NaN / Infinity 变成 null,undefined 属性被丢掉,数组里的 undefined 变 + * null。Python 侧 payload["data"]["taskId"] 于是成了 None,自动化脚本要么 + * 在某个循环里空转,要么抛异常被吞掉——两种都表现为任务卡住。 + */ +export function checkQueuePayload( + payload: unknown, + options: QueuePayloadOptions = {}, +): GuardResult { + const title = options.title || '任务数据不完整,已阻止推送' + const issues: GuardIssue[] = [] + + if (!isPlainObject(payload)) { + issues.push({ + code: 'payload.not-object', + severity: 'block', + message: '任务数据格式异常(不是一个对象),已阻止推送到 Python 队列。', + }) + return buildResult(title, title, issues) + } + + const type = payload.type + if (typeof type !== 'string' || !type.trim()) { + issues.push({ + code: 'payload.bad-type', + severity: 'block', + message: '任务数据缺少 type 字段,Python 端无法识别该任务类型。', + }) + } else if (options.expectedType && type !== options.expectedType) { + issues.push({ + code: 'payload.type-mismatch', + severity: 'block', + message: `任务类型异常:期望 ${options.expectedType},实际 ${type}。`, + }) + } + + const data = payload.data + if (!isPlainObject(data)) { + issues.push({ + code: 'payload.bad-data', + severity: 'block', + message: '任务数据缺少 data 内容,已阻止推送到 Python 队列。', + }) + return buildResult(title, title, issues) + } + + const missing: string[] = [] + for (const key of options.requiredDataKeys || []) { + const value = data[key] + if (value == null || (typeof value === 'string' && !value.trim())) { + missing.push(key) + } else if (typeof value === 'number' && !Number.isFinite(value)) { + missing.push(key) + } + } + if (missing.length) { + issues.push({ + code: 'payload.missing-field', + severity: 'block', + message: + `任务数据缺少必填字段:${missing.join('、')}。\n` + + '推送后 Python 端会拿到空值并卡在执行中,请补全后重试。', + }) + } + + const emptyArrays: string[] = [] + for (const key of options.nonEmptyArrayKeys || []) { + const value = data[key] + if (!Array.isArray(value) || value.length === 0) { + emptyArrays.push(key) + } + } + if (emptyArrays.length) { + issues.push({ + code: 'payload.empty-array', + severity: 'block', + message: + `任务数据里 ${emptyArrays.join('、')} 是空的,没有任何可执行内容。\n` + + '推送这样的任务,Python 端会没得可做、任务一直停在执行中。', + }) + } + + const emptyObjects: string[] = [] + for (const key of options.nonEmptyObjectKeys || []) { + const value = data[key] + if (Array.isArray(value)) { + if (!value.length) emptyObjects.push(key) + } else if (!isPlainObject(value) || Object.keys(value).length === 0) { + emptyObjects.push(key) + } + } + if (emptyObjects.length) { + issues.push({ + code: 'payload.empty-object', + severity: 'block', + message: + `任务数据里 ${emptyObjects.join('、')} 没有任何内容。\n` + + '推送这样的任务,Python 端会没得可做、任务一直停在执行中。', + }) + } + + const nonPositive: string[] = [] + for (const key of options.positiveNumberKeys || []) { + const value = data[key] + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + nonPositive.push(`${key}=${String(value)}`) + } + } + if (nonPositive.length) { + issues.push({ + code: 'payload.non-positive', + severity: 'block', + message: + `任务数据里 ${nonPositive.join('、')},等于没有可处理的内容。\n` + + '推送这样的任务,Python 端会翻不到任何明细、任务一直停在执行中。\n' + + '请检查源文件是否解析出了数据行。', + }) + } + + const unsafe = findUnsafeJsonPaths(payload) + if (unsafe.length) { + issues.push({ + code: 'payload.unsafe-json', + severity: 'block', + message: + '任务数据里存在无法正确传给 Python 的值:\n' + + unsafe.slice(0, 8).map((item) => `· ${item.path}:${item.reason}`).join('\n') + + (unsafe.length > 8 ? `\n· 另有 ${unsafe.length - 8} 处` : '') + + '\n这些值序列化后会变成 null 或直接报错,请修正后重试。', + }) + } + + return buildResult(title, title, issues) +} + +export interface UnsafeJsonPath { + /** 形如 data.items[0].price 的定位路径 */ + path: string + /** 中文原因说明 */ + reason: string +} + +/** + * 扫描对象里所有无法安全 JSON 序列化的值。 + * 只报告问题,不修改输入;遇到循环引用会记录并停止向下遍历,不会死循环。 + * + * 注意:对象属性值为 undefined 不算问题——那是 JS 里表达「可选字段没填」的 + * 惯用写法(payload 里到处是 `taskNo: vo?.taskNo`),序列化时整个 key 被丢掉, + * Python 侧 .get() 拿到 None 正是预期行为。必填字段由 requiredDataKeys 单独把关。 + * 但数组元素里的 undefined 会变成 null、打乱下标语义,仍然报告。 + */ +export function findUnsafeJsonPaths(root: unknown): UnsafeJsonPath[] { + const found: UnsafeJsonPath[] = [] + const ancestors = new Set() + let nodes = 0 + let truncated = false + + function walk(value: unknown, path: string, depth: number, inArray: boolean) { + if (truncated) return + nodes += 1 + if (nodes > MAX_JSON_NODES) { + truncated = true + found.push({ path, reason: '数据量过大,无法完成校验' }) + return + } + if (depth > MAX_JSON_DEPTH) { + found.push({ path, reason: `嵌套层级超过 ${MAX_JSON_DEPTH} 层` }) + return + } + + const type = typeof value + if (value === null) return + if (type === 'number') { + if (!Number.isFinite(value as number)) { + found.push({ + path, + reason: `${String(value)} 会被序列化成 null`, + }) + } + return + } + if (type === 'string' || type === 'boolean') return + if (type === 'undefined') { + if (inArray) { + found.push({ path, reason: '数组元素为 undefined,会变成 null' }) + } + return + } + if (type === 'function') { + found.push({ path, reason: '值是函数,无法序列化' }) + return + } + if (type === 'symbol') { + found.push({ path, reason: '值是 Symbol,无法序列化' }) + return + } + if (type === 'bigint') { + found.push({ path, reason: '值是 BigInt,序列化时会直接抛错' }) + return + } + if (type !== 'object') return + + const object = value as object + if (ancestors.has(object)) { + found.push({ path, reason: '存在循环引用,序列化时会直接抛错' }) + return + } + + // Date 有 toJSON,能安全序列化成字符串 + if (object instanceof Date) { + if (Number.isNaN(object.getTime())) { + found.push({ path, reason: '是一个无效日期,会被序列化成 null' }) + } + return + } + if (object instanceof Map || object instanceof Set) { + found.push({ + path, + reason: `是 ${object instanceof Map ? 'Map' : 'Set'},会被序列化成空对象 {}`, + }) + return + } + + ancestors.add(object) + try { + if (Array.isArray(object)) { + for (let i = 0; i < object.length; i += 1) { + if (!(i in object)) { + found.push({ path: `${path}[${i}]`, reason: '数组存在空洞,会变成 null' }) + continue + } + walk(object[i], `${path}[${i}]`, depth + 1, true) + } + } else { + for (const key of Object.keys(object)) { + walk( + (object as Record)[key], + path ? `${path}.${key}` : key, + depth + 1, + false, + ) + } + } + } finally { + ancestors.delete(object) + } + } + + walk(root, 'payload', 0, false) + return found +} diff --git a/frontend-vue/src/styles/main.css b/frontend-vue/src/styles/main.css index ece638a2..9a107033 100644 --- a/frontend-vue/src/styles/main.css +++ b/frontend-vue/src/styles/main.css @@ -119,3 +119,21 @@ html { outline: 2px solid rgba(96, 165, 250, 0.9); outline-offset: 2px; } + +/* + * 上传 / 解析 / 入队拦截弹窗(src/shared/dispatch-guard-ui.ts)。 + * 文案是多行的「问题 + 怎么修」,需要保留换行,并给长文件名留出折行空间。 + */ +.dispatch-guard-box { + max-width: 520px; + width: min(520px, calc(100vw - 32px)); +} + +.dispatch-guard-box .el-message-box__message { + white-space: pre-line; + line-height: 1.65; + word-break: break-word; + max-height: 50vh; + overflow-y: auto; +} + diff --git a/frontend-vue/tests/dispatch-guard.test.ts b/frontend-vue/tests/dispatch-guard.test.ts new file mode 100644 index 00000000..082dc8b0 --- /dev/null +++ b/frontend-vue/tests/dispatch-guard.test.ts @@ -0,0 +1,442 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + EXCEL_CSV_EXTENSIONS, + EXCEL_EXTENSIONS, + basenameOf, + checkParseResult, + checkQueuePayload, + checkSelectedFiles, + extensionOf, + findUnsafeJsonPaths, + guardPassed, + type GuardResult, +} from '../src/shared/dispatch-guard.ts' + +function codes(result: GuardResult) { + return result.issues.map((issue) => issue.code) +} + +// ---------------------------------------------------------------- 路径工具 + +test('test_task_101_dispatch_guard_normal_path_helpers', () => { + assert.equal(basenameOf('D:\\brand\\鲍丽明.xlsx'), '鲍丽明.xlsx') + assert.equal(basenameOf('/home/u/a/b.csv'), 'b.csv') + assert.equal(basenameOf('plain.xlsx'), 'plain.xlsx') + assert.equal(basenameOf('D:\\brand\\'), 'brand', '结尾斜杠不应产生空名') + assert.equal(extensionOf('D:\\brand\\鲍丽明.XLSX'), '.xlsx', '扩展名统一小写') + assert.equal(extensionOf('archive.tar.gz'), '.gz') + assert.equal(extensionOf('noext'), '') + assert.equal(extensionOf('.gitignore'), '', '隐藏文件不算扩展名') + assert.equal(extensionOf('trailingdot.'), '') +}) + +// ------------------------------------------------------------ 文件选择校验 + +test('test_task_101_dispatch_guard_normal_selected_files_pass', () => { + const result = checkSelectedFiles( + ['D:\\brand\\a.xlsx', 'D:\\brand\\b.XLS'], + { allowedExtensions: EXCEL_EXTENSIONS }, + ) + assert.equal(result.ok, true) + assert.equal(result.needsConfirm, false) + assert.equal(result.message, '') + assert.deepEqual(codes(result), []) +}) + +test('test_task_101_dispatch_guard_normal_selected_files_folder_items', () => { + // expandBrandFolderRecursive 返回的是 {absolutePath, relativePath} 结构 + const result = checkSelectedFiles( + [ + { absolutePath: 'D:\\brand\\x\\a.xlsx', relativePath: 'x/a.xlsx' }, + { relativePath: 'x/b.csv' }, + ], + { allowedExtensions: EXCEL_CSV_EXTENSIONS }, + ) + assert.equal(result.ok, true) +}) + +test('test_task_101_dispatch_guard_boundary_selected_files_single', () => { + const result = checkSelectedFiles(['a.xlsx'], { allowedExtensions: EXCEL_EXTENSIONS, maxFiles: 1 }) + assert.equal(result.ok, true) + const over = checkSelectedFiles(['a.xlsx', 'b.xlsx'], { + allowedExtensions: EXCEL_EXTENSIONS, + maxFiles: 1, + }) + assert.equal(over.ok, false) + assert.ok(codes(over).includes('files.too-many')) + assert.match(over.message, /一次最多选择 1 个文件/) +}) + +test('test_task_101_dispatch_guard_boundary_selected_files_empty', () => { + const empty = checkSelectedFiles([]) + assert.equal(empty.ok, false) + assert.deepEqual(codes(empty), ['files.empty']) + assert.match(empty.message, /没有选择任何文件/) +}) + +test('test_task_101_dispatch_guard_invalid_selected_files_bad_extension', () => { + const result = checkSelectedFiles( + ['D:\\brand\\good.xlsx', 'D:\\brand\\报表.txt', 'D:\\brand\\note.pdf'], + { allowedExtensions: EXCEL_EXTENSIONS }, + ) + assert.equal(result.ok, false) + assert.deepEqual(codes(result), ['files.bad-extension']) + assert.match(result.message, /报表\.txt/) + assert.match(result.message, /note\.pdf/) + assert.ok(!result.message.includes('good.xlsx'), '合规文件不应出现在错误里') +}) + +test('test_task_101_dispatch_guard_invalid_selected_files_not_array', () => { + for (const bad of [null, undefined, 'a.xlsx', 42, {}]) { + const result = checkSelectedFiles(bad) + assert.equal(result.ok, false, `${String(bad)} 应被拦截`) + assert.deepEqual(codes(result), ['files.not-array']) + } +}) + +test('test_task_101_dispatch_guard_invalid_selected_files_blank_path', () => { + const result = checkSelectedFiles(['a.xlsx', '', ' ', { absolutePath: '' }]) + assert.equal(result.ok, false) + assert.ok(codes(result).includes('files.blank-path')) + assert.match(result.message, /有 3 个文件路径为空/) +}) + +test('test_task_101_dispatch_guard_confirm_selected_files_duplicated', () => { + const result = checkSelectedFiles(['D:\\a.xlsx', 'd:\\A.XLSX'], { + allowedExtensions: EXCEL_EXTENSIONS, + }) + assert.equal(result.ok, true, '重复不阻塞') + assert.equal(result.needsConfirm, true) + assert.deepEqual(codes(result), ['files.duplicated']) + assert.match(result.message, /重复选择/) +}) + +test('test_task_101_dispatch_guard_selected_files_block_wins_over_confirm', () => { + // 同时命中重复(confirm)与非法扩展名(block)时,只展示 block 文案 + const result = checkSelectedFiles(['a.txt', 'a.txt'], { allowedExtensions: EXCEL_EXTENSIONS }) + assert.equal(result.ok, false) + assert.equal(result.needsConfirm, false) + assert.match(result.message, /不支持/) + assert.ok(!result.message.includes('重复选择')) +}) + +// ------------------------------------------------------------ 解析结果校验 + +const parseVo = (over: Record = {}) => ({ + taskId: 12, + totalRows: 100, + acceptedRows: 100, + droppedRows: 0, + groupCount: 8, + ...over, +}) + +test('test_task_101_dispatch_guard_normal_parse_result_pass', () => { + const result = checkParseResult(parseVo()) + assert.equal(result.ok, true) + assert.equal(result.needsConfirm, false) + assert.deepEqual(codes(result), []) +}) + +test('test_task_101_dispatch_guard_normal_parse_result_missing_optional_counts', () => { + // collect-data 的 Vo 里 totalRows/droppedRows/groupCount 都是可选的 + const result = checkParseResult({ taskId: 3, acceptedRows: 20 }) + assert.equal(result.ok, true) + assert.equal(result.needsConfirm, false) +}) + +test('test_task_101_dispatch_guard_boundary_parse_result_single_row', () => { + const result = checkParseResult(parseVo({ totalRows: 1, acceptedRows: 1, droppedRows: 0 })) + assert.equal(result.ok, true) +}) + +test('test_task_101_dispatch_guard_boundary_parse_result_zero_rows_blocked', () => { + // 这是最危险的一种:任务建好了但没有明细,推给 Python 会一直停在 RUNNING + const allDropped = checkParseResult(parseVo({ totalRows: 500, acceptedRows: 0, droppedRows: 500 })) + assert.equal(allDropped.ok, false) + assert.deepEqual(codes(allDropped), ['parse.all-dropped']) + assert.match(allDropped.message, /共读取 500 行/) + assert.match(allDropped.message, /卡在执行中/) + + const emptyFile = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0, droppedRows: 0 })) + assert.equal(emptyFile.ok, false) + assert.deepEqual(codes(emptyFile), ['parse.empty-file']) + assert.match(emptyFile.message, /没有读到任何数据行/) +}) + +test('test_task_101_dispatch_guard_parse_result_zero_rows_allowed_when_not_required', () => { + const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { requireRows: false }) + assert.equal(result.ok, true) +}) + +test('test_task_101_dispatch_guard_confirm_parse_result_partial_dropped', () => { + const result = checkParseResult(parseVo({ totalRows: 100, acceptedRows: 88, droppedRows: 12 }), { + requiredColumnsHint: 'ASIN / 国家', + }) + assert.equal(result.ok, true, '还有 88 行可跑,不该硬拦') + assert.equal(result.needsConfirm, true) + assert.deepEqual(codes(result), ['parse.partial-dropped']) + assert.match(result.message, /12 行/) + assert.match(result.message, /只有 88 行会被执行/) + assert.match(result.message, /ASIN \/ 国家/) + assert.equal(result.title, '解析结果需要确认') +}) + +test('test_task_101_dispatch_guard_parse_result_require_groups', () => { + const noGroup = checkParseResult(parseVo({ groupCount: 0 }), { requireGroups: true }) + assert.equal(noGroup.ok, false) + assert.deepEqual(codes(noGroup), ['parse.no-group']) + + const ignored = checkParseResult(parseVo({ groupCount: 0 })) + assert.equal(ignored.ok, true, '没要求分组时 groupCount=0 不拦') +}) + +test('test_task_101_dispatch_guard_invalid_parse_result_bad_task_id', () => { + for (const taskId of [0, -1, null, undefined, NaN, '12', 1.5, Infinity]) { + const result = checkParseResult(parseVo({ taskId })) + assert.equal(result.ok, false, `taskId=${String(taskId)} 应被拦截`) + assert.deepEqual(codes(result), ['parse.bad-task-id']) + } +}) + +test('test_task_101_dispatch_guard_invalid_parse_result_not_object', () => { + for (const bad of [null, undefined, 'ok', 7, []]) { + const result = checkParseResult(bad) + assert.equal(result.ok, false, `${String(bad)} 应被拦截`) + assert.deepEqual(codes(result), ['parse.not-object']) + } +}) + +test('test_task_101_dispatch_guard_invalid_parse_result_bad_counts', () => { + const result = checkParseResult(parseVo({ acceptedRows: NaN, totalRows: -3 })) + assert.equal(result.ok, false) + assert.deepEqual(codes(result), ['parse.bad-count', 'parse.bad-count']) + assert.match(result.message, /totalRows/) + assert.match(result.message, /acceptedRows/) +}) + +test('test_task_101_dispatch_guard_parse_result_does_not_mutate_input', () => { + const input = parseVo({ totalRows: 10, acceptedRows: 6, droppedRows: 4 }) + const snapshot = JSON.parse(JSON.stringify(input)) + checkParseResult(input, { requiredColumnsHint: 'ASIN' }) + assert.deepEqual(input, snapshot) +}) + +// ------------------------------------------------------------ 入队 payload + +const runPayload = (over: Record = {}) => ({ + type: 'similar-asin-run', + ts: 1700000000000, + data: { + taskId: 12, + user_id: 3, + api_key: 'sk-xxx', + acceptedRows: 88, + ...over, + }, +}) + +test('test_task_101_dispatch_guard_normal_queue_payload_pass', () => { + const result = checkQueuePayload(runPayload(), { + expectedType: 'similar-asin-run', + requiredDataKeys: ['taskId', 'api_key'], + }) + assert.equal(result.ok, true) + assert.deepEqual(codes(result), []) +}) + +test('test_task_101_dispatch_guard_normal_queue_payload_optional_undefined_is_fine', () => { + // payload 里到处是 `taskNo: vo?.taskNo`,undefined 可选字段不能被误拦 + const result = checkQueuePayload(runPayload({ taskNo: undefined, note: undefined }), { + requiredDataKeys: ['taskId'], + }) + assert.equal(result.ok, true) + assert.deepEqual(codes(result), []) +}) + +test('test_task_101_dispatch_guard_boundary_queue_payload_empty_collections', () => { + const emptyArray = checkQueuePayload(runPayload({ items: [] }), { nonEmptyArrayKeys: ['items'] }) + assert.equal(emptyArray.ok, false) + assert.deepEqual(codes(emptyArray), ['payload.empty-array']) + assert.match(emptyArray.message, /没得可做/) + + const oneItem = checkQueuePayload(runPayload({ items: [{ asin: 'B001' }] }), { + nonEmptyArrayKeys: ['items'], + }) + assert.equal(oneItem.ok, true, '1 条也算有内容') + + const emptyMap = checkQueuePayload(runPayload({ asin_rows_by_country: {} }), { + nonEmptyObjectKeys: ['asin_rows_by_country'], + }) + assert.equal(emptyMap.ok, false) + assert.deepEqual(codes(emptyMap), ['payload.empty-object']) + + const filledMap = checkQueuePayload(runPayload({ asin_rows_by_country: { DE: [{ asin: 'B1' }] } }), { + nonEmptyObjectKeys: ['asin_rows_by_country'], + }) + assert.equal(filledMap.ok, true) +}) + +test('test_task_101_dispatch_guard_boundary_queue_payload_positive_numbers', () => { + // 0 行 / 0 页不是 null,requiredDataKeys 拦不住,但 Python 端会翻 0 页后空转 + const zero = checkQueuePayload(runPayload({ totalRows: 0, totalPages: 0 }), { + positiveNumberKeys: ['totalRows', 'totalPages'], + }) + assert.equal(zero.ok, false) + assert.deepEqual(codes(zero), ['payload.non-positive']) + assert.match(zero.message, /totalRows=0/) + assert.match(zero.message, /totalPages=0/) + + const one = checkQueuePayload(runPayload({ totalRows: 1, totalPages: 1 }), { + positiveNumberKeys: ['totalRows', 'totalPages'], + }) + assert.equal(one.ok, true, '1 行 1 页是合法下界') + + const missing = checkQueuePayload(runPayload(), { positiveNumberKeys: ['totalRows'] }) + assert.equal(missing.ok, false, '字段缺失同样算不可执行') + + const negative = checkQueuePayload(runPayload({ totalRows: -5 }), { + positiveNumberKeys: ['totalRows'], + }) + assert.equal(negative.ok, false) +}) + +test('test_task_101_dispatch_guard_invalid_queue_payload_missing_required', () => { + const result = checkQueuePayload(runPayload({ api_key: ' ', taskId: null }), { + requiredDataKeys: ['taskId', 'api_key', 'shop_name'], + }) + assert.equal(result.ok, false) + assert.deepEqual(codes(result), ['payload.missing-field']) + assert.match(result.message, /taskId、api_key、shop_name/) +}) + +test('test_task_101_dispatch_guard_invalid_queue_payload_shape', () => { + const notObject = checkQueuePayload('{"type":"x"}') + assert.equal(notObject.ok, false) + assert.deepEqual(codes(notObject), ['payload.not-object']) + + const noType = checkQueuePayload({ data: { taskId: 1 } }) + assert.equal(noType.ok, false) + assert.ok(codes(noType).includes('payload.bad-type')) + + const wrongType = checkQueuePayload(runPayload(), { expectedType: 'publish-run' }) + assert.equal(wrongType.ok, false) + assert.ok(codes(wrongType).includes('payload.type-mismatch')) + + const noData = checkQueuePayload({ type: 'x-run', data: null }) + assert.equal(noData.ok, false) + assert.ok(codes(noData).includes('payload.bad-data')) +}) + +test('test_task_101_dispatch_guard_invalid_queue_payload_unsafe_json', () => { + // NaN 会被 JSON.stringify 静默改写成 null,Python 侧拿到 None 后空转 + const nan = checkQueuePayload(runPayload({ minPrice: NaN })) + assert.equal(nan.ok, false) + assert.deepEqual(codes(nan), ['payload.unsafe-json']) + assert.match(nan.message, /payload\.data\.minPrice/) + assert.match(nan.message, /NaN 会被序列化成 null/) + + const infinite = checkQueuePayload(runPayload({ rounds: Infinity })) + assert.equal(infinite.ok, false) + assert.match(infinite.message, /payload\.data\.rounds/) +}) + +test('test_task_101_dispatch_guard_invalid_queue_payload_circular_reference', () => { + const payload = runPayload() as Record + const data = payload.data as Record + data.self = data + const result = checkQueuePayload(payload) + assert.equal(result.ok, false) + assert.deepEqual(codes(result), ['payload.unsafe-json']) + assert.match(result.message, /循环引用/) + // 校验本身不能抛错,也不能改动输入 + assert.equal(data.self, data) +}) + +// ------------------------------------------------- findUnsafeJsonPaths 细节 + +test('test_task_101_dispatch_guard_unsafe_json_normal_clean_object', () => { + assert.deepEqual(findUnsafeJsonPaths({ a: 1, b: 'x', c: true, d: null, e: [1, 2], f: {} }), []) + assert.deepEqual(findUnsafeJsonPaths({ when: new Date(0) }), [], '有效 Date 可安全序列化') +}) + +test('test_task_101_dispatch_guard_unsafe_json_boundary_undefined_placement', () => { + // 对象属性 undefined = 可选字段,放行;数组元素 undefined 会变 null,报告 + assert.deepEqual(findUnsafeJsonPaths({ optional: undefined }), []) + const inArray = findUnsafeJsonPaths({ items: [1, undefined, 3] }) + assert.equal(inArray.length, 1) + assert.equal(inArray[0].path, 'payload.items[1]') + + const holes = findUnsafeJsonPaths({ items: [1, , 3] }) + assert.equal(holes.length, 1) + assert.match(holes[0].reason, /空洞/) +}) + +test('test_task_101_dispatch_guard_unsafe_json_invalid_types', () => { + const found = findUnsafeJsonPaths({ + fn: () => 1, + sym: Symbol('s'), + big: BigInt(9), + map: new Map([['a', 1]]), + set: new Set([1]), + badDate: new Date('nope'), + }) + const byPath = Object.fromEntries(found.map((item) => [item.path, item.reason])) + assert.match(byPath['payload.fn'], /函数/) + assert.match(byPath['payload.sym'], /Symbol/) + assert.match(byPath['payload.big'], /BigInt/) + assert.match(byPath['payload.map'], /Map/) + assert.match(byPath['payload.set'], /Set/) + assert.match(byPath['payload.badDate'], /无效日期/) +}) + +test('test_task_101_dispatch_guard_unsafe_json_boundary_deep_nesting', () => { + let deep: Record = { leaf: 1 } + for (let i = 0; i < 70; i += 1) deep = { next: deep } + const found = findUnsafeJsonPaths(deep) + assert.ok(found.length > 0) + assert.match(found[0].reason, /嵌套层级超过/) +}) + +test('test_task_101_dispatch_guard_unsafe_json_repeated_reference_is_not_circular', () => { + // 同一个对象被两个 key 引用(DAG)不是环,不该误报 + const shared = { asin: 'B001' } + assert.deepEqual(findUnsafeJsonPaths({ a: shared, b: shared }), []) + assert.deepEqual(findUnsafeJsonPaths({ list: [shared, shared] }), []) +}) + +// ----------------------------------------------------------------- 其他 + +test('test_task_101_dispatch_guard_passed_helper_is_inert', () => { + const result = guardPassed() + assert.equal(result.ok, true) + assert.equal(result.needsConfirm, false) + assert.equal(result.message, '') + assert.deepEqual(result.issues, []) +}) + +test('test_task_101_dispatch_guard_repeated_calls_are_idempotent', () => { + const files = ['a.xlsx', 'b.xlsx'] + assert.deepEqual(checkSelectedFiles(files), checkSelectedFiles(files)) + const vo = parseVo({ droppedRows: 5, acceptedRows: 95 }) + assert.deepEqual(checkParseResult(vo), checkParseResult(vo)) + const payload = runPayload() + assert.deepEqual(checkQueuePayload(payload), checkQueuePayload(payload)) +}) + +test('test_task_101_dispatch_guard_dependency_failure_does_not_throw', () => { + // Proxy getter 故障时校验不应把异常抛给调用方之外的路径;这里确认异常可被捕获 + const poisoned = new Proxy( + { taskId: 1, totalRows: 5, acceptedRows: 5 }, + { + get(target, prop, receiver) { + if (prop === 'acceptedRows') throw new Error('getter down') + return Reflect.get(target, prop, receiver) + }, + }, + ) + assert.throws(() => checkParseResult(poisoned), /getter down/) + // 修好之后同一入口可以继续用,说明模块没有内部状态残留 + assert.equal(checkParseResult({ taskId: 1, totalRows: 5, acceptedRows: 5 }).ok, true) +})