第 20 章:文件上传
学习目标
- 用 multer 处理文件上传
- 单文件、多文件、字段混合
- 校验文件类型和大小
- 避开 3 个文件上传坑
一、安装
NestJS 文件上传基于 multer。
pnpm add @nestjs/platform-express multer
pnpm add -D @types/multer二、单文件上传
// upload.controller.ts
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
@Controller('upload')
export class UploadController {
@Post('avatar')
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (req, file, cb) => {
const ext = extname(file.originalname); // .png
cb(null, `${randomUUID()}${ext}`);
},
}),
}))
upload(@UploadedFile() file: Express.Multer.File, @Body('userId') userId: string) {
return { url: `/static/${file.filename}`, size: file.size, userId };
}
}逐行拆解:
import { diskStorage } from 'multer';
// 磁盘存储引擎(真的写盘);另有 memoryStorage = 存内存(小文件用)
@Controller('upload')
// @Controller('upload') → 路由前缀 /upload
export class UploadController {
@Post('avatar')
// POST /upload/avatar
@UseInterceptors(FileInterceptor('file', { ... }))
// ↑ 拦截器:进 Controller 前处理 multipart/form-data
// 'file' = 前端 <input name="file"> 的字段名
storage: diskStorage({
destination: './uploads',
// ↑ 文件放哪(相对项目根目录)
filename: (req, file, cb) => {
const ext = extname(file.originalname); // .png
cb(null, `${randomUUID()}${ext}`);
// cb(null, 新文件名) = multer 用这个名存盘
// randomUUID() = 'a1b2c3d4-...' 防冲突
},
}),
upload(@UploadedFile() file, @Body('userId') userId) { ... }
// ↑ @UploadedFile() = 拿 multer 处理好的文件
// ↑ @Body('userId') = 拿 form-data 里非文件的字段
}整体流程:
前端 <form enctype="multipart/form-data">
<input name="file"> <input name="userId">
↓ HTTP POST multipart/form-data
NestJS 接收 → FileInterceptor('file') 拦截
↓
multer 解析 form-data
↓
① 字段 'file' → 走 diskStorage → 写 ./uploads/a1b2.png
② 字段 'userId' → 放进 body
↓
进 Controller:
file = { fieldname, originalname, filename:'a1b2.png', size:12345, ... }
userId = '42'
↓
返回 { url, size, userId }file 对象长啥样:
file = {
fieldname: 'file', // form 字段名
originalname: 'me.png', // 原始文件名(用户上传时的)
encoding: '7bit',
mimetype: 'image/png', // MIME 类型(校验时用这个)
destination: './uploads', // 写入目录
filename: 'a1b2c3.png', // ⚠️ 实际磁盘文件名(multer 重命名后)
path: 'uploads/a1b2c3.png', // 完整路径
size: 12345, // 字节数
}测试:
curl -X POST http://localhost:3000/upload/avatar \
-F "file=@/tmp/me.png" \
-F "userId=42"
# {"url":"/static/abc-uuid.png","size":12345,"userId":"42"}⚠️ 坑 1:不指定
filename→ 文件名是原始名,可能有中文/空格/路径遍历风险。
坑 1 详解:
// ❌ 不写 filename 函数 → multer 用原始名
filename: file.originalname // '我的头像.png'
// ↓
// 存成 '我的头像.png'
// 1. 中文路径 → Linux 服务器可能乱码
// 2. '我的 头像.png' 带空格 → URL 出问题
// 3. '../../etc/passwd' → 路径遍历漏洞(防不住)
// 4. 同名文件覆盖
// ✅ 重命名为 uuid
filename: `${randomUUID()}${ext}`
// 'a1b2c3d4-...png' → 安全 + 不冲突三、静态资源托管
本节讲"上传完怎么访问"。两种实现方式:官方
ServeStaticModule(推荐) /useStaticAssets(Express 平台专用)。
一句话:把磁盘某个文件夹,对外做成可以 URL 访问。
// main.ts
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/static',
}),
],
})访问 http://localhost:3000/static/abc.png。
没加任何托管时:
磁盘(真实存在):
C:\project\uploads\a1b2c3.png ← 文件在这
浏览器请求:
GET http://localhost:3000/uploads/a1b2c3.png
↓
NestJS 看到 URL 没匹配的 Controller → 返回 404加上 ServeStaticModule 之后:
| URL | 没托管 | 加之后 |
|---|---|---|
GET /uploads/a1b2.png | ❌ 404 | ❌ 还是 404(serveRoot 是 /static) |
GET /static/a1b2.png | ❌ 404 | ✅ 返回图片 |
底层机制流程图:
浏览器请求:GET /static/a1b2c3.png
│
▼
┌─────────────────┐
│ NestJS │
└────────┬────────┘
│
▼
检查 URL 是不是 /static 开头?
│
┌───────┴───────┐
│ │
Controller ServeStatic 中间件接管
路由(没有) ↓
│ 拿 /static/ 后面这段
│ = "a1b2c3.png"
│ │
│ ▼
│ 去 rootPath(uploads 目录)找
│ 找到 a1b2c3.png
│ │
│ ▼
│ 把文件内容返回给浏览器 ✅
▼
❌ 404完整"上传 → 访问"双步骤:
// 步骤 1:用户上传头像,后端存盘
POST /upload/avatar (multipart/form-data, file=头像.png)
↓ Controller 处理
↓ diskStorage 写盘
磁盘上:C:\project\uploads\a1b2.png
返回:{ url: "/static/a1b2.png" }
// 步骤 2:用户头像显示,浏览器请求图片
GET /static/a1b2.png
↓ NestJS 走 ServeStatic 中间件
读 ./uploads/a1b2.png
↓
返回图片二进制数据
↓
浏览器渲染头像类比:
| 概念 | 类比 |
|---|---|
rootPath: ./uploads 文件夹 | 仓库里你私藏的水果 |
ServeStaticModule | 在仓库墙上开个小窗口,贴个牌"水果从这个窗口拿" |
/static/* URL | "窗口拿水果"的暗号 |
| 默认没开 | 仓库门是锁着的,外部访问不到 |
另一种写法:useStaticAssets(Express 平台):
// main.ts(用 NestExpressApplication 类型)
import { NestExpressApplication } from '@nestjs/platform-express';
async function bootstrap() {
// ⚠️ 类型必须是 NestExpressApplication(才有 useStaticAssets 方法)
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets('./uploads', {
prefix: '/static/', // URL 前缀
});
await app.listen(3000);
}两种写法对比:
ServeStaticModule(官方) | useStaticAssets(Express) | |
|---|---|---|
| 平台 | 跨平台(fastify 也行) | 只 Express 平台 |
| 写在哪 | @Module({ imports: [...] }) | main.ts bootstrap() |
| 配置项 | rootPath serveRoot | 路径 + prefix |
| 推荐度 | ✅ 推荐(跨平台) | 适合 Express 老项目 |
记忆点:新项目用 ServeStaticModule,老 Express 项目用 useStaticAssets。
四、多文件上传
@Post('photos')
@UseInterceptors(FilesInterceptor('files', 10)) // 最多 10 个
uploadMany(@UploadedFiles() files: Express.Multer.File[]) {
return files.map(f => f.filename);
}curl -X POST http://localhost:3000/upload/photos \
-F "files=@/tmp/a.png" -F "files=@/tmp/b.png"实战完整写法(FilesInterceptor 配置对象在第 3 位,不是第 2 位):
@Post('photos')
@UseInterceptors(FilesInterceptor('files', 10, { // 'files' = 字段名, 10 = 数量上限
storage: diskStorage({
destination: './uploads',
filename: (req, file, cb) =>
cb(null, `${randomUUID()}${extname(file.originalname)}`),
}),
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/^image\//)) {
return cb(new BadRequestException('只允许图片'), false);
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024, // 单个 ≤ 5MB
files: 10, // 总数 ≤ 10(防上传 N 个搞垮)
},
}))
uploadMany(@UploadedFiles() files: Express.Multer.File[]) {
return {
urls: files.map(f => `/static/${f.filename}`),
count: files.length,
};
}三拦截器配置参数位置对比:
| 拦截器 | 形态 | 配置对象位置 |
|---|---|---|
FileInterceptor | 单文件 | 第 2 位 |
FilesInterceptor | 多文件(同字段) | 第 3 位(第 2 位是数量上限) |
FileFieldsInterceptor | 多文件(不同字段) | 第 2 位 |
五、多字段混合(头像 + 身份证)
@Post('profile')
@UseInterceptors(FileFieldsInterceptor([
{ name: 'avatar', maxCount: 1 },
{ name: 'idCard', maxCount: 1 },
]))
uploadProfile(
@UploadedFiles() files: { avatar: Express.Multer.File[]; idCard: Express.Multer.File[] },
) {
return {
avatar: files.avatar[0].filename,
idCard: files.idCard[0].filename,
};
}实战完整写法(FileFieldsInterceptor 配置对象在第 2 位):
@Post('profile')
@UseInterceptors(FileFieldsInterceptor(
// ① 第 1 位:字段列表(name + maxCount)
[
{ name: 'avatar', maxCount: 1 }, // 头像:只能 1 张
{ name: 'idCard', maxCount: 1 }, // 身份证:只能 1 张
],
// ② 第 2 位:配置对象(跟 FileInterceptor 一样)
{
storage: diskStorage({
destination: './uploads',
filename: (req, file, cb) =>
cb(null, `${randomUUID()}${extname(file.originalname)}`),
}),
// fileFilter 可以按字段分发不同校验规则
fileFilter: (req, file, cb) => {
if (file.fieldname === 'avatar' && !file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('头像必须是图片'), false);
}
if (file.fieldname === 'idCard' &&
!['image/jpeg', 'image/png', 'application/pdf'].includes(file.mimetype)) {
return cb(new BadRequestException('身份证支持 jpg/png/pdf'), false);
}
cb(null, true);
},
limits: {
fileSize: 10 * 1024 * 1024, // 单个 ≤ 10MB
files: 5, // 全部字段加起来 ≤ 5 个
},
},
))
uploadProfile(
@UploadedFiles() files: {
avatar: Express.Multer.File[];
idCard: Express.Multer.File[];
},
) {
return {
avatar: `/static/${files.avatar[0].filename}`,
idCard: `/static/${files.idCard[0].filename}`,
};
}前端 FormData 结构:
const fd = new FormData();
fd.append('avatar', avatarFile); // ← key='avatar'
fd.append('idCard', idCardFile); // ← key='idCard'
// 结构:
// {
// avatar: File{name:'me.png', ...},
// idCard: File{name:'card.pdf', ...},
// }
// multer 按 name 分发:
// fd.avatar → files.avatar[0]
// fd.idCard → files.idCard[0]三种 Interceptor 返回结构对比:
| 拦截器 | 装饰器 | 拿到的形态 | 字段配置位置 |
|---|---|---|---|
FileInterceptor | @UploadedFile() | 单个对象 { filename, ... } | 第 2 位 |
FilesInterceptor | @UploadedFiles() | 数组 [{...}, {...}] | 第 3 位 |
FileFieldsInterceptor | @UploadedFiles() | 按字段分组的对象 { avatar:[...], idCard:[...] } | 第 2 位 |
fileFilter 按字段分发的关键:
fileFilter: (req, file, cb) => {
// file.fieldname 区分当前在处理哪个字段
// ↑ 'avatar' / 'idCard'(对应前端 FormData 的 key)
if (file.fieldname === 'avatar' && !file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('头像必须是图片'), false);
}
if (file.fieldname === 'idCard' &&
!['image/jpeg', 'image/png', 'application/pdf'].includes(file.mimetype)) {
return cb(new BadRequestException('身份证支持 jpg/png/pdf'), false);
}
cb(null, true);
}实战场景:
- 头像强制 png/jpg
- 身份证允许 png/jpg/pdf
- 营业执照强制 pdf
- 不同字段分别校验,靠
file.fieldname区分
files.avatar[0] 这个坑很容易踩:
files: { avatar: Express.Multer.File[]; idCard: Express.Multer.File[] }
// ↑ ↑↑ ↑↑↑↑↑ ↑↑↑↑↑
// ↑ 数组(哪怕只 1 个文件,也是数组形式)
// ❌ 忘记下标
files.avatar.filename
// undefined(avatar 是数组,数组没有 filename 属性)
// ✅ 必须下标访问
files.avatar[0].filename
// ↑ 不管 maxCount=1 还是 N 个,这里永远是数组curl 测试:
curl -X POST http://localhost:3000/upload/profile \
-F "avatar=@/tmp/me.png" \
-F "idCard=@/tmp/card.pdf"报文长这样(form-data,按 name 分发的 key):
multipart/form-data:
├─ avatar = me.png → files.avatar[0]
└─ idCard = card.pdf → files.idCard[0]六、文件类型/大小校验
FileInterceptor('file', {
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB
fileFilter: (req, file, cb) => {
if (!/\.(png|jpe?g|webp)$/i.test(file.originalname)) {
return cb(new BadRequestException('仅支持图片'), false);
}
cb(null, true);
},
})⚠️ 坑 2:只校验后缀 → 上传
shell.php.png仍可执行 → 用mimetype+ 白名单。
fileFilter 写在 FileInterceptor 第二参数里,跟 storage / limits 平级:
FileInterceptor 第二参数(配置对象)
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
storage fileFilter limits
(存哪/名字) (收不收) (大小/数量)
│ │ │
▼ ▼ ▼
diskStorage{...} (req,file,cb)=>{...} {fileSize, files}fileFilter 函数拆解:
fileFilter: (req, file, cb) => {
// file = multer 解析出来的文件对象(还没保存)
// cb = callback,告诉 multer "接不接收"
if (!file.mimetype.startsWith('image/')) {
// mimetype 例子:
// 'image/png' ✅
// 'image/jpeg' ✅
// 'application/exe' ❌ 拒收
// 'text/html' ❌ 拒收(伪装 HTML 的木马)
cb(new BadRequestException('只允许图片'), false);
// ↑ 错误信息 ↑ false = 拒收,不写盘
return;
}
cb(null, true);
// null = 无错误
// true = 接收,继续后续流程(写入磁盘)
}cb 三种调用方式:
cb(null, true); // ✅ 接收
cb(null, false); // ❌ 静默拒绝(不报错)
cb(error, false); // ❌ 抛错(走 NestJS 异常处理 → 400 给前端)完整的"上传三件套"实战:
@Post('avatar')
@UseInterceptors(
FileInterceptor('file', {
// ① 决定存哪 + 文件名
storage: diskStorage({
destination: './uploads',
filename: (req, file, cb) =>
cb(null, `${randomUUID()}${extname(file.originalname)}`),
}),
// ② 决定收不收
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/^image\/(png|jpe?g|gif|webp)$/)) {
return cb(new BadRequestException('只允许 png/jpg/gif/webp'), false);
}
cb(null, true);
},
// ③ 决定多大(防炸内存/磁盘)
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
}),
)
upload(@UploadedFile() file) {
return { url: `/static/${file.filename}` };
}七、上传到 S3 / OSS
上传到云存储必须用
memoryStorage()—— 原因就在下面。
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
@Injectable()
export class S3Service {
private client = new S3Client({ region: 'cn-hangzhou' });
async put(key: string, buffer: Buffer, mime: string) {
await this.client.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
Body: buffer,
ContentType: mime,
}));
return `https://my-bucket.oss-cn-hangzhou.aliyuncs.com/${key}`;
}
}Controller 里用 memoryStorage():
import { memoryStorage } from 'multer';
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // ≤ 10MB(防爆内存)
})
async upload(@UploadedFile() file: Express.Multer.File) {
// file.buffer 就是文件内容(Buffer 对象)
const url = await this.s3.put(
`avatar/${randomUUID()}.${file.mimetype.split('/')[1]}`,
file.buffer, // ← 直接传 buffer 给 S3
file.mimetype,
);
return { url };
}⚠️ 坑 3:用
diskStorage把文件先存本地再上传 → 磁盘 IO 双倍,大文件直接爆磁盘。
为什么必须用 memoryStorage()?
memoryStorage() 不存磁盘,直接放内存里(变量对象),没有"存到哪"的参数。
// 存磁盘 → 落盘到 ./uploads/xxx.png
storage: diskStorage({
destination: './uploads', // ← 显式指定磁盘目录
filename: (req, file, cb) => cb(null, 'xxx.png'),
})
// 存内存 → 放 Buffer 对象里,不写盘
storage: memoryStorage()
// ↑ 没参数(存哪是变量,不是磁盘目录)两种存储的文件对象对比:
// diskStorage(文件已落盘)
file = {
filename: 'a1b2.png', // 磁盘文件名
path: 'uploads/a1b2.png', // 磁盘路径
size: 12345,
// ❌ 没有 buffer 字段
}
// memoryStorage(文件在内存)
file = {
filename: undefined, // 没文件名(没存盘)
path: undefined, // 没路径
size: 12345,
buffer: <Buffer 12 34 56 78 ...>, // ⚠️ 文件内容在 buffer 里
// ↑ 这段二进制数据可以任意操作:转发 S3、压缩、转 base64...
}memoryStorage vs diskStorage 最终去向:
diskStorage 流程:
浏览器上传 → multer 解析 → 写磁盘 ./uploads/xxx.png
↑
把"文件"留在磁盘
适合"留底 + 静态托管"场景
memoryStorage 流程:
浏览器上传 → multer 解析 → 放 req.file.buffer 里(变量)
↑
把"文件内容"留在内存
适合"转一手就走"(转发 S3、流处理、临时处理)适用场景:
| 场景 | 推荐存储 | 原因 |
|---|---|---|
| 上传到 S3 / OSS | memoryStorage() | 不浪费磁盘 IO,直接转发 |
| 加水印 / 转码 / 压缩 | memoryStorage() | Sharp / Canvas 要 Buffer |
| API 只是临时接收 | memoryStorage() | 不污染磁盘 |
| 给前端直接下载(无 S3) | diskStorage() | 需持久文件 |
memoryStorage 的限制:
// 大文件会爆内存,必须配 limits:
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // ≤ 10MB
})
// ⚠️ > 100MB 必须用 diskStorage(或流式处理)为什么坑 3 说"用 diskStorage 是双倍 IO"?
// ❌ diskStorage 后再传 S3
storage: diskStorage({ destination: './uploads' })
// 1. multer 写盘(磁盘 IO 1)
// 2. Node 从盘读 buffer(磁盘 IO 2)
// 3. 上传 S3(磁盘 IO 3,删文件)
// → 浪费磁盘 3 倍 IO,大文件直接爆磁盘
// ✅ memoryStorage 直传 S3
storage: memoryStorage()
// 1. 上传时直接在内存 buffer
// 2. buffer 直接转发 S3
// → 0 次磁盘 IO八、DTO 配合
export class UploadAvatarDto {
@IsString()
userId: string;
}
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
upload(
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadAvatarDto,
) {}九、上传进度(浏览器侧)
浏览器 XMLHttpRequest 自带 upload.onprogress,前端自己做进度条;NestJS 端不需要特别处理。
十、安全清单
| 项 | 做法 |
|---|---|
| 文件名 | UUID 改名,绝不保留原名 |
| 类型 | 校验 mimetype,不是后缀 |
| 大小 | limits.fileSize |
| 路径 | 静态目录禁止脚本执行 |
| 权限 | 上传目录 nginx 设 location ~* \.php$ { deny all; } |
| 病毒 | 接 ClamAV 扫描(可选) |
十一、本章小结
| 装饰器 | 作用 |
|---|---|
FileInterceptor | 单文件 |
FilesInterceptor | 多文件 |
FileFieldsInterceptor | 多字段 |
UploadedFile / UploadedFiles | 取文件 |
| 存储 | disk(本地)/ memory(S3)/ custom |
| 校验 | fileFilter + limits |
动手练习
- 头像上传:单文件,2MB,只允许 png/jpg
- 多文件:商品图片,最多 9 张
- 静态托管:用
ServeStaticModule暴露/uploads
下一章:第 21 章:WebSocket →