/** * 上传 / 解析 / 入队三道闸(Task 101)。 * * 现状:各 Tab 选完文件直接 upload_file_to_java,解析成功就把 taskId 交给 * pushToPythonQueue,enqueue_json 之前只判断 taskId 是否为真值。于是 * 「表头对不上导致 0 条有效行」「country_codes 为空」「payload 里混进 NaN」 * 这类问题都会被原样推给 Python 自动化——Python 端拿到空任务后既不报错也 * 没得可做,任务永远停在 RUNNING,前端就一直轮询,表现为「卡住」。 * * 本模块把这三处收敛成统一校验,全部在界面侧拦截: * - checkSelectedFiles:文件选完、上传前,挡掉空选择 / 非法扩展名 / 超量。 * - checkParseResult:Java 解析返回后,挡掉无效 taskId 与 0 有效行; * 部分行被丢弃降级为 confirm,由用户决定是否继续。 * - checkQueuePayload:enqueue_json 前的最后一道闸,挡掉缺字段、空数组, * 以及 JSON.stringify 会静默改写或直接抛错的值(NaN/Infinity → null、 * BigInt → 抛错、循环引用 → 抛错),这些到了 Python 侧就是 None。 * * 纯 TS 无副作用模块:不引入 Vue / Element Plus,输入对象永不修改,任何输入 * 都返回 GuardResult 而不抛错(弹窗文案由 dispatch-guard-ui 负责渲染)。 */ /** block:必须修数据,禁止继续;confirm:可疑但可继续,需用户确认 */ export type GuardSeverity = 'block' | 'confirm' export interface GuardIssue { /** 稳定的问题码,便于日志与测试断言 */ code: string /** 面向用户的中文说明 */ message: string severity: GuardSeverity } export interface GuardResult { /** 没有任何 block 级问题 */ ok: boolean /** 存在 confirm 级问题,需要用户点确认才能继续 */ needsConfirm: boolean /** 模态框标题 */ title: string /** 汇总文案,多条问题按换行拼接 */ message: string issues: GuardIssue[] } /** 常用扩展名白名单 */ export const EXCEL_EXTENSIONS = ['.xlsx', '.xls'] as const export const EXCEL_CSV_EXTENSIONS = ['.xlsx', '.xls', '.csv'] as const /** JSON 嵌套深度上限,超过基本可以断定是环或异常结构 */ const MAX_JSON_DEPTH = 64 /** 单次扫描的节点数上限,避免超大 payload 卡住 UI 线程 */ const MAX_JSON_NODES = 200_000 function buildResult( blockTitle: string, confirmTitle: string, issues: GuardIssue[], ): GuardResult { const blocking = issues.filter((issue) => issue.severity === 'block') const confirming = issues.filter((issue) => issue.severity === 'confirm') // 有 block 时只展示 block,避免把「可继续」的提示和「不可继续」的混在一个弹窗里 const shown = blocking.length ? blocking : confirming return { ok: blocking.length === 0, needsConfirm: blocking.length === 0 && confirming.length > 0, title: blocking.length ? blockTitle : confirmTitle, message: shown.map((issue) => issue.message).join('\n'), issues, } } /** 一个恒定放行的结果,供调用方在无需校验时占位 */ export function guardPassed(): GuardResult { return buildResult('', '', []) } /** * 手工构造一个 block 级结果,用于那些不属于文件/解析/payload 三类、 * 但同样会让 Python 端空转的前置条件(例如「一个国家都没选」)。 */ export function guardBlocked( title: string, message: string, code = 'precondition.failed', ): GuardResult { return buildResult(title, title, [{ code, severity: 'block', message }]) } function isPlainObject(value: unknown): value is Record { if (typeof value !== 'object' || value === null) return false if (Array.isArray(value)) return false const proto = Object.getPrototypeOf(value) return proto === Object.prototype || proto === null } function isNonNegativeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0 } function isPositiveInteger(value: unknown): value is number { return isNonNegativeInteger(value) && value > 0 } /** 从本地路径里取出文件名,同时兼容 Windows 反斜杠与 POSIX 斜杠 */ export function basenameOf(path: string): string { const segments = String(path).split(/[/\\]/) for (let i = segments.length - 1; i >= 0; i -= 1) { if (segments[i]) return segments[i] } return '' } /** 取小写扩展名(含点);无扩展名返回空串 */ export function extensionOf(path: string): string { const name = basenameOf(path) const dot = name.lastIndexOf('.') if (dot <= 0 || dot === name.length - 1) return '' return name.slice(dot).toLowerCase() } export interface SelectedFilesOptions { /** 允许的扩展名(小写含点);不传表示不限制 */ allowedExtensions?: readonly string[] /** 单次允许选择的最大文件数 */ maxFiles?: number /** 弹窗标题 */ title?: string } /** 选择结果里的一项:本地绝对路径字符串,或 expandBrandFolderRecursive 返回的条目 */ export type SelectedFileInput = | string | { absolutePath?: string relativePath?: string } function resolveSelectedPath(item: SelectedFileInput): string { if (typeof item === 'string') return item.trim() if (typeof item === 'object' && item !== null) { const record = item as { absolutePath?: unknown; relativePath?: unknown } if (typeof record.absolutePath === 'string' && record.absolutePath.trim()) { return record.absolutePath.trim() } if (typeof record.relativePath === 'string' && record.relativePath.trim()) { return record.relativePath.trim() } } return '' } /** * 文件选择校验:在 upload_file_to_java 之前跑,挡掉根本不该上传的输入。 * 扩展名不匹配一律 block —— 把 .txt 改名成 .xlsx 交给 Java 解析只会得到 * 一个语焉不详的后端错误,不如在选择时就说清楚。 */ export function checkSelectedFiles( paths: unknown, options: SelectedFilesOptions = {}, ): GuardResult { const blockTitle = options.title || '文件选择有问题' const confirmTitle = '选择的文件需要确认' const issues: GuardIssue[] = [] if (!Array.isArray(paths)) { issues.push({ code: 'files.not-array', severity: 'block', message: '没有拿到文件列表,请重新选择文件。', }) return buildResult(blockTitle, confirmTitle, issues) } if (!paths.length) { issues.push({ code: 'files.empty', severity: 'block', message: '没有选择任何文件,请先选择要处理的文件。', }) return buildResult(blockTitle, confirmTitle, issues) } const maxFiles = options.maxFiles if (maxFiles != null && maxFiles > 0 && paths.length > maxFiles) { issues.push({ code: 'files.too-many', severity: 'block', message: `一次最多选择 ${maxFiles} 个文件,当前选了 ${paths.length} 个,请分批处理。`, }) } const allowed = options.allowedExtensions ? options.allowedExtensions.map((ext) => ext.toLowerCase()) : null const invalidNames: string[] = [] let blankCount = 0 const seen = new Set() const duplicated: string[] = [] for (const item of paths as SelectedFileInput[]) { const resolved = resolveSelectedPath(item) if (!resolved) { blankCount += 1 continue } const key = resolved.toLowerCase() if (seen.has(key)) { const name = basenameOf(resolved) if (!duplicated.includes(name)) duplicated.push(name) } else { seen.add(key) } if (allowed) { const ext = extensionOf(resolved) if (!allowed.includes(ext)) { const name = basenameOf(resolved) || resolved if (!invalidNames.includes(name)) invalidNames.push(name) } } } if (blankCount) { issues.push({ code: 'files.blank-path', severity: 'block', message: `有 ${blankCount} 个文件路径为空,无法上传,请重新选择。`, }) } if (invalidNames.length && allowed) { issues.push({ code: 'files.bad-extension', severity: 'block', message: `以下文件类型不支持:${formatNameList(invalidNames)}\n` + `当前只接受 ${allowed.join(' / ')} 格式,请转换后重新选择。`, }) } if (duplicated.length) { issues.push({ code: 'files.duplicated', severity: 'confirm', message: `以下文件被重复选择,会被处理多次:${formatNameList(duplicated)}\n确认继续吗?`, }) } return buildResult(blockTitle, confirmTitle, issues) } function formatNameList(names: string[], limit = 5): string { if (names.length <= limit) return names.join('、') return `${names.slice(0, limit).join('、')} 等 ${names.length} 个` } /** 各模块 parse 接口返回的公共统计字段 */ export interface ParseOutcome { taskId?: unknown totalRows?: unknown acceptedRows?: unknown droppedRows?: unknown groupCount?: unknown sourceFilename?: unknown } export interface ParseResultOptions { /** 该模块必须解析出分组才能执行(如 appearance-patent 按主 ID 分组) */ requireGroups?: boolean /** 必须解析出有效行;默认 true */ requireRows?: boolean /** 必要字段名,用于拼「缺什么」的提示,如 'ASIN / 国家' */ requiredColumnsHint?: string title?: string } /** * 解析结果校验:Java 返回后、把 taskId 交给推送按钮之前跑。 * * 0 有效行是最危险的情况——任务在库里建好了,推给 Python 后没有任何明细可 * 执行,任务会一直停在 RUNNING。这里一律 block。 * 部分行被丢弃则降级成 confirm:日常确实存在「几行没 ASIN 也照跑」的用法, * 但要让用户明确知道少了多少行。 */ export function checkParseResult( result: unknown, options: ParseResultOptions = {}, ): GuardResult { const blockTitle = options.title || '解析结果无法执行' const confirmTitle = '解析结果需要确认' const issues: GuardIssue[] = [] if (!isPlainObject(result)) { issues.push({ code: 'parse.not-object', severity: 'block', message: '后端没有返回解析结果,请重新解析。', }) return buildResult(blockTitle, confirmTitle, issues) } const vo = result as ParseOutcome if (!isPositiveInteger(vo.taskId)) { issues.push({ code: 'parse.bad-task-id', severity: 'block', message: `后端未返回有效任务标识(taskId=${String(vo.taskId)}),请重新解析。`, }) // taskId 都不对,后面的行数统计没有讨论价值 return buildResult(blockTitle, confirmTitle, issues) } const totalRows = normalizeCount(vo.totalRows) const acceptedRows = normalizeCount(vo.acceptedRows) const droppedRows = normalizeCount(vo.droppedRows) const groupCount = normalizeCount(vo.groupCount) for (const [field, raw, parsed] of [ ['totalRows', vo.totalRows, totalRows], ['acceptedRows', vo.acceptedRows, acceptedRows], ['droppedRows', vo.droppedRows, droppedRows], ['groupCount', vo.groupCount, groupCount], ] as const) { if (raw != null && parsed == null) { issues.push({ code: 'parse.bad-count', severity: 'block', message: `解析结果字段 ${field} 异常(${String(raw)}),无法判断数据量,请重新解析。`, }) } } if (issues.length) return buildResult(blockTitle, confirmTitle, issues) const requireRows = options.requireRows !== false const columnsHint = options.requiredColumnsHint ? `(必要字段:${options.requiredColumnsHint})` : '' if (requireRows && acceptedRows === 0) { if (!totalRows) { issues.push({ code: 'parse.empty-file', severity: 'block', message: '文件里没有读到任何数据行。\n' + '请确认文件不是空表、数据不在隐藏的其他 Sheet 里,然后重新上传。', }) } else { issues.push({ code: 'parse.all-dropped', severity: 'block', message: `共读取 ${totalRows} 行,但没有一行包含完整的必要字段${columnsHint},全部被丢弃。\n` + '推送这样的任务会让 Python 端拿不到任何可执行明细、任务一直卡在执行中。\n' + '请检查表头列名是否与模板一致,修正后重新上传解析。', }) } return buildResult(blockTitle, confirmTitle, issues) } if (options.requireGroups && groupCount === 0) { issues.push({ code: 'parse.no-group', severity: 'block', message: '解析出了数据行,但没有生成任何分组,Python 端会没有可执行的批次。\n' + '请检查主 ID 列是否填写正确,修正后重新解析。', }) return buildResult(blockTitle, confirmTitle, issues) } if (droppedRows && droppedRows > 0) { const accepted = acceptedRows ?? 0 const total = totalRows || accepted + droppedRows issues.push({ code: 'parse.partial-dropped', severity: 'confirm', message: `共 ${total} 行,其中 ${droppedRows} 行因缺少必要字段${columnsHint}被丢弃,` + `只有 ${accepted} 行会被执行。\n` + `确认按这 ${accepted} 行继续吗?`, }) } return buildResult(blockTitle, confirmTitle, issues) } function normalizeCount(value: unknown): number | null { if (value == null) return null if (isNonNegativeInteger(value)) return value return null } export interface QueuePayloadOptions { /** data 下必须存在且非空的字段名 */ requiredDataKeys?: readonly string[] /** data 下必须是非空数组的字段名 */ nonEmptyArrayKeys?: readonly string[] /** data 下必须是非空对象(或非空数组)的字段名 */ nonEmptyObjectKeys?: readonly string[] /** * data 下必须是正数的字段名。用于「行数 / 页数」这类为 0 就等于没活干的字段—— * 它们不是 null,requiredDataKeys 拦不住,但 0 会让 Python 端翻 0 页后空转。 */ positiveNumberKeys?: readonly string[] /** 期望的 type 值;不匹配时 block */ expectedType?: string title?: string } /** * 入队 payload 校验:api.enqueue_json(payload) 之前的最后一道闸。 * * 除了业务字段,这里重点扫 JSON 安全性。pywebview 桥接会对 payload 做 * JSON 序列化,而 JSON.stringify 对几类值的处理是「静默改写」而不是报错: * NaN / Infinity 变成 null,undefined 属性被丢掉,数组里的 undefined 变 * null。Python 侧 payload["data"]["taskId"] 于是成了 None,自动化脚本要么 * 在某个循环里空转,要么抛异常被吞掉——两种都表现为任务卡住。 */ export function checkQueuePayload( payload: unknown, options: QueuePayloadOptions = {}, ): GuardResult { const title = options.title || '任务数据不完整,已阻止推送' const issues: GuardIssue[] = [] if (!isPlainObject(payload)) { issues.push({ code: 'payload.not-object', severity: 'block', message: '任务数据格式异常(不是一个对象),已阻止推送到 Python 队列。', }) return buildResult(title, title, issues) } const type = payload.type if (typeof type !== 'string' || !type.trim()) { issues.push({ code: 'payload.bad-type', severity: 'block', message: '任务数据缺少 type 字段,Python 端无法识别该任务类型。', }) } else if (options.expectedType && type !== options.expectedType) { issues.push({ code: 'payload.type-mismatch', severity: 'block', message: `任务类型异常:期望 ${options.expectedType},实际 ${type}。`, }) } const data = payload.data if (!isPlainObject(data)) { issues.push({ code: 'payload.bad-data', severity: 'block', message: '任务数据缺少 data 内容,已阻止推送到 Python 队列。', }) return buildResult(title, title, issues) } const missing: string[] = [] for (const key of options.requiredDataKeys || []) { const value = data[key] if (value == null || (typeof value === 'string' && !value.trim())) { missing.push(key) } else if (typeof value === 'number' && !Number.isFinite(value)) { missing.push(key) } } if (missing.length) { issues.push({ code: 'payload.missing-field', severity: 'block', message: `任务数据缺少必填字段:${missing.join('、')}。\n` + '推送后 Python 端会拿到空值并卡在执行中,请补全后重试。', }) } const emptyArrays: string[] = [] for (const key of options.nonEmptyArrayKeys || []) { const value = data[key] if (!Array.isArray(value) || value.length === 0) { emptyArrays.push(key) } } if (emptyArrays.length) { issues.push({ code: 'payload.empty-array', severity: 'block', message: `任务数据里 ${emptyArrays.join('、')} 是空的,没有任何可执行内容。\n` + '推送这样的任务,Python 端会没得可做、任务一直停在执行中。', }) } const emptyObjects: string[] = [] for (const key of options.nonEmptyObjectKeys || []) { const value = data[key] if (Array.isArray(value)) { if (!value.length) emptyObjects.push(key) } else if (!isPlainObject(value) || Object.keys(value).length === 0) { emptyObjects.push(key) } } if (emptyObjects.length) { issues.push({ code: 'payload.empty-object', severity: 'block', message: `任务数据里 ${emptyObjects.join('、')} 没有任何内容。\n` + '推送这样的任务,Python 端会没得可做、任务一直停在执行中。', }) } const nonPositive: string[] = [] for (const key of options.positiveNumberKeys || []) { const value = data[key] if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { nonPositive.push(`${key}=${String(value)}`) } } if (nonPositive.length) { issues.push({ code: 'payload.non-positive', severity: 'block', message: `任务数据里 ${nonPositive.join('、')},等于没有可处理的内容。\n` + '推送这样的任务,Python 端会翻不到任何明细、任务一直停在执行中。\n' + '请检查源文件是否解析出了数据行。', }) } const unsafe = findUnsafeJsonPaths(payload) if (unsafe.length) { issues.push({ code: 'payload.unsafe-json', severity: 'block', message: '任务数据里存在无法正确传给 Python 的值:\n' + unsafe.slice(0, 8).map((item) => `· ${item.path}:${item.reason}`).join('\n') + (unsafe.length > 8 ? `\n· 另有 ${unsafe.length - 8} 处` : '') + '\n这些值序列化后会变成 null 或直接报错,请修正后重试。', }) } return buildResult(title, title, issues) } export interface UnsafeJsonPath { /** 形如 data.items[0].price 的定位路径 */ path: string /** 中文原因说明 */ reason: string } /** * 扫描对象里所有无法安全 JSON 序列化的值。 * 只报告问题,不修改输入;遇到循环引用会记录并停止向下遍历,不会死循环。 * * 注意:对象属性值为 undefined 不算问题——那是 JS 里表达「可选字段没填」的 * 惯用写法(payload 里到处是 `taskNo: vo?.taskNo`),序列化时整个 key 被丢掉, * Python 侧 .get() 拿到 None 正是预期行为。必填字段由 requiredDataKeys 单独把关。 * 但数组元素里的 undefined 会变成 null、打乱下标语义,仍然报告。 */ export function findUnsafeJsonPaths(root: unknown): UnsafeJsonPath[] { const found: UnsafeJsonPath[] = [] const ancestors = new Set() let nodes = 0 let truncated = false function walk(value: unknown, path: string, depth: number, inArray: boolean) { if (truncated) return nodes += 1 if (nodes > MAX_JSON_NODES) { truncated = true found.push({ path, reason: '数据量过大,无法完成校验' }) return } if (depth > MAX_JSON_DEPTH) { found.push({ path, reason: `嵌套层级超过 ${MAX_JSON_DEPTH} 层` }) return } const type = typeof value if (value === null) return if (type === 'number') { if (!Number.isFinite(value as number)) { found.push({ path, reason: `${String(value)} 会被序列化成 null`, }) } return } if (type === 'string' || type === 'boolean') return if (type === 'undefined') { if (inArray) { found.push({ path, reason: '数组元素为 undefined,会变成 null' }) } return } if (type === 'function') { found.push({ path, reason: '值是函数,无法序列化' }) return } if (type === 'symbol') { found.push({ path, reason: '值是 Symbol,无法序列化' }) return } if (type === 'bigint') { found.push({ path, reason: '值是 BigInt,序列化时会直接抛错' }) return } if (type !== 'object') return const object = value as object if (ancestors.has(object)) { found.push({ path, reason: '存在循环引用,序列化时会直接抛错' }) return } // Date 有 toJSON,能安全序列化成字符串 if (object instanceof Date) { if (Number.isNaN(object.getTime())) { found.push({ path, reason: '是一个无效日期,会被序列化成 null' }) } return } if (object instanceof Map || object instanceof Set) { found.push({ path, reason: `是 ${object instanceof Map ? 'Map' : 'Set'},会被序列化成空对象 {}`, }) return } ancestors.add(object) try { if (Array.isArray(object)) { for (let i = 0; i < object.length; i += 1) { if (!(i in object)) { found.push({ path: `${path}[${i}]`, reason: '数组存在空洞,会变成 null' }) continue } walk(object[i], `${path}[${i}]`, depth + 1, true) } } else { for (const key of Object.keys(object)) { walk( (object as Record)[key], path ? `${path}.${key}` : key, depth + 1, false, ) } } } finally { ancestors.delete(object) } } walk(root, 'payload', 0, false) return found }