179 lines
4.8 KiB
Go
179 lines
4.8 KiB
Go
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
|
||
}
|