|
|
@@ -1 +1,337 @@
|
|
|
-import { GenericCrudService } from '@d8d/shared-crud';
|
|
|
+import { GenericCrudService } from '@d8d/shared-crud';
|
|
|
+import { DataSource } from 'typeorm';
|
|
|
+import { UnifiedFile } from '../entities/unified-file.entity';
|
|
|
+import { MinioService } from './minio.service';
|
|
|
+import { v4 as uuidv4 } from 'uuid';
|
|
|
+import { logger } from '@d8d/shared-utils';
|
|
|
+
|
|
|
+export class UnifiedFileService extends GenericCrudService<UnifiedFile> {
|
|
|
+ private readonly minioService: MinioService;
|
|
|
+
|
|
|
+ constructor(dataSource: DataSource) {
|
|
|
+ super(dataSource, UnifiedFile);
|
|
|
+ this.minioService = new MinioService();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 覆盖创建方法,设置默认值
|
|
|
+ */
|
|
|
+ override async create(data: Partial<UnifiedFile>, userId?: string | number): Promise<UnifiedFile> {
|
|
|
+ const fileData = {
|
|
|
+ ...data,
|
|
|
+ status: data.status ?? 1,
|
|
|
+ createdAt: new Date(),
|
|
|
+ updatedAt: new Date()
|
|
|
+ };
|
|
|
+
|
|
|
+ return super.create(fileData, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 覆盖更新方法,自动设置 updatedAt
|
|
|
+ */
|
|
|
+ override async update(id: number, data: Partial<UnifiedFile>, userId?: string | number): Promise<UnifiedFile | null> {
|
|
|
+ const updateData = {
|
|
|
+ ...data,
|
|
|
+ updatedAt: new Date()
|
|
|
+ };
|
|
|
+
|
|
|
+ return super.update(id, updateData, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 覆盖删除方法,实现软删除
|
|
|
+ */
|
|
|
+ override async delete(id: number, userId?: string | number): Promise<boolean> {
|
|
|
+ const file = await this.getById(id);
|
|
|
+ if (!file) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 软删除:设置 status = 0
|
|
|
+ await this.update(id, { status: 0 }, userId);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建文件记录并生成预签名上传URL
|
|
|
+ */
|
|
|
+ async createFile(data: Partial<UnifiedFile>) {
|
|
|
+ try {
|
|
|
+ // 生成唯一文件存储路径
|
|
|
+ const fileKey = `unified/${uuidv4()}-${data.fileName}`;
|
|
|
+ // 生成MinIO上传策略
|
|
|
+ const uploadPolicy = await this.minioService.generateUploadPolicy(fileKey);
|
|
|
+
|
|
|
+ // 准备文件记录数据
|
|
|
+ const fileData = {
|
|
|
+ ...data,
|
|
|
+ filePath: fileKey,
|
|
|
+ status: 1,
|
|
|
+ createdAt: new Date(),
|
|
|
+ updatedAt: new Date()
|
|
|
+ };
|
|
|
+
|
|
|
+ // 保存文件记录到数据库
|
|
|
+ const savedFile = await this.create(fileData as UnifiedFile);
|
|
|
+
|
|
|
+ // 返回文件记录和上传策略
|
|
|
+ return {
|
|
|
+ file: savedFile,
|
|
|
+ uploadPolicy
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Failed to create file:', error);
|
|
|
+ throw new Error('文件创建失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除文件记录及对应的MinIO文件
|
|
|
+ */
|
|
|
+ async deleteFile(id: number) {
|
|
|
+ // 获取文件记录
|
|
|
+ const file = await this.getById(id);
|
|
|
+ if (!file) {
|
|
|
+ throw new Error('文件不存在');
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 验证文件是否存在于MinIO
|
|
|
+ const fileExists = await this.minioService.objectExists(this.minioService.bucketName, file.filePath);
|
|
|
+ if (!fileExists) {
|
|
|
+ logger.error(`File not found in MinIO: ${this.minioService.bucketName}/${file.filePath}`);
|
|
|
+ } else {
|
|
|
+ // 从MinIO删除文件
|
|
|
+ await this.minioService.deleteObject(this.minioService.bucketName, file.filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 软删除数据库记录
|
|
|
+ await this.delete(id);
|
|
|
+
|
|
|
+ return true;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Failed to delete file:', error);
|
|
|
+ throw new Error('文件删除失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取文件访问URL
|
|
|
+ */
|
|
|
+ async getFileUrl(id: number) {
|
|
|
+ const file = await this.getById(id);
|
|
|
+ if (!file) {
|
|
|
+ throw new Error('文件不存在');
|
|
|
+ }
|
|
|
+
|
|
|
+ return this.minioService.getPresignedFileUrl(this.minioService.bucketName, file.filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保存文件记录并将文件内容直接上传到MinIO(支持自定义存储路径)
|
|
|
+ */
|
|
|
+ async saveFileWithCustomPath(
|
|
|
+ fileData: {
|
|
|
+ fileName: string;
|
|
|
+ fileSize: number;
|
|
|
+ mimeType: string;
|
|
|
+ createdBy?: number;
|
|
|
+ [key: string]: any;
|
|
|
+ },
|
|
|
+ fileContent: Buffer,
|
|
|
+ customPath?: string,
|
|
|
+ contentType?: string
|
|
|
+ ) {
|
|
|
+ try {
|
|
|
+ logger.db('Starting saveFileWithCustomPath process:', {
|
|
|
+ filename: fileData.fileName,
|
|
|
+ size: fileData.fileSize,
|
|
|
+ mimeType: fileData.mimeType,
|
|
|
+ customPath: customPath || 'auto-generated'
|
|
|
+ });
|
|
|
+
|
|
|
+ // 使用自定义路径或生成唯一文件存储路径
|
|
|
+ const fileKey = customPath || `unified/${uuidv4()}-${fileData.fileName}`;
|
|
|
+
|
|
|
+ // 确保存储桶存在
|
|
|
+ await this.minioService.ensureBucketExists();
|
|
|
+
|
|
|
+ // 直接上传文件内容到MinIO
|
|
|
+ const fileUrl = await this.minioService.createObject(
|
|
|
+ this.minioService.bucketName,
|
|
|
+ fileKey,
|
|
|
+ fileContent,
|
|
|
+ contentType || fileData.mimeType
|
|
|
+ );
|
|
|
+
|
|
|
+ // 准备文件记录数据
|
|
|
+ const completeFileData = {
|
|
|
+ ...fileData,
|
|
|
+ filePath: fileKey,
|
|
|
+ status: 1
|
|
|
+ };
|
|
|
+
|
|
|
+ // 保存文件记录到数据库
|
|
|
+ const savedFile = await this.create(completeFileData as any);
|
|
|
+
|
|
|
+ logger.db('File saved with custom path successfully:', {
|
|
|
+ fileId: savedFile.id,
|
|
|
+ filename: savedFile.fileName,
|
|
|
+ size: savedFile.fileSize,
|
|
|
+ path: fileKey,
|
|
|
+ url: fileUrl
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ file: savedFile,
|
|
|
+ url: fileUrl
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Failed to save file with custom path:', error);
|
|
|
+ throw new Error(`文件保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从URL下载文件并保存到MinIO
|
|
|
+ */
|
|
|
+ async downloadAndSaveFromUrl(
|
|
|
+ url: string,
|
|
|
+ fileData: {
|
|
|
+ createdBy?: number;
|
|
|
+ mimeType?: string;
|
|
|
+ customFileName?: string;
|
|
|
+ customPath?: string;
|
|
|
+ [key: string]: any;
|
|
|
+ },
|
|
|
+ options?: {
|
|
|
+ timeout?: number;
|
|
|
+ retries?: number;
|
|
|
+ }
|
|
|
+ ) {
|
|
|
+ try {
|
|
|
+ const axios = require('axios');
|
|
|
+
|
|
|
+ logger.db('Starting downloadAndSaveFromUrl process:', {
|
|
|
+ url,
|
|
|
+ customFileName: fileData.customFileName,
|
|
|
+ customPath: fileData.customPath
|
|
|
+ });
|
|
|
+
|
|
|
+ // 下载文件
|
|
|
+ const response = await axios.get(url, {
|
|
|
+ responseType: 'arraybuffer',
|
|
|
+ timeout: options?.timeout || 30000,
|
|
|
+ maxRedirects: 5,
|
|
|
+ headers: {
|
|
|
+ 'User-Agent': 'Mozilla/5.0 (compatible; FileDownloader/1.0)'
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const buffer = Buffer.from(response.data);
|
|
|
+
|
|
|
+ // 从URL或响应头中获取文件名
|
|
|
+ let fileName = fileData.customFileName;
|
|
|
+ if (!fileName) {
|
|
|
+ const contentDisposition = response.headers['content-disposition'];
|
|
|
+ if (contentDisposition) {
|
|
|
+ const filenameMatch = contentDisposition.match(/filename[*]?=(?:utf-8'')?(.+)/i);
|
|
|
+ if (filenameMatch) {
|
|
|
+ fileName = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!fileName) {
|
|
|
+ const urlPath = new URL(url).pathname;
|
|
|
+ fileName = urlPath.split('/').pop() || `file_${Date.now()}`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确保文件有扩展名
|
|
|
+ if (!fileName.includes('.') && fileData.mimeType) {
|
|
|
+ const ext = this.getExtensionFromMimeType(fileData.mimeType);
|
|
|
+ if (ext) {
|
|
|
+ fileName += `.${ext}`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确定MIME类型
|
|
|
+ let mimeType = fileData.mimeType || response.headers['content-type'];
|
|
|
+ if (!mimeType || mimeType === 'application/octet-stream') {
|
|
|
+ mimeType = this.inferMimeType(fileName);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存文件
|
|
|
+ const saveResult = await this.saveFileWithCustomPath(
|
|
|
+ {
|
|
|
+ ...fileData,
|
|
|
+ fileName,
|
|
|
+ fileSize: buffer.length,
|
|
|
+ mimeType
|
|
|
+ },
|
|
|
+ buffer,
|
|
|
+ fileData.customPath,
|
|
|
+ mimeType
|
|
|
+ );
|
|
|
+
|
|
|
+ logger.db('Download and save completed successfully:', {
|
|
|
+ fileId: saveResult.file.id,
|
|
|
+ fileName,
|
|
|
+ size: buffer.length,
|
|
|
+ url: saveResult.url
|
|
|
+ });
|
|
|
+
|
|
|
+ return saveResult;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Failed to download and save file from URL:', {
|
|
|
+ url,
|
|
|
+ error: error instanceof Error ? error.message : '未知错误'
|
|
|
+ });
|
|
|
+ throw new Error(`从URL下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据MIME类型获取文件扩展名
|
|
|
+ */
|
|
|
+ private getExtensionFromMimeType(mimeType: string): string | null {
|
|
|
+ const mimeMap: Record<string, string> = {
|
|
|
+ 'image/jpeg': 'jpg',
|
|
|
+ 'image/png': 'png',
|
|
|
+ 'image/gif': 'gif',
|
|
|
+ 'image/webp': 'webp',
|
|
|
+ 'image/svg+xml': 'svg',
|
|
|
+ 'application/pdf': 'pdf',
|
|
|
+ 'text/plain': 'txt',
|
|
|
+ 'application/json': 'json',
|
|
|
+ 'application/xml': 'xml',
|
|
|
+ 'video/mp4': 'mp4',
|
|
|
+ 'audio/mp3': 'mp3'
|
|
|
+ };
|
|
|
+ return mimeMap[mimeType] || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据文件名推断MIME类型
|
|
|
+ */
|
|
|
+ private inferMimeType(fileName: string): string {
|
|
|
+ const ext = fileName.toLowerCase().split('.').pop();
|
|
|
+ const extMap: Record<string, string> = {
|
|
|
+ 'jpg': 'image/jpeg',
|
|
|
+ 'jpeg': 'image/jpeg',
|
|
|
+ 'png': 'image/png',
|
|
|
+ 'gif': 'image/gif',
|
|
|
+ 'webp': 'image/webp',
|
|
|
+ 'svg': 'image/svg+xml',
|
|
|
+ 'pdf': 'application/pdf',
|
|
|
+ 'txt': 'text/plain',
|
|
|
+ 'json': 'application/json',
|
|
|
+ 'xml': 'application/xml',
|
|
|
+ 'mp4': 'video/mp4',
|
|
|
+ 'mp3': 'audio/mp3',
|
|
|
+ 'wav': 'audio/wav'
|
|
|
+ };
|
|
|
+ return extMap[ext || ''] || 'application/octet-stream';
|
|
|
+ }
|
|
|
+}
|