新增 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:
@@ -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
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -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<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 就等于没活干的字段——
|
||||
* 它们不是 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<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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 页不是 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<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)
|
||||
})
|
||||
Reference in New Issue
Block a user