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;
maxCount 与 limits.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. 练习题
- 使用
.array()编写最多 6 张商品图片的接口。 - 使用
.fields()接收 avatar、identityFront、identityBack。 - 测试
.none()收到文件时的错误码。 - 使用错误字段名调用
.single(),捕获LIMIT_UNEXPECTED_FILE。 - 设计多文件全部成功或全部失败的清理流程。
- 为动态表单使用
.any(),再补充允许字段和每字段数量检查。