- reading-platform-backend:NestJS 后端 - reading-platform-frontend:Vue3 前端 - reading-platform-java:Spring Boot 服务端
204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
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: '文件删除失败' };
|
|
}
|
|
}
|
|
}
|