06 错误处理、文件清理与一致性
1. Multer 如何传递错误
Multer 遇到错误时会调用 Express 的错误流程。可以使用全局错误处理中间件:
import multer from "multer";
import type {
ErrorRequestHandler,
} from "express";
export const uploadErrorHandler: ErrorRequestHandler = (
error,
_req,
res,
next,
) => {
if (error instanceof multer.MulterError) {
res.status(400).json({
code: error.code,
message: "文件上传失败",
field: error.field,
});
return;
}
next(error);
};
错误中间件必须安装在路由之后:
app.use(router);
app.use(uploadErrorHandler);
app.use(finalErrorHandler);
2. 常见 MulterError code
LIMIT_PART_COUNT
LIMIT_FILE_SIZE
LIMIT_FILE_COUNT
LIMIT_FIELD_KEY
LIMIT_FIELD_VALUE
LIMIT_FIELD_COUNT
LIMIT_UNEXPECTED_FILE
推荐映射成稳定业务 code:
const messageByMulterCode: Record<string, string> = {
LIMIT_PART_COUNT: "上传内容的组成部分过多",
LIMIT_FILE_SIZE: "文件超过大小限制",
LIMIT_FILE_COUNT: "上传文件数量过多",
LIMIT_FIELD_KEY: "字段名称过长",
LIMIT_FIELD_VALUE: "文本字段内容过大",
LIMIT_FIELD_COUNT: "文本字段数量过多",
LIMIT_UNEXPECTED_FILE: "出现未允许的文件字段",
};
不要把内部磁盘路径、堆栈和 storage 配置返回给客户端。
3. 手动调用 Multer 中间件
需要在当前路由中精确分类错误时:
const uploadAvatar = upload.single("avatar");
router.post("/avatar", (req, res, next) => {
uploadAvatar(req, res, (error: unknown) => {
if (error instanceof multer.MulterError) {
res.status(400).json({
code: error.code,
message:
messageByMulterCode[error.code] ??
"上传请求不合法",
});
return;
}
if (error !== undefined) {
next(error);
return;
}
if (req.file === undefined) {
res.status(400).json({ message: "缺少头像" });
return;
}
res.status(201).json({ message: "上传成功" });
});
});
普通项目使用统一错误中间件更容易维护;只有路由确实需要特殊响应时才手动调用。
4. fileFilter 自定义错误
export class UnsupportedFileTypeError extends Error {
constructor() {
super("不支持该文件类型");
this.name = "UnsupportedFileTypeError";
}
}
fileFilter(_req, file, callback) {
if (!allowedTypes.has(file.mimetype)) {
callback(new UnsupportedFileTypeError());
return;
}
callback(null, true);
}
错误处理中间件:
if (error instanceof UnsupportedFileTypeError) {
res.status(415).json({
code: "UNSUPPORTED_FILE_TYPE",
message: error.message,
});
return;
}
5. callback(null, false) 不会自动报错
callback(null, false);
表示跳过当前文件。请求可能继续执行,req.file 最终为 undefined。
Controller 无法仅凭 undefined 判断:
用户没有选择文件
文件字段名错误
fileFilter 返回 false
如果业务需要明确告诉客户端“类型非法”,更适合传递自定义错误。
6. 为什么业务失败后需要主动清理
下面的顺序中,文件已经写入磁盘:
Multer 保存文件
→ express-validator 检查 title
→ title 校验失败
Multer 不知道后续业务失败,因此不会自动删除文件。
同样:
文件保存成功
→ MySQL 写入失败
也需要应用补偿清理。
7. 安全删除临时文件
import { unlink } from "node:fs/promises";
import path from "node:path";
const incomingRoot = path.resolve(
"var",
"uploads",
"incoming",
);
async function removeIncomingFile(
filePath: string,
): Promise<void> {
const resolved = path.resolve(filePath);
const relative = path.relative(incomingRoot, resolved);
if (
relative.startsWith("..") ||
path.isAbsolute(relative)
) {
throw new Error("拒绝删除上传目录之外的文件");
}
try {
await unlink(resolved);
} catch (error: unknown) {
if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
) {
return;
}
throw error;
}
}
即使 req.file.path 通常来自服务器 storage 配置,删除函数仍应限制目标根目录,防止未来重构时把任意输入传入。
8. 清理单文件
router.post(
"/documents",
upload.single("document"),
documentValidation,
async (req, res, next): Promise<void> => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
if (req.file !== undefined) {
await removeIncomingFile(req.file.path);
}
res.status(400).json({ errors: errors.array() });
return;
}
// 数据库与正式存储处理。
res.status(201).json({ message: "成功" });
} catch (error: unknown) {
next(error);
}
},
);
更大的项目应把上传处理、验证和补偿封装到 Service,避免每个 Controller 重复 try/catch。
9. 清理多文件
async function cleanupFiles(
files: readonly Express.Multer.File[],
): Promise<void> {
const results = await Promise.allSettled(
files.map((file) => removeIncomingFile(file.path)),
);
for (const result of results) {
if (result.status === "rejected") {
console.error("清理上传文件失败", result.reason);
}
}
}
清理一个文件失败时,不应阻止继续清理其他文件。失败目标要写入结构化日志或清理任务表,不能静默忽略。
10. MySQL 事务不能回滚文件
await sequelize.transaction(async (transaction) => {
await UploadedFile.create(metadata, { transaction });
});
事务可以回滚数据库记录,但不能自动回滚:
- 本地文件写入;
- S3/OSS 对象;
- 病毒扫描任务;
- CDN 缓存。
需要补偿流程。
11. 推荐状态机
incoming
→ scanning
→ ready
→ rejected
→ deleting
→ deleted
基本流程:
- Multer 写入临时隔离区;
- 创建数据库
incoming记录; - 扫描和内容验证;
- 移动或上传正式存储;
- 数据库更新为
ready; - 任意失败进入 rejected 并清理。
下载接口只允许 ready。
12. 文件移动与数据库提交顺序
不存在完全没有风险的简单顺序:
先移动文件,再提交数据库
数据库失败时会产生孤儿文件。
先提交数据库,再移动文件
文件移动失败时数据库指向不存在的对象。
推荐通过状态字段和幂等任务完成最终一致:
数据库创建 incoming
→ 文件进入正式存储
→ 数据库更新 ready
定时任务处理长时间停留在 incoming 的记录和孤儿对象。
13. 客户端中断
客户端可能在上传过程中关闭页面或断开网络。要考虑:
- 临时文件是否被完整关闭;
- 部分文件是否残留;
- 请求
aborted; - 自定义 storage 是否释放资源;
- 对象存储分片上传是否取消;
- 临时文件清理任务。
不要在请求 aborted 事件里执行不受保护的同步大规模清理。通常记录状态并交给幂等异步清理更稳妥。
14. 定时清理
清理条件应基于:
状态仍为 incoming/rejected
创建时间早于安全阈值
没有正在运行的扫描或移动任务
目标位于明确临时目录
不要简单删除“目录中超过十分钟的所有文件”,否则可能误删仍在慢速上传或处理的文件。
15. 重试与幂等
删除不存在文件应视为成功;移动前检查最终对象是否已经存在;数据库状态更新使用条件:
UPDATE uploaded_file
SET status = 'ready'
WHERE id = ?
AND status = 'scanning';
利用受影响行数识别重复任务或非法状态迁移。
16. 练习题
- 将 MulterError code 映射成稳定中文 API 错误。
- 使用自定义错误区分“文件类型非法”和“缺少文件”。
- 编写只能删除 incomingRoot 内文件的安全删除函数。
- 设计多文件中第 3 个失败后的全部清理流程。
- 模拟 MySQL 写入失败,确认磁盘文件被补偿删除。
- 设计 incoming/scanning/ready/rejected 状态和定时清理条件。
- 分析先写数据库和先移动文件各自的故障窗口。