| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521 |
- import { GenericCrudService } from '../../utils/generic-crud.service';
- import { DataSource } from 'typeorm';
- import { File } from './file.entity';
- import { MinioService } from './minio.service';
- import { v4 as uuidv4 } from 'uuid';
- import { logger } from '../../utils/logger';
- export class FileService extends GenericCrudService<File> {
- private readonly minioService: MinioService;
- constructor(dataSource: DataSource) {
- super(dataSource, File);
- this.minioService = new MinioService();
- }
- /**
- * 创建文件记录并生成预签名上传URL
- */
- async createFile(data: Partial<File>) {
- try {
- // 生成唯一文件存储路径
- const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`;
- // 生成MinIO上传策略
- const uploadPolicy = await this.minioService.generateUploadPolicy(fileKey);
-
- // 准备文件记录数据
- const fileData = {
- ...data,
- path: fileKey,
- uploadTime: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- };
-
- // 保存文件记录到数据库
- const savedFile = await this.create(fileData as File);
-
- // 返回文件记录和上传策略
- 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.path);
- if (!fileExists) {
- logger.error(`File not found in MinIO: ${this.minioService.bucketName}/${file.path}`);
- // 仍然继续删除数据库记录,但记录警告日志
- } else {
- // 从MinIO删除文件
- await this.minioService.deleteObject(this.minioService.bucketName, file.path);
- }
- // 从数据库删除记录
- 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.path);
- }
- /**
- * 获取文件下载URL(带Content-Disposition头)
- */
- async getFileDownloadUrl(id: number) {
- const file = await this.getById(id);
- if (!file) {
- throw new Error('文件不存在');
- }
-
- const url = await this.minioService.getPresignedFileDownloadUrl(
- this.minioService.bucketName,
- file.path,
- file.name
- );
-
- return {
- url,
- filename: file.name
- };
- }
- /**
- * 创建多部分上传策略
- */
- async createMultipartUploadPolicy(data: Partial<File>, partCount: number) {
- try {
- // 生成唯一文件存储路径
- const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`;
-
- // 初始化多部分上传
- const uploadId = await this.minioService.createMultipartUpload(
- this.minioService.bucketName,
- fileKey
- );
-
- // 生成各部分上传URL
- const uploadUrls = await this.minioService.generateMultipartUploadUrls(
- this.minioService.bucketName,
- fileKey,
- uploadId,
- partCount
- );
-
- // 准备文件记录数据
- const fileData = {
- ...data,
- path: fileKey,
- uploadTime: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- };
-
- // 保存文件记录到数据库
- const savedFile = await this.create(fileData as File);
-
- // 返回文件记录和上传策略
- return {
- file: savedFile,
- uploadId,
- uploadUrls,
- bucket: this.minioService.bucketName,
- key: fileKey
- };
- } catch (error) {
- logger.error('Failed to create multipart upload policy:', error);
- throw new Error('创建多部分上传策略失败');
- }
- }
- /**
- * 完成分片上传
- */
- async completeMultipartUpload(data: {
- uploadId: string;
- bucket: string;
- key: string;
- parts: Array<{ partNumber: number; etag: string }>;
- }) {
- logger.db('Starting multipart upload completion:', {
- uploadId: data.uploadId,
- bucket: data.bucket,
- key: data.key,
- partsCount: data.parts.length
- });
- // 查找文件记录
- const file = await this.repository.findOneBy({ path: data.key });
- if (!file) {
- throw new Error('文件记录不存在');
- }
- try {
- // 完成MinIO分片上传 - 注意格式转换
- const result = await this.minioService.completeMultipartUpload(
- data.bucket,
- data.key,
- data.uploadId,
- data.parts.map(part => ({ PartNumber: part.partNumber, ETag: part.etag }))
- );
- // 更新文件大小等信息
- file.size = result.size;
- file.updatedAt = new Date();
- await this.repository.save(file);
- // 生成文件访问URL
- const url = this.minioService.getFileUrl(data.bucket, data.key);
- logger.db('Multipart upload completed successfully:', {
- fileId: file.id,
- size: result.size,
- key: data.key
- });
- return {
- fileId: file.id,
- url,
- key: data.key,
- size: result.size
- };
- } catch (error) {
- logger.error('Failed to complete multipart upload:', error);
- throw new Error('完成分片上传失败');
- }
- }
- /**
- * 保存文件记录并将文件内容直接上传到MinIO
- * @param fileData - 文件基础信息
- * @param fileContent - 文件内容(Buffer)
- * @param contentType - 文件MIME类型
- * @returns 保存的文件记录和文件访问URL
- */
- async saveFile(
- fileData: {
- name: string;
- size: number;
- mimeType: string;
- uploadUserId: number;
- [key: string]: any;
- },
- fileContent: Buffer,
- contentType?: string
- ) {
- try {
- logger.db('Starting saveFile process:', {
- filename: fileData.name,
- size: fileData.size,
- mimeType: fileData.mimeType,
- uploadUserId: fileData.uploadUserId
- });
- // 生成唯一文件存储路径
- const fileKey = `${fileData.uploadUserId}/${uuidv4()}-${fileData.name}`;
- // 确保存储桶存在
- await this.minioService.ensureBucketExists();
- // 直接上传文件内容到MinIO
- const fileUrl = await this.minioService.createObject(
- this.minioService.bucketName,
- fileKey,
- fileContent,
- contentType || fileData.mimeType
- );
- // 准备文件记录数据
- const completeFileData = {
- ...fileData,
- path: fileKey,
- uploadTime: new Date(),
- };
- // 保存文件记录到数据库
- const savedFile = await this.create(completeFileData as any);
- logger.db('File saved successfully:', {
- fileId: savedFile.id,
- filename: savedFile.name,
- size: savedFile.size,
- url: fileUrl
- });
- return {
- file: savedFile,
- url: fileUrl
- };
- } catch (error) {
- logger.error('Failed to save file:', error);
- throw new Error(`文件保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
- }
- }
- /**
- * 保存文件记录并将文件内容直接上传到MinIO(支持自定义存储路径)
- * @param fileData - 文件基础信息
- * @param fileContent - 文件内容(Buffer)
- * @param customPath - 自定义存储路径(可选)
- * @param contentType - 文件MIME类型
- * @returns 保存的文件记录和文件访问URL
- */
- async saveFileWithCustomPath(
- fileData: {
- name: string;
- size: number;
- mimeType: string;
- uploadUserId: number;
- [key: string]: any;
- },
- fileContent: Buffer,
- customPath?: string,
- contentType?: string
- ) {
- try {
- logger.db('Starting saveFileWithCustomPath process:', {
- filename: fileData.name,
- size: fileData.size,
- mimeType: fileData.mimeType,
- uploadUserId: fileData.uploadUserId,
- customPath: customPath || 'auto-generated'
- });
- // 使用自定义路径或生成唯一文件存储路径
- const fileKey = customPath || `${fileData.uploadUserId}/${uuidv4()}-${fileData.name}`;
- // 确保存储桶存在
- await this.minioService.ensureBucketExists();
- // 直接上传文件内容到MinIO
- const fileUrl = await this.minioService.createObject(
- this.minioService.bucketName,
- fileKey,
- fileContent,
- contentType || fileData.mimeType
- );
- // 准备文件记录数据
- const completeFileData = {
- ...fileData,
- path: fileKey,
- uploadTime: new Date(),
- // createdAt: new Date(),
- // updatedAt: new Date()
- };
- // 保存文件记录到数据库
- const savedFile = await this.create(completeFileData as any);
- logger.db('File saved with custom path successfully:', {
- fileId: savedFile.id,
- filename: savedFile.name,
- size: savedFile.size,
- 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
- * @param url - 文件URL
- * @param fileData - 文件基础信息(不含name和size,将自动获取)
- * @param options - 可选配置
- * @returns 保存的文件记录和文件访问URL
- */
- async downloadAndSaveFromUrl(
- url: string,
- fileData: {
- uploadUserId: 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,
- uploadUserId: fileData.uploadUserId,
- 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) {
- // 尝试从Content-Disposition头获取文件名
- const contentDisposition = response.headers['content-disposition'];
- if (contentDisposition) {
- const filenameMatch = contentDisposition.match(/filename[*]?=(?:utf-8'')?(.+)/i);
- if (filenameMatch) {
- fileName = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
- }
- }
-
- // 从URL路径获取文件名
- 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,
- name: fileName,
- size: buffer.length,
- mimeType,
- fileType: this.getFileTypeFromMimeType(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 : '未知错误',
- stack: error instanceof Error ? error.stack : undefined
- });
- 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';
- }
- /**
- * 根据MIME类型获取文件类型
- */
- private getFileTypeFromMimeType(mimeType: string): string {
- if (mimeType.startsWith('image/')) return 'image';
- if (mimeType.startsWith('video/')) return 'video';
- if (mimeType.startsWith('audio/')) return 'audio';
- if (mimeType === 'application/pdf') return 'document';
- if (mimeType.startsWith('text/')) return 'document';
- return 'other';
- }
- }
|