diff --git a/apps/api/src/modules/import/content-writer.ts b/apps/api/src/modules/import/content-writer.ts index 68e7038..13283aa 100644 --- a/apps/api/src/modules/import/content-writer.ts +++ b/apps/api/src/modules/import/content-writer.ts @@ -127,6 +127,23 @@ export interface ContentEventHandler { finish(): ImportPipelineOutcome } +/** + * 本写入器的 counts 键与归属(**counts 键是跨写入器的扁平全局命名空间**——import.service + * 按"返回值覆盖累加器同名键"合并,同名键 CLOBBER 不求和,各写入器必须各持独占键名,见 + * ImportPipelineOutcome.counts 的键契约注记)。本写入器独占:contentEntries / + * revisionsCreated / sourceRecords / skipped / unsupportedRecords / failedRecords—— + * **Task 4 media-writer 起新键名,勿复用 'skipped'**(语义不同会互相覆盖)。 + * 前四键恒出现在报告;unsupportedRecords/failedRecords 只在 >0 时出现(分项按事件计)。 + */ +interface ContentWriterCounts { + contentEntries: number + revisionsCreated: number + sourceRecords: number + skipped: number + unsupportedRecords?: number + failedRecords?: number +} + /** * 内容写入器的事件分派句柄。产出 counts: * - contentEntries:新建 content_entries 数(幂等重跑/变化路径为 0); @@ -134,7 +151,9 @@ export interface ContentEventHandler { * - sourceRecords:新建 source_records 数(内容/attachment/unsupported/error 全计; * 跨快照重导按快照补行故可为正); * - skipped:内容行未产修订的跳过数(同快照 checksum 命中、跨快照无业务变化、locale - * 拒收三类;attachment 命中不计——媒体域是 Task 4 主场)。 + * 拒收三类;attachment 命中不计——媒体域是 Task 4 主场); + * - unsupportedRecords/failedRecords:unsupported/error 事件分项数(**按事件计**非按 + * 插入行——重跑行幂等而计数如实;拆分只在分派点可知,Task 6 报告层无从重derive)。 * issues:locale 不匹配、未知 sourceType 兜底——显式报告(§7.6 禁静默)。 */ export function createContentEventHandler(identity: { @@ -152,7 +171,14 @@ export function createContentEventHandler(identity: { versions: ImportVersionBlock }): ContentEventHandler { const { db, siteId } = identity - const counts = { contentEntries: 0, revisionsCreated: 0, sourceRecords: 0, skipped: 0 } + // 交叉 Record:finish() 要把 counts 交还给 ImportPipelineOutcome(Record) + // ——接口无索引签名不可赋值;交叉后已知键类型不变、动态赋值兼容 + const counts: ContentWriterCounts & Record = { + contentEntries: 0, + revisionsCreated: 0, + sourceRecords: 0, + skipped: 0, + } const issues: string[] = [] /** 修订插入(纯 INSERT——不可变表;data 与 checksum 同构于 mapped_content) */ @@ -259,14 +285,18 @@ export function createContentEventHandler(identity: { const data = mappedContentOf(row) // 态 1:本快照行已存在(挂 entry——本写入器是 content 行 source_records 的唯一写入方, - // 挂接不变量由写入路径自身保证) + // 挂接不变量由写入路径自身保证)。 + // 已知死角:NULL-entry 占位行(外源 seed/未来写入方)+ 后续映射扩充重导 → 全新建分支 + // 插入撞六列唯一约束 23505;Task 6 收编(UPDATE 该行挂 entry)——见审查记录 const snapRow = await findSnapshotRecord(row) if (snapRow !== null && snapRow.contentEntryId !== null) { if (snapRow.normalizedChecksum === checksum) { counts.skipped++ return } - // checksum 异:快照行前移(checksum/source_url)+ 新修订 + current 前移(事务) + // checksum 异:快照行前移(checksum/source_url)+ 新修订 + current 前移(事务)。 + // lineage 语义(有意):source_records.import_job_id 留在建行 job(行的溯源=创建它的 + // 导入),本次 job 记在 content_revisions.import_job_id(谁产了新修订) const entryId = snapRow.contentEntryId // 事务回调外的 const 收窄(属性收窄不进闭包) await db.transaction(async (tx) => { await tx @@ -396,6 +426,10 @@ export function createContentEventHandler(identity: { ): Promise { // postId 缺失不硬造身份——source_id 以 rawLocation 回退(行可审计、身份不虚构) const sourceId = ev.postId ?? ev.rawLocation + // 分项计数按事件计(非按插入行):unsupported/failed 的拆分只在分派点可知, + // Task 6 报告层无法从 sourceRecords 总数重derive;重跑时行幂等但计数如实。 + if (parseStatus === 'unsupported') counts.unsupportedRecords = (counts.unsupportedRecords ?? 0) + 1 + else counts.failedRecords = (counts.failedRecords ?? 0) + 1 const inserted = await db .insert(sourceRecords) .values({ diff --git a/apps/api/src/modules/import/import.service.ts b/apps/api/src/modules/import/import.service.ts index b55deba..d82ba45 100644 --- a/apps/api/src/modules/import/import.service.ts +++ b/apps/api/src/modules/import/import.service.ts @@ -127,6 +127,14 @@ export interface ImportJobContext { * 无返回值,累加器现值原样进报告(部分产出可审计)。 */ export interface ImportPipelineOutcome { + /** + * 计数块(原样进报告)。**键契约:counts 键是跨写入器的扁平全局命名空间**—— + * 汇合规则按"返回值覆盖累加器同名键"合并(ctx.report.counts ⊕ outcome.counts), + * 同名键 **CLOBBER 不求和**:各写入器必须各持独占键名并在自身 JSDoc 声明归属 + * (Task 2 term-writer:termsProcessed/autoCreatedTerms/contentTermsCreated; + * Task 3 content-writer:contentEntries/revisionsCreated/sourceRecords/skipped/ + * unsupportedRecords/failedRecords——Task 4 media-writer 起新键名,勿复用 'skipped')。 + */ counts: Record issues: string[] } diff --git a/tests/integration/import-content.test.ts b/tests/integration/import-content.test.ts index 88b6175..10262dd 100644 --- a/tests/integration/import-content.test.ts +++ b/tests/integration/import-content.test.ts @@ -614,6 +614,8 @@ describe('内容写入器(稳定身份 + checksum skip)', () => { revisionsCreated: 0, sourceRecords: 2, skipped: 0, + unsupportedRecords: 1, + failedRecords: 1, }) // unsupported/error 是"有明确结果"的预期输入(source_records 行即审计),非 issue expect(report.issues).toStrictEqual([])