task-101: 上传/解析/入队三道前端拦截,阻止坏数据卡死 Python 自动化
Build Backend JAR / build (push) Has been cancelled

新增 shared/dispatch-guard.ts(纯 TS 校验,可单测)与 dispatch-guard-ui.ts
(ElMessageBox 弹窗层),在数据流的三个位置设闸:

- 选文件:空选择、非 xlsx/xls/csv、空路径直接拦;重复文件弹确认
- 解析结果:taskId 非法、totalRows 为 0、整批行被丢弃、需要分组却无分组直接拦;
  部分行被丢弃弹确认后才允许推送
- 入队前:结构不符、必填字段缺失、items/groups/country_codes 为空数组、
  行数页数为 0、JSON 不安全值(NaN/Infinity/BigInt/循环引用/数组洞)

覆盖 11 个推 Python 队列的 Tab 与 dedupe/split/convert 三个纯 Java Tab。
闸位按 Tab 挑选以免留下孤儿后端状态:collect-data 校验前置到
activateCollectDataTask 之前(任务保持 PENDING 无需回滚)、product-risk 的国家
检查提到建任务之前;已建任务后才拦下的复用各 Tab 原有失败补偿路径。

两处边界:对象属性 undefined 放行(taskNo 这类可选字段是惯用写法),只拦数组
元素 undefined 与数组洞;price-track 的 asin_rows_by_country 仅在 mode=asin
时要求非空,status 模式下 loadAsinRowsForAppClient 本就返回 {}。

顺带修复 similar-asin / appearance-patent 的 selectFiles 缺少 try/catch,
上传失败会残留上一批文件与解析结果。

测试:tests/dispatch-guard.test.ts 新增 37 个 test_task_101_* 用例。
This commit is contained in:
2026-09-01 12:53:12 +08:00
parent e2607ab723
commit 759f0b15d8
18 changed files with 1603 additions and 14 deletions
@@ -0,0 +1,67 @@
/**
* dispatch-guard 的界面层(Task 101)。
*
* 把 GuardResult 渲染成模态框:block 级用 alert(红色、只有「知道了」,
* 必须点掉),confirm 级用 confirm(「仍然继续」/「取消」)。之所以不用
* ElMessage toast,是因为这些提示直接决定要不要把任务推给 Python 自动化,
* 3 秒自动消失的 toast 很容易被划过去。
*
* 与 dispatch-guard.ts 分开放,是为了让核心校验保持纯 TS、可以用
* `node --test` 直接跑,不必把 Element Plus 拉进测试环境。
*/
import { ElMessageBox } from 'element-plus'
import type { GuardResult } from './dispatch-guard.ts'
/** 配套样式见 src/styles/main.css 的 .dispatch-guard-box(保留换行、限制宽度) */
const GUARD_BOX_CLASS = 'dispatch-guard-box'
async function alertBlocked(result: GuardResult) {
try {
await ElMessageBox.alert(result.message, result.title, {
type: 'error',
confirmButtonText: '知道了',
customClass: GUARD_BOX_CLASS,
dangerouslyUseHTMLString: false,
})
} catch {
// 用户按 ESC / 点遮罩关闭,同样视为已知悉;拦截结果不受影响
}
}
async function confirmSuspicious(result: GuardResult) {
try {
await ElMessageBox.confirm(result.message, result.title, {
type: 'warning',
confirmButtonText: '仍然继续',
cancelButtonText: '取消',
customClass: GUARD_BOX_CLASS,
dangerouslyUseHTMLString: false,
})
return true
} catch {
return false
}
}
/**
* 按顺序处理若干校验结果,返回是否放行。
*
* - 任一结果含 block 级问题:弹 alert 并返回 false(调用方直接 return)。
* - 只含 confirm 级问题:弹 confirm,用户点「仍然继续」返回 true,否则 false。
* - 全部通过:不弹任何东西,返回 true。
*
* 用法:
* if (!(await passGuard(checkParseResult(vo)))) return
*/
export async function passGuard(...results: GuardResult[]): Promise<boolean> {
const blocked = results.find((result) => !result.ok)
if (blocked) {
await alertBlocked(blocked)
return false
}
for (const result of results) {
if (!result.needsConfirm) continue
if (!(await confirmSuspicious(result))) return false
}
return true
}
+661
View File
@@ -0,0 +1,661 @@
/**
* 上传 / 解析 / 入队三道闸(Task 101)。
*
* 现状:各 Tab 选完文件直接 upload_file_to_java,解析成功就把 taskId 交给
* pushToPythonQueueenqueue_json 之前只判断 taskId 是否为真值。于是
* 「表头对不上导致 0 条有效行」「country_codes 为空」「payload 里混进 NaN」
* 这类问题都会被原样推给 Python 自动化——Python 端拿到空任务后既不报错也
* 没得可做,任务永远停在 RUNNING,前端就一直轮询,表现为「卡住」。
*
* 本模块把这三处收敛成统一校验,全部在界面侧拦截:
* - checkSelectedFiles:文件选完、上传前,挡掉空选择 / 非法扩展名 / 超量。
* - checkParseResultJava 解析返回后,挡掉无效 taskId 与 0 有效行;
* 部分行被丢弃降级为 confirm,由用户决定是否继续。
* - checkQueuePayloadenqueue_json 前的最后一道闸,挡掉缺字段、空数组,
* 以及 JSON.stringify 会静默改写或直接抛错的值(NaN/Infinity → null、
* BigInt → 抛错、循环引用 → 抛错),这些到了 Python 侧就是 None。
*
* 纯 TS 无副作用模块:不引入 Vue / Element Plus,输入对象永不修改,任何输入
* 都返回 GuardResult 而不抛错(弹窗文案由 dispatch-guard-ui 负责渲染)。
*/
/** block:必须修数据,禁止继续;confirm:可疑但可继续,需用户确认 */
export type GuardSeverity = 'block' | 'confirm'
export interface GuardIssue {
/** 稳定的问题码,便于日志与测试断言 */
code: string
/** 面向用户的中文说明 */
message: string
severity: GuardSeverity
}
export interface GuardResult {
/** 没有任何 block 级问题 */
ok: boolean
/** 存在 confirm 级问题,需要用户点确认才能继续 */
needsConfirm: boolean
/** 模态框标题 */
title: string
/** 汇总文案,多条问题按换行拼接 */
message: string
issues: GuardIssue[]
}
/** 常用扩展名白名单 */
export const EXCEL_EXTENSIONS = ['.xlsx', '.xls'] as const
export const EXCEL_CSV_EXTENSIONS = ['.xlsx', '.xls', '.csv'] as const
/** JSON 嵌套深度上限,超过基本可以断定是环或异常结构 */
const MAX_JSON_DEPTH = 64
/** 单次扫描的节点数上限,避免超大 payload 卡住 UI 线程 */
const MAX_JSON_NODES = 200_000
function buildResult(
blockTitle: string,
confirmTitle: string,
issues: GuardIssue[],
): GuardResult {
const blocking = issues.filter((issue) => issue.severity === 'block')
const confirming = issues.filter((issue) => issue.severity === 'confirm')
// 有 block 时只展示 block,避免把「可继续」的提示和「不可继续」的混在一个弹窗里
const shown = blocking.length ? blocking : confirming
return {
ok: blocking.length === 0,
needsConfirm: blocking.length === 0 && confirming.length > 0,
title: blocking.length ? blockTitle : confirmTitle,
message: shown.map((issue) => issue.message).join('\n'),
issues,
}
}
/** 一个恒定放行的结果,供调用方在无需校验时占位 */
export function guardPassed(): GuardResult {
return buildResult('', '', [])
}
/**
* 手工构造一个 block 级结果,用于那些不属于文件/解析/payload 三类、
* 但同样会让 Python 端空转的前置条件(例如「一个国家都没选」)。
*/
export function guardBlocked(
title: string,
message: string,
code = 'precondition.failed',
): GuardResult {
return buildResult(title, title, [{ code, severity: 'block', message }])
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0
}
function isPositiveInteger(value: unknown): value is number {
return isNonNegativeInteger(value) && value > 0
}
/** 从本地路径里取出文件名,同时兼容 Windows 反斜杠与 POSIX 斜杠 */
export function basenameOf(path: string): string {
const segments = String(path).split(/[/\\]/)
for (let i = segments.length - 1; i >= 0; i -= 1) {
if (segments[i]) return segments[i]
}
return ''
}
/** 取小写扩展名(含点);无扩展名返回空串 */
export function extensionOf(path: string): string {
const name = basenameOf(path)
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return ''
return name.slice(dot).toLowerCase()
}
export interface SelectedFilesOptions {
/** 允许的扩展名(小写含点);不传表示不限制 */
allowedExtensions?: readonly string[]
/** 单次允许选择的最大文件数 */
maxFiles?: number
/** 弹窗标题 */
title?: string
}
/** 选择结果里的一项:本地绝对路径字符串,或 expandBrandFolderRecursive 返回的条目 */
export type SelectedFileInput =
| string
| {
absolutePath?: string
relativePath?: string
}
function resolveSelectedPath(item: SelectedFileInput): string {
if (typeof item === 'string') return item.trim()
if (typeof item === 'object' && item !== null) {
const record = item as { absolutePath?: unknown; relativePath?: unknown }
if (typeof record.absolutePath === 'string' && record.absolutePath.trim()) {
return record.absolutePath.trim()
}
if (typeof record.relativePath === 'string' && record.relativePath.trim()) {
return record.relativePath.trim()
}
}
return ''
}
/**
* 文件选择校验:在 upload_file_to_java 之前跑,挡掉根本不该上传的输入。
* 扩展名不匹配一律 block —— 把 .txt 改名成 .xlsx 交给 Java 解析只会得到
* 一个语焉不详的后端错误,不如在选择时就说清楚。
*/
export function checkSelectedFiles(
paths: unknown,
options: SelectedFilesOptions = {},
): GuardResult {
const blockTitle = options.title || '文件选择有问题'
const confirmTitle = '选择的文件需要确认'
const issues: GuardIssue[] = []
if (!Array.isArray(paths)) {
issues.push({
code: 'files.not-array',
severity: 'block',
message: '没有拿到文件列表,请重新选择文件。',
})
return buildResult(blockTitle, confirmTitle, issues)
}
if (!paths.length) {
issues.push({
code: 'files.empty',
severity: 'block',
message: '没有选择任何文件,请先选择要处理的文件。',
})
return buildResult(blockTitle, confirmTitle, issues)
}
const maxFiles = options.maxFiles
if (maxFiles != null && maxFiles > 0 && paths.length > maxFiles) {
issues.push({
code: 'files.too-many',
severity: 'block',
message: `一次最多选择 ${maxFiles} 个文件,当前选了 ${paths.length} 个,请分批处理。`,
})
}
const allowed = options.allowedExtensions
? options.allowedExtensions.map((ext) => ext.toLowerCase())
: null
const invalidNames: string[] = []
let blankCount = 0
const seen = new Set<string>()
const duplicated: string[] = []
for (const item of paths as SelectedFileInput[]) {
const resolved = resolveSelectedPath(item)
if (!resolved) {
blankCount += 1
continue
}
const key = resolved.toLowerCase()
if (seen.has(key)) {
const name = basenameOf(resolved)
if (!duplicated.includes(name)) duplicated.push(name)
} else {
seen.add(key)
}
if (allowed) {
const ext = extensionOf(resolved)
if (!allowed.includes(ext)) {
const name = basenameOf(resolved) || resolved
if (!invalidNames.includes(name)) invalidNames.push(name)
}
}
}
if (blankCount) {
issues.push({
code: 'files.blank-path',
severity: 'block',
message: `${blankCount} 个文件路径为空,无法上传,请重新选择。`,
})
}
if (invalidNames.length && allowed) {
issues.push({
code: 'files.bad-extension',
severity: 'block',
message:
`以下文件类型不支持:${formatNameList(invalidNames)}\n` +
`当前只接受 ${allowed.join(' / ')} 格式,请转换后重新选择。`,
})
}
if (duplicated.length) {
issues.push({
code: 'files.duplicated',
severity: 'confirm',
message: `以下文件被重复选择,会被处理多次:${formatNameList(duplicated)}\n确认继续吗?`,
})
}
return buildResult(blockTitle, confirmTitle, issues)
}
function formatNameList(names: string[], limit = 5): string {
if (names.length <= limit) return names.join('、')
return `${names.slice(0, limit).join('、')}${names.length}`
}
/** 各模块 parse 接口返回的公共统计字段 */
export interface ParseOutcome {
taskId?: unknown
totalRows?: unknown
acceptedRows?: unknown
droppedRows?: unknown
groupCount?: unknown
sourceFilename?: unknown
}
export interface ParseResultOptions {
/** 该模块必须解析出分组才能执行(如 appearance-patent 按主 ID 分组) */
requireGroups?: boolean
/** 必须解析出有效行;默认 true */
requireRows?: boolean
/** 必要字段名,用于拼「缺什么」的提示,如 'ASIN / 国家' */
requiredColumnsHint?: string
title?: string
}
/**
* 解析结果校验:Java 返回后、把 taskId 交给推送按钮之前跑。
*
* 0 有效行是最危险的情况——任务在库里建好了,推给 Python 后没有任何明细可
* 执行,任务会一直停在 RUNNING。这里一律 block。
* 部分行被丢弃则降级成 confirm:日常确实存在「几行没 ASIN 也照跑」的用法,
* 但要让用户明确知道少了多少行。
*/
export function checkParseResult(
result: unknown,
options: ParseResultOptions = {},
): GuardResult {
const blockTitle = options.title || '解析结果无法执行'
const confirmTitle = '解析结果需要确认'
const issues: GuardIssue[] = []
if (!isPlainObject(result)) {
issues.push({
code: 'parse.not-object',
severity: 'block',
message: '后端没有返回解析结果,请重新解析。',
})
return buildResult(blockTitle, confirmTitle, issues)
}
const vo = result as ParseOutcome
if (!isPositiveInteger(vo.taskId)) {
issues.push({
code: 'parse.bad-task-id',
severity: 'block',
message: `后端未返回有效任务标识(taskId=${String(vo.taskId)}),请重新解析。`,
})
// taskId 都不对,后面的行数统计没有讨论价值
return buildResult(blockTitle, confirmTitle, issues)
}
const totalRows = normalizeCount(vo.totalRows)
const acceptedRows = normalizeCount(vo.acceptedRows)
const droppedRows = normalizeCount(vo.droppedRows)
const groupCount = normalizeCount(vo.groupCount)
for (const [field, raw, parsed] of [
['totalRows', vo.totalRows, totalRows],
['acceptedRows', vo.acceptedRows, acceptedRows],
['droppedRows', vo.droppedRows, droppedRows],
['groupCount', vo.groupCount, groupCount],
] as const) {
if (raw != null && parsed == null) {
issues.push({
code: 'parse.bad-count',
severity: 'block',
message: `解析结果字段 ${field} 异常(${String(raw)}),无法判断数据量,请重新解析。`,
})
}
}
if (issues.length) return buildResult(blockTitle, confirmTitle, issues)
const requireRows = options.requireRows !== false
const columnsHint = options.requiredColumnsHint
? `(必要字段:${options.requiredColumnsHint}`
: ''
if (requireRows && acceptedRows === 0) {
if (!totalRows) {
issues.push({
code: 'parse.empty-file',
severity: 'block',
message:
'文件里没有读到任何数据行。\n' +
'请确认文件不是空表、数据不在隐藏的其他 Sheet 里,然后重新上传。',
})
} else {
issues.push({
code: 'parse.all-dropped',
severity: 'block',
message:
`共读取 ${totalRows} 行,但没有一行包含完整的必要字段${columnsHint},全部被丢弃。\n` +
'推送这样的任务会让 Python 端拿不到任何可执行明细、任务一直卡在执行中。\n' +
'请检查表头列名是否与模板一致,修正后重新上传解析。',
})
}
return buildResult(blockTitle, confirmTitle, issues)
}
if (options.requireGroups && groupCount === 0) {
issues.push({
code: 'parse.no-group',
severity: 'block',
message:
'解析出了数据行,但没有生成任何分组,Python 端会没有可执行的批次。\n' +
'请检查主 ID 列是否填写正确,修正后重新解析。',
})
return buildResult(blockTitle, confirmTitle, issues)
}
if (droppedRows && droppedRows > 0) {
const accepted = acceptedRows ?? 0
const total = totalRows || accepted + droppedRows
issues.push({
code: 'parse.partial-dropped',
severity: 'confirm',
message:
`${total} 行,其中 ${droppedRows} 行因缺少必要字段${columnsHint}被丢弃,` +
`只有 ${accepted} 行会被执行。\n` +
`确认按这 ${accepted} 行继续吗?`,
})
}
return buildResult(blockTitle, confirmTitle, issues)
}
function normalizeCount(value: unknown): number | null {
if (value == null) return null
if (isNonNegativeInteger(value)) return value
return null
}
export interface QueuePayloadOptions {
/** data 下必须存在且非空的字段名 */
requiredDataKeys?: readonly string[]
/** data 下必须是非空数组的字段名 */
nonEmptyArrayKeys?: readonly string[]
/** data 下必须是非空对象(或非空数组)的字段名 */
nonEmptyObjectKeys?: readonly string[]
/**
* data 下必须是正数的字段名。用于「行数 / 页数」这类为 0 就等于没活干的字段——
* 它们不是 nullrequiredDataKeys 拦不住,但 0 会让 Python 端翻 0 页后空转。
*/
positiveNumberKeys?: readonly string[]
/** 期望的 type 值;不匹配时 block */
expectedType?: string
title?: string
}
/**
* 入队 payload 校验:api.enqueue_json(payload) 之前的最后一道闸。
*
* 除了业务字段,这里重点扫 JSON 安全性。pywebview 桥接会对 payload 做
* JSON 序列化,而 JSON.stringify 对几类值的处理是「静默改写」而不是报错:
* NaN / Infinity 变成 nullundefined 属性被丢掉,数组里的 undefined 变
* 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
}