package storage import ( "context" "fmt" "io" "net/url" "strings" "time" "carrotskin/pkg/config" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) // StorageClient S3兼容对象存储客户端包装 (支持RustFS、MinIO等) type StorageClient struct { client *minio.Client buckets map[string]string publicURL string // 公开访问URL前缀 } // NewStorage 创建新的对象存储客户端 (S3兼容,支持RustFS) func NewStorage(cfg config.RustFSConfig) (*StorageClient, error) { // 创建S3兼容客户端 // minio-go SDK支持所有S3兼容的存储,包括RustFS // 不指定Region,让SDK自动检测 client, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: cfg.UseSSL, }) if err != nil { return nil, fmt.Errorf("创建对象存储客户端失败: %w", err) } // 测试连接(如果AccessKey和SecretKey为空,跳过测试) if cfg.AccessKey != "" && cfg.SecretKey != "" { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err = client.ListBuckets(ctx) if err != nil { return nil, fmt.Errorf("对象存储连接测试失败: %w", err) } } // 构建公开访问URL publicURL := cfg.PublicURL if publicURL == "" { // 如果未配置 PublicURL,使用 Endpoint 构建 protocol := "http" if cfg.UseSSL { protocol = "https" } publicURL = fmt.Sprintf("%s://%s", protocol, cfg.Endpoint) } storageClient := &StorageClient{ client: client, buckets: cfg.Buckets, publicURL: publicURL, } return storageClient, nil } // GetClient 获取底层S3客户端 func (s *StorageClient) GetClient() *minio.Client { return s.client } // GetBucket 获取存储桶名称 func (s *StorageClient) GetBucket(name string) (string, error) { bucket, exists := s.buckets[name] if !exists { return "", fmt.Errorf("存储桶 %s 不存在", name) } return bucket, nil } // GeneratePresignedURL 生成预签名上传URL (PUT方法) func (s *StorageClient) GeneratePresignedURL(ctx context.Context, bucketName, objectName string, expires time.Duration) (string, error) { url, err := s.client.PresignedPutObject(ctx, bucketName, objectName, expires) if err != nil { return "", fmt.Errorf("生成预签名URL失败: %w", err) } return url.String(), nil } // PresignedPostPolicyResult 预签名POST策略结果 type PresignedPostPolicyResult struct { PostURL string // POST的URL FormData map[string]string // 表单数据 FileURL string // 文件的最终访问URL } // GeneratePresignedPostURL 生成预签名POST URL (支持表单上传) // 注意:使用时必须确保file字段是表单的最后一个字段 func (s *StorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*PresignedPostPolicyResult, error) { // 创建上传策略 policy := minio.NewPostPolicy() // 设置策略的基本信息 policy.SetBucket(bucketName) policy.SetKey(objectName) policy.SetExpires(time.Now().UTC().Add(expires)) // 设置文件大小限制 if err := policy.SetContentLengthRange(minSize, maxSize); err != nil { return nil, fmt.Errorf("设置文件大小限制失败: %w", err) } // 使用MinIO客户端和策略生成预签名的POST URL和表单数据 postURL, formData, err := s.client.PresignedPostPolicy(ctx, policy) if err != nil { return nil, fmt.Errorf("生成预签名POST URL失败: %w", err) } // 移除form_data中多余的bucket字段(MinIO Go SDK可能会添加这个字段,但会导致签名错误) // 注意:在Go中直接delete不存在的key是安全的 delete(formData, "bucket") // 使用配置的公开访问URL构造文件的永久访问URL fileURL := s.BuildFileURL(bucketName, objectName) return &PresignedPostPolicyResult{ PostURL: postURL.String(), FormData: formData, FileURL: fileURL, }, nil } // BuildFileURL 构建文件的公开访问URL func (s *StorageClient) BuildFileURL(bucketName, objectName string) string { return fmt.Sprintf("%s/%s/%s", s.publicURL, bucketName, objectName) } // GetPublicURL 获取公开访问URL前缀 func (s *StorageClient) GetPublicURL() string { return s.publicURL } // ObjectInfo 对象信息 type ObjectInfo struct { Size int64 LastModified time.Time ContentType string ETag string } // GetObject 获取对象内容和信息 func (s *StorageClient) GetObject(ctx context.Context, bucketName, objectName string) (io.ReadCloser, *ObjectInfo, error) { obj, err := s.client.GetObject(ctx, bucketName, objectName, minio.GetObjectOptions{}) if err != nil { return nil, nil, fmt.Errorf("获取对象失败: %w", err) } stat, err := obj.Stat() if err != nil { obj.Close() return nil, nil, fmt.Errorf("获取对象信息失败: %w", err) } info := &ObjectInfo{ Size: stat.Size, LastModified: stat.LastModified, ContentType: stat.ContentType, ETag: stat.ETag, } return obj, info, nil } // ParseFileURL 从文件URL中解析出bucket和objectName // URL格式: {publicURL}/{bucket}/{objectName} func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) { // 移除 publicURL 前缀 if !strings.HasPrefix(fileURL, s.publicURL) { return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL) } // 移除 publicURL 前缀和开头的 / path := strings.TrimPrefix(fileURL, s.publicURL) path = strings.TrimPrefix(path, "/") // 解析路径 parts := strings.SplitN(path, "/", 2) if len(parts) < 2 { return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName") } bucket = parts[0] objectName = parts[1] // URL解码 objectName decoded, err := url.PathUnescape(objectName) if err == nil { objectName = decoded } return bucket, objectName, nil }