[🚀] storage
This commit is contained in:
312
pkg/storage/client.go
Normal file
312
pkg/storage/client.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// Client MinIO客户端
|
||||
type Client struct {
|
||||
client *minio.Client
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewClient 创建MinIO客户端
|
||||
func NewClient(config *Config) (*Client, error) {
|
||||
client, err := minio.New(config.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(config.AccessKeyID, config.SecretAccessKey, ""),
|
||||
Secure: config.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建MinIO客户端失败: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
client: client,
|
||||
config: config,
|
||||
}
|
||||
|
||||
// 确保默认桶存在
|
||||
if config.BucketName != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := c.BucketExists(ctx, config.BucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("检查桶失败: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
if err := c.CreateBucket(ctx, config.BucketName); err != nil {
|
||||
return nil, fmt.Errorf("创建桶失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// UploadToken 上传凭证
|
||||
type UploadToken struct {
|
||||
Key string `json:"key"` // 文件存储路径
|
||||
UploadURL string `json:"upload_url"` // 预签名上传URL
|
||||
ExpiresAt time.Time `json:"expires_at"` // 过期时间
|
||||
BucketName string `json:"bucket_name"` // 桶名称
|
||||
AccessURL string `json:"access_url"` // 访问URL(可选)
|
||||
}
|
||||
|
||||
// DownloadToken 下载凭证
|
||||
type DownloadToken struct {
|
||||
Key string `json:"key"` // 文件key
|
||||
DownloadURL string `json:"download_url"` // 预签名下载URL
|
||||
ExpiresAt time.Time `json:"expires_at"` // 过期时间
|
||||
Filename string `json:"filename"` // 文件名(可选)
|
||||
}
|
||||
|
||||
// FileInfo 文件信息
|
||||
type FileInfo struct {
|
||||
Key string `json:"key"` // 文件key
|
||||
Size int64 `json:"size"` // 文件大小
|
||||
ETag string `json:"etag"` // ETag(MD5)
|
||||
ContentType string `json:"content_type"` // Content-Type
|
||||
LastModified time.Time `json:"last_modified"` // 最后修改时间
|
||||
Metadata map[string]string `json:"metadata"` // 元数据
|
||||
URL string `json:"url"` // 访问URL
|
||||
Exists bool `json:"exists"` // 是否存在
|
||||
}
|
||||
|
||||
// GenerateUploadToken 生成上传凭证
|
||||
func (c *Client) GenerateUploadToken(ctx context.Context, key string, bucketName ...string) (*UploadToken, error) {
|
||||
bucket := c.config.BucketName
|
||||
if len(bucketName) > 0 && bucketName[0] != "" {
|
||||
bucket = bucketName[0]
|
||||
}
|
||||
|
||||
// 生成预签名PUT URL
|
||||
presignedURL, err := c.client.PresignedPutObject(ctx, bucket, key, c.config.PresignExpires)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成上传凭证失败: %w", err)
|
||||
}
|
||||
|
||||
token := &UploadToken{
|
||||
Key: key,
|
||||
UploadURL: presignedURL.String(),
|
||||
ExpiresAt: time.Now().Add(c.config.PresignExpires),
|
||||
BucketName: bucket,
|
||||
}
|
||||
|
||||
// 如果配置了CDN域名,生成访问URL
|
||||
if c.config.CDNDomain != "" {
|
||||
token.AccessURL = c.buildCDNURL(bucket, key)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GenerateDownloadToken 生成下载凭证
|
||||
func (c *Client) GenerateDownloadToken(ctx context.Context, key string, bucketName ...string) (*DownloadToken, error) {
|
||||
bucket := c.config.BucketName
|
||||
if len(bucketName) > 0 && bucketName[0] != "" {
|
||||
bucket = bucketName[0]
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
exists, err := c.FileExists(ctx, key, bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("文件不存在: %s", key)
|
||||
}
|
||||
|
||||
// 生成预签名GET URL
|
||||
presignedURL, err := c.client.PresignedGetObject(ctx, bucket, key, c.config.PresignExpires, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成下载凭证失败: %w", err)
|
||||
}
|
||||
|
||||
token := &DownloadToken{
|
||||
Key: key,
|
||||
DownloadURL: presignedURL.String(),
|
||||
ExpiresAt: time.Now().Add(c.config.PresignExpires),
|
||||
Filename: filepath.Base(key),
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyFile 验证文件完整性
|
||||
func (c *Client) VerifyFile(ctx context.Context, key string, expectedMD5 string, bucketName ...string) (*FileInfo, error) {
|
||||
bucket := c.config.BucketName
|
||||
if len(bucketName) > 0 && bucketName[0] != "" {
|
||||
bucket = bucketName[0]
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
stat, err := c.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
errResponse := minio.ToErrorResponse(err)
|
||||
if errResponse.Code == "NoSuchKey" {
|
||||
return &FileInfo{
|
||||
Key: key,
|
||||
Exists: false,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("获取文件信息失败: %w", err)
|
||||
}
|
||||
|
||||
fileInfo := &FileInfo{
|
||||
Key: key,
|
||||
Size: stat.Size,
|
||||
ETag: strings.Trim(stat.ETag, "\""), // 去除引号
|
||||
ContentType: stat.ContentType,
|
||||
LastModified: stat.LastModified,
|
||||
Metadata: stat.UserMetadata,
|
||||
Exists: true,
|
||||
URL: c.buildAccessURL(bucket, key),
|
||||
}
|
||||
|
||||
// 如果提供了期望的MD5,进行验证
|
||||
if expectedMD5 != "" {
|
||||
if !c.compareMD5(fileInfo.ETag, expectedMD5) {
|
||||
return fileInfo, fmt.Errorf("文件MD5不匹配,期望: %s, 实际: %s", expectedMD5, fileInfo.ETag)
|
||||
}
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// CalculateFileMD5 计算文件MD5(从MinIO下载并计算)
|
||||
func (c *Client) CalculateFileMD5(ctx context.Context, key string, bucketName ...string) (string, error) {
|
||||
bucket := c.config.BucketName
|
||||
if len(bucketName) > 0 && bucketName[0] != "" {
|
||||
bucket = bucketName[0]
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
object, err := c.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("下载文件失败: %w", err)
|
||||
}
|
||||
defer func() { _ = object.Close() }()
|
||||
|
||||
// 计算MD5
|
||||
hash := md5.New()
|
||||
if _, err := io.Copy(hash, object); err != nil {
|
||||
return "", fmt.Errorf("计算MD5失败: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// FileExists 检查文件是否存在
|
||||
func (c *Client) FileExists(ctx context.Context, key string, bucketName ...string) (bool, error) {
|
||||
bucket := c.config.BucketName
|
||||
if len(bucketName) > 0 && bucketName[0] != "" {
|
||||
bucket = bucketName[0]
|
||||
}
|
||||
|
||||
_, err := c.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
errResponse := minio.ToErrorResponse(err)
|
||||
if errResponse.Code == "NoSuchKey" {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("检查文件失败: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func (c *Client) DeleteFile(ctx context.Context, key string, bucketName ...string) error {
|
||||
bucket := c.config.BucketName
|
||||
if len(bucketName) > 0 && bucketName[0] != "" {
|
||||
bucket = bucketName[0]
|
||||
}
|
||||
|
||||
err := c.client.RemoveObject(ctx, bucket, key, minio.RemoveObjectOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
func (c *Client) GetFileInfo(ctx context.Context, key string, bucketName ...string) (*FileInfo, error) {
|
||||
return c.VerifyFile(ctx, key, "", bucketName...)
|
||||
}
|
||||
|
||||
// BucketExists 检查桶是否存在
|
||||
func (c *Client) BucketExists(ctx context.Context, bucketName string) (bool, error) {
|
||||
exists, err := c.client.BucketExists(ctx, bucketName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("检查桶失败: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// CreateBucket 创建桶
|
||||
func (c *Client) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
err := c.client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
|
||||
Region: c.config.Region,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建桶失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBucketPublic 设置桶为公开访问
|
||||
func (c *Client) SetBucketPublic(ctx context.Context, bucketName string) error {
|
||||
policy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}]
|
||||
}`, bucketName)
|
||||
|
||||
err := c.client.SetBucketPolicy(ctx, bucketName, policy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("设置桶策略失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAccessURL 构建访问URL
|
||||
func (c *Client) buildAccessURL(bucket, key string) string {
|
||||
if c.config.CDNDomain != "" {
|
||||
return c.buildCDNURL(bucket, key)
|
||||
}
|
||||
|
||||
protocol := "http"
|
||||
if c.config.UseSSL {
|
||||
protocol = "https"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s/%s/%s", protocol, c.config.Endpoint, bucket, key)
|
||||
}
|
||||
|
||||
// buildCDNURL 构建CDN URL
|
||||
func (c *Client) buildCDNURL(bucket, key string) string {
|
||||
return fmt.Sprintf("%s/%s/%s", strings.TrimRight(c.config.CDNDomain, "/"), bucket, key)
|
||||
}
|
||||
|
||||
// compareMD5 比较MD5
|
||||
func (c *Client) compareMD5(etag, md5 string) bool {
|
||||
etag = strings.ToLower(strings.Trim(etag, "\""))
|
||||
md5 = strings.ToLower(strings.Trim(md5, "\""))
|
||||
return etag == md5
|
||||
}
|
||||
Reference in New Issue
Block a user