Files
backend/pkg/storage/minio.go
lan a51535a465 feat: Add texture rendering endpoints and service methods
- Introduced new API endpoints for rendering textures, avatars, capes, and previews, enhancing the texture handling capabilities.
- Implemented corresponding service methods in the TextureHandler to process rendering requests and return appropriate responses.
- Updated the TextureRenderService interface to include methods for rendering textures, avatars, and capes, along with their respective parameters.
- Enhanced error handling for invalid texture IDs and added support for different rendering types and formats.
- Updated go.mod to include the webp library for image processing.
2025-12-07 10:10:28 +08:00

230 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}[?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
}