feat(import): 全链编排与导入报告

Task 6(Plan 3):四家写入器汇流为默认编排管道。

- import.service:注入 events/pipeline 缺席时走真实事件流(runWxrAdapter
  惰性 async generator,mapConfig.locale=site.default_locale 前置事实 8);
  分派严格串行 content→term→media→route(禁 Promise.all);finish 硬序
  term(sync)→content(sync)→media(await)→route(await);counts 由 ⊕ 覆盖
  改为逐 key 求和聚合;新增 stale 回收(同站点 queued/running >24h 一条
  SQL 置 stale);报告新增 paths/productsPath+newsPath、issueGroups 六组、
  sanitize 清洗统计(事件流 changedRows/byDiffType 聚合)、adapterTiming。
- content-writer:NULL-entry 收编(快照行在而 content_entry 空,五列先验
  三分支:无先验新建挂接/同 checksum 挂接 skipped/异 checksum 挂接新版本)。
- route-writer:attachment 降级汇总按去重 sourceId 计(重跑仍必现);
  normalizeListPath 导出;writeAttachmentRoute 返回 written/resolved/blocked。
- 四家写入器 identity 收敛共享基类型 WriterContext =
  Pick<ImportJobContext,'db'|'siteId'|'connectionId'>(type-only import,
  无运行时环依赖)。
- CLI import 增 --products-path/--news-path(前置事实 9,缺省 /products/
  /news/)记入 job 报告。
- 测试:tests/integration/import-full.test.ts 6 例(全链 zh-CN 接线证明
  +published 全 NULL/报告四域计数+sanitize+分组/幂等重跑零写语义/
  无 media 快照域缺失不失败/NULL-entry 收编/stale 回收/REPORT_WRITE_FAILED
  遗留补测);import-job 契约测试钉死 counts 求和(100⊕3=103)。
  全量 323 通过;typecheck/lint 干净。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 20:08:06 +08:00
parent 27a8282367
commit 8003de336b
8 changed files with 856 additions and 89 deletions
+5 -5
View File
@@ -11,7 +11,7 @@
// 显式 id 不存在或属他站 → SNAPSHOT_NOT_FOUND;站点不存在 → SITE_NOT_FOUND;均零 job 行;
// 5. failed 路径:管道抛错 → running→failed(error_code/error_summary/finished_at,
// '[pipeline]' 前缀),重提交同 key 复用失败 job 不重跑;管道抛 ImportError 保留原 code;
// 6. Outcome 契约:ctx.report 累加 ⊕ 返回值(counts 同名键返回值覆盖)→ succeeded_with_issues;
// 6. Outcome 契约:ctx.report 累加 ⊕ 返回值(counts 同名键**求和**——Task 6 编排聚合)→ succeeded_with_issues;
// 7. 复用口径忠实化:reuse 返回只含 job 行锚定字段(快照/版本字段缺席);
// 8. 23505 收敛确定性探针:未提交孪生 job 持有幂等键(真实 PG 锁语义,零代理)。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
@@ -434,7 +434,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
})
})
it('Outcome 契约:ctx.report 累加 ⊕ 返回值(返回值 counts 覆盖同名键),issues 非空 → succeeded_with_issues,计数与问题入报告', async () => {
it('Outcome 契约:ctx.report 累加 ⊕ 返回值按**键求和**(Task 6 编排聚合),issues 非空 → succeeded_with_issues,计数与问题入报告', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
@@ -449,7 +449,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
pipeline: async (jobCtx) => {
// 写入器式累加(Task 6 写入器的使用形态)
jobCtx.report.counts['terms'] = 2
jobCtx.report.counts['contentEntries'] = 999 // 被返回值同名键覆盖
jobCtx.report.counts['contentEntries'] = 100 // 与返回值同名键**求和**(Task 6 钉死:防覆盖安全网)
jobCtx.report.issues.push('自建缺失 term:1(category/uncategorized)')
return {
counts: { contentEntries: 3, routes: 8 },
@@ -468,8 +468,8 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
await readFile(join(ctx.importReportRoot, result.jobId, 'reports', 'import.json'), 'utf8'),
) as { status: string; counts: Record<string, number>; issues: string[] }
expect(report.status).toBe('succeeded_with_issues')
// 汇合规则:counts 按键合并(返回值覆盖累加器同名键)、issues 累加器在前返回值在后
expect(report.counts).toStrictEqual({ terms: 2, contentEntries: 3, routes: 8 })
// 汇合规则:counts 按键**求和**(Task 6 钉死——CLOBBER 废弃)、issues 累加器在前返回值在后
expect(report.counts).toStrictEqual({ terms: 2, contentEntries: 103, routes: 8 })
expect(report.issues).toStrictEqual([
'自建缺失 term:1(category/uncategorized)',
'悬空 attachment 引用:2 条(mini 203/204)',