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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user