refactor(api): 优化文件上传验证分层

Controller 层(Multer 配置):
- 添加全局文件大小限制(10MB)和类型过滤
- 在文件写入内存前进行基本验证,防止恶意大文件

Service 层(业务规则):
- 新增 FILE_PURPOSE_CONFIG 配置映射
- 按场景(头像/附件)进行细化验证
- 提取 validateFile 方法,职责清晰

常量重组织:
- storage.constants.ts 只保留全局限制和存储路径
- file.constants.ts 添加场景特定限制配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 13:07:20 +08:00
parent c935d0b6aa
commit 5fab73e514
4 changed files with 105 additions and 16 deletions

View File

@@ -1,13 +1,24 @@
// 允许的图片 MIME 类型
export const ALLOWED_IMAGE_TYPES = [
// ============ 全局文件上传限制Controller 层兜底)============
// 全局允许的 MIME 类型
export const GLOBAL_ALLOWED_TYPES = [
// 图片
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
// 文档(未来扩展)
// 'application/pdf',
// 'application/msword',
];
// 最大文件大小(2MB
export const MAX_FILE_SIZE = 2 * 1024 * 1024;
// 全局最大文件大小(10MB
export const GLOBAL_MAX_FILE_SIZE = 10 * 1024 * 1024;
// ============ 存储路径前缀 ============
// 头像存储路径前缀
export const AVATAR_PREFIX = 'avatars';
// 附件存储路径前缀
export const ATTACHMENT_PREFIX = 'attachments';

View File

@@ -7,3 +7,42 @@ export type FilePurpose = (typeof FilePurpose)[keyof typeof FilePurpose];
// 临时 URL 默认有效期(秒)
export const DEFAULT_PRESIGNED_URL_EXPIRES = 5 * 60; // 5 分钟
// ============ 场景特定限制Service 层业务验证)============
// 头像场景:允许的 MIME 类型
export const AVATAR_ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
// 头像场景最大文件大小2MB
export const AVATAR_MAX_SIZE = 2 * 1024 * 1024;
// 附件场景:允许的 MIME 类型
export const ATTACHMENT_ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
// 'application/pdf',
];
// 附件场景最大文件大小10MB
export const ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024;
// 场景配置映射
export const FILE_PURPOSE_CONFIG = {
[FilePurpose.AVATAR]: {
allowedTypes: AVATAR_ALLOWED_TYPES,
maxSize: AVATAR_MAX_SIZE,
maxSizeLabel: '2MB',
},
[FilePurpose.ATTACHMENT]: {
allowedTypes: ATTACHMENT_ALLOWED_TYPES,
maxSize: ATTACHMENT_MAX_SIZE,
maxSizeLabel: '10MB',
},
} as const;

View File

@@ -33,12 +33,34 @@ import { FileService } from './file.service';
import { CurrentUser } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { SkipEncryption } from '@/common/crypto/decorators/skip-encryption.decorator';
import {
GLOBAL_ALLOWED_TYPES,
GLOBAL_MAX_FILE_SIZE,
} from '@/common/storage/storage.constants';
interface AuthUser {
id: string;
email: string;
}
// Multer 文件过滤器:全局类型验证
const fileFilter = (
_req: Express.Request,
file: Express.Multer.File,
cb: (error: Error | null, acceptFile: boolean) => void
) => {
if (GLOBAL_ALLOWED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException(
`不支持的文件类型: ${file.mimetype},仅支持: ${GLOBAL_ALLOWED_TYPES.join(', ')}`
),
false
);
}
};
@ApiTags('文件')
@Controller('files')
@UseGuards(JwtAuthGuard)
@@ -48,7 +70,12 @@ export class FileController {
@Post('upload')
@SkipEncryption()
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: GLOBAL_MAX_FILE_SIZE },
fileFilter,
})
)
@ApiOperation({ summary: '上传文件' })
@ApiConsumes('multipart/form-data')
@ApiBody({

View File

@@ -1,10 +1,9 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import type { File, Prisma } from '@prisma/client';
import { DEFAULT_PRESIGNED_URL_EXPIRES, FilePurpose } from './file.constants';
import { DEFAULT_PRESIGNED_URL_EXPIRES, FilePurpose, FILE_PURPOSE_CONFIG } from './file.constants';
import { CrudService } from '@/common/crud/crud.service';
import { ALLOWED_IMAGE_TYPES, MAX_FILE_SIZE } from '@/common/storage/storage.constants';
import { StorageService } from '@/common/storage/storage.service';
import { PrismaService } from '@/prisma/prisma.service';
@@ -31,15 +30,8 @@ export class FileService extends CrudService<
uploaderId: string,
purpose: FilePurpose,
): Promise<File> {
// 验证文件类型(头像场景)
if (purpose === FilePurpose.AVATAR) {
if (!ALLOWED_IMAGE_TYPES.includes(file.mimetype)) {
throw new BadRequestException('不支持的文件类型,仅支持 JPEG、PNG、GIF、WebP');
}
if (file.size > MAX_FILE_SIZE) {
throw new BadRequestException('文件大小不能超过 2MB');
}
}
// 根据场景验证文件
this.validateFile(file, purpose);
// 上传到 MinIO
const objectName = await this.storageService.uploadFile(file, purpose);
@@ -57,6 +49,26 @@ export class FileService extends CrudService<
});
}
/**
* 根据场景验证文件类型和大小
*/
private validateFile(file: Express.Multer.File, purpose: FilePurpose): void {
const config = FILE_PURPOSE_CONFIG[purpose];
if (!config) {
throw new BadRequestException(`不支持的文件用途: ${purpose}`);
}
if (!config.allowedTypes.includes(file.mimetype)) {
throw new BadRequestException(
`不支持的文件类型: ${file.mimetype},仅支持: ${config.allowedTypes.join(', ')}`
);
}
if (file.size > config.maxSize) {
throw new BadRequestException(`文件大小不能超过 ${config.maxSizeLabel}`);
}
}
/**
* 获取临时访问 URL
*/