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 });
});

事务可以回滚数据库记录,但不能自动回滚:

需要补偿流程。

11. 推荐状态机

incoming
  → scanning
  → ready
  → rejected
  → deleting
  → deleted

基本流程:

  1. Multer 写入临时隔离区;
  2. 创建数据库 incoming 记录;
  3. 扫描和内容验证;
  4. 移动或上传正式存储;
  5. 数据库更新为 ready
  6. 任意失败进入 rejected 并清理。

下载接口只允许 ready

12. 文件移动与数据库提交顺序

不存在完全没有风险的简单顺序:

先移动文件,再提交数据库

数据库失败时会产生孤儿文件。

先提交数据库,再移动文件

文件移动失败时数据库指向不存在的对象。

推荐通过状态字段和幂等任务完成最终一致:

数据库创建 incoming
  → 文件进入正式存储
  → 数据库更新 ready

定时任务处理长时间停留在 incoming 的记录和孤儿对象。

13. 客户端中断

客户端可能在上传过程中关闭页面或断开网络。要考虑:

不要在请求 aborted 事件里执行不受保护的同步大规模清理。通常记录状态并交给幂等异步清理更稳妥。

14. 定时清理

清理条件应基于:

状态仍为 incoming/rejected
创建时间早于安全阈值
没有正在运行的扫描或移动任务
目标位于明确临时目录

不要简单删除“目录中超过十分钟的所有文件”,否则可能误删仍在慢速上传或处理的文件。

15. 重试与幂等

删除不存在文件应视为成功;移动前检查最终对象是否已经存在;数据库状态更新使用条件:

UPDATE uploaded_file
SET status = 'ready'
WHERE id = ?
  AND status = 'scanning';

利用受影响行数识别重复任务或非法状态迁移。

16. 练习题

  1. 将 MulterError code 映射成稳定中文 API 错误。
  2. 使用自定义错误区分“文件类型非法”和“缺少文件”。
  3. 编写只能删除 incomingRoot 内文件的安全删除函数。
  4. 设计多文件中第 3 个失败后的全部清理流程。
  5. 模拟 MySQL 写入失败,确认磁盘文件被补偿删除。
  6. 设计 incoming/scanning/ready/rejected 状态和定时清理条件。
  7. 分析先写数据库和先移动文件各自的故障窗口。

官方参考