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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user