fix(import): 失败报告部分冲刷;列表路径校验(建行前);JSDoc 求和措辞;reason 归档
Task 6 质量评审四项修复: - 失败报告部分冲刷(Fix 1):orchestratedPipeline 分派/finish 包 try/catch—— 中途抛错时把可安全取得的部分产出冲入 ctx.report 后重抛:term/content 的 finish 是纯返回(已算成复用、未算成就地补算 trySyncFinish,自身抛错放弃 该部分不掩盖原错)、已收集的 adapter 问题/清洗统计、进度计数 eventsDispatched(orchestrator 键,仅失败路径出现——成功路径不占 18 键面)。 media/route finish 依赖库与先序产物,失败路径不调用(其键缺席如实反映)。 - 列表路径校验(Fix 3):ImportErrorCode 增 INVALID_LIST_PATH;解析期防线 移至阶段 2.5(建 job 行前——建行/置 running 后再抛会留悬挂行);复用 route-writer 导出的 isSafePath;normalizeListPath 自带安全拒绝(写入器 自调用位双覆盖)。CLI 无需新码(既有 ImportError 通道按 code 呈现)。 - JSDoc 求和措辞(Fix 2):content-writer/media-writer 的 counts 键契约 注记由"返回值覆盖同名键 CLOBBER"改为"同名键求和(Task 6 钉死——防覆盖 安全网)",与 ImportPipelineOutcome.counts 契约对齐。 - reason 归档(Fix 4):分派循环对 unsupported/error 事件把 reason/issue 推入 adapter 问题组(此前被静默丢弃)。 测试:import-full 增 2 例(手构流 3 事件后爆炸 → failed + eventsDispatched=3 + 部分 content/term 计数 + unsupported/error reason 双归档;--products-path /../x/ 等 → INVALID_LIST_PATH 零 job 行)。全量 325 通过;typecheck/lint 干净。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -14,13 +14,19 @@
|
||||
// UPDATE 挂 entry(不撞 23505、sourceRecords 不虚增);
|
||||
// 5. stale 回收(义务④):同 site 超 24h 的 queued/running 置 stale,新近 job 不受影响;
|
||||
// 6. REPORT_WRITE_FAILED(Task 1 遗留):报告写盘失败 → failed 终态、report_path 不落。
|
||||
// 7. 失败报告部分冲刷(Task 6 审查 Fix 1):事件流中途抛错 → job failed 且报告携带
|
||||
// eventsDispatched 进度计数 + 部分 content/term 计数(media/route finish 依赖库不调用);
|
||||
// unsupported/error 事件的 reason/issue 归档 adapter 组(Fix 4——此前被丢弃);
|
||||
// 8. 主列表路径解析期防线(Fix 3):--products-path/--news-path 含穿越段 →
|
||||
// INVALID_LIST_PATH 且零 job 行(建行前抛出——建行后再抛会留悬挂 running 行)。
|
||||
// 运行于 globalSetup 临时库(ADR-015);storage/报告根 os.tmpdir,finally 清理。
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
|
||||
import type { AdapterEvent } from '@sf/adapter-wp'
|
||||
import { createImportJob, ImportError } from '../../apps/api/src/modules/import/import.service.js'
|
||||
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
|
||||
import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
|
||||
|
||||
@@ -467,4 +473,132 @@ describe('编排集成 + 导入报告(四家写入器汇流)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('失败报告部分冲刷(Fix 1):事件流中途抛错 → failed + 报告含 eventsDispatched 与部分 content/term 计数;unsupported/error reason 归档 adapter 组(Fix 4)', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const w = await makeWork('sf-full-flush')
|
||||
try {
|
||||
const { key, siteId } = await registerWxrOnly(db, w)
|
||||
try {
|
||||
// 手构流 + 真编排(注入 events 而不注入 pipeline):3 个事件成功分派后,
|
||||
// 第 4 次拉取抛错——分派循环内炸(非写入器抛),编排器须冲刷部分产出后重抛
|
||||
async function* bomb(): AsyncGenerator<AdapterEvent, void, void> {
|
||||
yield {
|
||||
kind: 'row',
|
||||
row: {
|
||||
sourceType: 'wp:post',
|
||||
sourceId: '301',
|
||||
status: 'publish',
|
||||
locale: 'en',
|
||||
taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company' }],
|
||||
attachmentRefs: [],
|
||||
seo: {},
|
||||
knownFields: {},
|
||||
extras: {},
|
||||
rawLocation: 'wxr://item/301',
|
||||
},
|
||||
issues: [],
|
||||
sanitizeDiffs: [],
|
||||
changed: false,
|
||||
}
|
||||
yield {
|
||||
kind: 'unsupported',
|
||||
sourceType: 'wp:nav_menu_item',
|
||||
reason: 'post_type 未配置映射',
|
||||
rawLocation: 'wxr://item/7',
|
||||
postId: '68',
|
||||
}
|
||||
yield { kind: 'error', sourceType: 'wp:post', issue: 'post_id 缺失或非法', rawLocation: 'wxr://item/9' }
|
||||
throw new Error('BOOM: 事件流在第 4 次拉取时爆炸')
|
||||
}
|
||||
const result = await createImportJob(db, { siteKey: key, reportRoot: w.reportRoot, events: bomb() })
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.reportPath).not.toBeNull()
|
||||
|
||||
const job = (await db.execute(`select error_code, error_summary, report_path, finished_at
|
||||
from import_jobs where id = '${result.jobId}'`)) as any
|
||||
expect(job[0].error_code).toBe('PIPELINE_FAILED')
|
||||
expect(String(job[0].error_summary)).toMatch(/^\[pipeline\] /)
|
||||
expect(job[0].report_path).not.toBeNull() // 失败也落报告(部分产出可审计)
|
||||
expect(job[0].finished_at).not.toBeNull()
|
||||
|
||||
const report = await readReport(w, result.jobId)
|
||||
expect(report.status).toBe('failed')
|
||||
expect(report.error.code).toBe('PIPELINE_FAILED')
|
||||
// 部分冲刷面:成功分派的 3 事件的 content/term 计数 + 进度计数 eventsDispatched
|
||||
//(media/route finish 依赖库与先序产物——失败路径不调用,其键缺席)
|
||||
expect(report.counts).toStrictEqual({
|
||||
eventsDispatched: 3,
|
||||
contentEntries: 1,
|
||||
revisionsCreated: 1,
|
||||
sourceRecords: 3,
|
||||
skipped: 0,
|
||||
unsupportedRecords: 1,
|
||||
failedRecords: 1,
|
||||
termsProcessed: 0,
|
||||
autoCreatedTerms: 1,
|
||||
contentTermsCreated: 1,
|
||||
})
|
||||
// unsupported/error 的 reason/issue 归档 adapter 组(Fix 4——此前被静默丢弃)
|
||||
expect(report.issueGroups.adapter).toHaveLength(2)
|
||||
expect(report.issueGroups.adapter[0]).toContain('不支持对象')
|
||||
expect(report.issueGroups.adapter[0]).toContain('post_type 未配置映射')
|
||||
expect(report.issueGroups.adapter[1]).toContain('适配器错误')
|
||||
expect(report.issueGroups.adapter[1]).toContain('post_id 缺失或非法')
|
||||
// 冲刷的 issue 同时在 flat 清单;content/term/media/route 组空(本流无其问题)
|
||||
expect(report.issues).toHaveLength(2)
|
||||
expect(report.issueGroups).toStrictEqual({
|
||||
content: [],
|
||||
term: [],
|
||||
media: [],
|
||||
route: [],
|
||||
adapter: report.issues,
|
||||
orchestrator: [],
|
||||
})
|
||||
// 落库审计:已分派事件的部分写入保留(各写入器单语句已提交,失败不回滚)
|
||||
expect(await countOf(db, `select count(*)::int as n from content_entries where site_id = '${siteId}'`)).toBe(1)
|
||||
} finally {
|
||||
await cleanupSiteRows(db, siteId)
|
||||
}
|
||||
} finally {
|
||||
await rm(w.work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('主列表路径解析期防线(Fix 3):--products-path/--news-path 含穿越段 → INVALID_LIST_PATH、零 job 行(建行前抛出)', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const w = await makeWork('sf-full-path')
|
||||
try {
|
||||
const { key, siteId } = await registerWxrOnly(db, w)
|
||||
try {
|
||||
const cases: Array<[string, { productsPath?: string; newsPath?: string }]> = [
|
||||
['productsPath 穿越段', { productsPath: '/../x/' }],
|
||||
['newsPath 穿越段', { newsPath: '/news/../../etc/' }],
|
||||
]
|
||||
for (const [label, paths] of cases) {
|
||||
const err: ImportError = await createImportJob(db, {
|
||||
siteKey: key,
|
||||
reportRoot: w.reportRoot,
|
||||
...paths,
|
||||
}).then(
|
||||
() => {
|
||||
throw new Error(`用例「${label}」不应成功`)
|
||||
},
|
||||
(e: ImportError) => e,
|
||||
)
|
||||
expect(err, label).toBeInstanceOf(ImportError)
|
||||
expect(err.code, label).toBe('INVALID_LIST_PATH')
|
||||
expect(err.message, label).toContain('..') // 消息携带原始入参(操作者可定位)
|
||||
}
|
||||
// 零 job 行:解析期失败(建行前)——建行后再抛会留悬挂 running 行
|
||||
expect(await countOf(db, `select count(*)::int as n from import_jobs where site_id = '${siteId}'`)).toBe(0)
|
||||
} finally {
|
||||
await cleanupSiteRows(db, siteId)
|
||||
}
|
||||
} finally {
|
||||
await rm(w.work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user