fix(import): 媒体工厂守卫与加固——manifest 形态/侧车门控/冲突约束名/窗口边界与冲突测试
评审加固五项: - 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) - 单测边界对拍:'<svg' 恰好收在 1024 字节窗口内(起点 1020)检出;起点 1021 被截断为 '<sv' → null 310 测试(308+2);typecheck/lint 三绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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<MediaEventHandler> {
|
||||
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<string, string>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
@@ -63,4 +63,13 @@ describe('detectMediaType 手写签名表', () => {
|
||||
const beyond = Buffer.concat([Buffer.alloc(1200, 0x20), Buffer.from('<svg>')])
|
||||
expect(detectMediaType(beyond)).toBe(null)
|
||||
})
|
||||
|
||||
it('SVG 窗口边界逐字节对拍:<svg 恰好收在 1024 字节内 → 检出;起点 1021 被截断 → null', () => {
|
||||
// '<svg' 起点索引 1020:占 1020-1023,末字节恰在窗口(0-1023)内 → 检出
|
||||
const flush = Buffer.concat([Buffer.alloc(1020, 0x20), Buffer.from('<svg')])
|
||||
expect(detectMediaType(flush)).toStrictEqual({ type: 'image/svg+xml', svg: true })
|
||||
// 起点索引 1021:窗口只含 '<sv','g' 被截在窗外 → 不检出
|
||||
const truncated = Buffer.concat([Buffer.alloc(1021, 0x20), Buffer.from('<svg')])
|
||||
expect(detectMediaType(truncated)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user