You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 12:51:31