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
+28 -23
View File
@@ -72,7 +72,7 @@ import type { AdapterEvent } from '@sf/adapter-wp'
import type { UniversalRow } from '@sf/contracts'
import { mediaAssets, routeRegistry, sites, sourceRecords, taxonomies, terms } from '../../db/schema.js'
import { CONTENT_TYPE_KEY_MAP } from './content-writer.js'
import type { ImportDb, ImportPipeline, ImportPipelineOutcome } from './import.service.js'
import type { ImportPipeline, ImportPipelineOutcome, WriterContext } from './import.service.js'
/** CLI --products-path 默认值(前置事实 9:记入 job metadata,本写入器收规范化值) */
export const DEFAULT_PRODUCTS_PATH = '/products/'
@@ -88,8 +88,8 @@ export function foldCollisionKey(path: string): string {
return path.normalize('NFC').toLowerCase().replace(/\/+$/, '')
}
/** 主列表路径规范化(CLI 参数防线:补首尾斜杠) */
function normalizeListPath(path: string): string {
/** 主列表路径规范化(CLI 参数防线:补首尾斜杠);Task 6 起编排层导入用于报告记录实际生效值 */
export function normalizeListPath(path: string): string {
const withLeading = path.startsWith('/') ? path : `/${path}`
return withLeading.endsWith('/') ? withLeading : `${withLeading}/`
}
@@ -176,11 +176,7 @@ interface RoutePayload {
* identity 注记:connectionId/snapshotId 是 entry 锚点解析与 source_snapshot_id 溯源的
* 必需输入(与 content/media 同源——Task 6 从 ImportJobContext 共享注入)。
*/
export function createRouteEventHandler(identity: {
db: ImportDb
siteId: string
/** source_records 五列身份解析的连接锚点(content-writer 同口径) */
connectionId: string
export function createRouteEventHandler(identity: WriterContext & {
/** 行溯源(route_registry.source_snapshot_id) */
snapshotId: string
/** locale 校验基准(与 content/media 同拒收口径——不双报) */
@@ -443,20 +439,27 @@ export function createRouteEventHandler(identity: {
/**
* finish 期单条 attachment 处理:URL 层防线(link 在场 → 解析 → ?p=/&page_id= 排除 → 路径
* 安全)先行,资产反查在后 → 301 载荷(查询/permalink 双形态)。返回 true 仅当实际写入
* (created/updated——重复事件 no-op 不重复计入降级汇总)。
* 安全)先行,资产反查在后 → 301 载荷(查询/permalink 双形态)。返回三态:
* - 'written':实际写入(created/updated);
* - 'resolved':目标解析成功且行全等 no-op(幂等重跑——降级处置照常成立,只是零写入);
* - 'blocked':目标缺席/歧义/防线拒绝(issue 已记,路由未登记)。
* 降级汇总按 **去重 source_id 的 resolved∪written** 计(Task 6 口径:降级是处置事实,
* 重跑如实再现;同 attachment 重复事件不虚报——Plan 3 钉死语义)。
*/
async function writeAttachmentRoute(att: PendingAttachment, host: string): Promise<boolean> {
async function writeAttachmentRoute(
att: PendingAttachment,
host: string,
): Promise<'written' | 'resolved' | 'blocked'> {
if (att.sourceUrl === '') {
issues.push(`attachment 缺 link(wp:attachment/${att.sourceId},${att.rawLocation}):无页面 URL 证据——301 路由不登记`)
return false
return 'blocked'
}
let parsed: URL
try {
parsed = new URL(att.sourceUrl)
} catch (err) {
issues.push(`attachment link 解析失败(wp:attachment/${att.sourceId},${att.sourceUrl}):${err instanceof Error ? err.message : String(err)}——301 路由不登记`)
return false
return 'blocked'
}
// ?p=/&page_id= 排除口径与内容行一致(Fix 对齐:attachment link 同形态同排除——
// excluded_with_reason 语义见模块头;?attachment_id= 不在排除表,照常 query_key 建模)
@@ -464,12 +467,12 @@ export function createRouteEventHandler(identity: {
if (parsed.searchParams.has(param)) {
counts.routesExcludedQueries++
issues.push(`查询 URL 排除(wp:attachment/${att.sourceId},${att.sourceUrl}):?${param}= 形态不登记 route_registry(route_kind 无 excluded 值——查询路由 SP3;spec §6.1/§6.2 excluded_with_reason 以计数+本 issue 落报告)`)
return false
return 'blocked'
}
}
if (!isSafePath(parsed.pathname)) {
issues.push(`attachment 路径安全校验拒绝(wp:attachment/${att.sourceId},${parsed.pathname}):含 NUL/反斜杠/控制字符/穿越段——301 路由不登记(完整校验规则 SP3 验证器)`)
return false
return 'blocked'
}
const assetRows = await db
.select({ publicPath: mediaAssets.publicPath })
@@ -485,12 +488,12 @@ export function createRouteEventHandler(identity: {
.limit(2)
if (assetRows.length === 0) {
issues.push(`attachment 301 目标缺席(wp:attachment/${att.sourceId},${att.sourceUrl}):media_assets 无对应资产(媒体三级对拍 miss?)——attachment 页路由未登记`)
return false
return 'blocked'
}
if (assetRows.length > 1) {
// LIMIT 2 探针只知下界——歧义消息按 ≥2 表述(不自动选,spec §5.5)
issues.push(`attachment 多资产歧义(wp:attachment/${att.sourceId}):同 source_id 命中 ≥2 行——不自动选(spec §5.5),attachment 页路由未登记`)
return false
return 'blocked'
}
const publicPath = assetRows[0]!.publicPath
const queryKey = canonicalQueryKey(parsed)
@@ -513,7 +516,7 @@ export function createRouteEventHandler(identity: {
priority: 0,
verificationStatus: 'verified_redirect',
})
return outcome === 'created' || outcome === 'updated'
return outcome === 'created' || outcome === 'updated' ? 'written' : 'resolved'
}
return {
@@ -637,13 +640,15 @@ export function createRouteEventHandler(identity: {
}
}
// ③ 暂存 attachment 301——成功计数汇总为一条降级 issue(前置事实 10)
let downgraded = 0
// ③ 暂存 attachment 301——降级汇总为一条 issue(前置事实 10);计数按去重
// source_id 的已解析处置数(written∪resolved):幂等重跑 no-op 零写入但降级处置
// 照常成立、如实再现(Plan 3 钉死);同 attachment 重复事件不虚报(去重)
const downgradedSources = new Set<string>()
for (const att of pendingAttachments) {
if (await writeAttachmentRoute(att, host)) downgraded++
if ((await writeAttachmentRoute(att, host)) !== 'blocked') downgradedSources.add(att.sourceId)
}
if (downgraded > 0) {
issues.push(`attachment 页降级(前置事实 10):${downgraded} 条 attachment 页一律 301 → 媒体文件——仅 WXR 证据、无 sitemap 解析(sitemap→html 分支留待 SP3)`)
if (downgradedSources.size > 0) {
issues.push(`attachment 页降级(前置事实 10):${downgradedSources.size} 条 attachment 页一律 301 → 媒体文件——仅 WXR 证据、无 sitemap 解析(sitemap→html 分支留待 SP3)`)
}
// ④ asset 行(media_assets 全表——site 的旧 URL 空间台账;source_url 是来源原文)