import { Client } from 'minio'; import { logger } from '@/server/utils/logger'; import * as process from 'node:process'; export class MinioService { private readonly client: Client; public readonly bucketName: string; constructor() { this.client = new Client({ endPoint: process.env.MINIO_HOST || 'localhost', port: parseInt(process.env.MINIO_PORT || '443'), useSSL: process.env.MINIO_USE_SSL !== 'false', accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin' }); this.bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai'; } // 设置桶策略为"公读私写" async setPublicReadPolicy(bucketName: string = this.bucketName) { const policy = { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": {"AWS": "*"}, "Action": ["s3:GetObject"], "Resource": [`arn:aws:s3:::${bucketName}/*`] }, { "Effect": "Allow", "Principal": {"AWS": "*"}, "Action": ["s3:ListBucket"], "Resource": [`arn:aws:s3:::${bucketName}`] } ] }; try { await this.client.setBucketPolicy(bucketName, JSON.stringify(policy)); logger.db(`Bucket policy set to public read for: ${bucketName}`); } catch (error) { logger.error(`Failed to set bucket policy for ${bucketName}:`, error); throw error; } } // 确保存储桶存在 async ensureBucketExists(bucketName: string = this.bucketName) { try { const exists = await this.client.bucketExists(bucketName); if (!exists) { await this.client.makeBucket(bucketName); await this.setPublicReadPolicy(bucketName); logger.db(`Created new bucket: ${bucketName}`); } return true; } catch (error) { logger.error(`Failed to ensure bucket exists: ${bucketName}`, error); throw error; } } // 生成上传策略 async generateUploadPolicy(fileKey: string) { await this.ensureBucketExists(); const expiresAt = new Date(Date.now() + 3600 * 1000); const policy = this.client.newPostPolicy(); policy.setBucket(this.bucketName); policy.setKey(fileKey); policy.setExpires(expiresAt); const { postURL, formData } = await this.client.presignedPostPolicy(policy); return { 'x-amz-algorithm': formData['x-amz-algorithm'], 'x-amz-credential': formData['x-amz-credential'], 'x-amz-date': formData['x-amz-date'], 'x-amz-security-token': formData['x-amz-security-token'] || undefined, policy: formData['policy'], 'x-amz-signature': formData['x-amz-signature'], host: postURL, key: fileKey, bucket: this.bucketName, }; } // 生成文件访问URL getFileUrl(bucketName: string, fileKey: string) { const protocol = process.env.MINIO_USE_SSL !== 'false' ? 'https' : 'http'; const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : ''; return `${protocol}://${process.env.MINIO_HOST}${port}/${bucketName}/${fileKey}`; } // 生成预签名文件访问URL(用于私有bucket) async getPresignedFileUrl(bucketName: string, fileKey: string, expiresInSeconds = 3600) { try { const url = await this.client.presignedGetObject(bucketName, fileKey, expiresInSeconds); logger.db(`Generated presigned URL for ${bucketName}/${fileKey}, expires in ${expiresInSeconds}s`); return url; } catch (error) { logger.error(`Failed to generate presigned URL for ${bucketName}/${fileKey}:`, error); throw error; } } // 创建分段上传会话 async createMultipartUpload(bucketName: string, objectName: string) { try { const uploadId = await this.client.initiateNewMultipartUpload(bucketName, objectName, {}); logger.db(`Created multipart upload for ${objectName} with ID: ${uploadId}`); return uploadId; } catch (error) { logger.error(`Failed to create multipart upload for ${objectName}:`, error); throw error; } } // 生成分段上传预签名URL async generateMultipartUploadUrls( bucketName: string, objectName: string, uploadId: string, partCount: number, expiresInSeconds = 3600 ) { try { const partUrls = []; for (let partNumber = 1; partNumber <= partCount; partNumber++) { const url = await this.client.presignedUrl( 'put', bucketName, objectName, expiresInSeconds, { uploadId, partNumber: partNumber.toString() } ); partUrls.push(url); } return partUrls; } catch (error) { logger.error(`Failed to generate multipart upload URLs for ${objectName}:`, error); throw error; } } // 完成分段上传 async completeMultipartUpload( bucketName: string, objectName: string, uploadId: string, parts: { ETag: string; PartNumber: number }[] ): Promise<{ size: number }> { try { await this.client.completeMultipartUpload( bucketName, objectName, uploadId, parts.map(p => ({ part: p.PartNumber, etag: p.ETag })) ); logger.db(`Completed multipart upload for ${objectName} with ID: ${uploadId}`); // 获取对象信息以获取文件大小 const stat = await this.client.statObject(bucketName, objectName); return { size: stat.size }; } catch (error) { logger.error(`Failed to complete multipart upload for ${objectName}:`, error); throw error; } } // 上传文件 async createObject(bucketName: string, objectName: string, fileContent: Buffer, contentType: string = 'application/octet-stream') { try { await this.ensureBucketExists(bucketName); await this.client.putObject(bucketName, objectName, fileContent, fileContent.length, { 'Content-Type': contentType }); logger.db(`Created object: ${bucketName}/${objectName}`); return this.getFileUrl(bucketName, objectName); } catch (error) { logger.error(`Failed to create object ${bucketName}/${objectName}:`, error); throw error; } } // 检查文件是否存在 async objectExists(bucketName: string, objectName: string): Promise { try { await this.client.statObject(bucketName, objectName); return true; } catch (error) { if ((error as Error).message.includes('not found')) { return false; } logger.error(`Error checking existence of object ${bucketName}/${objectName}:`, error); throw error; } } // 删除文件 async deleteObject(bucketName: string, objectName: string) { try { await this.client.removeObject(bucketName, objectName); logger.db(`Deleted object: ${bucketName}/${objectName}`); } catch (error) { logger.error(`Failed to delete object ${bucketName}/${objectName}:`, error); throw error; } } }