Files
crawler-plugin/frontend-vue/src/shared/dispatch-guard.ts
T
huangzd1997 759f0b15d8
Build Backend JAR / build (push) Has been cancelled
task-101: 上传/解析/入队三道前端拦截,阻止坏数据卡死 Python 自动化
新增 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_* 用例。
2026-09-01 12:53:12 +08:00

662 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 上传 / 解析 / 入队三道闸(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 变
* 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
}