Files
cms-website/apps/api/src/modules/import/route-writer.ts
T
浅念 8003de336b 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>
2026-10-21 20:08:06 +08:00

715 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 路由注册表写入器(Plan 3 Task 5,spec §5.6/§6.1/§6.2/§6.3、前置事实 9/10/11、ADR-007):
// 消费适配器事件流把 §6.1 处置全表落 route_registry——route 域的领域 owner(唯一写入方)。
//
// §6.1 全表处置(阶段 3 可产生的行):
// - 内容 permalink → html:URL pathname 保真(含 .html 后缀/大小写/尾斜杠),target_content_id
// 经 source_records → content_entry_id 关联(content-writer 先处理的顺序契约,Task 6 编排);
// - 首页 / → html;主列表 /products/ /news/ → html(CLI 参数 --products-path/--news-path 注入,
// 默认即此——前置事实 9);站点维度页在 finish() 生成(无行级输入源);
// - 产品/文章分类(category/product_cat)→ html:taxonomies.public_base_path 常量 + term slug
// (如 /category/company/);term 事件本身不在 handleEvent 消费——分类页在 finish() 由
// terms×taxonomies 全表生成(term-writer 的物化产物是唯一输入,双序终态一致);
// - tag/product_tag → redirect 301 到对应主列表(post 域 → news 列表、product 域 → products
// 列表;本切片不生成 tag 页——§6.1 钉死);
// - attachment 页 → **一律 301 媒体文件**(前置事实 10 降级:阶段 3 无 sitemap 解析器,
// sitemap→html 分支留待 SP3)。时序:依赖 media-writer 先落 media_assets——本写入器的
// attachment 处理推迟到 finish()(暂存事件、反查 media_assets by source_id 取 public_path)。
// 降级在报告**一条汇总 issue**(不每条一 issue);目标缺席(三级对拍 miss 未插资产行)则
// 逐条 issue + 不登记路由(§7.6 禁静默);
// - 上传媒体路径 → asset:finish() 由 media_assets 全表生成(public_path = 旧 uploads 路径,
// target_file 暂同 public_path——产物媒体文件路径**占位**,构建器落地后改 dist 相对路径。
// 含 quarantined/unsupported 资产:route_registry 是旧 URL 空间台账,可服务性是构建域裁决)。
// `?attachment_id=` 查询形态的 attachment 页:查询路由单独建模(spec §5.6"查询路由不与
// path 静默合并")——query_key='attachment_id=<id>'、query_policy='exact'、exact_route_key=
// pathname;permalink 形态则按纯路径行(query_key NULL、policy 'ignore')。
//
// **合理空缺**(§6.1 行无阶段 3 输入源,不产行不造行):人工映射(redirect/unresolved)、
// 确认永久删除(410/gone)、日期/作者归档(WXR 无归档 URL 证据;301 主列表的逐条登记留待
// sitemap/日志解析能力,SP3)。§6.2 门要求的 unknown_source_gap 归档同理推迟。
//
// **?p=/&page_id= 处置(钉死决策)**:spec §6.1 要求登记 excluded_with_reason,但
// route_kind 六值(html/asset/redirect/gone/dynamic/unresolved)**无 excluded 值**——查询
// 路由本身是 SP3 能力(§6.1 明示"登记不实现")。故本写入器对含 ?p=/&page_id= 的来源 URL
// **不插 route_registry 行**,以 counts.routesExcludedQueries + 逐条 issue 落报告
// (excluded_with_reason 的报告语义),行级登记随 SP3 查询路由能力一起落地。内容行与
// attachment link **同口径排除**(?attachment_id= 不在排除表——attachment 页处置是 §6.1
// 明示的 301 行,照常 query_key 建模)。
//
// exact/collision 双键(ADR-007):exact_route_key 保留大小写/尾斜杠语义(/About 与 /about
// 两行并存);collision_key 折叠(前置事实 9 钉死:NFC + lowercase + 去尾斜杠)只作风险
// 检测——折叠同键而 exact 不同 → routesCollisions + issue 预警(不计失败、不拦截)。
//
// host 单值决策:全部行落 sites.production_origin 的 hostname(站点配置是新站的权威 host;
// 内容 sourceUrl 的旧 host 变体/www 与 apex 的 canonicalization 是 SP3+ 域)——原文完整保留
// 在 source_path_raw。exact/collision 检测均在 (site, host) 内进行。
//
// upsert 三分(route 表可变——不同于 content_revisions 不可变纪律):
// - miss → INSERT(routesCreated++;redirect 类另计 routesRedirects++——按本运行**新插**计,
// 与 mediaRegistered 口径一致,幂等重跑归零);
// - exact 命中且同 owner(route_kind + target_content_id + target_file 三元组相等)→ 比对
// 描述字段(source_path_raw/target_status/redirect_status/redirect_location/indexable/
// priority/source_status/verification_status),有差即 UPDATE(routesUpdated++),无差
// no-op(幂等重跑零计数);
// - exact 命中而异 owner → **不静默覆盖**:issue 列出先到/后到,保留先到(文档序 first-wins)。
// owner 判据注记:html 行以 target_content_id 判(两内容同路径 → 冲突);attachment 301
// 以 target_file(=public_path,对同一 attachment 恒稳——public_path 由其自身 source_url
// 派生)判,不同 attachment 同页路径 → 冲突;tag 301 的 target_file NULL(term 行按
// exact key 唯一归属,参数变化只触发同 owner 的 redirect_location UPDATE)。
// permalink 变化(同 entry 新路径):新 exact key INSERT、旧路径行**原样保留**(不自动
// 降级为 301——旧路径的 redirect 处置是 SEO diff/人工映射域,SP3+;Task 3 已钉死不建新
// entry,route 侧不虚构目标)。
//
// counts 键(**跨写入器扁平全局命名空间**,见 ImportPipelineOutcome.counts 契约——勿复用
// content/term/media 前缀键):routesCreated/routesUpdated/routesRedirects/routesCollisions/
// routesExcludedQueries。issues:排除查询/冲突/碰撞预警/降级汇总/301 目标缺席/多资产歧义/
// permalink 解析失败/路径安全拒绝——全部显式报告(§7.6 禁静默)。
//
// 事务边界:全部单语句原子操作(SELECT → INSERT/UPDATE 序列无跨语句不变量——并发同 key
// 由 route_exact_uq 唯一约束兜底,冲突上抛按 PIPELINE_FAILED 处置),与 term/media 同纪律
// (ctx.db 直连约定)。
import { and, desc, eq, inArray, isNotNull, isNull, ne } from 'drizzle-orm'
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 { ImportPipeline, ImportPipelineOutcome, WriterContext } from './import.service.js'
/** CLI --products-path 默认值(前置事实 9:记入 job metadata,本写入器收规范化值) */
export const DEFAULT_PRODUCTS_PATH = '/products/'
/** CLI --news-path 默认值(同上) */
export const DEFAULT_NEWS_PATH = '/news/'
/**
* collision_key 折叠函数(前置事实 9 钉死):NFC + lowercase + 去尾斜杠(根路径 '/' 折为 '')。
* 只用于风险检测,永不参与唯一约束与请求匹配(ADR-007)。折叠仅在本写入器 JS 侧计算落列
* ——勿用 PG normalize 在库侧重算(ICU 版本差异会致折叠结果漂移、预警失真)。
*/
export function foldCollisionKey(path: string): string {
return path.normalize('NFC').toLowerCase().replace(/\/+$/, '')
}
/** 主列表路径规范化(CLI 参数防线:补首尾斜杠);Task 6 起编排层导入用于报告记录实际生效值 */
export function normalizeListPath(path: string): string {
const withLeading = path.startsWith('/') ? path : `/${path}`
return withLeading.endsWith('/') ? withLeading : `${withLeading}/`
}
/**
* §6.1 的 taxonomy 路由处置表(key 必须在 term-writer 的 TERM_TAXONOMY_DEFAULTS 四键内):
* category/product_cat → 分类列表页 html;post_tag/product_tag → 301 到对应主列表
* (post 域 → news、product 域 → products)。表外 taxonomy(nav_menu 等)不产路由行
* (term-writer JSDoc 同口径:仅记录不消费)。
*/
type TermRouteDisposition = { kind: 'list' } | { kind: 'redirect'; list: 'news' | 'products' }
type TermRouteDispositions = Readonly<Record<string, TermRouteDisposition>>
const TERM_ROUTE_DISPOSITIONS: TermRouteDispositions = Object.freeze({
category: Object.freeze({ kind: 'list' }) as TermRouteDisposition,
product_cat: Object.freeze({ kind: 'list' }) as TermRouteDisposition,
post_tag: Object.freeze({ kind: 'redirect', list: 'news' }) as TermRouteDisposition,
product_tag: Object.freeze({ kind: 'redirect', list: 'products' }) as TermRouteDisposition,
})
/** §6.1 excluded_with_reason 的查询参数(?p= 与 ?page_id=——查询路由 SP3,见模块头钉死决策) */
const EXCLUDED_QUERY_PARAMS: ReadonlySet<string> = new Set(['p', 'page_id'])
/** 落值常量:本阶段全行 source_status='from_import'(待实数据改为报告语义);target_status 随 kind */
const SOURCE_STATUS_IMPORT = 'from_import'
const TARGET_STATUS_OK = '200'
const TARGET_STATUS_REDIRECT = '301'
const REDIRECT_DEFAULT = 301
/**
* 可分派的路由写入器句柄(Task 6 编排形状,与 term/content/media 同构——finish 异步:
* attachment 301/站点页/分类页推迟到流终,见模块头)。句柄非可重入:一个句柄只服务一遍流。
*/
export interface RouteEventHandler {
/** 单事件分派(term/unsupported/error 为 no-op——分类页 finish 期由 terms 表生成) */
handleEvent(ev: AdapterEvent): Promise<void>
/** 流终:站点页 + 分类/tag 页 + 暂存 attachment 301 + asset 行 → 取回累计产出(调用后句柄弃用) */
finish(): Promise<ImportPipelineOutcome>
}
/** 本写入器独占 counts 键(route 前缀,全局命名空间契约——见模块头) */
interface RouteWriterCounts {
/** 本运行新插的 route_registry 行数(幂等重跑为 0) */
routesCreated: number
/** exact 命中且同 owner 但描述字段有差 → UPDATE 的行数 */
routesUpdated: number
/** 本运行新插的 redirect 类行数(301 类计数——routesCreated 的子集口径) */
routesRedirects: number
/** collision_key 折叠碰撞预警次数(不计失败——两行并存是 ADR-007 语义) */
routesCollisions: number
/** ?p=/&page_id= 查询形态排除数(内容行与 attachment link 同口径,不插行——excluded_with_reason 的报告落点) */
routesExcludedQueries: number
}
/** finish 期反查用的 attachment 暂存项(handleEvent 只暂存不解析——media_assets 未必已齐) */
interface PendingAttachment {
sourceId: string
/** <link> 原串(?attachment_id= 或 permalink 形态的判别输入);缺席 = '' */
sourceUrl: string
rawLocation: string
}
/** 单行写入载荷(writeRoute 三分 upsert 的输入;routePath 同时落 route_path 与 exact_route_key) */
interface RoutePayload {
/** issue 行内的人类可读归属描述(不落库) */
label: string
host: string
sourcePathRaw: string
routePath: string
queryPolicy: 'ignore' | 'exact'
queryKey: string | null
routeKind: 'html' | 'asset' | 'redirect'
targetStatus: string
targetContentId: string | null
targetFile: string | null
redirectStatus: number | null
redirectLocation: string | null
indexable: boolean
priority: number
verificationStatus: string
}
/**
* 路由注册表写入器的事件分派句柄。
* identity 注记:connectionId/snapshotId 是 entry 锚点解析与 source_snapshot_id 溯源的
* 必需输入(与 content/media 同源——Task 6 从 ImportJobContext 共享注入)。
*/
export function createRouteEventHandler(identity: WriterContext & {
/** 行溯源(route_registry.source_snapshot_id) */
snapshotId: string
/** locale 校验基准(与 content/media 同拒收口径——不双报) */
defaultLocale: string
/** CLI --products-path(前置事实 9 默认 /products/) */
productsPath: string
/** CLI --news-path(前置事实 9 默认 /news/) */
newsPath: string
}): RouteEventHandler {
const { db, siteId } = identity
const productsPath = normalizeListPath(identity.productsPath)
const newsPath = normalizeListPath(identity.newsPath)
const counts: RouteWriterCounts & Record<string, number> = {
routesCreated: 0,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
}
const issues: string[] = []
const pendingAttachments: PendingAttachment[] = []
/** 碰撞预警的运行内去重(键 = 折叠键 + 双 exact 排序对——重跑/对向写入只报一次) */
const flaggedCollisionPairs = new Set<string>()
/** host/origin 惰性缓存(首个需要者解析一次;site 行由 register 建立必在) */
let cachedOrigin: { origin: string; host: string } | null = null
async function siteOrigin(): Promise<{ origin: string; host: string }> {
if (cachedOrigin !== null) return cachedOrigin
const rows = await db
.select({ origin: sites.productionOrigin })
.from(sites)
.where(eq(sites.id, siteId))
.limit(1)
const origin = rows[0]?.origin
if (origin === undefined) {
throw new Error(`站点不存在:${siteId}——路由写入器无法解析 production_origin host`)
}
try {
cachedOrigin = { origin: origin.replace(/\/+$/, ''), host: new URL(origin).hostname }
} catch {
throw new Error(`站点 production_origin 非法 URL:${origin}(register 契约破裂)`)
}
return cachedOrigin
}
/** null 归一相等(undefined/null/值 三态对拍) */
function nullEq(a: string | number | null | undefined, b: string | number | null | undefined): boolean {
return (a ?? null) === (b ?? null)
}
/**
* 路径安全防线(最小集):NUL/反斜杠/控制字符/穿越段拒绝——**全部四个写入域统一经过**
* (内容 permalink / attachment link / finish 期分类页 / finish 期 asset public_path:
* 后两者输入是 media-writer 的原样切片与 WXR 原始 slug,同携真实穿越向量)。完整校验
* 规则(RFC 非保留字符解码、Windows 保留设备名、尾部空格点、超长路径——spec §5.6)
* 是 SP3 验证器职责。
*/
function isSafePath(path: string): boolean {
if (path.includes('\0') || path.includes('\\')) return false
for (let i = 0; i < path.length; i++) {
const code = path.charCodeAt(i)
if (code < 0x20 || code === 0x7f) return false
}
return !path.split('/').includes('..')
}
/** query_key 规范形态:'k=v' 按 URL 序 & 连接(无查询 → null = 纯路径路由) */
function canonicalQueryKey(u: URL): string | null {
const pairs = [...u.searchParams].map(([k, v]) => `${k}=${v}`)
return pairs.length === 0 ? null : pairs.join('&')
}
/** source_records 五列身份 → content_entry_id(content-writer 接缝,latest first——与 term-writer 同口径) */
async function resolveEntryAnchor(row: UniversalRow): Promise<string | null> {
const rows = await db
.select({ contentEntryId: sourceRecords.contentEntryId })
.from(sourceRecords)
.where(
and(
eq(sourceRecords.siteId, siteId),
eq(sourceRecords.sourceConnectionId, identity.connectionId),
eq(sourceRecords.sourceType, row.sourceType),
eq(sourceRecords.sourceId, row.sourceId),
eq(sourceRecords.locale, row.locale),
isNotNull(sourceRecords.contentEntryId),
),
)
.orderBy(desc(sourceRecords.createdAt))
.limit(1)
return rows[0]?.contentEntryId ?? null
}
/** writeRoute 的四态产出(降级计数只认实际写入——'created'/'updated') */
type WriteOutcome = 'created' | 'updated' | 'noop' | 'conflict'
/**
* 单行写入(三分 upsert,见模块头):碰撞预警 → exact 查 → miss INSERT / 同 owner 比对
* UPDATE / 异 owner 冲突 issue 保留先到。返回四态(调用方按需消费——如 attachment 降级
* 计数只数实际写入,重复事件 no-op 不重复计数)。
*/
async function writeRoute(p: RoutePayload): Promise<WriteOutcome> {
const collisionKey = foldCollisionKey(p.routePath)
// 碰撞预警(ADR-007:折叠同键而 exact 不同——两行并存,只预警不拦截)
const offenders = await db
.select({ exact: routeRegistry.exactRouteKey })
.from(routeRegistry)
.where(
and(
eq(routeRegistry.siteId, siteId),
eq(routeRegistry.host, p.host),
eq(routeRegistry.collisionKey, collisionKey),
ne(routeRegistry.exactRouteKey, p.routePath),
),
)
.limit(1)
const offender = offenders[0]?.exact
if (offender !== undefined) {
const pairKey = `${collisionKey}\u0000${[offender, p.routePath].sort().join('\u0001')}`
if (!flaggedCollisionPairs.has(pairKey)) {
flaggedCollisionPairs.add(pairKey)
counts.routesCollisions++
issues.push(
`路由 collision_key 折叠碰撞(${offender} 与 ${p.routePath} 折叠同键 ${JSON.stringify(collisionKey)}):两行并存(ADR-007 双键语义),预警不计失败`,
)
}
}
// exact 查(query_key NULL 语义对拍——route_exact_uq 的第四列)
const queryKeyCond = p.queryKey === null ? isNull(routeRegistry.queryKey) : eq(routeRegistry.queryKey, p.queryKey)
const existingRows = await db
.select()
.from(routeRegistry)
.where(
and(
eq(routeRegistry.siteId, siteId),
eq(routeRegistry.host, p.host),
eq(routeRegistry.exactRouteKey, p.routePath),
queryKeyCond,
),
)
.limit(1)
const existing = existingRows[0]
if (existing === undefined) {
await db.insert(routeRegistry).values({
siteId,
host: p.host,
sourcePathRaw: p.sourcePathRaw,
routePath: p.routePath,
exactRouteKey: p.routePath,
collisionKey,
queryPolicy: p.queryPolicy,
queryKey: p.queryKey,
sourceStatus: SOURCE_STATUS_IMPORT,
targetStatus: p.targetStatus,
routeKind: p.routeKind,
targetContentId: p.targetContentId,
targetFile: p.targetFile,
redirectStatus: p.redirectStatus,
redirectLocation: p.redirectLocation,
indexable: p.indexable,
priority: p.priority,
sourceSnapshotId: identity.snapshotId,
verificationStatus: p.verificationStatus,
})
counts.routesCreated++
if (p.routeKind === 'redirect') counts.routesRedirects++
return 'created'
}
// owner 判据三元组(见模块头 upsert 注记)
const sameOwner =
existing.routeKind === p.routeKind &&
nullEq(existing.targetContentId, p.targetContentId) &&
nullEq(existing.targetFile, p.targetFile)
if (!sameOwner) {
const existingTarget = existing.targetContentId ?? existing.targetFile ?? existing.redirectLocation ?? existing.sourcePathRaw
const queryPart = p.queryKey === null ? '' : `?${p.queryKey}`
issues.push(
`路由 exact_key 冲突(${p.host}${p.routePath}${queryPart}):保留先到(${existing.routeKind} → ${existingTarget})——跳过后到(${p.label},${p.routeKind})——不静默覆盖`,
)
return 'conflict'
}
const set: Partial<typeof routeRegistry.$inferInsert> = {}
if (existing.sourcePathRaw !== p.sourcePathRaw) set.sourcePathRaw = p.sourcePathRaw
if (existing.targetStatus !== p.targetStatus) set.targetStatus = p.targetStatus
if (!nullEq(existing.redirectStatus, p.redirectStatus)) set.redirectStatus = p.redirectStatus
if (!nullEq(existing.redirectLocation, p.redirectLocation)) set.redirectLocation = p.redirectLocation
if (existing.indexable !== p.indexable) set.indexable = p.indexable
if (existing.priority !== p.priority) set.priority = p.priority
if (existing.sourceStatus !== SOURCE_STATUS_IMPORT) set.sourceStatus = SOURCE_STATUS_IMPORT
if (existing.verificationStatus !== p.verificationStatus) set.verificationStatus = p.verificationStatus
if (Object.keys(set).length > 0) {
set.updatedAt = new Date()
await db.update(routeRegistry).set(set).where(eq(routeRegistry.id, existing.id))
counts.routesUpdated++
return 'updated'
}
// 全等:幂等 no-op(重跑零计数)
return 'noop'
}
/**
* 内容行主路径:permalink 解析(URL pathname + 查询检测)→ html / excluded 三分。
* locale 口径与 content/media 一致(拒收行不双报);非 typeKey 映射类型(nav_menu 等
* record-only 行)无页面语义,静默越过。
*/
async function handleContentRow(row: UniversalRow): Promise<void> {
if (row.locale !== identity.defaultLocale) return
if (!Object.hasOwn(CONTENT_TYPE_KEY_MAP, row.sourceType)) return
const url = row.sourceUrl
if (url === undefined || url === '') {
issues.push(`内容行缺 permalink(${row.sourceType}/${row.sourceId},${row.rawLocation}):sourceUrl 缺席——路由不登记(无法保真的路径是人工映射域,§6.1 空缺)`)
return
}
let parsed: URL
try {
parsed = new URL(url)
} catch (err) {
issues.push(`permalink 解析失败(${row.sourceType}/${row.sourceId},${url}):${err instanceof Error ? err.message : String(err)}——路由不登记`)
return
}
// ?p=/&page_id=(§6.1 excluded_with_reason——模块头钉死:不插行,计数+issue 落报告)
for (const param of EXCLUDED_QUERY_PARAMS) {
if (parsed.searchParams.has(param)) {
counts.routesExcludedQueries++
issues.push(`查询 URL 排除(${row.sourceType}/${row.sourceId},${url}):?${param}= 形态不登记 route_registry(route_kind 无 excluded 值——查询路由 SP3;spec §6.1/§6.2 excluded_with_reason 以计数+本 issue 落报告)`)
return
}
}
const path = parsed.pathname
if (!isSafePath(path)) {
issues.push(`路径安全校验拒绝(${row.sourceType}/${row.sourceId},${path}):含 NUL/反斜杠/控制字符/穿越段——路由不登记(完整校验规则 SP3 验证器)`)
return
}
const entryId = await resolveEntryAnchor(row)
if (entryId === null) {
issues.push(`内容行路由无 entry 锚点(${row.sourceType}/${row.sourceId}/${row.locale},${row.rawLocation}):source_records 无 content_entry_id(content-writer 未先处理该行?)——路由跳过`)
return
}
const { host } = await siteOrigin()
await writeRoute({
label: `内容 ${row.sourceType}/${row.sourceId}`,
host,
sourcePathRaw: url,
routePath: path,
queryPolicy: 'ignore',
queryKey: null,
routeKind: 'html',
targetStatus: TARGET_STATUS_OK,
targetContentId: entryId,
targetFile: null,
redirectStatus: null,
redirectLocation: null,
indexable: true,
priority: 500,
verificationStatus: 'verified_html',
})
}
/**
* finish 期单条 attachment 处理:URL 层防线(link 在场 → 解析 → ?p=/&page_id= 排除 → 路径
* 安全)先行,资产反查在后 → 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<'written' | 'resolved' | 'blocked'> {
if (att.sourceUrl === '') {
issues.push(`attachment 缺 link(wp:attachment/${att.sourceId},${att.rawLocation}):无页面 URL 证据——301 路由不登记`)
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 'blocked'
}
// ?p=/&page_id= 排除口径与内容行一致(Fix 对齐:attachment link 同形态同排除——
// excluded_with_reason 语义见模块头;?attachment_id= 不在排除表,照常 query_key 建模)
for (const param of EXCLUDED_QUERY_PARAMS) {
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 'blocked'
}
}
if (!isSafePath(parsed.pathname)) {
issues.push(`attachment 路径安全校验拒绝(wp:attachment/${att.sourceId},${parsed.pathname}):含 NUL/反斜杠/控制字符/穿越段——301 路由不登记(完整校验规则 SP3 验证器)`)
return 'blocked'
}
const assetRows = await db
.select({ publicPath: mediaAssets.publicPath })
.from(mediaAssets)
.where(
and(
eq(mediaAssets.siteId, siteId),
eq(mediaAssets.sourceConnectionId, identity.connectionId),
eq(mediaAssets.sourceId, att.sourceId),
),
)
.orderBy(mediaAssets.sourceUrl)
.limit(2)
if (assetRows.length === 0) {
issues.push(`attachment 301 目标缺席(wp:attachment/${att.sourceId},${att.sourceUrl}):media_assets 无对应资产(媒体三级对拍 miss?)——attachment 页路由未登记`)
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 'blocked'
}
const publicPath = assetRows[0]!.publicPath
const queryKey = canonicalQueryKey(parsed)
const outcome = await writeRoute({
label: `attachment ${att.sourceId}`,
host,
sourcePathRaw: att.sourceUrl,
routePath: parsed.pathname,
queryPolicy: queryKey === null ? 'ignore' : 'exact',
queryKey,
routeKind: 'redirect',
targetStatus: TARGET_STATUS_REDIRECT,
targetContentId: null,
// target_file = public_path:owner 判据锚(同一 attachment 的 public_path 恒稳)+ 301
// 目标落 asset 的 §6.3 约束留痕
targetFile: publicPath,
redirectStatus: REDIRECT_DEFAULT,
redirectLocation: publicPath,
indexable: false,
priority: 0,
verificationStatus: 'verified_redirect',
})
return outcome === 'created' || outcome === 'updated' ? 'written' : 'resolved'
}
return {
async handleEvent(ev) {
if (ev.kind !== 'row') return // term/unsupported/error:分类页 finish 期由 terms 表生成;余无路由信息
const row = ev.row
if (row.sourceType === 'wp:attachment') {
// 暂存不解析(media_assets 未必已齐——模块头时序设计);locale 口径与 media-writer 一致
if (row.locale !== identity.defaultLocale) return
pendingAttachments.push({
sourceId: row.sourceId,
sourceUrl: row.sourceUrl ?? '',
rawLocation: row.rawLocation,
})
return
}
await handleContentRow(row)
},
/**
* 流终生成域(全部推迟到此的理由见各段注记):
* ① 站点维度页(首页/两主列表——site 配置输入,无行级事件);
* ② 分类页 + tag 301(terms×taxonomies 全表——term 事件的物化产物在 term-writer,双序一致);
* ③ 暂存 attachment 的 301(media_assets 已齐——media-writer.finish 先于本 finish 的编排契约);
* ④ asset 行(media_assets 全表——旧 URL 空间台账,含非 ready 资产,见模块头)。
*/
async finish() {
const { origin, host } = await siteOrigin()
// ① 站点维度页
await writeRoute({
label: '首页(站点配置 /)',
host,
sourcePathRaw: `${origin}/`,
routePath: '/',
queryPolicy: 'ignore',
queryKey: null,
routeKind: 'html',
targetStatus: TARGET_STATUS_OK,
targetContentId: null,
targetFile: null,
redirectStatus: null,
redirectLocation: null,
indexable: true,
priority: 1000,
verificationStatus: 'verified_html',
})
for (const listPath of [productsPath, newsPath]) {
await writeRoute({
label: `主列表页(CLI 参数 ${listPath})`,
host,
sourcePathRaw: `${origin}${listPath}`,
routePath: listPath,
queryPolicy: 'ignore',
queryKey: null,
routeKind: 'html',
targetStatus: TARGET_STATUS_OK,
targetContentId: null,
targetFile: null,
redirectStatus: null,
redirectLocation: null,
indexable: true,
priority: 900,
verificationStatus: 'verified_html',
})
}
// ② 分类页 + tag 301(terms×taxonomies;public_base_path 从 taxonomies 行取——term-writer 落的前置事实 9 常量)
const termRows = await db
.select({ taxonomyKey: taxonomies.key, basePath: taxonomies.publicBasePath, slug: terms.slug })
.from(terms)
.innerJoin(taxonomies, eq(terms.taxonomyId, taxonomies.id))
.where(and(eq(terms.siteId, siteId), inArray(taxonomies.key, Object.keys(TERM_ROUTE_DISPOSITIONS))))
.orderBy(taxonomies.key, terms.slug)
for (const t of termRows) {
const disposition = TERM_ROUTE_DISPOSITIONS[t.taxonomyKey]!
const withSlug = `${t.basePath}${t.slug}`
const pagePath = withSlug.endsWith('/') ? withSlug : `${withSlug}/`
// 安全防线与内容 permalink 同口径:slug 是 WXR 原始串(穿越向量真实存在)
if (!isSafePath(pagePath)) {
issues.push(`分类页路径安全校验拒绝(${t.taxonomyKey}/${t.slug},${pagePath}):含 NUL/反斜杠/控制字符/穿越段——路由不登记(完整校验规则 SP3 验证器)`)
continue
}
if (disposition.kind === 'list') {
await writeRoute({
label: `分类页 ${t.taxonomyKey}/${t.slug}`,
host,
sourcePathRaw: `${origin}${pagePath}`,
routePath: pagePath,
queryPolicy: 'ignore',
queryKey: null,
routeKind: 'html',
targetStatus: TARGET_STATUS_OK,
targetContentId: null,
targetFile: null,
redirectStatus: null,
redirectLocation: null,
indexable: true,
priority: 800,
verificationStatus: 'verified_html',
})
} else {
const target = disposition.list === 'news' ? newsPath : productsPath
await writeRoute({
label: `tag 301 ${t.taxonomyKey}/${t.slug}`,
host,
sourcePathRaw: `${origin}${pagePath}`,
routePath: pagePath,
queryPolicy: 'ignore',
queryKey: null,
routeKind: 'redirect',
targetStatus: TARGET_STATUS_REDIRECT,
targetContentId: null,
targetFile: null,
redirectStatus: REDIRECT_DEFAULT,
redirectLocation: target,
indexable: false,
priority: 0,
verificationStatus: 'verified_redirect',
})
}
}
// ③ 暂存 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)) !== 'blocked') downgradedSources.add(att.sourceId)
}
if (downgradedSources.size > 0) {
issues.push(`attachment 页降级(前置事实 10):${downgradedSources.size} 条 attachment 页一律 301 → 媒体文件——仅 WXR 证据、无 sitemap 解析(sitemap→html 分支留待 SP3)`)
}
// ④ asset 行(media_assets 全表——site 的旧 URL 空间台账;source_url 是来源原文)
const assetRows = await db
.select({ sourceUrl: mediaAssets.sourceUrl, publicPath: mediaAssets.publicPath })
.from(mediaAssets)
.where(eq(mediaAssets.siteId, siteId))
.orderBy(mediaAssets.publicPath)
for (const a of assetRows) {
// 安全防线与内容 permalink 同口径:public_path 是 media-writer 对来源 URL 的原样切片
//(相对路径保留 .. 段——穿越向量真实存在),不因来源是库表而豁免
if (!isSafePath(a.publicPath)) {
issues.push(`媒体路径安全校验拒绝(media_assets public_path=${a.publicPath}):含 NUL/反斜杠/控制字符/穿越段——asset 路由不登记(完整校验规则 SP3 验证器)`)
continue
}
await writeRoute({
label: `媒体 asset ${a.publicPath}`,
host,
sourcePathRaw: a.sourceUrl,
routePath: a.publicPath,
queryPolicy: 'ignore',
queryKey: null,
routeKind: 'asset',
targetStatus: TARGET_STATUS_OK,
targetContentId: null,
// 产物媒体文件路径占位(构建器落地后改 dist 相对路径——模块头)
targetFile: a.publicPath,
redirectStatus: null,
redirectLocation: null,
indexable: false,
priority: 100,
verificationStatus: 'verified_asset',
})
}
return { counts, issues }
},
}
}
/**
* 路由注册表管道写入器({@link createRouteEventHandler} 的自消费整流便捷壳):单遍拉完
* ctx.events、逐事件分派、流终返回 outcome。Task 6 编排以句柄形状直接分派(content 先行、
* media.finish 先于 route.finish 的顺序契约见模块头);productsPath/newsPath 缺省用 CLI
* 默认常量(前置事实 9)。
*/
export function createRouteWriter(paths?: { productsPath?: string; newsPath?: string }): ImportPipeline {
return async (ctx) => {
const handler = createRouteEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
defaultLocale: ctx.defaultLocale,
productsPath: paths?.productsPath ?? DEFAULT_PRODUCTS_PATH,
newsPath: paths?.newsPath ?? DEFAULT_NEWS_PATH,
})
for await (const ev of ctx.events) {
await handler.handleEvent(ev)
}
return handler.finish()
}
}