import { createReadStream } from 'fs' import { mkdir, open, rm } from 'fs/promises' import { randomUUID } from 'crypto' import path from 'path' import { pipeline } from 'stream/promises' import { createInflateRaw } from 'zlib' /** * 任务-详情编辑用的流式 ZIP 读取器。 * * 只依赖 Node 自带 zlib:读中央目录定位每个条目,再按条目把解压结果 * 流式写到磁盘。全程不把整个压缩包读进内存,历史素材的分组 ZIP * (主图/SKU图/详情图)可能数百 MB 甚至更大。 * * 只支持本系统自己打包出来的常规布局: * - method 0(store)/ method 8(deflate) * - UTF-8 文件名、数据描述符(flag bit 3)、ZIP64 扩展 * 不支持加密与其它压缩方法,遇到即报错。 */ const EOCD_SIGNATURE = 0x06054b50 const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50 const ZIP64_EOCD_SIGNATURE = 0x06064b50 const CENTRAL_HEADER_SIGNATURE = 0x02014b50 const LOCAL_HEADER_SIGNATURE = 0x04034b50 export interface ZipEntryInfo { /** 中央目录中的原始文件名 */ name: string compressedSize: number uncompressedSize: number compressionMethod: number localHeaderOffset: number flags: number } interface ZipCentralDirectory { entries: ZipEntryInfo[] } function readUInt16LE(buffer: Buffer, offset: number): number { return buffer.readUInt16LE(offset) } function readUInt32LE(buffer: Buffer, offset: number): number { return buffer.readUInt32LE(offset) } function readBigUInt64LE(buffer: Buffer, offset: number): number { const value = buffer.readBigUInt64LE(offset) if (value > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error('ZIP 内的偏移或大小超出安全范围') } return Number(value) } async function readSlice( handle: Awaited>, position: number, length: number, ): Promise { const buffer = Buffer.alloc(length) const { bytesRead } = await handle.read(buffer, 0, length, position) if (bytesRead !== length) { throw new Error('压缩包已损坏:读取内容不完整') } return buffer } function findEocdSignature(tail: Buffer): number { // EOCD 固定 22 字节起,注释最长 65535,从尾部向前扫签名。 for (let offset = tail.length - 22; offset >= 0; offset -= 1) { if (readUInt32LE(tail, offset) === EOCD_SIGNATURE) { return offset } } throw new Error('文件不是有效的 ZIP 压缩包') } async function locateZip64Eocd( handle: Awaited>, eocdOffsetInTail: number, tailStart: number, fileSize: number, ): Promise<{ entryCount: number; cdSize: number; cdOffset: number } | null> { if (eocdOffsetInTail < 20) { return null } const locatorPosition = tailStart + eocdOffsetInTail - 20 if (locatorPosition < 0 || locatorPosition + 20 > fileSize) { return null } const locator = await readSlice(handle, locatorPosition, 20) if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) { return null } const zip64EocdOffset = readBigUInt64LE(locator, 8) const zip64Eocd = await readSlice(handle, zip64EocdOffset, 56) if (readUInt32LE(zip64Eocd, 0) !== ZIP64_EOCD_SIGNATURE) { throw new Error('压缩包已损坏:ZIP64 目录定位失败') } const entryCount = readBigUInt64LE(zip64Eocd, 32) const cdSize = readBigUInt64LE(zip64Eocd, 40) const cdOffset = readBigUInt64LE(zip64Eocd, 48) return { entryCount, cdSize, cdOffset } } async function readCentralDirectory( zipPath: string, ): Promise { const handle = await open(zipPath, 'r') try { const stats = await handle.stat() if (!stats.isFile() || stats.size < 22) { throw new Error('文件不是有效的 ZIP 压缩包') } const tailLength = Math.min(stats.size, 22 + 65535 + 20 + 56) const tailStart = stats.size - tailLength const tail = await readSlice(handle, tailStart, tailLength) const eocdOffsetInTail = findEocdSignature(tail) const eocdAbsolute = tailStart + eocdOffsetInTail let entryCount = readUInt16LE(tail, eocdOffsetInTail + 10) let cdSize = readUInt32LE(tail, eocdOffsetInTail + 12) let cdOffset = readUInt32LE(tail, eocdOffsetInTail + 16) if ( entryCount === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff ) { const zip64 = await locateZip64Eocd( handle, eocdOffsetInTail, tailStart, stats.size, ) if (!zip64) { throw new Error('压缩包已损坏:ZIP64 目录缺失') } entryCount = zip64.entryCount cdSize = zip64.cdSize cdOffset = zip64.cdOffset } if (entryCount <= 0) { return { entries: [] } } const directoryBuffer = await readSlice(handle, cdOffset, cdSize) const entries: ZipEntryInfo[] = [] let cursor = 0 for (let index = 0; index < entryCount; index += 1) { if (cursor + 46 > directoryBuffer.length) { throw new Error('压缩包已损坏:中央目录不完整') } if (readUInt32LE(directoryBuffer, cursor) !== CENTRAL_HEADER_SIGNATURE) { throw new Error('压缩包已损坏:中央目录条目签名异常') } const flags = readUInt16LE(directoryBuffer, cursor + 8) const compressionMethod = readUInt16LE(directoryBuffer, cursor + 10) let compressedSize = readUInt32LE(directoryBuffer, cursor + 20) let uncompressedSize = readUInt32LE(directoryBuffer, cursor + 24) const nameLength = readUInt16LE(directoryBuffer, cursor + 28) const extraLength = readUInt16LE(directoryBuffer, cursor + 30) const commentLength = readUInt16LE(directoryBuffer, cursor + 32) let localHeaderOffset = readUInt32LE(directoryBuffer, cursor + 42) const extraStart = cursor + 46 + nameLength const extraEnd = extraStart + extraLength if (extraEnd > directoryBuffer.length) { throw new Error('压缩包已损坏:中央目录扩展字段不完整') } let extraCursor = extraStart while (extraCursor + 4 <= extraEnd) { const headerId = readUInt16LE(directoryBuffer, extraCursor) const dataSize = readUInt16LE(directoryBuffer, extraCursor + 2) if (headerId === 0x0001) { let fieldCursor = extraCursor + 4 const fieldEnd = extraCursor + 4 + dataSize if (uncompressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) { uncompressedSize = readBigUInt64LE(directoryBuffer, fieldCursor) fieldCursor += 8 } if (compressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) { compressedSize = readBigUInt64LE(directoryBuffer, fieldCursor) fieldCursor += 8 } if (localHeaderOffset === 0xffffffff && fieldCursor + 8 <= fieldEnd) { localHeaderOffset = readBigUInt64LE(directoryBuffer, fieldCursor) fieldCursor += 8 } } extraCursor += 4 + dataSize } const name = directoryBuffer.toString('utf8', cursor + 46, extraStart) entries.push({ name, compressedSize, uncompressedSize, compressionMethod, localHeaderOffset, flags, }) cursor = extraEnd + commentLength } return { entries } } finally { await handle.close() } } /** 列出 ZIP 内的条目(按中央目录顺序,跳过目录项)。 */ export async function listZipEntries(zipPath: string): Promise { const { entries } = await readCentralDirectory(zipPath) return entries.filter((entry) => !entry.name.endsWith('/')) } /** * 把 ZIP 条目解压到目标目录。 * * 返回值保持中央目录顺序;文件名只保留最后一段路径, * 拒绝空名和穿越命名,避免历史数据里的异常条目写出目录树。 */ export async function extractZipEntriesToDirectory( zipPath: string, destinationDir: string, ): Promise> { const { entries } = await readCentralDirectory(zipPath) await mkdir(destinationDir, { recursive: true }) const extracted: Array<{ entryName: string; filePath: string; size: number }> = [] for (const entry of entries) { if (entry.name.endsWith('/')) { continue } const normalized = entry.name.replace(/\\/g, '/') const baseName = normalized.split('/').pop() || '' if (!baseName || baseName === '.' || baseName === '..') { throw new Error(`压缩包含异常文件名,拒绝处理: ${entry.name}`) } if ((entry.flags & 0x1) !== 0) { throw new Error(`压缩包含加密文件,不支持编辑: ${baseName}`) } const outputPath = path.join(destinationDir, `${extracted.length}-${baseName}`) const written = await extractSingleEntry(handle => extractByEntry(handle, entry, outputPath)) if (written !== entry.uncompressedSize) { throw new Error(`压缩包已损坏:条目 ${baseName} 解压后大小不一致`) } extracted.push({ entryName: baseName, filePath: outputPath, size: entry.uncompressedSize, }) } return extracted async function extractSingleEntry( worker: (handle: Awaited>) => Promise, ): Promise { const handle = await open(zipPath, 'r') try { return await worker(handle) } finally { await handle.close() } } } async function extractByEntry( handle: Awaited>, entry: ZipEntryInfo, outputPath: string, ): Promise { const localHeader = await readSlice(handle, entry.localHeaderOffset, 30) if (readUInt32LE(localHeader, 0) !== LOCAL_HEADER_SIGNATURE) { throw new Error(`压缩包已损坏:条目 ${entry.name} 本地头异常`) } const nameLength = readUInt16LE(localHeader, 26) const extraLength = readUInt16LE(localHeader, 28) const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength if (entry.compressionMethod === 0) { // store:直接按范围拷贝原始字节 return copyRangeToFile(handle, dataStart, entry.compressedSize, outputPath) } if (entry.compressionMethod === 8) { const source = createRangeReadStream(handle, dataStart, entry.compressedSize) const inflater = createInflateRaw() let inflatedBytes = 0 inflater.on('data', (chunk: Buffer) => { inflatedBytes += chunk.length }) const output = await createFileWriteStream(outputPath) try { await pipeline(source, inflater, output) } catch (error) { output.destroy() throw error } return inflatedBytes } throw new Error(`压缩包含不支持的压缩方式(method ${entry.compressionMethod}),无法编辑`) } // ---- 以下为内部辅助,避免引入额外依赖 ---- async function copyRangeToFile( handle: Awaited>, start: number, length: number, outputPath: string, ): Promise { const CHUNK = 1024 * 1024 const output = await createFileWriteStream(outputPath) let written = 0 try { let position = start while (written < length) { const size = Math.min(CHUNK, length - written) const buffer = await readSlice(handle, position, size) await new Promise((resolve, reject) => { output.write(buffer, (error) => (error ? reject(error) : resolve())) }) written += size position += size } await new Promise((resolve, reject) => { output.end(() => resolve()) output.on('error', reject) }) return written } catch (error) { output.destroy() throw error } } function createRangeReadStream( handle: Awaited>, start: number, length: number, ) { // fd 由外层 close;createReadStream 仅负责按窗口读取 return createReadStream('', { fd: handle.fd, autoClose: false, start, end: start + length - 1, }) } async function createFileWriteStream(outputPath: string) { const { createWriteStream } = await import('fs') return createWriteStream(outputPath, { flags: 'wx' }) } /** 创建任务-详情编辑用的临时工作目录,返回路径与清理函数。 */ export async function createTaskDetailEditWorkspace(): Promise<{ dir: string cleanup: () => Promise }> { const { getUploadRootDir } = await import('@/lib/upload-paths') const dir = path.join(getUploadRootDir(), 'temp', `task-detail-edit-${randomUUID()}`) await mkdir(dir, { recursive: true }) return { dir, cleanup: () => rm(dir, { recursive: true, force: true }), } }