02 上传 API 与多文件上传

1. .single(fieldname)

const uploadAvatar = upload.single("avatar");

只接受一个字段名为 avatar 的文件,结果位于:

req.file

适合:

即使业务只允许一个文件,也应同时设置:

limits: {
  files: 1,
}

.single() 已约束字段层面的单文件,limits.files 还能限制整个 multipart 请求的文件 part 数量。

2. .array(fieldname, maxCount)

const uploadGallery = multer({
  storage,
  limits: {
    files: 8,
    fileSize: 5 * 1024 * 1024,
  },
}).array("photos", 8);

所有文件使用相同字段名:

formData.append("photos", file1);
formData.append("photos", file2);
formData.append("photos", file3);

服务端结果:

req.files

TypeScript 中先判断:

if (!Array.isArray(req.files)) {
  res.status(400).json({ message: "没有上传图片" });
  return;
}

const files: Express.Multer.File[] = req.files;

maxCountlimits.files

.array("photos", 8)

限制 photos 字段最多 8 个。

limits: { files: 8 }

限制整个请求最多 8 个文件 part。生产接口建议同时配置,防止出现其他意外文件字段。

3. .fields(fields)

当不同文件有不同业务意义时使用:

const uploadProfile = upload.fields([
  {
    name: "avatar",
    maxCount: 1,
  },
  {
    name: "gallery",
    maxCount: 8,
  },
]);

前端:

formData.append("avatar", avatarFile);

for (const file of galleryFiles) {
  formData.append("gallery", file);
}

结果不是数组,而是以字段名为 key 的对象:

{
  avatar: [avatarFile],
  gallery: [galleryFile1, galleryFile2]
}

类型缩小:

type ProfileFiles = {
  avatar?: Express.Multer.File[];
  gallery?: Express.Multer.File[];
};

if (
  req.files === undefined ||
  Array.isArray(req.files)
) {
  res.status(400).json({ message: "文件结构不正确" });
  return;
}

const files = req.files as ProfileFiles;
const avatar = files.avatar?.[0];
const gallery = files.gallery ?? [];

类型断言不能验证运行时结构,因此仍然要检查字段是否存在和数量是否符合业务要求。

4. .none()

const textMultipart = multer().none();

它只接受 multipart 文本字段:

router.post(
  "/multipart-text",
  textMultipart,
  (req, res) => {
    res.json(req.body);
  },
);

如果请求包含文件,会产生:

LIMIT_UNEXPECTED_FILE

只有当客户端协议必须使用 multipart、但当前接口禁止文件时才需要 .none()。普通文本表单可以使用 express.urlencoded()

5. .any()

upload.any()

它接受所有文件字段,结果放在 req.files 数组中。

风险:

只有动态表单构建器等确实不知道字段名的场景才考虑使用,并且仍要校验字段白名单、总数量和每个字段数量。

6. 多文件接口示例

const imageTypes = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
]);

const uploadPhotos = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 3 * 1024 * 1024,
    files: 8,
    fields: 5,
    parts: 13,
  },
  fileFilter(_req, file, callback) {
    if (!imageTypes.has(file.mimetype)) {
      callback(new Error("只允许 JPEG、PNG 或 WebP"));
      return;
    }

    callback(null, true);
  },
});

router.post(
  "/photos",
  uploadPhotos.array("photos", 8),
  async (req, res): Promise<void> => {
    if (!Array.isArray(req.files) || req.files.length === 0) {
      res.status(400).json({ message: "至少上传一张图片" });
      return;
    }

    // 后续还要检查真实文件内容,再保存到正式存储。
    res.status(201).json({
      count: req.files.length,
    });
  },
);

MemoryStorage 只适合受严格大小、数量和并发限制的小文件。这里的 mimetype 检查也只是第一层快速过滤。

7. 多文件的原子性问题

假设上传 5 个文件,第 4 个不合法:

文件 1 已写入临时目录
文件 2 已写入临时目录
文件 3 已写入临时目录
文件 4 失败
文件 5 未处理

业务必须决定:

全部成功

任意文件失败时清理该请求已经产生的所有文件。这是多数表单接口更容易理解的语义。

部分成功

响应中分别返回每个文件状态。适合批量素材管理,但接口和重试逻辑更复杂。

文件系统和对象存储不属于 MySQL 事务,必须通过临时状态与补偿清理实现“一致效果”。

8. 同名文件

多个用户可能上传:

image.jpg
image.jpg
image.jpg

不能用 originalname 作为最终存储名,否则会覆盖或产生竞争。应由服务器生成独立 storage key。

9. 字段名是 API 契约

.fields() 的字段名应稳定、可文档化:

avatar
identityFront
identityBack
attachments

不要允许客户端把业务类型直接写成任意文件路径或目录名。

10. 练习题

  1. 使用 .array() 编写最多 6 张商品图片的接口。
  2. 使用 .fields() 接收 avatar、identityFront、identityBack。
  3. 测试 .none() 收到文件时的错误码。
  4. 使用错误字段名调用 .single(),捕获 LIMIT_UNEXPECTED_FILE
  5. 设计多文件全部成功或全部失败的清理流程。
  6. 为动态表单使用 .any(),再补充允许字段和每字段数量检查。

官方参考