返回首页

Node.js 文件上传实现详解

1. 问题解析

文件上传是 Web 开发中的常见需求。理解 multipart/form-data 格式、boundary 分隔符以及 HTTP 协议细节,对于实现稳定可靠的文件上传功能至关重要。Node.js 提供了多种方式处理文件上传,从原生实现到成熟的中间件方案。

2. 核心概念

2.1 multipart/form-data 协议

当表单包含文件上传时,enctype 必须设置为 multipart/form-data

<form action="/upload" method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" />
  <input type="text" name="username" />
  <button type="submit">上传</button>
</form>

2.2 HTTP 请求结构

POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 1234

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"

john_doe
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg

[二进制文件数据]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

2.3 关键概念

概念 说明
boundary 分隔符,用于区分不同表单字段
Content-Disposition 描述字段信息,包含 name 和 filename
Content-Type 文件 MIME 类型
Stream 大文件应使用流式处理避免内存溢出

3. 详细解答

3.1 原生实现文件上传解析

const http = require('http');
const fs = require('fs');
const path = require('path');

/**
 * 简化的 multipart/form-data 解析器
 */
class MultipartParser {
  constructor(boundary) {
    this.boundary = Buffer.from('--' + boundary);
    this.state = 'boundary';
    this.parts = [];
    this.currentPart = null;
    this.buffer = Buffer.alloc(0);
  }

  parse(chunk) {
    this.buffer = Buffer.concat([this.buffer, chunk]);
    this.processBuffer();
  }

  processBuffer() {
    while (this.buffer.length > 0) {
      if (this.state === 'boundary') {
        const idx = this.buffer.indexOf(this.boundary);
        if (idx === -1) break;

        this.buffer = this.buffer.slice(idx + this.boundary.length);

        // 检查是否是最后一个 boundary (以 -- 结尾)
        if (this.buffer.slice(0, 2).toString() === '--') {
          this.state = 'end';
          break;
        }

        // 跳过 \r\n
        if (this.buffer.slice(0, 2).toString() === '\r\n') {
          this.buffer = this.buffer.slice(2);
        }

        this.state = 'header';
        this.currentPart = { headers: {}, data: Buffer.alloc(0) };
      }

      if (this.state === 'header') {
        const endIdx = this.buffer.indexOf('\r\n\r\n');
        if (endIdx === -1) break;

        const headerStr = this.buffer.slice(0, endIdx).toString();
        this.parseHeaders(headerStr);

        this.buffer = this.buffer.slice(endIdx + 4);
        this.state = 'data';
      }

      if (this.state === 'data') {
        const nextBoundary = this.buffer.indexOf(this.boundary);
        if (nextBoundary === -1) {
          // 保留部分数据,可能包含不完整的 boundary
          if (this.buffer.length > this.boundary.length) {
            const keep = this.boundary.length;
            this.currentPart.data = Buffer.concat([
              this.currentPart.data,
              this.buffer.slice(0, -keep)
            ]);
            this.buffer = this.buffer.slice(-keep);
          }
          break;
        }

        // 提取数据(去掉末尾的 \r\n)
        let data = this.buffer.slice(0, nextBoundary - 2);
        this.currentPart.data = Buffer.concat([this.currentPart.data, data]);
        this.parts.push(this.currentPart);

        this.buffer = this.buffer.slice(nextBoundary);
        this.state = 'boundary';
      }
    }
  }

  parseHeaders(headerStr) {
    const lines = headerStr.split('\r\n');
    for (const line of lines) {
      const [key, ...valueParts] = line.split(':');
      if (valueParts.length > 0) {
        const value = valueParts.join(':').trim();
        this.currentPart.headers[key.toLowerCase()] = value;

        // 解析 Content-Disposition
        if (key.toLowerCase() === 'content-disposition') {
          const match = value.match(/name="([^"]+)"/);
          if (match) this.currentPart.name = match[1];

          const filenameMatch = value.match(/filename="([^"]+)"/);
          if (filenameMatch) this.currentPart.filename = filenameMatch[1];
        }
      }
    }
  }
}

// 使用原生方式处理文件上传
const server = http.createServer((req, res) => {
  if (req.url === '/upload' && req.method === 'POST') {
    const contentType = req.headers['content-type'];
    const boundaryMatch = contentType.match(/boundary=([^;]+)/);

    if (!boundaryMatch) {
      res.statusCode = 400;
      res.end('Missing boundary');
      return;
    }

    const parser = new MultipartParser(boundaryMatch[1]);
    const uploadDir = path.join(__dirname, 'uploads');

    // 确保上传目录存在
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir, { recursive: true });
    }

    req.on('data', chunk => {
      parser.parse(chunk);
    });

    req.on('end', () => {
      const results = [];

      for (const part of parser.parts) {
        if (part.filename) {
          // 保存文件
          const filename = Date.now() + '-' + part.filename;
          const filepath = path.join(uploadDir, filename);
          fs.writeFileSync(filepath, part.data);

          results.push({
            field: part.name,
            filename: part.filename,
            savedAs: filename,
            size: part.data.length
          });
        } else {
          // 普通表单字段
          results.push({
            field: part.name,
            value: part.data.toString()
          });
        }
      }

      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ success: true, files: results }));
    });

    req.on('error', (err) => {
      console.error('Upload error:', err);
      res.statusCode = 500;
      res.end('Upload failed');
    });
  } else {
    res.statusCode = 404;
    res.end('Not Found');
  }
});

server.listen(3000, () => {
  console.log('Server running on port 3000');
});

3.2 使用 koa-body 处理文件上传

const Koa = require('koa');
const koaBody = require('koa-body');
const fs = require('fs');
const path = require('path');

const app = new Koa();

// 配置 koa-body
app.use(koaBody({
  multipart: true,              // 启用文件上传
  formidable: {
    uploadDir: path.join(__dirname, 'uploads'), // 临时文件目录
    keepExtensions: true,       // 保留文件扩展名
    maxFileSize: 10 * 1024 * 1024, // 最大文件大小 10MB
    maxFields: 10,              // 最大字段数
    hash: 'md5'                 // 计算文件 hash
  }
}));

// 文件上传接口
app.use(async (ctx) => {
  if (ctx.path === '/upload' && ctx.method === 'POST') {
    const files = ctx.request.files;
    const body = ctx.request.body;

    console.log('表单字段:', body);
    console.log('上传的文件:', files);

    const results = [];

    // 处理多个文件
    for (const key in files) {
      const file = files[key];

      // 如果是多文件上传,file 是数组
      const fileArray = Array.isArray(file) ? file : [file];

      for (const f of fileArray) {
        // 移动文件到目标目录
        const targetDir = path.join(__dirname, 'uploads', 'permanent');
        if (!fs.existsSync(targetDir)) {
          fs.mkdirSync(targetDir, { recursive: true });
        }

        const targetPath = path.join(targetDir, f.newFilename || path.basename(f.filepath));
        fs.renameSync(f.filepath, targetPath);

        results.push({
          field: key,
          originalName: f.originalFilename,
          savedName: path.basename(targetPath),
          size: f.size,
          mimeType: f.mimetype,
          hash: f.hash
        });
      }
    }

    ctx.body = {
      success: true,
      message: '上传成功',
      data: results
    };
  } else if (ctx.path === '/upload' && ctx.method === 'GET') {
    // 返回上传表单
    ctx.type = 'html';
    ctx.body = `
      <form action="/upload" method="POST" enctype="multipart/form-data">
        <input type="text" name="username" placeholder="用户名" /><br><br>
        <input type="file" name="avatar" accept="image/*" /><br><br>
        <input type="file" name="documents" multiple /><br><br>
        <button type="submit">上传</button>
      </form>
    `;
  } else {
    ctx.status = 404;
    ctx.body = 'Not Found';
  }
});

app.listen(3000);

3.3 使用 koa-multer 处理文件上传

const Koa = require('koa');
const Router = require('@koa/router');
const multer = require('@koa/multer');
const path = require('path');

const app = new Koa();
const router = new Router();

// 配置存储
const storage = multer.diskStorage({
  // 目标目录
  destination: (req, file, cb) => {
    cb(null, path.join(__dirname, 'uploads'));
  },
  // 文件名
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + '-' + uniqueSuffix + ext);
  }
});

// 文件过滤
const fileFilter = (req, file, cb) => {
  // 只允许图片
  if (file.mimetype.startsWith('image/')) {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片文件'), false);
  }
};

// 创建 multer 实例
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 5                   // 最多5个文件
  }
});

// 单文件上传
router.post('/upload/single', upload.single('avatar'), (ctx) => {
  ctx.body = {
    success: true,
    file: ctx.file
  };
});

// 多文件上传(相同字段)
router.post('/upload/multiple', upload.array('photos', 5), (ctx) => {
  ctx.body = {
    success: true,
    files: ctx.files
  };
});

// 多字段文件上传
router.post('/upload/fields', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'gallery', maxCount: 8 }
]), (ctx) => {
  ctx.body = {
    success: true,
    files: ctx.files
  };
});

// 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err instanceof multer.MulterError) {
      // Multer 错误
      if (err.code === 'LIMIT_FILE_SIZE') {
        ctx.status = 400;
        ctx.body = { error: '文件大小超过限制' };
      } else if (err.code === 'LIMIT_FILE_COUNT') {
        ctx.status = 400;
        ctx.body = { error: '文件数量超过限制' };
      } else {
        ctx.status = 400;
        ctx.body = { error: err.message };
      }
    } else {
      throw err;
    }
  }
});

app.use(router.routes());
app.listen(3000);

3.4 流式处理大文件上传

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

/**
 * 流式文件上传处理
 * 适合大文件,避免内存溢出
 */
function handleStreamUpload(req, res, uploadDir) {
  const contentType = req.headers['content-type'];
  const boundaryMatch = contentType.match(/boundary=([^;]+)/);

  if (!boundaryMatch) {
    res.statusCode = 400;
    res.end('Missing boundary');
    return;
  }

  const boundary = Buffer.from('--' + boundaryMatch[1]);
  let state = 'boundary';
  let currentFile = null;
  let currentField = null;
  let buffer = Buffer.alloc(0);
  let headerBuffer = '';

  // 确保上传目录存在
  if (!fs.existsSync(uploadDir)) {
    fs.mkdirSync(uploadDir, { recursive: true });
  }

  req.on('data', (chunk) => {
    buffer = Buffer.concat([buffer, chunk]);

    while (buffer.length > 0) {
      if (state === 'boundary') {
        const idx = buffer.indexOf(boundary);
        if (idx === -1) {
          // 如果缓冲区太大还没找到 boundary,保留最后一部分
          if (buffer.length > boundary.length * 2) {
            buffer = buffer.slice(-boundary.length * 2);
          }
          break;
        }

        // 关闭之前的文件
        if (currentFile) {
          currentFile.stream.end();
          currentFile = null;
        }

        buffer = buffer.slice(idx + boundary.length);

        // 检查是否结束
        if (buffer.slice(0, 2).toString() === '--') {
          state = 'end';
          break;
        }

        // 跳过 \r\n
        if (buffer.slice(0, 2).toString() === '\r\n') {
          buffer = buffer.slice(2);
        }

        state = 'header';
        headerBuffer = '';
      }

      if (state === 'header') {
        const endIdx = buffer.indexOf('\r\n\r\n');
        if (endIdx === -1) {
          headerBuffer += buffer.toString();
          buffer = Buffer.alloc(0);
          break;
        }

        headerBuffer += buffer.slice(0, endIdx).toString();
        buffer = buffer.slice(endIdx + 4);

        // 解析 headers
        const headers = {};
        headerBuffer.split('\r\n').forEach(line => {
          const [key, ...valueParts] = line.split(':');
          if (valueParts.length) {
            headers[key.toLowerCase()] = valueParts.join(':').trim();
          }
        });

        const disposition = headers['content-disposition'] || '';
        const nameMatch = disposition.match(/name="([^"]+)"/);
        const filenameMatch = disposition.match(/filename="([^"]+)"/);

        if (filenameMatch) {
          // 文件字段
          const filename = Date.now() + '-' + filenameMatch[1];
          const filepath = path.join(uploadDir, filename);

          currentFile = {
            name: nameMatch?.[1],
            filename: filenameMatch[1],
            savedPath: filepath,
            stream: fs.createWriteStream(filepath),
            hash: crypto.createHash('md5')
          };

          currentFile.stream.on('error', (err) => {
            console.error('File write error:', err);
          });
        } else if (nameMatch) {
          // 普通字段
          currentField = { name: nameMatch[1], value: '' };
        }

        state = 'data';
      }

      if (state === 'data') {
        const nextBoundary = buffer.indexOf(boundary);

        if (nextBoundary === -1) {
          // 没有完整的 boundary,写入数据并保留部分缓冲区
          const keepLength = boundary.length;
          const writeLength = Math.max(0, buffer.length - keepLength);

          if (writeLength > 0) {
            const data = buffer.slice(0, writeLength);

            if (currentFile) {
              currentFile.stream.write(data);
              currentFile.hash.update(data);
            } else if (currentField) {
              currentField.value += data.toString();
            }

            buffer = buffer.slice(writeLength);
          }
          break;
        }

        // 找到 boundary,写入剩余数据(去掉末尾的 \r\n)
        const data = buffer.slice(0, nextBoundary - 2);

        if (currentFile) {
          currentFile.stream.write(data);
          currentFile.stream.end();
          currentFile.hash.update(data);

          console.log('File saved:', {
            field: currentFile.name,
            originalName: currentFile.filename,
            path: currentFile.savedPath,
            md5: currentFile.hash.digest('hex')
          });

          currentFile = null;
        } else if (currentField) {
          currentField.value += data.toString();
          console.log('Field:', currentField.name, '=', currentField.value);
          currentField = null;
        }

        buffer = buffer.slice(nextBoundary);
        state = 'boundary';
      }
    }
  });

  req.on('end', () => {
    if (currentFile) {
      currentFile.stream.end();
    }
    res.end(JSON.stringify({ success: true }));
  });

  req.on('error', (err) => {
    console.error('Upload error:', err);
    if (currentFile) {
      currentFile.stream.destroy();
      // 删除不完整的文件
      fs.unlink(currentFile.savedPath, () => {});
    }
    res.statusCode = 500;
    res.end('Upload failed');
  });
}

// 使用
const server = http.createServer((req, res) => {
  if (req.url === '/upload' && req.method === 'POST') {
    handleStreamUpload(req, res, path.join(__dirname, 'uploads'));
  } else {
    res.statusCode = 404;
    res.end('Not Found');
  }
});

server.listen(3000);

4. 深入理解

4.1 文件上传安全风险

const path = require('path');

/**
 * 文件上传安全检查
 */
class UploadSecurity {
  // 危险的文件扩展名
  static dangerousExtensions = [
    '.exe', '.dll', '.bat', '.cmd', '.sh', '.php',
    '.jsp', '.asp', '.aspx', '.py', '.rb', '.pl'
  ];

  // 检查文件扩展名
  static checkExtension(filename) {
    const ext = path.extname(filename).toLowerCase();
    if (this.dangerousExtensions.includes(ext)) {
      throw new Error(`危险的文件类型: ${ext}`);
    }
    return true;
  }

  // 检查 MIME 类型与扩展名是否匹配
  static checkMimeType(filename, mimetype) {
    const ext = path.extname(filename).toLowerCase();

    const mimeMap = {
      '.jpg': ['image/jpeg', 'image/jpg'],
      '.jpeg': ['image/jpeg'],
      '.png': ['image/png'],
      '.gif': ['image/gif'],
      '.pdf': ['application/pdf'],
      '.txt': ['text/plain']
    };

    const allowedMimes = mimeMap[ext];
    if (allowedMimes && !allowedMimes.includes(mimetype)) {
      throw new Error(`MIME 类型不匹配: ${mimetype}${ext}`);
    }

    return true;
  }

  // 生成安全的文件名
  static generateSafeFilename(originalName) {
    const ext = path.extname(originalName);
    const timestamp = Date.now();
    const random = Math.random().toString(36).substring(2, 15);
    return `${timestamp}-${random}${ext}`;
  }

  // 验证文件内容(魔术数字检查)
  static async validateFileContent(filepath, expectedType) {
    const fs = require('fs');
    const buffer = Buffer.alloc(8);
    const fd = fs.openSync(filepath, 'r');
    fs.readSync(fd, buffer, 0, 8, 0);
    fs.closeSync(fd);

    // 文件签名(魔术数字)
    const signatures = {
      'image/jpeg': [0xFF, 0xD8, 0xFF],
      'image/png': [0x89, 0x50, 0x4E, 0x47],
      'image/gif': [0x47, 0x49, 0x46, 0x38],
      'application/pdf': [0x25, 0x50, 0x44, 0x46]
    };

    const expected = signatures[expectedType];
    if (expected) {
      for (let i = 0; i < expected.length; i++) {
        if (buffer[i] !== expected[i]) {
          throw new Error('文件内容验证失败');
        }
      }
    }

    return true;
  }
}

// 使用示例
app.use(async (ctx) => {
  const file = ctx.request.files?.avatar;
  if (!file) return;

  try {
    // 安全检查
    UploadSecurity.checkExtension(file.originalFilename);
    UploadSecurity.checkMimeType(file.originalFilename, file.mimetype);

    // 生成安全文件名
    const safeName = UploadSecurity.generateSafeFilename(file.originalFilename);
    const targetPath = path.join(uploadDir, safeName);

    // 移动文件
    fs.renameSync(file.filepath, targetPath);

    // 验证文件内容
    await UploadSecurity.validateFileContent(targetPath, file.mimetype);

    ctx.body = { success: true, filename: safeName };
  } catch (err) {
    // 清理临时文件
    if (file.filepath && fs.existsSync(file.filepath)) {
      fs.unlinkSync(file.filepath);
    }
    ctx.status = 400;
    ctx.body = { error: err.message };
  }
});

4.2 断点续传实现

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

/**
 * 断点续传上传管理器
 */
class ResumableUpload {
  constructor(uploadDir) {
    this.uploadDir = uploadDir;
    this.tempDir = path.join(uploadDir, '.temp');

    if (!fs.existsSync(this.tempDir)) {
      fs.mkdirSync(this.tempDir, { recursive: true });
    }
  }

  // 初始化上传,返回 uploadId
  initUpload(filename, fileSize) {
    const uploadId = crypto.randomUUID();
    const metaPath = path.join(this.tempDir, `${uploadId}.meta`);

    const metadata = {
      uploadId,
      filename,
      fileSize,
      chunkSize: 1024 * 1024, // 1MB 分片
      uploadedChunks: [],
      createdAt: Date.now()
    };

    fs.writeFileSync(metaPath, JSON.stringify(metadata));

    return {
      uploadId,
      chunkSize: metadata.chunkSize,
      totalChunks: Math.ceil(fileSize / metadata.chunkSize)
    };
  }

  // 上传分片
  uploadChunk(uploadId, chunkIndex, chunkData) {
    const metaPath = path.join(this.tempDir, `${uploadId}.meta`);
    const chunkPath = path.join(this.tempDir, `${uploadId}.${chunkIndex}`);

    if (!fs.existsSync(metaPath)) {
      throw new Error('Upload not found');
    }

    // 保存分片
    fs.writeFileSync(chunkPath, chunkData);

    // 更新元数据
    const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
    if (!metadata.uploadedChunks.includes(chunkIndex)) {
      metadata.uploadedChunks.push(chunkIndex);
    }
    fs.writeFileSync(metaPath, JSON.stringify(metadata));

    return {
      uploadedChunks: metadata.uploadedChunks.length,
      totalChunks: Math.ceil(metadata.fileSize / metadata.chunkSize)
    };
  }

  // 合并分片
  finalizeUpload(uploadId) {
    const metaPath = path.join(this.tempDir, `${uploadId}.meta`);
    const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));

    const totalChunks = Math.ceil(metadata.fileSize / metadata.chunkSize);

    // 检查是否所有分片都已上传
    if (metadata.uploadedChunks.length !== totalChunks) {
      throw new Error('Not all chunks uploaded');
    }

    // 合并文件
    const finalPath = path.join(this.uploadDir, metadata.filename);
    const writeStream = fs.createWriteStream(finalPath);

    for (let i = 0; i < totalChunks; i++) {
      const chunkPath = path.join(this.tempDir, `${uploadId}.${i}`);
      const chunk = fs.readFileSync(chunkPath);
      writeStream.write(chunk);

      // 清理分片
      fs.unlinkSync(chunkPath);
    }

    writeStream.end();

    // 清理元数据
    fs.unlinkSync(metaPath);

    return { path: finalPath, size: metadata.fileSize };
  }

  // 获取上传进度
  getProgress(uploadId) {
    const metaPath = path.join(this.tempDir, `${uploadId}.meta`);

    if (!fs.existsSync(metaPath)) {
      return null;
    }

    const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
    const totalChunks = Math.ceil(metadata.fileSize / metadata.chunkSize);

    return {
      uploadId,
      uploadedChunks: metadata.uploadedChunks,
      totalChunks,
      percent: Math.round((metadata.uploadedChunks.length / totalChunks) * 100)
    };
  }
}

// API 路由实现
const router = require('@koa/router')();
const uploadManager = new ResumableUpload('./uploads');

// 初始化上传
router.post('/upload/init', (ctx) => {
  const { filename, fileSize } = ctx.request.body;
  const result = uploadManager.initUpload(filename, fileSize);
  ctx.body = result;
});

// 上传分片
router.post('/upload/chunk/:uploadId', (ctx) => {
  const { uploadId } = ctx.params;
  const { chunkIndex } = ctx.request.body;
  const chunkData = ctx.request.files.chunk;

  const result = uploadManager.uploadChunk(
    uploadId,
    parseInt(chunkIndex),
    fs.readFileSync(chunkData.filepath)
  );

  ctx.body = result;
});

// 完成上传
router.post('/upload/finalize/:uploadId', (ctx) => {
  const { uploadId } = ctx.params;
  const result = uploadManager.finalizeUpload(uploadId);
  ctx.body = { success: true, file: result };
});

// 查询进度
router.get('/upload/progress/:uploadId', (ctx) => {
  const { uploadId } = ctx.params;
  const progress = uploadManager.getProgress(uploadId);
  ctx.body = progress || { error: 'Upload not found' };
});

5. 最佳实践

5.1 文件上传配置建议

const koaBody = require('koa-body');
const path = require('path');

// 推荐的 koa-body 配置
const uploadConfig = {
  multipart: true,
  formidable: {
    // 上传目录
    uploadDir: path.join(__dirname, 'temp'),

    // 保留扩展名
    keepExtensions: true,

    // 文件大小限制(10MB)
    maxFileSize: 10 * 1024 * 1024,

    // 总表单大小限制
    maxFieldsSize: 20 * 1024 * 1024,

    // 最大字段数
    maxFields: 10,

    // 是否对上传的文件进行哈希计算
    hash: 'md5',

    // 文件是否可写
    writable: true,

    // 遇到错误时是否保留临时文件(调试用)
    keepExtensions: false
  },
  // 是否在错误时抛出
  onError: (err, ctx) => {
    console.error('Upload error:', err);
    ctx.throw(400, '文件上传失败');
  }
};

app.use(koaBody(uploadConfig));

// 定期清理临时文件
const cron = require('node-cron');
const fs = require('fs');

cron.schedule('0 0 * * *', () => {
  const tempDir = path.join(__dirname, 'temp');
  const files = fs.readdirSync(tempDir);
  const now = Date.now();

  files.forEach(file => {
    const filePath = path.join(tempDir, file);
    const stats = fs.statSync(filePath);

    // 删除超过 24 小时的临时文件
    if (now - stats.mtime.getTime() > 24 * 60 * 60 * 1000) {
      fs.unlinkSync(filePath);
    }
  });
});

5.2 云存储上传

const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { createReadStream } = require('fs');

// AWS S3 上传
const s3Client = new S3Client({
  region: 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY,
    secretAccessKey: process.env.AWS_SECRET_KEY
  }
});

async function uploadToS3(filePath, key, options = {}) {
  const stream = createReadStream(filePath);

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    Body: stream,
    ContentType: options.contentType || 'application/octet-stream',
    ACL: options.isPublic ? 'public-read' : 'private'
  });

  await s3Client.send(command);

  return {
    key,
    url: options.isPublic
      ? `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`
      : await getSignedUrl(key)
  };
}

// 阿里云 OSS 上传
const OSS = require('ali-oss');

const ossClient = new OSS({
  region: 'oss-cn-hangzhou',
  accessKeyId: process.env.OSS_ACCESS_KEY,
  accessKeySecret: process.env.OSS_SECRET_KEY,
  bucket: process.env.OSS_BUCKET
});

async function uploadToOSS(filePath, key) {
  const result = await ossClient.put(key, filePath);
  return {
    key: result.name,
    url: result.url,
    etag: result.etag
  };
}

6. 面试要点

  1. multipart/form-data 格式理解

    • 使用 boundary 分隔不同字段
    • 每个部分包含 Content-Disposition 头
    • 文件字段有 filename 和 Content-Type
    • 最后一个 boundary 以 -- 结尾
  2. 文件上传的安全考虑

    • 验证文件扩展名和 MIME 类型
    • 限制文件大小和数量
    • 使用魔术数字验证文件内容
    • 生成随机文件名防止覆盖
    • 存储目录不可执行
  3. 大文件处理方案

    • 使用 Stream 避免内存溢出
    • 实现断点续传支持大文件
    • 分片上传提高可靠性
    • 使用流式解析减少内存占用
  4. 常用中间件对比

    • koa-body:功能全面,支持 JSON、表单、文件
    • koa-multer:专注于文件上传,配置灵活
    • formidable:底层解析库,koa-body 底层使用
    • busboy:纯流式解析,性能最好
  5. 性能优化

    • 使用流式处理大文件
    • 设置合理的文件大小限制
    • 使用对象存储(S3/OSS)分担服务器压力
    • 实现断点续传减少重复上传
    • 定期清理临时文件