diff --git a/lib/__tests__/task-material-processor.test.ts b/lib/__tests__/task-material-processor.test.ts index f5e418a..a7a3378 100644 --- a/lib/__tests__/task-material-processor.test.ts +++ b/lib/__tests__/task-material-processor.test.ts @@ -1,6 +1,6 @@ /** @jest-environment node */ -import { generatePreviewImages } from '@/lib/task-material-processor' +import { generatePreviewImages, resolveUniqueZipEntryName } from '@/lib/task-material-processor' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { uploadToS3 } from '@/lib/s3' import { compressImage } from '@/lib/image-processor' @@ -80,4 +80,14 @@ describe('task-material-processor', () => { ) expect(uploadToS3).not.toHaveBeenCalled() }) + + test('resolveUniqueZipEntryName 应避免同名文件在解压时互相覆盖', () => { + const usedNames = new Set() + + expect(resolveUniqueZipEntryName('01.jpg', 0, usedNames)).toBe('01.jpg') + expect(resolveUniqueZipEntryName('01.jpg', 1, usedNames)).toBe('01-2.jpg') + expect(resolveUniqueZipEntryName('01.JPG', 2, usedNames)).toBe('01-3.JPG') + expect(resolveUniqueZipEntryName('主图/01.jpg', 3, usedNames)).toBe('主图_01.jpg') + expect(resolveUniqueZipEntryName('', 4, usedNames)).toBe('file-005') + }) }) diff --git a/lib/task-material-processor.ts b/lib/task-material-processor.ts index 9ec604c..a42065c 100644 --- a/lib/task-material-processor.ts +++ b/lib/task-material-processor.ts @@ -68,6 +68,46 @@ function normalizeMaterialAssetFileUrl(value: unknown): string { return String(value || ''); } +export function resolveUniqueZipEntryName( + originalName: string | null | undefined, + index: number, + usedNames: Set, +): string { + const fallbackName = `file-${String(index + 1).padStart(3, '0')}`; + const safeName = String(originalName || fallbackName) + .replace(/[\0\r\n]/g, ' ') + .replace(/[\/\\]+/g, '_') + .trim() || fallbackName; + + const addIfUnique = (candidate: string) => { + const key = candidate.toLowerCase(); + if (usedNames.has(key)) { + return false; + } + + usedNames.add(key); + return true; + }; + + if (addIfUnique(safeName)) { + return safeName; + } + + const ext = path.extname(safeName); + const baseName = (ext ? safeName.slice(0, -ext.length) : safeName) || fallbackName; + + for (let duplicateIndex = 2; duplicateIndex < 10000; duplicateIndex += 1) { + const candidate = `${baseName}-${duplicateIndex}${ext}`; + if (addIfUnique(candidate)) { + return candidate; + } + } + + const lastResortName = `${baseName}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}${ext}`; + usedNames.add(lastResortName.toLowerCase()); + return lastResortName; +} + /** * 从chunk临时目录打包文件成zip */ @@ -205,11 +245,18 @@ export async function createZipFromFiles( // 连接archive到输出流 archive.pipe(output); - // 添加每个文件到zip - for (const file of files) { - console.log(`[Zip] Adding file: ${file.name} (${file.size} bytes)`); - archive.file(file.path, { name: file.name }); - } + // 添加每个文件到zip。上传文件可能来自多个文件夹,文件名会重复; + // ZIP 内同名条目在很多解压软件中会互相覆盖,因此这里必须保证条目名唯一。 + const usedZipEntryNames = new Set(); + files.forEach((file, index) => { + const zipEntryName = resolveUniqueZipEntryName(file.name, index, usedZipEntryNames); + if (zipEntryName !== file.name) { + console.warn(`[Zip] Duplicate or unsafe file name adjusted: ${file.name} -> ${zipEntryName}`); + } + + console.log(`[Zip] Adding file: ${zipEntryName} (${file.size} bytes)`); + archive.file(file.path, { name: zipEntryName }); + }); // 完成压缩 archive.finalize();