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:
@@ -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 是来源原文)
|
||||
|
||||
Reference in New Issue
Block a user