/** * 文件上传API路由 * Handle file uploads for videos and cover images */ import { Router, type Request, type Response } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { authenticateToken } from '../middleware/auth.js'; const router = Router(); // Multer错误处理中间件 const handleMulterError = (error: any, req: Request, res: Response, next: any) => { console.error('Multer错误:', error); if (error instanceof multer.MulterError) { switch (error.code) { case 'LIMIT_FILE_SIZE': return res.status(400).json({ success: false, message: '文件大小超出限制(最大100MB)' }); case 'LIMIT_FILE_COUNT': return res.status(400).json({ success: false, message: '文件数量超出限制' }); case 'LIMIT_UNEXPECTED_FILE': return res.status(400).json({ success: false, message: '意外的文件字段' }); default: return res.status(400).json({ success: false, message: `文件上传错误: ${error.message}` }); } } if (error.message) { return res.status(400).json({ success: false, message: error.message }); } return res.status(500).json({ success: false, message: '文件上传失败' }); }; // 确保上传目录存在 const ensureUploadDir = (dir: string) => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }; // 配置multer用于视频文件上传 const videoStorage = multer.memoryStorage(); const videoUpload = multer({ storage: videoStorage, limits: { fileSize: 100 * 1024 * 1024 // 100MB限制 }, fileFilter: (req, file, cb) => { console.log('视频文件过滤器检查,文件类型:', file.mimetype, '文件名:', file.originalname); // 获取文件扩展名 const fileExtension = path.extname(file.originalname).toLowerCase(); const mimeType = file.mimetype ? file.mimetype.toLowerCase() : ''; // 支持的MIME类型(与前端保持一致) const allowedTypes = [ // 标准视频MIME类型 'video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo', 'video/x-ms-wmv', 'video/x-flv', 'video/webm', 'video/ogg', 'video/3gpp', 'video/3gpp2', 'video/x-matroska', 'video/mp2t', 'video/x-ms-asf', 'video/x-m4v', 'video/divx', 'video/xvid', // 应用程序类型(某些视频格式) 'application/octet-stream', 'application/x-msvideo', 'application/vnd.rn-realmedia', 'application/x-shockwave-flash' ]; // 支持的文件扩展名 const allowedExtensions = [ '.mp4', '.webm', '.ogg', '.avi', '.mov', '.3gp', '.3g2', '.wmv', '.flv', '.mkv', '.m4v', '.ts', '.asf', '.mpg', '.mpeg', '.m2v', '.rmvb', '.rm', '.divx', '.xvid', '.f4v', '.m2ts', '.mts', '.vob', '.dat', '.amv' ]; // 验证逻辑: // 1. 扩展名匹配(优先级最高) // 2. MIME类型完全匹配 // 3. MIME类型以video/开头且扩展名正确 // 4. MIME类型为空但扩展名正确 const isExtensionValid = allowedExtensions.includes(fileExtension); const isMimeTypeValid = allowedTypes.includes(mimeType); const isVideoMimeType = mimeType.startsWith('video/'); const isValid = isExtensionValid || isMimeTypeValid || (isVideoMimeType && isExtensionValid) || (!mimeType && isExtensionValid); console.log('文件验证结果:', { fileExtension, mimeType, isExtensionValid, isMimeTypeValid, isVideoMimeType, isValid }); if (isValid) { console.log('视频文件类型检查通过'); cb(null, true); } else { console.log('视频文件类型检查失败,不支持的格式:', mimeType, '扩展名:', fileExtension); cb(new Error(`不支持的视频格式。文件类型: ${mimeType}, 扩展名: ${fileExtension}`)); } } }); // 配置multer用于封面图片上传 const coverStorage = multer.memoryStorage(); const coverUpload = multer({ storage: coverStorage, limits: { fileSize: 10 * 1024 * 1024 // 10MB限制 }, fileFilter: (req, file, cb) => { console.log('封面图片过滤器检查,文件类型:', file.mimetype); const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; if (allowedTypes.includes(file.mimetype)) { console.log('封面图片类型检查通过'); cb(null, true); } else { console.log('封面图片类型检查失败,不支持的格式:', file.mimetype); cb(new Error('不支持的图片格式')); } } }); /** * 上传视频文件 * POST /api/upload/video */ router.post('/video', authenticateToken, videoUpload.single('video'), handleMulterError, async (req: Request, res: Response): Promise => { try { console.log('视频文件上传请求开始'); const file = req.file; if (!file) { console.log('错误: 没有视频文件'); res.status(400).json({ success: false, message: '请选择要上传的视频文件' }); return; } // 生成文件名 const timestamp = Date.now(); const originalName = file.originalname; const ext = path.extname(originalName); const filename = `video_${timestamp}_${Math.random().toString(36).substring(2)}${ext}`; // 确保上传目录存在 const uploadDir = path.join(process.cwd(), 'uploads', 'videos'); ensureUploadDir(uploadDir); // 保存文件 const filePath = path.join(uploadDir, filename); const relativePath = path.join('uploads', 'videos', filename).replace(/\\/g, '/'); console.log('开始保存视频文件到:', filePath); fs.writeFileSync(filePath, file.buffer); console.log('视频文件保存成功'); // 返回文件URL const fileUrl = `/${relativePath}`; res.status(200).json({ success: true, message: '视频文件上传成功', data: { url: fileUrl, filename: filename, size: file.size, mimetype: file.mimetype } }); } catch (error) { console.error('视频文件上传失败:', error); res.status(500).json({ success: false, message: '视频文件上传失败' }); } }); /** * 上传封面图片 * POST /api/upload/cover */ router.post('/cover', authenticateToken, coverUpload.single('cover'), handleMulterError, async (req: Request, res: Response): Promise => { try { console.log('封面图片上传请求开始'); const file = req.file; if (!file) { console.log('错误: 没有封面图片文件'); res.status(400).json({ success: false, message: '请选择要上传的封面图片' }); return; } // 生成文件名 const timestamp = Date.now(); const originalName = file.originalname; const ext = path.extname(originalName); const filename = `cover_${timestamp}_${Math.random().toString(36).substring(2)}${ext}`; // 确保上传目录存在 const uploadDir = path.join(process.cwd(), 'uploads', 'covers'); ensureUploadDir(uploadDir); // 保存文件 const filePath = path.join(uploadDir, filename); const relativePath = path.join('uploads', 'covers', filename).replace(/\\/g, '/'); console.log('开始保存封面图片到:', filePath); fs.writeFileSync(filePath, file.buffer); console.log('封面图片保存成功'); // 返回文件URL const fileUrl = `/${relativePath}`; res.status(200).json({ success: true, message: '封面图片上传成功', data: { url: fileUrl, filename: filename, size: file.size, mimetype: file.mimetype } }); } catch (error) { console.error('封面图片上传失败:', error); res.status(500).json({ success: false, message: '封面图片上传失败' }); } }); export default router;