kindergarten/reading-platform-backend/src/modules/file-upload/file-upload.service.ts
tonytech 7f757b6a63 初始提交:幼儿园阅读平台三端代码
- reading-platform-backend:NestJS 后端
- reading-platform-frontend:Vue3 前端
- reading-platform-java:Spring Boot 服务端
2026-02-28 17:51:15 +08:00

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: '文件删除失败' };
}
}
}