fix(import): term 边缘欠报闭合(null-id 双序等价/同 slug 双 id 报告);handleEvent 分派形状

审查修复:
- null-id wp:term 双序等价:seen 分支同样报缺 term_id issue;自建行 name-only
  收编(UPDATE name 为权威名、source_id 不动),term 先行/row 先行终态 name
  与 issue 双序一致(测试钉死);真实 id 行不覆盖(first-wins)
- 同 slug 双 term_id:镜像条件显式报告(保留先到、跳过后到 + rawLocation),
  文档序 first-wins 策略 JSDoc 钉死(测试含关联绑保留行)
- 冲突重查未命中由 found[0]! 改显式 throw(taxonomy/term 两处,含键上下文)
- adoptTerm catch 注明 ctx.db 直连约定(事务内 25P02 连锁风险——Task 6 包
  事务时须重估);收编撞唯一约束路径补测试(自建行原样保留 + issue)
- 重构:内部件抽为 createTermEventHandler({handleEvent, finish}) 可分派形状
  (Task 6 单消费方拉流分派、无 tee 的编排意图),createTermWriter 为自消费
  整流便捷壳

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 16:44:25 +08:00
parent 191c275713
commit 8485c51d59
2 changed files with 510 additions and 211 deletions
+214
View File
@@ -462,4 +462,218 @@ describe('term 物化(含自建缺失)', () => {
}
})
})
it('null-id wp:term 双序等价:name-only 收编——term 先行/row 先行终态 name 与 issue 双序一致', async () => {
await withTestDb(async (db) => {
// 两序各起独立站点(终态对比不受他序残留影响);work 根共用、子目录隔离
const work = await mkdtemp(join(tmpdir(), 'sf-terms-nullid-'))
const runScenario = async (order: 'term-first' | 'row-first') => {
const dir = join(work, order)
const key = `sfterm-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: [FIXTURES_ROOT],
storageRoot: join(dir, 'storage'),
reportRoot: join(dir, 'artifacts', 'register'),
})
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
const nullIdTerm = termEvent({
termId: null,
taxonomy: 'category',
slug: 'company-news',
name: 'Company News',
})
const row = rowEvent({
sourceId: '501',
taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company-news' }],
})
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(dir, 'artifacts', 'import'),
events: eventsOf(order === 'term-first' ? [nullIdTerm, row] : [row, nullIdTerm]),
pipeline: seededTermWriter(async (ctx) => {
await seedContentEntry(ctx, { sourceType: 'wp:post', sourceId: '501', typeKey: 'news_article' })
}),
})
expect(result.status).toBe('succeeded_with_issues')
const termsRows = (await db.execute(`select t.source_id, t.slug, t.name
from terms t join taxonomies x on x.id = t.taxonomy_id
where t.site_id = '${reg.siteId}'`)) as any
const ct = (await db.execute(`select r.source_id as row_id, t.slug, c.is_primary
from content_terms c
join source_records r on r.content_entry_id = c.content_entry_id
join terms t on t.id = c.term_id
where c.site_id = '${reg.siteId}'`)) as any
const report = JSON.parse(
await readFile(join(dir, 'artifacts', 'import', result.jobId, 'reports', 'import.json'), 'utf8'),
) as { issues: string[] }
return { terms: plain(termsRows), ct: plain(ct), issues: report.issues }
} finally {
await cleanupSiteRows(db, reg.siteId)
}
}
try {
const termFirst = await runScenario('term-first')
const rowFirst = await runScenario('row-first')
// 双序终态一致:单行 auto:company-news、name 为 wp:term 权威名(name-only 收编)
const expectedTerms = [
{ source_id: 'auto:company-news', slug: 'company-news', name: 'Company News' },
]
expect(termFirst.terms).toStrictEqual(expectedTerms)
expect(rowFirst.terms).toStrictEqual(expectedTerms)
expect(termFirst.ct).toStrictEqual(rowFirst.ct)
expect(termFirst.ct).toStrictEqual([
{ row_id: '501', slug: 'company-news', is_primary: true },
])
// issue 双序均在(缺 term_id 的 wp:term 事件无论先后到都报告)
expect(termFirst.issues).toHaveLength(1)
expect(rowFirst.issues).toHaveLength(1)
expect(termFirst.issues[0]).toMatch(/缺 term_id/)
expect(rowFirst.issues[0]).toMatch(/缺 term_id/)
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('同 slug 双 term_id:文档序 first-wins——保留先到 id、后到跳过并报告,关联绑保留行、name 不被覆盖', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-terms-dualid-'))
try {
const key = `sfterm-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: [FIXTURES_ROOT],
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'register'),
})
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
const events: AdapterEvent[] = [
termEvent({ termId: 31, taxonomy: 'category', slug: 'company', name: 'Company' }),
termEvent({ termId: 99, taxonomy: 'category', slug: 'company', name: 'Company Dup' }),
rowEvent({ sourceId: '501', taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company' }] }),
]
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: seededTermWriter(async (ctx) => {
await seedContentEntry(ctx, { sourceType: 'wp:post', sourceId: '501', typeKey: 'news_article' })
}),
})
expect(result.status).toBe('succeeded_with_issues')
// 单行:先到的 31 保留(name 不被后到 99 的事件覆盖——first-wins)
const termsRows = (await db.execute(`select t.source_id, t.slug, t.name
from terms t join taxonomies x on x.id = t.taxonomy_id
where t.site_id = '${reg.siteId}'`)) as any
expect(plain(termsRows)).toStrictEqual([
{ source_id: '31', slug: 'company', name: 'Company' },
])
// issue:保留/跳过双 id + rawLocation 均可审计
const report = JSON.parse(
await readFile(join(work, 'artifacts', 'import', result.jobId, 'reports', 'import.json'), 'utf8'),
) as { counts: Record<string, number>; issues: string[] }
expect(report.issues).toHaveLength(1)
expect(report.issues[0]).toMatch(/同 slug 双 term_id:保留 31、跳过 99/)
// 关联绑保留行(31);计数零自建
const ct = (await db.execute(`select r.source_id as row_id, t.source_id as term_source_id, c.is_primary
from content_terms c
join source_records r on r.content_entry_id = c.content_entry_id
join terms t on t.id = c.term_id
where c.site_id = '${reg.siteId}'`)) as any
expect(plain(ct)).toStrictEqual([
{ row_id: '501', term_source_id: '31', is_primary: true },
])
expect(report.counts).toStrictEqual({
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 1,
})
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('收编撞唯一约束:source_id 已被他行占用 → 自建行原样保留 + issue(写入器唯一 try/catch 路径)', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-terms-adopt-'))
try {
const key = `sfterm-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: [FIXTURES_ROOT],
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'register'),
})
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
// 事件 1 占位 source_id=31(slug 不同→不自建命中);事件 2 按 slug 自建
// auto:company;事件 3 收编 UPDATE auto:company→31 撞 terms_identity_uq
const events: AdapterEvent[] = [
termEvent({ termId: 31, taxonomy: 'category', slug: 'company-alt', name: 'Alt' }),
rowEvent({ sourceId: '501', taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company' }] }),
termEvent({ termId: 31, taxonomy: 'category', slug: 'company', name: 'Company' }),
]
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: seededTermWriter(async (ctx) => {
await seedContentEntry(ctx, { sourceType: 'wp:post', sourceId: '501', typeKey: 'news_article' })
}),
})
expect(result.status).toBe('succeeded_with_issues')
// 两行并存:占位 31 原样;自建行整语句回退(source_id 与 name 都未被收编改动)
const termsRows = (await db.execute(`select t.source_id, t.slug, t.name
from terms t join taxonomies x on x.id = t.taxonomy_id
where t.site_id = '${reg.siteId}' order by t.source_id`)) as any
expect(plain(termsRows)).toStrictEqual([
{ source_id: '31', slug: 'company-alt', name: 'Alt' },
{ source_id: 'auto:company', slug: 'company', name: 'company' },
])
const report = JSON.parse(
await readFile(join(work, 'artifacts', 'import', result.jobId, 'reports', 'import.json'), 'utf8'),
) as { counts: Record<string, number>; issues: string[] }
expect(report.issues).toHaveLength(1)
expect(report.issues[0]).toMatch(/收编撞唯一约束.*31/)
// 关联仍挂保留的自建行;自建计数不回退(收编失败,行仍是自建终态)
const ct = (await db.execute(`select r.source_id as row_id, t.source_id as term_source_id, c.is_primary
from content_terms c
join source_records r on r.content_entry_id = c.content_entry_id
join terms t on t.id = c.term_id
where c.site_id = '${reg.siteId}'`)) as any
expect(plain(ct)).toStrictEqual([
{ row_id: '501', term_source_id: 'auto:company', is_primary: true },
])
expect(report.counts).toStrictEqual({
termsProcessed: 2,
autoCreatedTerms: 1,
contentTermsCreated: 1,
})
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
})