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()
}
}
+676
View File
@@ -0,0 +1,676 @@
// Plan 3 Task 5 失败测试(先于实现):路由注册表写入器(spec §6.1 处置全表 + ADR-007 双键 + 幂等)。
// 覆盖契约(对照计划 Task 5 与前置事实 9/10/11、spec §5.6/§6.1/§6.2):
// 1. mini 全链 §6.1 全表:内容 permalink → html(.html 后缀路径保真 + target_content_id 关联);
// 首页 / → html;主列表 /products/ /news/(CLI 参数注入,默认即此)→ html;分类页
// (public_base_path 常量 + term slug,如 /category/company/)→ html;媒体上传路径 → asset
// (media_assets.public_path,target_file 占位);attachment 页 → 一律 301 媒体文件
// (?attachment_id= 查询形态,query_key 单独建模;前置事实 10 降级一条汇总 issue);
// 2. ?p=/&page_id= 查询形态:不插 route_registry 行(route_kind 六值无 excluded——查询路由
// SP3),routesExcludedQueries 计数 + issue 说明(spec §6.1 excluded_with_reason 报告语义);
// 3. exact/collision 双键(ADR-007):/About 与 /about 两行并存 + collision 预警 issue 不计失败;
// 同 exact_key 双内容冲突 → 保留文档序先到 + issue 列后者(不静默覆盖);
// 4. attachment 301 目标缺席(media_assets 无行):路由不登记 + issue 如实报告(§7.6);
// 5. 幂等:重跑同目标 0 新行 0 更新;目标变(--news-path 参数变化 → tag 301 目标变)→ UPDATE
// 不新增(route 表可变)。
// counts 键(route 前缀独占):routesCreated/routesUpdated/routesRedirects/routesCollisions/
// routesExcludedQueries——与 content/term/media 前缀并存无碰撞(全局命名空间契约)。
// 编排顺序契约:content 先于 route(entry 锚点经 source_records 解析);media.finish 先于
// route.finish(attachment 301 反查 media_assets 需资产已齐)。运行于 globalSetup 临时库
// (ADR-015);报告根 os.tmpdir,finally 清理。
import { createReadStream } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { runWxrAdapter, type AdapterEvent } from '@sf/adapter-wp'
import type { UniversalRow } from '@sf/contracts'
import type { ImportPipeline } from '../../apps/api/src/modules/import/import.service.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
import { createContentEventHandler } from '../../apps/api/src/modules/import/content-writer.js'
import { createTermEventHandler } from '../../apps/api/src/modules/import/term-writer.js'
import { createMediaEventHandlerFromContext } from '../../apps/api/src/modules/import/media-writer.js'
import {
DEFAULT_NEWS_PATH,
DEFAULT_PRODUCTS_PATH,
createRouteEventHandler,
} from '../../apps/api/src/modules/import/route-writer.js'
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/** 伪 PNG 体(魔数校验只看签名——载荷唯一避免跨测试撞哈希) */
function pngBytes(seed: string): Buffer {
return Buffer.concat([PNG_SIG, Buffer.from(`png-body:${seed}`, 'latin1')])
}
/** 手构事件流的 AsyncIterable 包装(每次调用产新迭代器——重跑各流独立) */
async function* eventsOf(events: AdapterEvent[]): AsyncGenerator<AdapterEvent, void, void> {
yield* events
}
/** 事件流收集(mini 全链一饮而尽) */
async function collectMiniEvents(): Promise<AdapterEvent[]> {
const out: AdapterEvent[] = []
for await (const ev of runWxrAdapter(createReadStream(WXR_FIXTURE), { mapConfig: { locale: 'en' } })) {
out.push(ev)
}
return out
}
/** 手构内容 row 事件(sourceUrl 是路由域的输入锚点;url 空串 = 缺席键) */
function contentEvent(spec: { sourceId: string; url: string; sourceType?: string }): AdapterEvent {
const row: UniversalRow = {
sourceType: spec.sourceType ?? 'wp:post',
sourceId: spec.sourceId,
status: 'publish',
locale: 'en',
taxonomyRefs: [],
attachmentRefs: [],
seo: {},
knownFields: {},
extras: {},
rawLocation: `wxr://item/${spec.sourceId}`,
}
if (spec.url !== '') row.sourceUrl = spec.url
return { kind: 'row', row, issues: [], sanitizeDiffs: [], changed: false }
}
/** 手构 wp:term 事件(分类页/301 在 finish 期由 terms 表生成——term-writer 先物化) */
function termEvent(spec: { termId: number; taxonomy: string; slug: string; name: string }): AdapterEvent {
return {
kind: 'term',
term: {
rawLocation: `wxr://term/${spec.termId}`,
termId: spec.termId,
taxonomy: spec.taxonomy,
slug: spec.slug,
name: spec.name,
},
}
}
/** route-writer 构造(默认主列表路径——CLI 参数 --products-path/--news-path 缺省值的注入位) */
function routeHandlerOf(
ctx: Parameters<ImportPipeline>[0],
paths?: { productsPath?: string; newsPath?: string },
): ReturnType<typeof createRouteEventHandler> {
return 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,
})
}
/** mini 全链编排(Task 6 雏形):content → term → media → route 逐事件分派(顺序契约:
* content 先行是 route/media 的 entry 锚点前提;media.finish 先于 route.finish 是 attachment
* 301 反查 media_assets 的时序前提);四写入器 counts 展开合并(前缀无碰撞键) */
function fullPipeline(): ImportPipeline {
return async (ctx) => {
const content = createContentEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
jobId: ctx.jobId,
defaultLocale: ctx.defaultLocale,
versions: ctx.versions,
})
const term = createTermEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId })
const media = await createMediaEventHandlerFromContext(ctx)
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await content.handleEvent(ev)
await term.handleEvent(ev)
await media.handleEvent(ev)
await route.handleEvent(ev)
}
const c = content.finish()
const t = term.finish()
const m = await media.finish()
const r = await route.finish()
return { counts: { ...c.counts, ...t.counts, ...m.counts, ...r.counts }, issues: [...c.issues, ...t.issues, ...m.issues, ...r.issues] }
}
}
/** content + term + route 组合(无媒体——attachment 301 目标缺席场景) */
function contentTermRoutePipeline(): ImportPipeline {
return async (ctx) => {
const content = createContentEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
jobId: ctx.jobId,
defaultLocale: ctx.defaultLocale,
versions: ctx.versions,
})
const term = createTermEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId })
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await content.handleEvent(ev)
await term.handleEvent(ev)
await route.handleEvent(ev)
}
const c = content.finish()
const t = term.finish()
const r = await route.finish()
return { counts: { ...c.counts, ...t.counts, ...r.counts }, issues: [...c.issues, ...t.issues, ...r.issues] }
}
}
/** content + route 组合(双键/冲突/排除查询的手构场景——terms 表无行则分类页不生成) */
function contentRoutePipeline(): ImportPipeline {
return async (ctx) => {
const content = createContentEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
jobId: ctx.jobId,
defaultLocale: ctx.defaultLocale,
versions: ctx.versions,
})
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await content.handleEvent(ev)
await route.handleEvent(ev)
}
const c = content.finish()
const r = await route.finish()
return { counts: { ...c.counts, ...r.counts }, issues: [...c.issues, ...r.issues] }
}
}
/** term + route 组合(tag 301 幂等/参数变化场景——无内容行) */
function termRoutePipeline(paths?: { newsPath?: string }): ImportPipeline {
return async (ctx) => {
const term = createTermEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId })
const route = routeHandlerOf(ctx, paths)
for await (const ev of ctx.events) {
await term.handleEvent(ev)
await route.handleEvent(ev)
}
const t = term.finish()
const r = await route.finish()
return { counts: { ...t.counts, ...r.counts }, issues: [...t.issues, ...r.issues] }
}
}
/** 本文件写入的行清场:FK 反序(route_registry 先于 source_snapshots/sites;两张不可变表走触发器开关) */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
/** mini 登记(无媒体):register → {key, reg, wxr} */
async function registerMini(db: Db, work: string) {
const key = `sfroutes-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: [FIXTURES_ROOT, work],
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'register'),
})
return { key, reg, wxr: reg.snapshots.find((s) => s.kind === 'wxr')! }
}
/** 导入报告读取 */
async function readReport(work: string, jobId: string): Promise<{
status: string
counts: Record<string, number>
issues: string[]
}> {
return JSON.parse(await readFile(join(work, 'artifacts', 'import', jobId, 'reports', 'import.json'), 'utf8'))
}
/** route_registry 全行瘦身投影(postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败) */
interface RouteSlim {
path: string
q: string | null
kind: string
tStatus: string
rStatus: number | null
loc: string | null
file: string | null
idx: boolean
pri: number
ver: string
entry: string | null
}
async function routesOf(db: Db, siteId: string): Promise<RouteSlim[]> {
const rows = (await db.execute(`select route_path, query_key, route_kind, target_status, redirect_status,
redirect_location, target_file, indexable, priority, verification_status, target_content_id
from route_registry where site_id = '${siteId}' order by route_path, query_key nulls first`)) as any
return rows.map((r: any) => ({
path: r.route_path,
q: r.query_key,
kind: r.route_kind,
tStatus: r.target_status,
rStatus: r.redirect_status,
loc: r.redirect_location,
file: r.target_file,
idx: r.indexable,
pri: r.priority,
ver: r.verification_status,
entry: r.target_content_id,
}))
}
describe('路由注册表写入器(§6.1 处置全表 + ADR-007 双键)', () => {
it('mini 全链:内容/首页/列表/分类 html + 媒体 asset + attachment 301(查询形态)+ counts 全键 + 降级汇总 issue', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-full-'))
try {
const events = await collectMiniEvents()
// 媒体目录(同 import-media 用例):fixture-a 按 uploads 层级、fixture-b 扁平 + 侧车
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, '2026/10'), { recursive: true })
const pngA = pngBytes(`routes-a-${rand()}`)
await writeFile(join(mediaDir, '2026/10/fixture-a.png'), pngA)
const guidB = `b-${rand()}.png`
await writeFile(join(mediaDir, guidB), pngBytes(`routes-b-${rand()}`))
const sidecarPath = join(work, 'media-map.json')
await writeFile(
sidecarPath,
JSON.stringify([
{ sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png', filePath: guidB },
]),
)
const key = `sfroutes-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
mediaDir,
mediaMapPath: sidecarPath,
allowedRoots: [FIXTURES_ROOT, work],
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'register'),
})
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: fullPipeline(),
})
// 悬空引用(media 203/204)+ attachment 降级汇总 → issues 非空 → succeeded_with_issues
expect(result.status).toBe('succeeded_with_issues')
// entry 锚点对照表(source_records → content_entry_id,路由 target_content_id 关联断言用)
const entryOf = new Map<string, string>()
for (const r of (await db.execute(`select source_id, content_entry_id from source_records
where site_id = '${reg.siteId}' and source_type in ('wp:post','wp:product','wp:page')`)) as any) {
entryOf.set(r.source_id, r.content_entry_id)
}
expect(entryOf.size).toBe(3)
// 12 行全量对拍(§6.1 阶段 3 可产生的全部行类各就位):
// 1 首页 + 2 主列表 + 3 内容 permalink + 2 分类 + 2 attachment 301 + 2 媒体 asset
const rows = await routesOf(db, reg.siteId)
expect(rows).toStrictEqual([
{ path: '/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 1000, ver: 'verified_html', entry: null },
{ path: '/', q: 'attachment_id=201', kind: 'redirect', tStatus: '301', rStatus: 301, loc: '/wp-content/uploads/2026/10/fixture-a.png', file: '/wp-content/uploads/2026/10/fixture-a.png', idx: false, pri: 0, ver: 'verified_redirect', entry: null },
{ path: '/', q: 'attachment_id=202', kind: 'redirect', tStatus: '301', rStatus: 301, loc: '/wp-content/uploads/2026/10/fixture-b.png', file: '/wp-content/uploads/2026/10/fixture-b.png', idx: false, pri: 0, ver: 'verified_redirect', entry: null },
{ path: '/about-us/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 500, ver: 'verified_html', entry: entryOf.get('121')! },
{ path: '/category/company/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 800, ver: 'verified_html', entry: null },
{ path: '/news/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 900, ver: 'verified_html', entry: null },
// .html 后缀路径保真(spec §6.1 内容 permalink 尽量按原路径输出)
{ path: '/news/fixture-news-a.html', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 500, ver: 'verified_html', entry: entryOf.get('101')! },
{ path: '/product-category/pipe-fittings/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 800, ver: 'verified_html', entry: null },
{ path: '/products/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 900, ver: 'verified_html', entry: null },
{ path: '/products/fixture-product/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 500, ver: 'verified_html', entry: entryOf.get('111')! },
{ path: '/wp-content/uploads/2026/10/fixture-a.png', q: null, kind: 'asset', tStatus: '200', rStatus: null, loc: null, file: '/wp-content/uploads/2026/10/fixture-a.png', idx: false, pri: 100, ver: 'verified_asset', entry: null },
{ path: '/wp-content/uploads/2026/10/fixture-b.png', q: null, kind: 'asset', tStatus: '200', rStatus: null, loc: null, file: '/wp-content/uploads/2026/10/fixture-b.png', idx: false, pri: 100, ver: 'verified_asset', entry: null },
])
// 键语义横切断言(ADR-007 双键 + host 单值 + 来源原文 + 快照溯源):
const rawRows = (await db.execute(`select host, source_path_raw, route_path, exact_route_key,
collision_key, query_policy, query_key, source_status, source_snapshot_id
from route_registry where site_id = '${reg.siteId}'`)) as any
const siteRow = (await db.execute(`select production_origin from sites where id = '${reg.siteId}'`)) as any
const expectedHost = new URL(siteRow[0].production_origin).hostname
for (const r of rawRows) {
expect(r.host).toBe(expectedHost) // 单 host = production_origin(旧 host 变体 SP3)
expect(r.exact_route_key).toBe(r.route_path) // 请求匹配键 = 路径原文(不折叠)
expect(r.source_status).toBe('from_import') // 本阶段语义(待实数据改报告语义)
expect(r.source_snapshot_id).toBe(wxr.id)
expect(r.query_policy).toBe(r.query_key === null ? 'ignore' : 'exact')
}
// collision_key 折叠(前置事实 9:NFC + lowercase + 去尾斜杠):尾斜杠折叠、根路径折空
const collisionOf = new Map(rawRows.map((r: any) => [r.route_path, r.collision_key]))
expect(collisionOf.get('/products/')).toBe('/products')
expect(collisionOf.get('/news/')).toBe('/news')
expect(collisionOf.get('/')).toBe('')
expect(collisionOf.get('/news/fixture-news-a.html')).toBe('/news/fixture-news-a.html')
// source_path_raw 永远保存来源原文(内容行 = permalink 原串;attachment = link 原串;
// asset = media_assets.source_url;站点页 = production_origin 拼路径)
const rawOf = new Map(rawRows.map((r: any) => [`${r.route_path}\u0000${r.query_key}`, r.source_path_raw]))
expect(rawOf.get('/news/fixture-news-a.html\u0000null')).toBe('https://fixture.example/news/fixture-news-a.html')
expect(rawOf.get('/\u0000attachment_id=201')).toBe('https://fixture.example/?attachment_id=201')
expect(rawOf.get('/wp-content/uploads/2026/10/fixture-a.png\u0000null')).toBe('https://fixture.example/wp-content/uploads/2026/10/fixture-a.png')
expect(rawOf.get('/\u0000null')).toBe(`${siteRow[0].production_origin}/`)
// counts 全键:content 四键 + term 三键 + media 六键 + route 五键(全局命名空间并存无碰撞)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 3,
revisionsCreated: 3,
sourceRecords: 5,
skipped: 0,
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 1,
mediaRegistered: 2,
mediaFromSidecar: 1,
mediaMissing: 0,
mediaQuarantined: 0,
mediaDanglingRefs: 2,
mediaAmbiguous: 0,
routesCreated: 12,
routesUpdated: 0,
routesRedirects: 2,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// attachment 降级一条汇总(前置事实 10)而非每条一 issue;悬空 2 条来自 media-writer
const downgrade = report.issues.filter((i) => i.includes('attachment 页降级'))
expect(downgrade).toHaveLength(1)
expect(downgrade[0]).toContain('2 条')
expect(report.issues.filter((i) => i.includes('悬空'))).toHaveLength(2)
expect(report.issues).toHaveLength(3)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('exact/collision 双键:/About 与 /about 两行并存 + collision 预警不计失败;同 exact_key 双内容冲突保留先到 + issue 列后者', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-dual-'))
try {
const events: AdapterEvent[] = [
contentEvent({ sourceId: '301', url: 'https://h.example/About' }),
contentEvent({ sourceId: '302', url: 'https://h.example/about' }),
contentEvent({ sourceId: '303', url: 'https://h.example/dup/' }),
contentEvent({ sourceId: '304', url: 'https://h.example/dup/' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: contentRoutePipeline(),
})
// collision 预警 + exact 冲突都是 issue(不计失败——job 仍是 succeeded 族)
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 3 站点页 + /About + /about + /dup/(首条)= 6 行;大小写两条并存(ADR-007 语义)
expect(rows).toHaveLength(6)
const about = rows.filter((r) => r.path === '/About' || r.path === '/about')
expect(about).toHaveLength(2)
expect(about.every((r) => r.kind === 'html')).toBe(true)
// /dup/ 只有先到内容(303)的行——后者不静默覆盖
const dup = rows.filter((r) => r.path === '/dup/')
expect(dup).toHaveLength(1)
const entryOf = new Map<string, string>()
for (const r of (await db.execute(`select source_id, content_entry_id from source_records
where site_id = '${reg.siteId}' and source_id in ('303','304')`)) as any) {
entryOf.set(r.source_id, r.content_entry_id)
}
expect(dup[0]!.entry).toBe(entryOf.get('303'))
expect(dup[0]!.entry).not.toBe(entryOf.get('304'))
// collision_key 折叠同键(/About 与 /about → /about)——预警一条、两行并存
const rawRows = (await db.execute(`select route_path, collision_key from route_registry
where site_id = '${reg.siteId}' and route_path in ('/About','/about')`)) as any
expect(rawRows.find((r: any) => r.route_path === '/About').collision_key).toBe('/about')
expect(rawRows.find((r: any) => r.route_path === '/about').collision_key).toBe('/about')
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 4,
revisionsCreated: 4,
sourceRecords: 4,
skipped: 0,
routesCreated: 6,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 1,
routesExcludedQueries: 0,
})
const collision = report.issues.filter((i) => i.includes('collision_key 折叠碰撞'))
expect(collision).toHaveLength(1)
expect(collision[0]).toContain('/About')
expect(collision[0]).toContain('/about')
const conflict = report.issues.filter((i) => i.includes('exact_key 冲突'))
expect(conflict).toHaveLength(1)
expect(conflict[0]).toContain('304')
expect(conflict[0]).toContain('保留先到')
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('?p=/&page_id= 查询形态:不插 route_registry 行(查询路由 SP3),routesExcludedQueries 计数 + issue 说明', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-qexc-'))
try {
const events: AdapterEvent[] = [
contentEvent({ sourceId: '401', url: 'https://h.example/?p=999' }),
contentEvent({ sourceId: '402', url: 'https://h.example/?page_id=888' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: contentRoutePipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 仅 3 条站点页(查询形态不登记——含 query_key 的行为 0)
expect(rows).toHaveLength(3)
expect(rows.every((r) => r.q === null)).toBe(true)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 2,
revisionsCreated: 2,
sourceRecords: 2,
skipped: 0,
routesCreated: 3,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 2,
})
const excluded = report.issues.filter((i) => i.includes('查询 URL 排除'))
expect(excluded).toHaveLength(2)
expect(excluded.some((i) => i.includes('p=999'))).toBe(true)
expect(excluded.some((i) => i.includes('page_id=888'))).toBe(true)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('attachment 301 目标缺席(media_assets 无行):路由不登记 + issue 如实报告(§7.6 禁静默)', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-amiss-'))
try {
const events = await collectMiniEvents()
const { key, reg, wxr } = await registerMini(db, work)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: contentTermRoutePipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 无媒体管道:3 站点页 + 3 内容 + 2 分类 = 8;attachment 301/asset 均无行
expect(rows).toHaveLength(8)
expect(rows.every((r) => r.kind !== 'redirect' && r.kind !== 'asset')).toBe(true)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 3,
revisionsCreated: 3,
sourceRecords: 5,
skipped: 0,
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 1,
routesCreated: 8,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
const missing = report.issues.filter((i) => i.includes('301 目标缺席'))
expect(missing).toHaveLength(2)
expect(missing.some((i) => i.includes('201'))).toBe(true)
expect(missing.some((i) => i.includes('202'))).toBe(true)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('幂等:重跑同目标 0 新行 0 更新;目标变(--news-path 变化 → tag 301 目标变)→ UPDATE 不新增', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-idem-'))
try {
const events: AdapterEvent[] = [
termEvent({ termId: 41, taxonomy: 'post_tag', slug: 'foo', name: 'Foo' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
const base = {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
}
// 首跑:首页 + 2 主列表 + /tag/foo/ 301 → /news/(§6.1 tag → 对应主列表)
const first = await createImportJob(db, { ...base, events: eventsOf(events), pipeline: termRoutePipeline() })
expect(first.status).toBe('succeeded')
let rows = await routesOf(db, reg.siteId)
expect(rows).toHaveLength(4)
const tagRow = rows.find((r) => r.path === '/tag/foo/')!
expect(tagRow.kind).toBe('redirect')
expect(tagRow.loc).toBe('/news/')
expect(tagRow.rStatus).toBe(301)
const firstReport = await readReport(work, first.jobId)
expect(firstReport.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 4,
routesUpdated: 0,
routesRedirects: 1,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 二跑(同流同参数,换幂等键):0 新行 0 更新(幂等三态的 no-op 面)
const again = await createImportJob(db, {
...base,
idempotencyKey: `sfroutes-rerun-${rand()}`,
events: eventsOf(events),
pipeline: termRoutePipeline(),
})
expect(again.status).toBe('succeeded')
rows = await routesOf(db, reg.siteId)
expect(rows).toHaveLength(4)
const againReport = await readReport(work, again.jobId)
expect(againReport.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 0,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 三跑(--news-path /blog/):tag 301 目标变 → 同行 UPDATE(route 表可变);
// /blog/ 新列表页 INSERT;/news/ 旧行保留(陈旧行清理是 SP3 SEO diff 域,不自动 301)
const third = await createImportJob(db, {
...base,
idempotencyKey: `sfroutes-repath-${rand()}`,
events: eventsOf(events),
pipeline: termRoutePipeline({ newsPath: '/blog/' }),
})
expect(third.status).toBe('succeeded')
rows = await routesOf(db, reg.siteId)
expect(rows).toHaveLength(5)
expect(rows.find((r) => r.path === '/tag/foo/')!.loc).toBe('/blog/')
expect(rows.find((r) => r.path === '/tag/foo/')!.rStatus).toBe(301)
expect(rows.find((r) => r.path === '/news/')).toBeDefined()
expect(rows.find((r) => r.path === '/blog/')!.kind).toBe('html')
const thirdReport = await readReport(work, third.jobId)
expect(thirdReport.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 1,
routesUpdated: 1,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
})