759f0b15d8
Build Backend JAR / build (push) Has been cancelled
新增 shared/dispatch-guard.ts(纯 TS 校验,可单测)与 dispatch-guard-ui.ts
(ElMessageBox 弹窗层),在数据流的三个位置设闸:
- 选文件:空选择、非 xlsx/xls/csv、空路径直接拦;重复文件弹确认
- 解析结果:taskId 非法、totalRows 为 0、整批行被丢弃、需要分组却无分组直接拦;
部分行被丢弃弹确认后才允许推送
- 入队前:结构不符、必填字段缺失、items/groups/country_codes 为空数组、
行数页数为 0、JSON 不安全值(NaN/Infinity/BigInt/循环引用/数组洞)
覆盖 11 个推 Python 队列的 Tab 与 dedupe/split/convert 三个纯 Java Tab。
闸位按 Tab 挑选以免留下孤儿后端状态:collect-data 校验前置到
activateCollectDataTask 之前(任务保持 PENDING 无需回滚)、product-risk 的国家
检查提到建任务之前;已建任务后才拦下的复用各 Tab 原有失败补偿路径。
两处边界:对象属性 undefined 放行(taskNo 这类可选字段是惯用写法),只拦数组
元素 undefined 与数组洞;price-track 的 asin_rows_by_country 仅在 mode=asin
时要求非空,status 模式下 loadAsinRowsForAppClient 本就返回 {}。
顺带修复 similar-asin / appearance-patent 的 selectFiles 缺少 try/catch,
上传失败会残留上一批文件与解析结果。
测试:tests/dispatch-guard.test.ts 新增 37 个 test_task_101_* 用例。
662 lines
22 KiB
TypeScript
662 lines
22 KiB
TypeScript
/**
|
||
* 上传 / 解析 / 入队三道闸(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
|
||
}
|