Skip to content

文件上传

一、Multer 集成

NestJS 内置 Multer 支持,零配置即可处理 multipart/form-data

bash
npm install -D @types/multer

二、单文件上传

typescript
// posts/posts.controller.ts
import {
  Post, UploadedFile, UseInterceptors,
  BadRequestException, ParseFilePipe, MaxFileSizeValidator, FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiBody } from '@nestjs/swagger';

@Controller('posts')
export class PostsController {
  @ApiOperation({ summary: '上传封面图片' })
  @ApiConsumes('multipart/form-data')   // Swagger 显示为文件上传
  @ApiBody({
    schema: {
      type: 'object',
      properties: { file: { type: 'string', format: 'binary' } },
    },
  })
  @Post('cover')
  @UseInterceptors(FileInterceptor('file'))  // 'file' 是表单字段名
  uploadCover(
    @UploadedFile(
      new ParseFilePipe({
        validators: [
          new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),  // 5MB
          new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
        ],
      }),
    )
    file: Express.Multer.File,
  ) {
    return {
      originalname: file.originalname,
      size: file.size,
      mimetype: file.mimetype,
      filename: file.filename,   // 存到磁盘时有值
      path: file.path,
    };
  }
}

三、多文件上传

typescript
import { FilesInterceptor, FileFieldsInterceptor } from '@nestjs/platform-express';

// 同一字段多文件(最多 10 张)
@Post('gallery')
@UseInterceptors(FilesInterceptor('images', 10))
uploadGallery(@UploadedFiles() files: Express.Multer.File[]) {
  return files.map(f => ({ name: f.filename, size: f.size }));
}

// 不同字段的文件(如头像 + 身份证)
@Post('kyc')
@UseInterceptors(
  FileFieldsInterceptor([
    { name: 'avatar', maxCount: 1 },
    { name: 'idCard', maxCount: 2 },
  ]),
)
uploadKyc(
  @UploadedFiles()
  files: { avatar?: Express.Multer.File[]; idCard?: Express.Multer.File[] },
) {
  return {
    avatar: files.avatar?.[0]?.filename,
    idCards: files.idCard?.map(f => f.filename),
  };
}

四、存储策略

内存存储(小文件,临时处理)

typescript
import { memoryStorage } from 'multer';

@UseInterceptors(FileInterceptor('file', {
  storage: memoryStorage(),   // 存在 file.buffer 里
  limits: { fileSize: 2 * 1024 * 1024 },  // 2MB
}))
uploadAndProcess(@UploadedFile() file: Express.Multer.File) {
  // file.buffer 可直接传给 sharp 处理
  const buffer = file.buffer;
}

磁盘存储(本地服务器)

typescript
import { diskStorage } from 'multer';
import { extname } from 'path';
import { randomUUID } from 'crypto';

const diskStorageConfig = diskStorage({
  destination: (req, file, callback) => {
    // 按日期分目录,避免单目录文件过多
    const date = new Date().toISOString().slice(0, 7); // '2024-03'
    callback(null, `./uploads/${date}`);
  },
  filename: (req, file, callback) => {
    // 用 UUID 防止文件名冲突和路径穿越攻击
    const uniqueName = `${randomUUID()}${extname(file.originalname)}`;
    callback(null, uniqueName);
  },
});

@UseInterceptors(FileInterceptor('file', {
  storage: diskStorageConfig,
  fileFilter: (req, file, callback) => {
    // 自定义过滤逻辑(返回 false 拒绝)
    if (!file.mimetype.match(/^image\//)) {
      return callback(new BadRequestException('只允许上传图片'), false);
    }
    callback(null, true);
  },
}))

静态文件服务

typescript
// main.ts:将 uploads 目录作为静态资源提供
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
  prefix: '/uploads/',   // 访问路径:http://localhost:3000/uploads/xxx.jpg
});

五、云存储(AWS S3)

本地磁盘不适合多实例部署,生产应用上传到对象存储:

bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
typescript
// upload/upload.service.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';

@Injectable()
export class UploadService {
  private s3: S3Client;
  private bucket: string;

  constructor(private config: ConfigService) {
    this.s3 = new S3Client({
      region: config.get('AWS_REGION'),
      credentials: {
        accessKeyId: config.get('AWS_ACCESS_KEY_ID'),
        secretAccessKey: config.get('AWS_SECRET_ACCESS_KEY'),
      },
    });
    this.bucket = config.get('AWS_S3_BUCKET');
  }

  async upload(file: Express.Multer.File): Promise<string> {
    const ext = file.originalname.split('.').pop();
    const key = `uploads/${new Date().toISOString().slice(0, 7)}/${randomUUID()}.${ext}`;

    await this.s3.send(new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      Body: file.buffer,
      ContentType: file.mimetype,
      // 公开读取(头像、文章图片)
      ACL: 'public-read',
    }));

    return `https://${this.bucket}.s3.amazonaws.com/${key}`;
  }

  async delete(url: string): Promise<void> {
    // 从 URL 提取 key
    const key = url.split('.amazonaws.com/')[1];
    await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
  }

  // 预签名 URL(前端直传 S3,减轻服务器压力)
  async getPresignedUploadUrl(filename: string, contentType: string) {
    const key = `uploads/${randomUUID()}-${filename}`;
    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType,
    });
    const url = await getSignedUrl(this.s3, command, { expiresIn: 300 }); // 5 分钟有效
    return { uploadUrl: url, key };
  }
}
typescript
// 使用云存储的 Controller(内存存储后上传 S3)
@Post('avatar')
@UseInterceptors(FileInterceptor('file', {
  storage: memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },
}))
async uploadAvatar(
  @UploadedFile(new ParseFilePipe({
    validators: [new FileTypeValidator({ fileType: /^image\// })],
  }))
  file: Express.Multer.File,
  @CurrentUser() user: User,
) {
  const url = await this.uploadService.upload(file);
  await this.usersService.updateAvatar(user.id, url);
  return { url };
}

六、图片处理(Sharp)

bash
npm install sharp @types/sharp
typescript
// upload/image.service.ts
import sharp from 'sharp';

@Injectable()
export class ImageService {
  // 生成缩略图并转 WebP
  async processAvatar(buffer: Buffer): Promise<Buffer> {
    return sharp(buffer)
      .resize(200, 200, {
        fit: 'cover',       // 裁剪填充(不变形)
        position: 'center',
      })
      .webp({ quality: 80 })  // 转为 WebP,减小体积
      .toBuffer();
  }

  // 生成多尺寸(响应式图片)
  async generateSizes(buffer: Buffer) {
    const sizes = [
      { name: 'sm', width: 400 },
      { name: 'md', width: 800 },
      { name: 'lg', width: 1200 },
    ];

    return Promise.all(
      sizes.map(async ({ name, width }) => ({
        name,
        buffer: await sharp(buffer)
          .resize(width, null, { withoutEnlargement: true })  // 不放大小图
          .jpeg({ quality: 85, progressive: true })
          .toBuffer(),
      })),
    );
  }
}

七、安全注意事项

typescript
// ❌ 危险:使用用户提供的文件名(路径穿越攻击)
const path = `./uploads/${file.originalname}`;  // 用户可传 '../../etc/passwd'

// ✅ 安全:始终用 UUID 生成文件名
const filename = `${randomUUID()}${extname(file.originalname)}`;

// ❌ 危险:只检查扩展名(可伪造)
if (file.originalname.endsWith('.jpg')) { ... }

// ✅ 安全:检查 MIME 类型和文件魔数
new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ })

// 限制文件大小(防止 DoS)
limits: { fileSize: 10 * 1024 * 1024 }  // 最大 10MB

// 上传后扫描病毒(高安全要求场景)
// npm install clamscan

八、全局 Multer 配置

typescript
// app.module.ts:统一配置,避免每个 Controller 重复
import { MulterModule } from '@nestjs/platform-express';

@Module({
  imports: [
    MulterModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        storage: config.get('NODE_ENV') === 'production'
          ? memoryStorage()    // 生产:内存存储后上传 S3
          : diskStorage({ destination: './uploads' }), // 本地:磁盘存储
        limits: {
          fileSize: 10 * 1024 * 1024,  // 10MB
          files: 5,                     // 一次最多 5 个文件
        },
      }),
    }),
  ],
})

可运行 Demo: practice/07-extensions — 单/多文件上传 + MIME 校验 Demo,接口:POST /files/avatar


常见错误

错误原因解决
上传成功但文件不在服务器destination 目录不存在启动时 fs.mkdirSync('./uploads', { recursive: true })
文件类型校验被绕过仅检查扩展名或 MIME 头读取文件 magic bytes(前 4 字节)验证真实类型
多实例部署本地文件丢失文件存在单个实例磁盘上使用 S3/OSS 等对象存储,或共享挂载卷
file.bufferundefined使用了 diskStorage 而非 memoryStorage上传到云存储时用 memoryStorage(),本地存储用 diskStorage()

NestJS 深度学习体系