03 存储、文件元数据与下载

1. dest 快速存储

const upload = multer({
  dest: "uploads/",
});

适合学习和原型。Multer 会使用随机文件名避免直接冲突,但文件通常没有扩展名。

不要因为 dest 简单,就把 uploads/ 直接暴露成静态目录:

// 不建议把所有用户上传原样公开。
app.use("/uploads", express.static("uploads"));

未完成内容检测和权限检查的文件不应公开访问。

2. DiskStorage

import { randomUUID } from "node:crypto";
import path from "node:path";
import multer from "multer";

const uploadRoot = path.resolve("var", "uploads", "incoming");

const storage = multer.diskStorage({
  destination(_req, _file, callback) {
    callback(null, uploadRoot);
  },

  filename(_req, _file, callback) {
    callback(null, randomUUID());
  },
});

目录创建责任

传递字符串 dest 时 Multer 可以确保目录存在。destination() 使用函数时,应用需要在启动阶段创建并检查目录。

不要在每个上传请求中临时创建一组不受控制的用户目录。

文件扩展名

Multer 不会自动追加扩展名。如果需要扩展名,应该在完成实际内容检测后根据服务端确认的类型决定,而不是直接相信:

path.extname(file.originalname)

更安全的结构是临时阶段不依赖扩展名,验证后生成最终 storage key:

019...uuid.webp

3. req.body 的时序

下面的设计不可靠:

destination(req, file, callback) {
  callback(null, `uploads/${req.body.userId}`);
}

multipart 允许客户端先发送文件,再发送 userId。storage 回调执行时 req.body.userId 可能还不存在。

用户身份应来自已经执行的认证中间件:

req.user.id

而不是来自可伪造且时序不确定的 multipart 文本字段。

4. MemoryStorage

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 1 * 1024 * 1024,
    files: 1,
  },
});

文件结果:

req.file.buffer

适合:

不适合:

例如 100 个并发请求,每个 10MB,理论上就可能占用约 1GB 文件 Buffer,还没有计算 Node.js 和业务对象的开销。

5. 文件应该保存在哪里

本机临时目录

适合:

临时文件必须有清理策略。

本机持久目录

适合单机小项目,但需要考虑:

对象存储

生产环境常见选择:

Amazon S3
阿里云 OSS
腾讯云 COS
MinIO

优势:

MySQL BLOB

不是绝对禁止,但一般文件不建议默认放进 MySQL:

只有小型、强事务绑定且有明确设计的二进制数据才考虑 BLOB。

6. 推荐元数据表

CREATE TABLE uploaded_file (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  storage_key VARCHAR(255) NOT NULL,
  original_name VARCHAR(255) NOT NULL,
  download_name VARCHAR(255) NOT NULL,
  detected_mime_type VARCHAR(100) NOT NULL,
  size_bytes BIGINT UNSIGNED NOT NULL,
  sha256 CHAR(64) NULL,
  status VARCHAR(30) NOT NULL,
  created_by BIGINT UNSIGNED NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_uploaded_file_storage_key (storage_key)
);

不要保存依赖某台服务器部署目录的绝对路径。数据库通常保存逻辑 storage key,由存储服务根据环境解析实际位置。

7. 是否需要 Hash

随机存储名

randomUUID()

用于避免命名冲突和隐藏用户文件名,普通上传系统通常已经足够。

内容 Hash

SHA-256(file content)

用途:

Hash 不是病毒检测,也不能证明文件业务安全。

去重的复杂性

如果两个用户上传相同文件并共用一个对象:

因此“计算 Hash”和“按 Hash 去重”是两个不同决策。

8. 流式计算 SHA-256

大文件不应先全部读入内存:

import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";

export async function sha256File(
  filePath: string,
): Promise<string> {
  const hash = createHash("sha256");
  const stream = createReadStream(filePath);

  for await (const chunk of stream) {
    hash.update(chunk);
  }

  return hash.digest("hex");
}

9. 下载时恢复原始名称

磁盘文件可以叫:

01941fd7-dde0-7c82-8a2f-3ed3ad76ed34

数据库保存:

original_name = 2026年项目计划.pdf
download_name = 2026年项目计划.pdf

下载:

router.get("/files/:id/download", async (req, res) => {
  const file = await fileService.findAccessibleFile(
    Number(req.params.id),
    req.user.id,
  );

  if (file === null) {
    res.sendStatus(404);
    return;
  }

  res.download(
    file.absolutePath,
    file.downloadName,
  );
});

res.download() 会设置附件响应头并读取文件。仍然需要:

10. 为什么存储名和下载名要分离

存储名关注:唯一、稳定、安全、适合存储系统
下载名关注:用户可读、原始语义、Content-Disposition

尝试用一个名称同时满足两者,会带来覆盖、路径、编码和安全问题。

11. 练习题

  1. 使用 DiskStorage 和 UUID 生成临时存储名。
  2. 设计 uploaded_file 表并说明每个字段用途。
  3. 为一个 100MB 文件使用流计算 SHA-256。
  4. 对比本机目录和对象存储在多服务器部署中的差异。
  5. 实现通过数据库 ID 下载文件并恢复原始中文名称。
  6. 设计按 Hash 去重时的引用计数和删除流程。

官方参考