feat(import): 路由注册表生成

§6.1 处置全表落 route_registry(route 域唯一写入方):
- 内容 permalink → html(路径保真 + target_content_id 经 source_records 关联);
  首页 / 与主列表 /products/ /news/(CLI 参数注入)finish 期生成;
- category/product_cat → html(public_base_path 常量 + term slug);post_tag/
  product_tag → 301 对应主列表(terms×taxonomies 全表 finish 期生成);
- attachment 页一律 301 媒体文件(前置事实 10 降级一条汇总 issue;暂存事件、
  finish 反查 media_assets——依赖 media-writer 先落资产的时序契约);
  ?attachment_id= 查询形态走 query_key 单独建模(spec §5.6 查询路由不并 path);
- 上传媒体路径 → asset(media_assets 全表;target_file 为产物路径占位);
- ?p=/&page_id= 不插行(route_kind 六值无 excluded——查询路由 SP3),计数
  routesExcludedQueries + 逐条 issue 落 excluded_with_reason 报告语义;
- ADR-007 双键:/About 与 /about 并存 + collision_key 折叠预警不计失败;
  同 exact_key 异 owner 冲突保留先到 + issue 列后者;
- upsert 三分:miss INSERT / 同 owner 描述字段 diff UPDATE / 异 owner 冲突;
  幂等重跑零计数,--news-path 变化触发 tag 301 目标同行 UPDATE。

counts 独占五键(route 前缀):routesCreated/routesUpdated/routesRedirects/
routesCollisions/routesExcludedQueries。315 测试(+5);typecheck/lint 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 19:15:21 +08:00
parent c8a2ed81e5
commit 9d2cc0a46d
2 changed files with 1348 additions and 0 deletions
+672
View File
@@ -0,0 +1,672 @@
// 路由注册表写入器(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 查询路由能力一起落地。
//
// 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 { ImportDb, ImportPipeline, ImportPipelineOutcome } 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)。
*/
export function foldCollisionKey(path: string): string {
return path.normalize('NFC').toLowerCase().replace(/\/+$/, '')
}
/** 主列表路径规范化(CLI 参数防线:补首尾斜杠) */
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= 查询形态排除数(不插行——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: {
db: ImportDb
siteId: string
/** source_records 五列身份解析的连接锚点(content-writer 同口径) */
connectionId: string
/** 行溯源(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/反斜杠/控制字符/穿越段拒绝。完整校验规则(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
}
/**
* 单行写入(三分 upsert,见模块头):碰撞预警 → exact 查 → miss INSERT / 同 owner 比对
* UPDATE / 异 owner 冲突 issue 保留先到。
*/
async function writeRoute(p: RoutePayload): Promise<void> {
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
}
// 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
}
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++
}
// 全等:幂等 no-op(重跑零计数)
}
/**
* 内容行主路径: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 处理:资产反查 → 301 载荷(查询/permalink 双形态) */
async function writeAttachmentRoute(att: PendingAttachment, host: string): Promise<boolean> {
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 || att.rawLocation}):media_assets 无对应资产(媒体三级对拍 miss?)——attachment 页路由未登记`)
return false
}
if (assetRows.length > 1) {
issues.push(`attachment 多资产歧义(wp:attachment/${att.sourceId}):同 source_id 命中 ${assetRows.length} 行——不自动选(spec §5.5),attachment 页路由未登记`)
return false
}
const publicPath = assetRows[0]!.publicPath
if (att.sourceUrl === '') {
issues.push(`attachment 缺 link(wp:attachment/${att.sourceId},${att.rawLocation}):无页面 URL 证据——301 路由不登记`)
return false
}
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
}
if (!isSafePath(parsed.pathname)) {
issues.push(`attachment 路径安全校验拒绝(wp:attachment/${att.sourceId},${parsed.pathname})——301 路由不登记`)
return false
}
const queryKey = canonicalQueryKey(parsed)
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 true
}
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}/`
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)
let downgraded = 0
for (const att of pendingAttachments) {
if (await writeAttachmentRoute(att, host)) downgraded++
}
if (downgraded > 0) {
issues.push(`attachment 页降级(前置事实 10):${downgraded} 条 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) {
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()
}
}