From 7e6ce91001dcd0de52514c8871a96ffb61fdb0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 21 Oct 2026 18:56:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(import):=20=E5=AA=92=E4=BD=93=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=E5=AE=88=E5=8D=AB=E4=B8=8E=E5=8A=A0=E5=9B=BA=E2=80=94?= =?UTF-8?q?=E2=80=94manifest=20=E5=BD=A2=E6=80=81/=E4=BE=A7=E8=BD=A6?= =?UTF-8?q?=E9=97=A8=E6=8E=A7/=E5=86=B2=E7=AA=81=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=E5=90=8D/=E7=AA=97=E5=8F=A3=E8=BE=B9=E7=95=8C=E4=B8=8E?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审加固五项: - manifest 形态守卫:bundle 在场而 metadata.manifest 非数组 → throw(与 storageRoot 守卫同风格),不与"bundle 合法缺席=空清单"共用静默空 coercion - 侧车门控:空清单下级②不可达,跳过侧车加载——破损侧车副本不因无用输入 让整 job 失败 - public_path 冲突 catch 按约束名精确认领(postgres.js 的 constraint_name, drizzle 包一层在 cause)——未来加第三唯一索引不误标,其余 23505 原样上抛 - 集成用例:不同 source_url 同旧 uploads 路径 → 后到不覆盖、issue 报告、 只留一行(mediaRegistered=1) - 单测边界对拍:' --- apps/api/src/modules/import/media-writer.ts | 24 +++++++-- tests/integration/import-media.test.ts | 56 +++++++++++++++++++++ tests/unit/magic-bytes.test.ts | 9 ++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/import/media-writer.ts b/apps/api/src/modules/import/media-writer.ts index 62c42cd..c7c6c81 100644 --- a/apps/api/src/modules/import/media-writer.ts +++ b/apps/api/src/modules/import/media-writer.ts @@ -325,7 +325,13 @@ export function createMediaEventHandler(identity: { if (fromSidecar) counts.mediaFromSidecar++ } } catch (err) { - if (isUniqueViolation(err)) { + // 唯一约束按约束名精确认领:定向 onConflictDoNothing 已消解 source_url 键,此处 + // 23505 只可能是 public_path 唯一键——但仍校验约束名(未来加第三唯一索引时不误标)。 + // postgres.js 的错误字段是 constraint_name(drizzle 包一层,真错在 cause) + if ( + isUniqueViolation(err) && + (err as { cause?: { constraint_name?: unknown } }).cause?.constraint_name === 'media_assets_public_path_uq' + ) { issues.push( `媒体 public_path 冲突(${row.sourceType}/${row.sourceId},${publicPath}):同站公开路径已被他行占用——未登记(不静默覆盖)`, ) @@ -541,8 +547,16 @@ export async function createMediaEventHandlerFromContext( >, ): Promise { const bundle = ctx.mediaBundleSnapshot - const manifestRaw = bundle?.metadata.manifest - const manifest: MediaManifestEntry[] = Array.isArray(manifestRaw) ? (manifestRaw as MediaManifestEntry[]) : [] + // manifest 形态守卫:bundle 在场而 manifest 非数组是登记期不变量破裂(bundle 缺席 + // = 空清单是合法输入——三级全走 missing 如实报告),不与"合法缺席"共用静默空 coercion + let manifest: MediaManifestEntry[] + if (bundle === null) { + manifest = [] + } else if (Array.isArray(bundle.metadata.manifest)) { + manifest = bundle.metadata.manifest as MediaManifestEntry[] + } else { + throw new Error('media_bundle 快照 metadata.manifest 形态异常(非数组)——登记期不变量破裂,拒绝静默按空清单处理') + } let storageRoot = '' if (manifest.length > 0) { const raw = bundle?.metadata.storageRoot @@ -560,7 +574,9 @@ export async function createMediaEventHandlerFromContext( storageRoot, mediaBundleStoragePath: bundle?.storagePath ?? '', mediaManifest: manifest, - sidecarMap: await loadSidecarMap(ctx), + // 侧车门控:空清单下级②不可达(侧车只能解析到清单实体)——跳过侧车加载,破损 + // 侧车副本不因无用输入让整 job 失败 + sidecarMap: manifest.length > 0 ? await loadSidecarMap(ctx) : new Map(), }) } diff --git a/tests/integration/import-media.test.ts b/tests/integration/import-media.test.ts index 0d582f8..638dcc6 100644 --- a/tests/integration/import-media.test.ts +++ b/tests/integration/import-media.test.ts @@ -564,6 +564,62 @@ describe('媒体登记写入器(三级对拍 + 魔数/隔离)', () => { }) }) + it('public_path 唯一键冲突:不同 source_url 同旧 uploads 路径 → 后到不覆盖、issue 报告、只留一行', async () => { + await withTestDb(async (db) => { + const work = await mkdtemp(join(tmpdir(), 'sf-media-pconf-')) + try { + const dup = pngBytes(`dup-${rand()}`) + await mkdir(join(work, '2026/10'), { recursive: true }) + await writeFile(join(work, '2026/10/dup.png'), dup) + const manifest = [manifestEntryOf('2026/10/dup.png', dup)] + // 两 attachment:不同源 URL(不同 host 的 uploads 域)、同一旧 uploads 相对路径 + // → 级①命中同一清单实体 → public_path 相同 → 第二行撞 media_assets_public_path_uq + const events: AdapterEvent[] = [ + attachmentEvent({ sourceId: '971', url: 'https://fixture.example/wp-content/uploads/2026/10/dup.png' }), + attachmentEvent({ sourceId: '972', url: 'https://mirror.example/wp-content/uploads/2026/10/dup.png' }), + ] + 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: directMediaPipeline({ storageRoot: work, manifest, sidecar: [] }), + }) + expect(result.status).toBe('succeeded_with_issues') + // 先到行保留、后到不静默覆盖——同公开路径是真实冲突(同站命名空间不重不解) + const assets = (await db.execute(`select source_id, source_url, public_path, status + from media_assets where site_id = '${reg.siteId}' order by source_id`)) as any + expect(plain(assets)).toStrictEqual([ + { + source_id: '971', + source_url: 'https://fixture.example/wp-content/uploads/2026/10/dup.png', + public_path: '/wp-content/uploads/2026/10/dup.png', + status: 'ready', + }, + ]) + const report = await readReport(work, result.jobId) + expect(report.counts).toStrictEqual({ + mediaRegistered: 1, + mediaFromSidecar: 0, + mediaMissing: 0, + mediaQuarantined: 0, + mediaDanglingRefs: 0, + mediaAmbiguous: 0, + }) + const conflict = report.issues.filter((i) => i.includes('public_path 冲突')) + expect(conflict).toHaveLength(1) + expect(conflict[0]).toContain('972') + } finally { + await cleanupSiteRows(db, reg.siteId) + } + } finally { + await rm(work, { recursive: true, force: true }) + } + }) + }) + it('幂等重跑:media_assets/media_usages 零增行、mediaRegistered=0;悬空引用按流如实再现', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-idem-')) diff --git a/tests/unit/magic-bytes.test.ts b/tests/unit/magic-bytes.test.ts index 71f2710..1206c39 100644 --- a/tests/unit/magic-bytes.test.ts +++ b/tests/unit/magic-bytes.test.ts @@ -63,4 +63,13 @@ describe('detectMediaType 手写签名表', () => { const beyond = Buffer.concat([Buffer.alloc(1200, 0x20), Buffer.from('')]) expect(detectMediaType(beyond)).toBe(null) }) + + it('SVG 窗口边界逐字节对拍: { + // '