Skip to content
第 187 / 250 章Node⏱ 12 分钟阅读

第 187 章:文件上传与静态资源

学习目标

  • 掌握单文件 / 多文件上传
  • 学会文件类型与大小校验
  • 集成对象存储(S3/OSS)
  • 提供静态资源服务

一、文件上传基础

1.1 安装

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

1.2 单文件上传

typescript
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller('upload')
export class UploadController {
  @Post('avatar')
  @UseInterceptors(FileInterceptor('file'))   // 字段名 file
  uploadAvatar(
    @UploadedFile() file: Express.Multer.File,
    @Body() body: { userId: string },
  ) {
    console.log(file);
    // file.originalname / mimetype / size / buffer / path

    return {
      url: `/uploads/${file.filename}`,
      originalName: file.originalname,
      size: file.size,
      mimetype: file.mimetype,
    };
  }
}

1.3 多文件上传

typescript
@Post('files')
@UseInterceptors(FilesInterceptor('files', 10))   // 最多 10 个
uploadFiles(@UploadedFiles() files: Express.Multer.File[]) {
  return files.map(f => ({
    name: f.originalname,
    size: f.size,
  }));
}

1.4 多字段上传

typescript
@Post('mixed')
@UseInterceptors(FileFieldsInterceptor([
  { name: 'avatar', maxCount: 1 },
  { name: 'photos', maxCount: 8 },
]))
uploadMixed(
  @UploadedFiles() files: {
    avatar?: Express.Multer.File[];
    photos?: Express.Multer.File[];
  },
) {
  return {
    avatar: files.avatar?.[0]?.filename,
    photos: files.photos?.map(p => p.filename),
  };
}

二、文件配置

2.1 diskStorage(磁盘)

typescript
import { diskStorage } from 'multer';
import { extname } from 'path';
import { v4 as uuidv4 } from 'uuid';

@Post('upload')
@UseInterceptors(FileInterceptor('file', {
  storage: diskStorage({
    destination: './uploads',
    filename: (req, file, callback) => {
      const uniqueName = `${uuidv4()}${extname(file.originalname)}`;
      callback(null, uniqueName);
    },
  }),
  limits: {
    fileSize: 5 * 1024 * 1024,   // 5MB
  },
  fileFilter: (req, file, callback) => {
    if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
      return callback(new BadRequestException('只支持图片'), false);
    }
    callback(null, true);
  },
}))
upload(@UploadedFile() file: Express.Multer.File) {}

2.2 memoryStorage(内存)

typescript
import { memoryStorage } from 'multer';

@Post('upload')
@UseInterceptors(FileInterceptor('file', {
  storage: memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },
}))
upload(@UploadedFile() file: Express.Multer.File) {
  // file.buffer 是 Buffer,直接用
  // 适合上传到 OSS/S3
}

三、静态资源服务

3.1 简单方式

typescript
// main.ts
import { NestExpressApplication } from '@nestjs/platform-express';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets('public', { prefix: '/static' });
  // 访问 http://localhost:3000/static/xxx.png
  await app.listen(3000);
}

3.2 SPA History 模式

typescript
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // 静态文件
  app.useStaticAssets('public');

  // SPA fallback
  app.setBaseViewsDir('views');
  await app.listen(3000);
}

四、自定义存储引擎

typescript
import { v4 as uuidv4 } from 'uuid';
import { extname } from 'path';
import * as fs from 'fs';

@Injectable()
export class CustomStorage implements MulterOptions['storage'] {
  uploadPath = './uploads';

  getDestination(req: Request, file: Express.Multer.File, callback: (error: Error | null, destination: string) => void) {
    const date = new Date();
    const dest = `${this.uploadPath}/${date.getFullYear()}-${date.getMonth() + 1}`;
    if (!fs.existsSync(dest)) {
      fs.mkdirSync(dest, { recursive: true });
    }
    callback(null, dest);
  }

  getFilename(req: Request, file: Express.Multer.File, callback: (error: Error | null, filename: string) => void) {
    const uniqueName = `${uuidv4()}${extname(file.originalname)}`;
    callback(null, uniqueName);
  }
}

// 注册
FileInterceptor('file', {
  storage: new CustomStorage(),
})

五、文件大小 / 类型 Pipe

typescript
import { PipeTransform, Injectable, BadRequestException, ArgumentMetadata } from '@nestjs/common';

@Injectable()
export class FileSizeValidationPipe implements PipeTransform {
  constructor(private maxSize: number) {}

  transform(file: Express.Multer.File) {
    if (file.size > this.maxSize) {
      throw new BadRequestException(`文件大小不能超过 ${this.maxSize} 字节`);
    }
    return file;
  }
}

@Injectable()
export class FileTypeValidationPipe implements PipeTransform {
  constructor(private allowedTypes: string[]) {}

  transform(file: Express.Multer.File) {
    if (!this.allowedTypes.includes(file.mimetype)) {
      throw new BadRequestException(
        `仅支持: ${this.allowedTypes.join(', ')}`,
      );
    }
    return file;
  }
}

// 使用
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(
  @UploadedFile(
    new FileSizeValidationPipe(5 * 1024 * 1024),
    new FileTypeValidationPipe(['image/jpeg', 'image/png']),
  )
  file: Express.Multer.File,
) {}

六、上传到阿里云 OSS

6.1 安装

bash
pnpm add ali-oss

6.2 服务

typescript
import * as OSS from 'ali-oss';

@Injectable()
export class OssService {
  private client: OSS;

  constructor(private config: ConfigService) {
    this.client = new OSS({
      region: config.get('OSS_REGION'),
      accessKeyId: config.get('OSS_ACCESS_KEY_ID'),
      accessKeySecret: config.get('OSS_ACCESS_KEY_SECRET'),
      bucket: config.get('OSS_BUCKET'),
    });
  }

  async upload(file: Express.Multer.File, key?: string) {
    const fileName = key || `${uuidv4()}${extname(file.originalname)}`;
    const result = await this.client.put(fileName, file.buffer);

    return {
      url: result.url,
      key: result.name,
    };
  }

  async delete(key: string) {
    return this.client.delete(key);
  }
}

6.3 Controller

typescript
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
async uploadAvatar(
  @UploadedFile() file: Express.Multer.File,
): Promise<{ url: string }> {
  const result = await this.ossService.upload(file, `avatar/${uuidv4()}`);
  return { url: result.url };
}

七、上传到 AWS S3

bash
pnpm add @aws-sdk/client-s3
typescript
@Injectable()
export class S3Service {
  private client: S3Client;

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

  async upload(file: Express.Multer.File, key: string) {
    await this.client.send(new PutObjectCommand({
      Bucket: this.config.get('S3_BUCKET'),
      Key: key,
      Body: file.buffer,
      ContentType: file.mimetype,
    }));

    return {
      url: `https://${this.config.get('S3_BUCKET')}.s3.amazonaws.com/${key}`,
      key,
    };
  }
}

八、断点续传 / 分片

typescript
@Post('chunk')
@UseInterceptors(FileInterceptor('chunk'))
async uploadChunk(
  @UploadedFile() chunk: Express.Multer.File,
  @Body() body: {
    hash: string;
    index: number;
    total: number;
  },
) {
  const chunkDir = `./chunks/${body.hash}`;
  if (!fs.existsSync(chunkDir)) {
    fs.mkdirSync(chunkDir, { recursive: true });
  }

  const chunkPath = `${chunkDir}/${body.index}`;
  fs.writeFileSync(chunkPath, chunk.buffer);

  // 全部上传完,合并
  if (body.index + 1 === body.total) {
    const merged = fs.createWriteStream(`./uploads/${body.hash}`);
    for (let i = 0; i < body.total; i++) {
      const data = fs.readFileSync(`${chunkDir}/${i}`);
      merged.write(data);
    }
    merged.end();
    fs.rmSync(chunkDir, { recursive: true });
  }

  return { received: true };
}

九、图片处理(sharp)

bash
pnpm add sharp
typescript
import * as sharp from 'sharp';

@Injectable()
export class ImageService {
  async thumbnail(file: Express.Multer.File, width = 200) {
    return sharp(file.buffer)
      .resize(width)
      .jpeg({ quality: 80 })
      .toBuffer();
  }

  async compress(file: Express.Multer.File) {
    return sharp(file.buffer)
      .jpeg({ quality: 70 })
      .toBuffer();
  }
}

十、虚拟文件上传

typescript
import { ParseFilePipeBuilder, MaxFileSizeValidator, FileTypeValidator } from '@nestjs/common';

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(
  @UploadedFile(
    new ParseFilePipeBuilder()
      .addMaxSizeValidator({ maxSize: 5 * 1024 * 1024 })
      .addFileTypeValidator({ fileType: 'image/(jpeg|png|gif)' })
      .build({ fileIsRequired: true }),
  )
  file: Express.Multer.File,
) {
  return { size: file.size };
}

十一、本章小结

Interceptor用途
FileInterceptor单文件
FilesInterceptor多文件
FileFieldsInterceptor多字段
AnyFilesInterceptor任意字段
Storage特点
diskStorage存磁盘
memoryStorage存内存(转 OSS)
Custom自定义

动手练习

  1. 实现单文件上传到本地
  2. 加大小限制(2MB)+ 图片格式校验
  3. 集成阿里云 OSS
  4. 用 sharp 压缩上传的图片

推荐阅读


下一章:第 188 章:缓存(Cache)与性能优化

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