import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { extname, join, basename } from 'path'; import { promises as fs } from 'fs'; // 文件类型配置 const FILE_TYPE_CONFIG = { cover: { allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], maxSize: 10 * 1024 * 1024, // 10MB folder: 'covers', }, ebook: { allowedMimeTypes: [ 'application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ], maxSize: 300 * 1024 * 1024, // 300MB folder: 'ebooks', }, audio: { allowedMimeTypes: ['audio/mpeg', 'audio/wav', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/ogg'], maxSize: 300 * 1024 * 1024, // 300MB folder: 'audio', }, video: { allowedMimeTypes: ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'], maxSize: 300 * 1024 * 1024, // 300MB folder: 'videos', }, ppt: { allowedMimeTypes: [ 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/pdf', // 也允许PDF格式 ], maxSize: 300 * 1024 * 1024, // 300MB folder: join('materials', 'ppt'), }, poster: { allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], maxSize: 10 * 1024 * 1024, // 10MB folder: join('materials', 'posters'), }, document: { allowedMimeTypes: [ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ], maxSize: 300 * 1024 * 1024, // 300MB folder: 'documents', }, other: { allowedMimeTypes: [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', ], maxSize: 300 * 1024 * 1024, // 300MB folder: 'other', }, }; @Injectable() export class FileUploadService { private readonly logger = new Logger(FileUploadService.name); private readonly uploadBasePath = join(process.cwd(), 'uploads', 'courses'); constructor() { this.ensureDirectoriesExist(); } /** * 确保所有必要的目录存在 */ private async ensureDirectoriesExist() { const directories = Object.values(FILE_TYPE_CONFIG).map((config) => join(this.uploadBasePath, config.folder), ); for (const dir of directories) { try { await fs.mkdir(dir, { recursive: true }); this.logger.log(`Ensured directory exists: ${dir}`); } catch (error) { this.logger.error(`Failed to create directory ${dir}:`, error); } } } /** * 验证文件 */ validateFile( file: Express.Multer.File, type: string, ): { valid: boolean; error?: string } { const config = FILE_TYPE_CONFIG[type as keyof typeof FILE_TYPE_CONFIG] || FILE_TYPE_CONFIG.other; // 检查文件大小 if (file.size > config.maxSize) { const maxSizeMB = (config.maxSize / (1024 * 1024)).toFixed(0); return { valid: false, error: `文件大小超过限制,最大允许 ${maxSizeMB}MB`, }; } // 检查 MIME 类型 if (!config.allowedMimeTypes.includes(file.mimetype)) { return { valid: false, error: `不支持的文件类型: ${file.mimetype}`, }; } return { valid: true }; } /** * 保存文件 */ async saveFile( file: Express.Multer.File, type: string, courseId?: string, ): Promise<{ filePath: string; fileName: string }> { const config = FILE_TYPE_CONFIG[type as keyof typeof FILE_TYPE_CONFIG] || FILE_TYPE_CONFIG.other; // 生成安全的文件名(避免中文和特殊字符编码问题) const timestamp = Date.now(); const randomStr = Math.random().toString(36).substring(2, 8); const originalExt = extname(file.originalname) || ''; const courseIdPrefix = courseId ? `${courseId}_` : ''; const newFileName = `${courseIdPrefix}${timestamp}_${randomStr}${originalExt}`; // 目标路径 const targetDir = join(this.uploadBasePath, config.folder); const targetPath = join(targetDir, newFileName); try { // 写入文件 await fs.writeFile(targetPath, file.buffer); // 返回相对路径(用于 API 响应和数据库存储) const relativePath = `/uploads/courses/${config.folder}/${newFileName}`; this.logger.log(`File saved: ${targetPath}`); return { filePath: relativePath, fileName: newFileName, }; } catch (error) { this.logger.error('Failed to save file:', error); throw new BadRequestException('文件保存失败'); } } /** * 删除文件 */ async deleteFile(filePath: string): Promise<{ success: boolean; error?: string }> { try { // 安全检查:确保路径在 uploads 目录内,防止目录遍历攻击 if (!filePath.startsWith('/uploads/')) { return { success: false, error: '非法的文件路径' }; } // 防止路径遍历攻击 const normalizedPath = filePath.replace(/\.\./g, ''); const fullPath = join(process.cwd(), normalizedPath); // 确保最终路径仍在 uploads 目录内 if (!fullPath.startsWith(join(process.cwd(), 'uploads'))) { return { success: false, error: '非法的文件路径' }; } // 检查文件是否存在 try { await fs.access(fullPath); } catch { this.logger.warn(`File not found: ${fullPath}`); return { success: true, error: '文件不存在' }; // 文件不存在也返回成功 } // 删除文件 await fs.unlink(fullPath); this.logger.log(`File deleted: ${fullPath}`); return { success: true }; } catch (error) { this.logger.error('Failed to delete file:', error); return { success: false, error: '文件删除失败' }; } } }