package storage import ( "context" "crypto/md5" "encoding/hex" "fmt" "io" "path/filepath" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) // Client MinIO客户端 type Client struct { client *minio.Client config *Config } // NewClient 创建MinIO客户端 func NewClient(config *Config) (*Client, error) { client, err := minio.New(config.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(config.AccessKeyID, config.SecretAccessKey, ""), Secure: config.UseSSL, }) if err != nil { return nil, fmt.Errorf("创建MinIO客户端失败: %w", err) } c := &Client{ client: client, config: config, } // 确保默认桶存在 if config.BucketName != "" { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() exists, err := c.BucketExists(ctx, config.BucketName) if err != nil { return nil, fmt.Errorf("检查桶失败: %w", err) } if !exists { if err := c.CreateBucket(ctx, config.BucketName); err != nil { return nil, fmt.Errorf("创建桶失败: %w", err) } } } return c, nil } // UploadToken 上传凭证 type UploadToken struct { Key string `json:"key"` // 文件存储路径 UploadURL string `json:"upload_url"` // 预签名上传URL ExpiresAt time.Time `json:"expires_at"` // 过期时间 BucketName string `json:"bucket_name"` // 桶名称 AccessURL string `json:"access_url"` // 访问URL(可选) } // DownloadToken 下载凭证 type DownloadToken struct { Key string `json:"key"` // 文件key DownloadURL string `json:"download_url"` // 预签名下载URL ExpiresAt time.Time `json:"expires_at"` // 过期时间 Filename string `json:"filename"` // 文件名(可选) } // FileInfo 文件信息 type FileInfo struct { Key string `json:"key"` // 文件key Size int64 `json:"size"` // 文件大小 ETag string `json:"etag"` // ETag(MD5) ContentType string `json:"content_type"` // Content-Type LastModified time.Time `json:"last_modified"` // 最后修改时间 Metadata map[string]string `json:"metadata"` // 元数据 URL string `json:"url"` // 访问URL Exists bool `json:"exists"` // 是否存在 } // GenerateUploadToken 生成上传凭证 func (c *Client) GenerateUploadToken(ctx context.Context, key string, bucketName ...string) (*UploadToken, error) { bucket := c.config.BucketName if len(bucketName) > 0 && bucketName[0] != "" { bucket = bucketName[0] } // 生成预签名PUT URL presignedURL, err := c.client.PresignedPutObject(ctx, bucket, key, c.config.PresignExpires) if err != nil { return nil, fmt.Errorf("生成上传凭证失败: %w", err) } token := &UploadToken{ Key: key, UploadURL: presignedURL.String(), ExpiresAt: time.Now().Add(c.config.PresignExpires), BucketName: bucket, } // 如果配置了CDN域名,生成访问URL if c.config.CDNDomain != "" { token.AccessURL = c.buildCDNURL(bucket, key) } return token, nil } // GenerateDownloadToken 生成下载凭证 func (c *Client) GenerateDownloadToken(ctx context.Context, key string, bucketName ...string) (*DownloadToken, error) { bucket := c.config.BucketName if len(bucketName) > 0 && bucketName[0] != "" { bucket = bucketName[0] } // 检查文件是否存在 exists, err := c.FileExists(ctx, key, bucket) if err != nil { return nil, err } if !exists { return nil, fmt.Errorf("文件不存在: %s", key) } // 生成预签名GET URL presignedURL, err := c.client.PresignedGetObject(ctx, bucket, key, c.config.PresignExpires, nil) if err != nil { return nil, fmt.Errorf("生成下载凭证失败: %w", err) } token := &DownloadToken{ Key: key, DownloadURL: presignedURL.String(), ExpiresAt: time.Now().Add(c.config.PresignExpires), Filename: filepath.Base(key), } return token, nil } // VerifyFile 验证文件完整性 func (c *Client) VerifyFile(ctx context.Context, key string, expectedMD5 string, bucketName ...string) (*FileInfo, error) { bucket := c.config.BucketName if len(bucketName) > 0 && bucketName[0] != "" { bucket = bucketName[0] } // 获取文件信息 stat, err := c.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}) if err != nil { errResponse := minio.ToErrorResponse(err) if errResponse.Code == "NoSuchKey" { return &FileInfo{ Key: key, Exists: false, }, nil } return nil, fmt.Errorf("获取文件信息失败: %w", err) } fileInfo := &FileInfo{ Key: key, Size: stat.Size, ETag: strings.Trim(stat.ETag, "\""), // 去除引号 ContentType: stat.ContentType, LastModified: stat.LastModified, Metadata: stat.UserMetadata, Exists: true, URL: c.buildAccessURL(bucket, key), } // 如果提供了期望的MD5,进行验证 if expectedMD5 != "" { if !c.compareMD5(fileInfo.ETag, expectedMD5) { return fileInfo, fmt.Errorf("文件MD5不匹配,期望: %s, 实际: %s", expectedMD5, fileInfo.ETag) } } return fileInfo, nil } // CalculateFileMD5 计算文件MD5(从MinIO下载并计算) func (c *Client) CalculateFileMD5(ctx context.Context, key string, bucketName ...string) (string, error) { bucket := c.config.BucketName if len(bucketName) > 0 && bucketName[0] != "" { bucket = bucketName[0] } // 下载文件 object, err := c.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{}) if err != nil { return "", fmt.Errorf("下载文件失败: %w", err) } defer func() { _ = object.Close() }() // 计算MD5 hash := md5.New() if _, err := io.Copy(hash, object); err != nil { return "", fmt.Errorf("计算MD5失败: %w", err) } return hex.EncodeToString(hash.Sum(nil)), nil } // FileExists 检查文件是否存在 func (c *Client) FileExists(ctx context.Context, key string, bucketName ...string) (bool, error) { bucket := c.config.BucketName if len(bucketName) > 0 && bucketName[0] != "" { bucket = bucketName[0] } _, err := c.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}) if err != nil { errResponse := minio.ToErrorResponse(err) if errResponse.Code == "NoSuchKey" { return false, nil } return false, fmt.Errorf("检查文件失败: %w", err) } return true, nil } // DeleteFile 删除文件 func (c *Client) DeleteFile(ctx context.Context, key string, bucketName ...string) error { bucket := c.config.BucketName if len(bucketName) > 0 && bucketName[0] != "" { bucket = bucketName[0] } err := c.client.RemoveObject(ctx, bucket, key, minio.RemoveObjectOptions{}) if err != nil { return fmt.Errorf("删除文件失败: %w", err) } return nil } // GetFileInfo 获取文件信息 func (c *Client) GetFileInfo(ctx context.Context, key string, bucketName ...string) (*FileInfo, error) { return c.VerifyFile(ctx, key, "", bucketName...) } // BucketExists 检查桶是否存在 func (c *Client) BucketExists(ctx context.Context, bucketName string) (bool, error) { exists, err := c.client.BucketExists(ctx, bucketName) if err != nil { return false, fmt.Errorf("检查桶失败: %w", err) } return exists, nil } // CreateBucket 创建桶 func (c *Client) CreateBucket(ctx context.Context, bucketName string) error { err := c.client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{ Region: c.config.Region, }) if err != nil { return fmt.Errorf("创建桶失败: %w", err) } return nil } // SetBucketPublic 设置桶为公开访问 func (c *Client) SetBucketPublic(ctx context.Context, bucketName string) error { policy := fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": {"AWS": ["*"]}, "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::%s/*"] }] }`, bucketName) err := c.client.SetBucketPolicy(ctx, bucketName, policy) if err != nil { return fmt.Errorf("设置桶策略失败: %w", err) } return nil } // buildAccessURL 构建访问URL func (c *Client) buildAccessURL(bucket, key string) string { if c.config.CDNDomain != "" { return c.buildCDNURL(bucket, key) } protocol := "http" if c.config.UseSSL { protocol = "https" } return fmt.Sprintf("%s://%s/%s/%s", protocol, c.config.Endpoint, bucket, key) } // buildCDNURL 构建CDN URL func (c *Client) buildCDNURL(bucket, key string) string { return fmt.Sprintf("%s/%s/%s", strings.TrimRight(c.config.CDNDomain, "/"), bucket, key) } // compareMD5 比较MD5 func (c *Client) compareMD5(etag, md5 string) bool { etag = strings.ToLower(strings.Trim(etag, "\"")) md5 = strings.ToLower(strings.Trim(md5, "\"")) return etag == md5 }