Skip to content
第 20 章 后端 ⏱ 12 分钟阅读

第 20 章:文件上传 ​

学习目标 ​

  • 用 multer 处理文件上传
  • 单文件、多文件、字段混合
  • 校验文件类型和大小
  • 避开 3 个文件上传坑

一、安装 ​

NestJS 文件上传基于 multer。

bash
pnpm add @nestjs/platform-express multer
pnpm add -D @types/multer

二、单文件上传 ​

typescript
// 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 };
  }
}

逐行拆解:

typescript
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 对象长啥样:

typescript
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,                        // 字节数
}

测试:

bash
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 详解:

typescript
// ❌ 不写 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 访问。

typescript
// 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

完整"上传 → 访问"双步骤:

typescript
// 步骤 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 平台):

typescript
// 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。

四、多文件上传 ​

typescript
@Post('photos')
@UseInterceptors(FilesInterceptor('files', 10))     // 最多 10 个
uploadMany(@UploadedFiles() files: Express.Multer.File[]) {
  return files.map(f => f.filename);
}
bash
curl -X POST http://localhost:3000/upload/photos \
  -F "files=@/tmp/a.png" -F "files=@/tmp/b.png"

实战完整写法(FilesInterceptor 配置对象在第 3 位,不是第 2 位):

typescript
@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 位

五、多字段混合(头像 + 身份证) ​

typescript
@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 位):

typescript
@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 结构:

typescript
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 按字段分发的关键:

typescript
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] 这个坑很容易踩:

typescript
files: { avatar: Express.Multer.File[]; idCard: Express.Multer.File[] }
//                                ↑ ↑↑ ↑↑↑↑↑ ↑↑↑↑↑
//                                ↑ 数组(哪怕只 1 个文件,也是数组形式)

// ❌ 忘记下标
files.avatar.filename
// undefined(avatar 是数组,数组没有 filename 属性)

// ✅ 必须下标访问
files.avatar[0].filename
//   ↑ 不管 maxCount=1 还是 N 个,这里永远是数组

curl 测试:

bash
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]

六、文件类型/大小校验 ​

typescript
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 函数拆解:

typescript
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 三种调用方式:

typescript
cb(null, true);          // ✅ 接收
cb(null, false);         // ❌ 静默拒绝(不报错)
cb(error, false);        // ❌ 抛错(走 NestJS 异常处理 → 400 给前端)

完整的"上传三件套"实战:

typescript
@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() —— 原因就在下面。

typescript
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():

typescript
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() 不存磁盘,直接放内存里(变量对象),没有"存到哪"的参数。

typescript
// 存磁盘 → 落盘到 ./uploads/xxx.png
storage: diskStorage({
  destination: './uploads',                  // ← 显式指定磁盘目录
  filename: (req, file, cb) => cb(null, 'xxx.png'),
})

// 存内存 → 放 Buffer 对象里,不写盘
storage: memoryStorage()
//      ↑ 没参数(存哪是变量,不是磁盘目录)

两种存储的文件对象对比:

typescript
// 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 / OSSmemoryStorage()不浪费磁盘 IO,直接转发
加水印 / 转码 / 压缩memoryStorage()Sharp / Canvas 要 Buffer
API 只是临时接收memoryStorage()不污染磁盘
给前端直接下载(无 S3)diskStorage()需持久文件

memoryStorage 的限制:

typescript
// 大文件会爆内存,必须配 limits:
FileInterceptor('file', {
  storage: memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 },                 // ≤ 10MB
})

// ⚠️ > 100MB 必须用 diskStorage(或流式处理)

为什么坑 3 说"用 diskStorage 是双倍 IO"?

typescript
// ❌ 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 配合 ​

typescript
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

动手练习 ​

  1. 头像上传:单文件,2MB,只允许 png/jpg
  2. 多文件:商品图片,最多 9 张
  3. 静态托管:用 ServeStaticModule 暴露 /uploads

下一章:第 21 章:WebSocket →

本站基于 VitePress 构建 · 由 StackHub 团队维护