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, {
@@ -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<boolean> {
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
}
+661
View File
@@ -0,0 +1,661 @@
/**
* / / Task 101
*
* Tab upload_file_to_java taskId
* pushToPythonQueueenqueue_json taskId
* 0 country_codes payload NaN
* Python Python
* RUNNING
*
*
* - checkSelectedFiles / /
* - checkParseResultJava taskId 0
* confirm
* - checkQueuePayloadenqueue_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<string, unknown> {
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<string>()
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
* nullrequiredDataKeys 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 nullundefined undefined
* nullPython 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<object>()
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<string, unknown>)[key],
path ? `${path}.${key}` : key,
depth + 1,
false,
)
}
}
} finally {
ancestors.delete(object)
}
}
walk(root, 'payload', 0, false)
return found
}
+18
View File
@@ -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;
}
+442
View File
@@ -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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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 页不是 nullrequiredDataKeys 拦不住,但 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 静默改写成 nullPython 侧拿到 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<string, unknown>
const data = payload.data as Record<string, unknown>
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<string, unknown> = { 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)
})