PDF分片上传至Node服务器后无法打开问题(纯原生Node实现)
问题现象
从前端向Node服务器上传PDF文件时,文件虽成功写入服务器指定存储路径,但打开时提示「文件无法打开,发生错误」,要求基于纯原生Node方案排查修复,不使用multer等第三方文件上传类库。
问题根因
现有代码存在3个直接导致PDF损坏的硬伤:
- 分片计算逻辑完全错误:以原始二进制文件的字节大小为基准计算分片数量、切片偏移,但实际切片操作是针对base64编码后的字符串执行的。base64编码后数据体积会比原二进制文件大33%左右,且每个base64字符对应6bit二进制数据,和原文件的字节偏移完全不匹配,切片时会直接丢失、错切内容。
- 后端写入模式错误:
fs.writeFileSync默认是覆盖写入模式,每次收到分片都直接写入同一个目标文件路径,最终服务器存储的文件只有最后一个分片的内容,不可能正常打开。 - 分片传输缺少必要标识:前端没有给分片附带文件唯一ID、分片序号,后端既无法区分当前分片属于哪个文件,也无法确认分片的先后顺序,只要有并发请求就会出现写串、拼接顺序错误的问题。
修复方案(纯原生实现,无第三方上传依赖)
前端修复代码
直接传输二进制分片,放弃base64编码减少传输体积,同时补全分片必要标识:
const uploadFile = document.getElementById("uploadFile"); uploadFile.addEventListener("change", (event) => { readFile(event.target.files[0]); }); function readFile(file) { const uploadDesignPDF = `http://localhost:7000/api/upload/design`; const fileReader = new FileReader(); // 直接读取二进制缓冲区,避免base64编码带来的体积浪费和偏移问题 fileReader.readAsArrayBuffer(file); fileReader.addEventListener("load", async (event) => { const fileBuffer = new Uint8Array(event.target.result); const fileSize = fileBuffer.byteLength; const chunkSize = 85000; const totalChunks = Math.ceil(fileSize / chunkSize); // 生成文件唯一标识,避免多文件并发上传时写串 const fileId = `${Date.now()}_${Math.random().toString(36).slice(2)}`; for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, fileSize); const chunk = fileBuffer.slice(start, end); // 用FormData传输二进制分片,无需额外编码 const formData = new FormData(); formData.append("fileId", fileId); formData.append("chunkIndex", i); formData.append("totalChunks", totalChunks); formData.append("chunk", new Blob([chunk])); const response = await fetch(uploadDesignPDF, { method: "POST", body: formData }); const result = await response.json(); console.log(result); } }); }
后端修复代码
原生实现FormData解析,分片先存临时文件,全部分片上传完成后按顺序合并:
const fs = require('fs'); const path = require('path'); // 提前初始化存储目录 const TEMP_CHUNK_DIR = path.resolve('./designs/temp'); const FINAL_FILE_DIR = path.resolve('./designs'); if (!fs.existsSync(TEMP_CHUNK_DIR)) fs.mkdirSync(TEMP_CHUNK_DIR, { recursive: true }); if (!fs.existsSync(FINAL_FILE_DIR)) fs.mkdirSync(FINAL_FILE_DIR, { recursive: true }); // 原生FormData解析方法,无第三方依赖 function parseFormData(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const boundary = req.headers['content-type'].split('boundary=')[1]; const buffer = Buffer.concat(chunks); const result = {}; // 按分隔符切分表单字段 const parts = buffer.split(`--${boundary}`).filter(part => part.length && !part.includes('--')); for (const part of parts) { const headerEnd = part.indexOf('\r\n\r\n'); const headers = part.slice(0, headerEnd).toString(); const content = part.slice(headerEnd + 4, part.length - 2); if (headers.includes('filename=')) { result.chunk = content; } else { const nameMatch = headers.match(/name="([^"]+)"/); if (nameMatch) result[nameMatch[1]] = content.toString(); } } resolve(result); }); req.on('error', reject); }); } const uploadDesigns = async (req, res) => { try { const { fileId, chunkIndex, totalChunks, chunk } = await parseFormData(req); // 分片写入临时目录,文件名绑定文件ID和分片序号 const chunkPath = path.join(TEMP_CHUNK_DIR, `${fileId}_${chunkIndex}`); fs.writeFileSync(chunkPath, chunk); // 校验是否所有分片均上传完成 const uploadedCount = fs.readdirSync(TEMP_CHUNK_DIR) .filter(name => name.startsWith(fileId)).length; if (uploadedCount === Number(totalChunks)) { const finalFilePath = path.join(FINAL_FILE_DIR, 'testingPDF6.pdf'); const writeStream = fs.createWriteStream(finalFilePath); // 按分片序号顺序合并 for (let i = 0; i < totalChunks; i++) { const currentChunkPath = path.join(TEMP_CHUNK_DIR, `${fileId}_${i}`); writeStream.write(fs.readFileSync(currentChunkPath)); // 合并完成后删除临时分片 fs.unlinkSync(currentChunkPath); } writeStream.end(); } res.status(200).json({ message: "chunk upload success" }); } catch (error) { console.error(error); res.status(500).json({ message: "upload failed" }); } } module.exports = { uploadDesigns };
额外注意事项
- 禁止直接用
writeFileSync/writeFile对同一个目标文件路径接分片,默认覆盖模式会导致文件内容缺失,优先采用临时分片存储+最终合并的方案,稳定性更高。 - 非必要不要用base64传输二进制文件,会额外增加33%传输体积,也容易因为字符编码、切片偏移问题导致文件损坏,直接传输二进制Buffer/Blob是最稳妥的方案。
- 分片上传必须携带文件唯一标识、分片序号参数,否则后端无法正确拼接分片,并发场景下必然出现文件损坏。
内容的提问来源于stack exchange,提问作者zachjohn987
相关产品推荐
相关产品推荐

