文件上传
一、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-presignertypescript
// 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/sharptypescript
// 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.buffer 为 undefined | 使用了 diskStorage 而非 memoryStorage | 上传到云存储时用 memoryStorage(),本地存储用 diskStorage() |