第 187 章:文件上传与静态资源
学习目标
- 掌握单文件 / 多文件上传
- 学会文件类型与大小校验
- 集成对象存储(S3/OSS)
- 提供静态资源服务
一、文件上传基础
1.1 安装
bash
pnpm add @nestjs/platform-express multer
pnpm add -D @types/multer1.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-oss6.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-s3typescript
@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 sharptypescript
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 | 自定义 |
动手练习
- 实现单文件上传到本地
- 加大小限制(2MB)+ 图片格式校验
- 集成阿里云 OSS
- 用 sharp 压缩上传的图片
推荐阅读
下一章:第 188 章:缓存(Cache)与性能优化 →