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