Browse Source

✨ feat(files): add minio service for file storage management

- implement Minio client initialization with environment variable configuration
- add bucket management: ensure bucket exists and set public read policy
- implement single file upload policy generation with 1-hour expiration
- add file access URL generation method
- support multipart upload: create session, generate pre-signed URLs and complete upload
- integrate logger for monitoring bucket operations and upload processes
yourname 8 months ago
parent
commit
bea0bf43d1
1 changed files with 160 additions and 0 deletions
  1. 160 0
      src/server/modules/files/minio.service.ts

+ 160 - 0
src/server/modules/files/minio.service.ts

@@ -0,0 +1,160 @@
+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_ENDPOINT || 'localhost',
+      port: parseInt(process.env.MINIO_PORT || '9000'),
+      useSSL: process.env.MINIO_USE_SSL === 'true',
+      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 }[]
+  ) {
+    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}`);
+    } catch (error) {
+      logger.error(`Failed to complete multipart upload for ${objectName}:`, error);
+      throw error;
+    }
+  }
+}