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 } // 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}[?query],自动忽略查询参数 func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) { u, err := url.Parse(fileURL) if err != nil { return "", "", fmt.Errorf("URL解析失败: %w", err) } // 校验前缀(协议+主机+端口) public, err := url.Parse(s.publicURL) if err != nil { return "", "", fmt.Errorf("publicURL解析失败: %w", err) } if u.Scheme != public.Scheme || u.Host != public.Host { return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL) } // 去掉前缀与开头的斜杠,仅使用路径部分,不包含 query path := strings.TrimPrefix(u.Path, "/") // 如果 publicURL 自带路径前缀,移除该前缀 pubPath := strings.TrimPrefix(public.Path, "/") if pubPath != "" { if !strings.HasPrefix(path, pubPath) { return "", "", fmt.Errorf("URL格式不正确,缺少前缀 %s", public.Path) } path = strings.TrimPrefix(path, pubPath) 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 if decoded, decErr := url.PathUnescape(objectName); decErr == nil { objectName = decoded } return bucket, objectName, nil } // UploadObject 上传对象到存储 func (s *StorageClient) UploadObject(ctx context.Context, bucketName, objectName string, reader io.Reader, size int64, contentType string) error { _, err := s.client.PutObject(ctx, bucketName, objectName, reader, size, minio.PutObjectOptions{ ContentType: contentType, }) if err != nil { return fmt.Errorf("上传对象失败: %w", err) } return nil }