|
|
@@ -0,0 +1,204 @@
|
|
|
+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 ? 'https' : 'http';
|
|
|
+ const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
|
|
|
+ return `${protocol}://${process.env.MINIO_ENDPOINT}${port}/${bucketName}/${fileKey}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建分段上传会话
|
|
|
+ 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<boolean> {
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|