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
适合:
- 小头像;
- 立即上传对象存储;
- 读取 Magic Number;
- 无需保留临时磁盘文件的小型处理。
不适合:
- 视频;
- 大 PDF;
- 高并发批量文件;
- 未设置大小和数量限制的公网接口。
例如 100 个并发请求,每个 10MB,理论上就可能占用约 1GB 文件 Buffer,还没有计算 Node.js 和业务对象的开销。
5. 文件应该保存在哪里
本机临时目录
适合:
- 隔离和扫描;
- 图片转码;
- 短期导入文件;
- 对象存储上传前的中转。
临时文件必须有清理策略。
本机持久目录
适合单机小项目,但需要考虑:
- 容器重新部署后文件是否保留;
- 多进程是否看到同一目录;
- 多服务器如何共享;
- 磁盘容量和备份;
- 权限与静态访问。
对象存储
生产环境常见选择:
Amazon S3
阿里云 OSS
腾讯云 COS
MinIO
优势:
- 与应用实例解耦;
- 容量扩展;
- 生命周期管理;
- 权限和预签名 URL;
- CDN;
- 多副本和备份能力。
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”和“按 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() 会设置附件响应头并读取文件。仍然需要:
- 对下载名做安全规范化;
- 检查用户权限;
- 不允许客户端传入任意磁盘路径;
- 未扫描文件不可下载;
- 对私有对象存储使用短期预签名 URL。
10. 为什么存储名和下载名要分离
存储名关注:唯一、稳定、安全、适合存储系统
下载名关注:用户可读、原始语义、Content-Disposition
尝试用一个名称同时满足两者,会带来覆盖、路径、编码和安全问题。
11. 练习题
- 使用 DiskStorage 和 UUID 生成临时存储名。
- 设计 uploaded_file 表并说明每个字段用途。
- 为一个 100MB 文件使用流计算 SHA-256。
- 对比本机目录和对象存储在多服务器部署中的差异。
- 实现通过数据库 ID 下载文件并恢复原始中文名称。
- 设计按 Hash 去重时的引用计数和删除流程。