diff --git a/apps/api/src/modules/source/register.service.ts b/apps/api/src/modules/source/register.service.ts index 4a462a8..cf02d93 100644 --- a/apps/api/src/modules/source/register.service.ts +++ b/apps/api/src/modules/source/register.service.ts @@ -1,5 +1,6 @@ // source:register 输入登记服务(Plan 2 Task 7,spec §7.1 / ADR-004/008): -// 把 WXR / sitemap / 媒体目录三类输入登记为 content-addressed 快照—— +// 把 WXR / sitemap / 媒体目录 / 媒体映射侧车(manual_url_list,Plan 3 Task 0)四类输入 +// 登记为 content-addressed 快照—— // 副本落 storage/snapshots///(文件)或 /(媒体整树), // 源输入文件从此不再被后续任务读取(导入只寻址副本)。 // @@ -25,7 +26,7 @@ // 新 uuid 重写)但不再被本服务清理——手工清理策略:CLI 未运行时整目录删除 .tmp/ 即可。 import { createHash, randomUUID } from 'node:crypto' import { createReadStream, createWriteStream, existsSync } from 'node:fs' -import { lstat, mkdir, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { pipeline } from 'node:stream/promises' @@ -49,13 +50,14 @@ export type SourceDb = NodePgDatabase */ type SourceDbWriter = Pick -export type SourceSnapshotKind = 'wxr' | 'sitemap' | 'media_bundle' +export type SourceSnapshotKind = 'wxr' | 'sitemap' | 'media_bundle' | 'manual_url_list' -/** 各 kind 快照的 adapter_version:wxr 用适配器包单源版本;sitemap/media 尚无解析器,占位钉死 */ +/** 各 kind 快照的 adapter_version:wxr 用适配器包单源版本;sitemap/media/media_map 尚无解析器,占位钉死 */ const SNAPSHOT_ADAPTER_VERSION: Record = { wxr: `adapter-wp@${ADAPTER_VERSION}`, sitemap: 'sitemap@0', media_bundle: 'media-bundle@1', + manual_url_list: 'manual-url-list@1', } export type SourceRegisterErrorCode = @@ -66,6 +68,7 @@ export type SourceRegisterErrorCode = | 'INPUT_NOT_DIR' | 'EMPTY_MEDIA_DIR' | 'INPUT_READ_FAILED' + | 'INVALID_MEDIA_MAP' | 'COPY_FAILED' /** 登记失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */ @@ -89,6 +92,12 @@ export interface RegisterInput { wxrPath?: string sitemapPath?: string mediaDir?: string + /** + * 媒体映射侧车 JSON(Plan 3 Task 0,评审 Issue 1):[{sourceUrl, filePath}] 数组—— + * 旧 cms 库 media_assets 导出的 URL→扁平文件名映射;登记为 kind=manual_url_list 快照。 + * 导入管道对拍三级中的第二级数据源(①清单 uploads 路径 → ②本侧车 → ③missing 报告)。 + */ + mediaMapPath?: string /** 输入路径白名单(realpath 后必须位于其中之一);缺省 [repoRoot/storage/incoming] */ allowedRoots?: string[] /** 快照存储根(副本落 /snapshots/...);缺省 repoRoot/storage */ @@ -256,9 +265,49 @@ interface Ingested { durationMs: number } -/** 文件输入(wxr/sitemap):temp 副本上单遍 copy+hash → 按 sha 定名落位(已存在则命中复用) */ +/** + * 校验 media-map 侧车 JSON 形状并数条目:[{sourceUrl: string, filePath: string}] 数组, + * 每条两字段均为非空字符串。形状即契约(Plan 3 对拍二级的数据源)——脏输入在登记期 + * 拒绝(快照不可变,落库后再发现无法补救)。返回条目数入 metadata。 + */ +async function validateMediaMap(path: string): Promise { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(path, 'utf8')) + } catch (err) { + throw new SourceRegisterError( + 'INVALID_MEDIA_MAP', + `media-map 不是合法 JSON:${path}(${err instanceof Error ? err.message : String(err)})`, + ) + } + if (!Array.isArray(parsed)) { + throw new SourceRegisterError( + 'INVALID_MEDIA_MAP', + `media-map 顶层必须是数组([{sourceUrl, filePath}]):${path},实际 ${parsed === null ? 'null' : typeof parsed}`, + ) + } + for (let i = 0; i < parsed.length; i++) { + const entry = parsed[i] as unknown + const ok = + entry !== null && + typeof entry === 'object' && + typeof (entry as { sourceUrl?: unknown }).sourceUrl === 'string' && + (entry as { sourceUrl: string }).sourceUrl !== '' && + typeof (entry as { filePath?: unknown }).filePath === 'string' && + (entry as { filePath: string }).filePath !== '' + if (!ok) { + throw new SourceRegisterError( + 'INVALID_MEDIA_MAP', + `media-map 第 ${i} 条不满足形状 {sourceUrl: string, filePath: string}:${path}`, + ) + } + } + return parsed.length +} + +/** 文件输入(wxr/sitemap/media-map):temp 副本上单遍 copy+hash → 按 sha 定名落位(已存在则命中复用) */ async function ingestFile( - kind: 'wxr' | 'sitemap', + kind: 'wxr' | 'sitemap' | 'manual_url_list', src: string, storageRoot: string, ): Promise { @@ -276,6 +325,17 @@ async function ingestFile( `${kind} 输入读取失败:${src}(${err instanceof Error ? err.message : String(err)})`, ) } + // media-map:形状校验在副本 rename 落位**之前**——脏输入不产 content-addressed 副本 + //(校验读 temp 副本而非源,登记窗口内源被改写不会使校验结果与哈希脱钩) + let entryCount: number | undefined + if (kind === 'manual_url_list') { + try { + entryCount = await validateMediaMap(tmp) + } catch (err) { + await rm(tmp, { force: true }) + throw err + } + } const rel = `snapshots/${kind}/${sha256.slice(0, 2)}/${sha256}` let copied: boolean try { @@ -286,6 +346,8 @@ async function ingestFile( `${kind} 快照副本落位失败:${join(storageRoot, rel)}(${err instanceof Error ? err.message : String(err)})`, ) } + const metadata: Record = { sourceName: basename(src) } + if (entryCount !== undefined) metadata.entryCount = entryCount return { kind, sourcePath: src, @@ -293,7 +355,7 @@ async function ingestFile( byteSize: bytes, storagePath: rel, copied, - metadata: { sourceName: basename(src) }, + metadata, durationMs: performance.now() - t0, } } @@ -578,20 +640,33 @@ export async function registerSource(db: SourceDb, input: RegisterInput): Promis let wxrReal: string | undefined let sitemapReal: string | undefined let mediaReal: string | undefined + let mediaMapReal: string | undefined if (input.wxrPath !== undefined) wxrReal = await validateInputPath(input.wxrPath, 'file', allowedRoots, 'wxr') if (input.sitemapPath !== undefined) sitemapReal = await validateInputPath(input.sitemapPath, 'file', allowedRoots, 'sitemap') if (input.mediaDir !== undefined) mediaReal = await validateInputPath(input.mediaDir, 'directory', allowedRoots, 'media') - if (wxrReal === undefined && sitemapReal === undefined && mediaReal === undefined) { - throw new SourceRegisterError('NO_INPUTS', '未提供任何输入(wxrPath / sitemapPath / mediaDir 至少其一)') + if (input.mediaMapPath !== undefined) + mediaMapReal = await validateInputPath(input.mediaMapPath, 'file', allowedRoots, 'media-map') + if ( + wxrReal === undefined && + sitemapReal === undefined && + mediaReal === undefined && + mediaMapReal === undefined + ) { + throw new SourceRegisterError( + 'NO_INPUTS', + '未提供任何输入(wxrPath / sitemapPath / mediaDir / mediaMapPath 至少其一)', + ) } - // 阶段 1:摄取(hash + 副本落位)——顺序固定 wxr → sitemap → media(报告输入清单稳定序) + // 阶段 1:摄取(hash + 副本落位)——顺序固定 wxr → sitemap → media → media-map(报告输入清单稳定序) const ingested: Ingested[] = [] if (wxrReal !== undefined) ingested.push(await ingestFile('wxr', wxrReal, storageRoot)) if (sitemapReal !== undefined) ingested.push(await ingestFile('sitemap', sitemapReal, storageRoot)) if (mediaReal !== undefined) ingested.push(await ingestMediaDir(mediaReal, storageRoot)) + if (mediaMapReal !== undefined) + ingested.push(await ingestFile('manual_url_list', mediaMapReal, storageRoot)) // 阶段 2:DB 写入——单事务(site→connection→snapshots 全成或全回滚;"任何失败零落库" // 由此成立)。connection 的 select-then-insert 在事务内靠 advisory xact lock 串行化。 diff --git a/docs/evidence/media-map-export.md b/docs/evidence/media-map-export.md new file mode 100644 index 0000000..c5a4d27 --- /dev/null +++ b/docs/evidence/media-map-export.md @@ -0,0 +1,87 @@ +# 媒体映射侧车导出证据(Plan 3 · Task 0,评审 Issue 1) + +背景(评审实证):真实媒体目录 `D:/project/cms/storage/media/54f91c49-fffb-4c58-87c8-6c2cf6aaef79` 为扁平 GUID 命名(2625 文件,无 yyyy/mm 子目录),与 WXR attachment_url(`https://hesupipe.com/wp-content/uploads///` 形态)**零精确命中**——唯一映射在旧 cms 库 `media_assets` 表(`source_url` → `file_path`)。本任务把该映射只读导出为侧车 JSON 并登记为 `manual_url_list` 快照,作为阶段 3 导入管道对拍三级中第二级的数据源。 + +## 导出 + +| 项 | 值 | +|---|---| +| 日期 | 2026-10-21 | +| 数据源 | 旧 cms PostgreSQL(容器 `infrastructure-postgres-1`,宿主 5433;凭证按 compose 只读环境 `cms`/`cms`) | +| 表/过滤 | `media_assets`,`site_id = '54f91c49-fffb-4c58-87c8-6c2cf6aaef79' AND source_url IS NOT NULL`(867 行中 8 行 source_url 为 NULL 不带出) | +| 导出行数 | **859**(与评审预期 859 一致;file_path 全部非空——另行只读核过 0 行空值) | +| 输出 | `storage/incoming/media-map-hesupipe.json`(143,945 B,SHA-256 `c935d444f52b6bb8d9fa5e511066cac5b0a684676986041882cfbc54a0da6a2e`;storage/ 已 gitignore) | +| 导出脚本 | `scripts/export-media-map.mjs`(幂等可重跑——重跑同 SELECT 覆写同路径,两次运行 SHA-256 一致已验证) | + +实际执行的命令原文(仓库根 `D:\project\cms-one`): + +```bash +pnpm exec node scripts/export-media-map.mjs --audit "D:/project/cms/storage/wxr/fd04d6ba8a1316b6.xml" +``` + +脚本内执行的唯一一条 cms 侧语句(经 `docker exec … psql --csv -t -c`,child_process execFile 参数数组传递、不经 shell): + +```sql +SELECT source_url, file_path FROM media_assets +WHERE site_id = '54f91c49-fffb-4c58-87c8-6c2cf6aaef79' AND source_url IS NOT NULL +ORDER BY source_url +``` + +连接方式说明(任务给了二选一):选 `docker exec psql`——宿主无需装 pg 客户端、凭证由容器内环境承担;备选 node-postgres 直连 5433 需在脚本里钉死 compose 凭证,弃。 + +## 覆盖率预估(侧车 × WXR attachment_url 对照) + +对照口径:WXR(`fd04d6ba8a1316b6.xml`,855 个 ``、去重后仍 855)逐条对侧车 `sourceUrl` 集合 join。归一化口径(估计用):去首尾空白 + 去尾斜杠 + 整串小写。 + +| 指标 | 值 | +|---|---| +| WXR attachment_url(去重) | 855 | +| 侧车条目 | 859 | +| **精确命中**(原文相等) | **836** | +| 仅归一后命中(大小写/尾斜杠救回) | **0**(本数据集无需归一化救援——未命中即真不在侧车) | +| **未命中** | **19** | +| 命中率(去重口径) | **97.8%**(836/855) | +| 侧车未被 WXR 引用的条目(悬空行) | 23(859 − 836;对拍三级用不上,仅记录) | + +自洽核对:836 命中 + 19 未命中 = 855;859 侧车 − 836 被引用 = 23 悬空。 + +### 未命中清单(全部 19 条) + +特征:**全部是 pdf/mp4/zip/txt/特殊字符文件名**,无常规 png/jpg——推测旧 cms 迁移时这批非图片资产未入 `media_assets`(或入在其他 site)。阶段 3 对拍时它们将落入第三级 missing 报告(按计划不插 media_assets 行,只进报告)。 + +```text +https://hesupipe.com/wp-content/uploads/2019/12/WPPOP-Introduction.pdf +https://hesupipe.com/wp-content/uploads/2019/12/WPPOP-Introduction.zip +https://hesupipe.com/wp-content/uploads/2019/12/WPPOP-Introduction.txt +https://hesupipe.com/wp-content/uploads/2019/12/wppop-ipad-pro-video.mp4 +https://hesupipe.com/wp-content/uploads/2023/12/hesu-video.mp4 +https://hesupipe.com/wp-content/uploads/2023/12/HESU-PIPE-ABOUNT.mp4 +https://hesupipe.com/wp-content/uploads/2023/12/418311fef2a99d2b4bc018.mp4 +https://hesupipe.com/wp-content/uploads/2023/12/hesuvideo.mp4 +https://hesupipe.com/wp-content/uploads/2023/05/same-as-never_1_207_translate.pdf +https://hesupipe.com/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe、-1.jpg +https://hesupipe.com/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe、-1-1.jpg +https://hesupipe.com/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe、-2.jpg +https://hesupipe.com/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe、-3.jpg +https://hesupipe.com/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe、-6.jpg +https://hesupipe.com/wp-content/uploads/2023/12/HDPE-Double-Wall-Corrugated-Pipe.pdf +https://hesupipe.com/wp-content/uploads/2023/12/HESU-PPR-PIPES-FITTINGS.pdf +https://hesupipe.com/wp-content/uploads/2023/12/HESU-PVC-PIPE-FITTINGS.pdf +https://hesupipe.com/wp-content/uploads/2023/12/HESU-HDPE-WATER-PIPES-FITTINGS.pdf +https://hesupipe.com/wp-content/uploads/2024/10/HDPE-carat-pipe-(2).jpg +``` + +## 登记冒烟(临时库,验后已 DROP) + +`--media-map` 输入通道对真实侧车文件的 CLI 冒烟(临时库 `sf_smoke_mediamap_t0`,本项目 PG 5434,非 cms 库;验后 DROP 已执行): + +```bash +DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_smoke_mediamap_t0" \ + pnpm sf source:register --site hesupipe-smoke --media-map storage/incoming/media-map-hesupipe.json +``` + +结果:`manual_url_list` 快照 1 行——sha256 `c935d444f52b…`(= 导出文件哈希)、byte_size 143945、metadata `entryCount=859`、adapter_version `manual-url-list@1`、storage_path 指向 content-addressed 副本;重跑复用(snapshot 复用/副本已存在,行数仍 1)。 + +## 只读声明 + +本任务对 `D:\project\cms`(旧 cms 仓库、其 PostgreSQL 容器、WXR 与媒体文件)**只执行了读取类操作**:`docker exec … psql` 上列 SELECT(及行数核对的 SELECT)与 WXR 文件读取(grep/哈希/正则抽取)。未对 cms 数据库执行任何写语句(INSERT/UPDATE/DELETE/DDL 均无)、未写 cms 文件系统、未挂载任何卷。本项目(cms-one)侧写入仅限自身 storage/ 与 artifacts/。连接凭证信息仅从 compose 文件读得,未修改任何配置。 diff --git a/scripts/export-media-map.mjs b/scripts/export-media-map.mjs new file mode 100644 index 0000000..fbe8a3f --- /dev/null +++ b/scripts/export-media-map.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// 媒体映射侧车导出(Plan 3 · Task 0,评审 Issue 1——真实数据实证): +// 旧 cms 库(容器 infrastructure-postgres-1,宿主 5433)media_assets 表存有该站唯一的 +// WXR attachment_url(yyyy/mm 形态)→ 媒体清单扁平 GUID 文件名 映射。本脚本只读导出为 +// [{sourceUrl, filePath}] JSON 数组,供 `sf source:register --media-map` 登记 +// kind=manual_url_list 快照(导入管道对拍三级中的第二级数据源)。 +// +// 连接方式(二选一里选了 docker exec psql):经 child_process execFile 调 +// `docker exec psql --csv -t -c "