2025-11-28 23:30:49 +08:00
|
|
|
|
package storage
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
2025-12-03 10:58:39 +08:00
|
|
|
|
"io"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strings"
|
2025-11-28 23:30:49 +08:00
|
|
|
|
"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 {
|
2025-12-02 11:22:14 +08:00
|
|
|
|
client *minio.Client
|
|
|
|
|
|
buckets map[string]string
|
|
|
|
|
|
publicURL string // 公开访问URL前缀
|
2025-11-28 23:30:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-02 11:22:14 +08:00
|
|
|
|
// 构建公开访问URL
|
|
|
|
|
|
publicURL := cfg.PublicURL
|
|
|
|
|
|
if publicURL == "" {
|
|
|
|
|
|
// 如果未配置 PublicURL,使用 Endpoint 构建
|
|
|
|
|
|
protocol := "http"
|
|
|
|
|
|
if cfg.UseSSL {
|
|
|
|
|
|
protocol = "https"
|
|
|
|
|
|
}
|
|
|
|
|
|
publicURL = fmt.Sprintf("%s://%s", protocol, cfg.Endpoint)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-28 23:30:49 +08:00
|
|
|
|
storageClient := &StorageClient{
|
2025-12-02 11:22:14 +08:00
|
|
|
|
client: client,
|
|
|
|
|
|
buckets: cfg.Buckets,
|
|
|
|
|
|
publicURL: publicURL,
|
2025-11-28 23:30:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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字段是表单的最后一个字段
|
2025-12-02 11:22:14 +08:00
|
|
|
|
func (s *StorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*PresignedPostPolicyResult, error) {
|
2025-11-28 23:30:49 +08:00
|
|
|
|
// 创建上传策略
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
2025-12-02 11:22:14 +08:00
|
|
|
|
// 使用配置的公开访问URL构造文件的永久访问URL
|
|
|
|
|
|
fileURL := s.BuildFileURL(bucketName, objectName)
|
2025-11-28 23:30:49 +08:00
|
|
|
|
|
|
|
|
|
|
return &PresignedPostPolicyResult{
|
|
|
|
|
|
PostURL: postURL.String(),
|
|
|
|
|
|
FormData: formData,
|
|
|
|
|
|
FileURL: fileURL,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2025-12-02 11:22:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2025-12-03 10:58:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|