262 lines
7.9 KiB
TypeScript
Executable File
262 lines
7.9 KiB
TypeScript
Executable File
/**
|
||
* 文件上传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<void> => {
|
||
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<void> => {
|
||
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; |