diff --git a/apps/api/src/modules/import/route-writer.ts b/apps/api/src/modules/import/route-writer.ts new file mode 100644 index 0000000..50b62de --- /dev/null +++ b/apps/api/src/modules/import/route-writer.ts @@ -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='、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> +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 = 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 + /** 流终:站点页 + 分类/tag 页 + 暂存 attachment 301 + asset 行 → 取回累计产出(调用后句柄弃用) */ + finish(): Promise +} + +/** 本写入器独占 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 + /** 原串(?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 = { + routesCreated: 0, + routesUpdated: 0, + routesRedirects: 0, + routesCollisions: 0, + routesExcludedQueries: 0, + } + const issues: string[] = [] + const pendingAttachments: PendingAttachment[] = [] + /** 碰撞预警的运行内去重(键 = 折叠键 + 双 exact 排序对——重跑/对向写入只报一次) */ + const flaggedCollisionPairs = new Set() + /** 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 { + 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 { + 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 = {} + 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 { + 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 { + 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() + } +} diff --git a/tests/integration/import-routes.test.ts b/tests/integration/import-routes.test.ts new file mode 100644 index 0000000..cd1d6bb --- /dev/null +++ b/tests/integration/import-routes.test.ts @@ -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 { + yield* events +} + +/** 事件流收集(mini 全链一饮而尽) */ +async function collectMiniEvents(): Promise { + 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[0], + paths?: { productsPath?: string; newsPath?: string }, +): ReturnType { + 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 { + 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 + 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 { + 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() + 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() + 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 }) + } + }) + }) +})