diff --git a/go.mod b/go.mod index 29ef963..5a6c03c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,11 @@ go 1.25.6 require ( github.com/apolloconfig/agollo/v4 v4.4.0 + github.com/aws/aws-sdk-go-v2 v1.41.2 + github.com/aws/aws-sdk-go-v2/config v1.32.10 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 + github.com/aws/smithy-go v1.24.1 github.com/gin-contrib/pprof v1.5.3 github.com/gin-gonic/gin v1.11.0 github.com/go-playground/validator/v10 v10.30.1 @@ -50,6 +55,20 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.3.2 // indirect github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 // indirect github.com/bytedance/sonic v1.14.0 // indirect diff --git a/go.sum b/go.sum index 312efa8..4511af8 100644 --- a/go.sum +++ b/go.sum @@ -798,6 +798,44 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.45.1/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= +github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= diff --git a/pkg/storage/config.go b/pkg/storage/config.go deleted file mode 100644 index 017357c..0000000 --- a/pkg/storage/config.go +++ /dev/null @@ -1,15 +0,0 @@ -package storage - -import "time" - -// Config MinIO配置 -type Config struct { - Endpoint string `yaml:"endpoint" json:"endpoint"` // MinIO地址 - AccessKeyID string `yaml:"access_key_id" json:"access_key_id"` // AccessKey - SecretAccessKey string `yaml:"secret_access_key" json:"secret_access_key"` // SecretKey - UseSSL bool `yaml:"use_ssl" json:"use_ssl"` // 是否使用SSL - BucketName string `yaml:"bucket_name" json:"bucket_name"` // 默认桶名称 - Region string `yaml:"region" json:"region"` // 区域 - CDNDomain string `yaml:"cdn_domain" json:"cdn_domain"` // CDN域名(可选) - PresignExpires time.Duration `yaml:"presign_expires" json:"presign_expires"` // 预签名URL过期时间,默认15分钟 -} diff --git a/pkg/storage/client.go b/pkg/storage/minio_client.go similarity index 90% rename from pkg/storage/client.go rename to pkg/storage/minio_client.go index 3dbc559..af559b1 100644 --- a/pkg/storage/client.go +++ b/pkg/storage/minio_client.go @@ -1,3 +1,5 @@ +//go:build minio + package storage import ( @@ -14,6 +16,18 @@ import ( "github.com/minio/minio-go/v7/pkg/credentials" ) +// Config MinIO配置 +type Config struct { + Endpoint string `yaml:"endpoint" json:"endpoint"` // MinIO地址 + AccessKeyID string `yaml:"access_key_id" json:"access_key_id"` // AccessKey + SecretAccessKey string `yaml:"secret_access_key" json:"secret_access_key"` // SecretKey + UseSSL bool `yaml:"use_ssl" json:"use_ssl"` // 是否使用SSL + BucketName string `yaml:"bucket_name" json:"bucket_name"` // 默认桶名称 + Region string `yaml:"region" json:"region"` // 区域 + CDNDomain string `yaml:"cdn_domain" json:"cdn_domain"` // CDN域名(可选) + PresignExpires time.Duration `yaml:"presign_expires" json:"presign_expires"` // 预签名URL过期时间,默认15分钟 +} + // Client MinIO客户端 type Client struct { client *minio.Client diff --git a/pkg/storage/s3_client.go b/pkg/storage/s3_client.go new file mode 100644 index 0000000..74e2ec7 --- /dev/null +++ b/pkg/storage/s3_client.go @@ -0,0 +1,441 @@ +//go:build !minio + +package storage + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" +) + +// Config S3配置 +type Config struct { + Endpoint string `yaml:"endpoint" json:"endpoint"` // S3地址(可选,留空使用AWS) + AccessKeyID string `yaml:"access_key_id" json:"access_key_id"` // AccessKey + SecretAccessKey string `yaml:"secret_access_key" json:"secret_access_key"` // SecretKey + UseSSL bool `yaml:"use_ssl" json:"use_ssl"` // 是否使用SSL + BucketName string `yaml:"bucket_name" json:"bucket_name"` // 默认桶名称 + Region string `yaml:"region" json:"region"` // 区域 + CDNDomain string `yaml:"cdn_domain" json:"cdn_domain"` // CDN域名(可选) + PresignExpires time.Duration `yaml:"presign_expires" json:"presign_expires"` // 预签名URL过期时间,默认15分钟 +} + +// Client S3客户端 +type Client struct { + client *s3.Client + presignClient *s3.PresignClient + config *Config +} + +// NewClient 创建S3客户端 +func NewClient(ctx context.Context, cfg *Config) (*Client, error) { + // 设置默认值 + if cfg.PresignExpires == 0 { + cfg.PresignExpires = 15 * time.Minute + } + if cfg.UseSSL && cfg.Endpoint == "" { + cfg.UseSSL = true // AWS默认使用SSL + } + + // 构建AWS配置选项 + var opts []func(*config.LoadOptions) error + + // 设置区域 + if cfg.Region != "" { + opts = append(opts, config.WithRegion(cfg.Region)) + } else { + opts = append(opts, config.WithRegion("us-east-1")) // 默认区域 + cfg.Region = "us-east-1" + } + + // 设置凭证 + if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + )) + } + + awsCfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("加载AWS配置失败: %w", err) + } + + // 创建S3客户端选项 + s3Opts := []func(*s3.Options){ + func(o *s3.Options) { + // 自定义端点(用于兼容S3的对象存储) + if cfg.Endpoint != "" { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = true // 使用路径风格访问,兼容MinIO + } + // SSL设置 + if !cfg.UseSSL { + o.EndpointOptions.DisableHTTPS = true + } + }, + } + + s3Client := s3.NewFromConfig(awsCfg, s3Opts...) + presignClient := s3.NewPresignClient(s3Client) + + c := &Client{ + client: s3Client, + presignClient: presignClient, + config: cfg, + } + + // 确保默认桶存在 + if cfg.BucketName != "" { + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + exists, err := c.BucketExists(timeoutCtx, cfg.BucketName) + if err != nil { + return nil, fmt.Errorf("检查桶失败: %w", err) + } + if !exists { + if err := c.CreateBucket(timeoutCtx, cfg.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 + presignedReq, err := c.presignClient.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(c.config.PresignExpires)) + if err != nil { + return nil, fmt.Errorf("生成上传凭证失败: %w", err) + } + + token := &UploadToken{ + Key: key, + UploadURL: presignedReq.URL, + 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 + presignedReq, err := c.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(c.config.PresignExpires)) + if err != nil { + return nil, fmt.Errorf("生成下载凭证失败: %w", err) + } + + token := &DownloadToken{ + Key: key, + DownloadURL: presignedReq.URL, + 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] + } + + // 获取文件信息 + output, err := c.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + // 检查是否是 NoSuchKey 错误 + if c.isNotFoundError(err) { + return &FileInfo{ + Key: key, + Exists: false, + }, nil + } + return nil, fmt.Errorf("获取文件信息失败: %w", err) + } + + // 转换元数据 + metadata := make(map[string]string) + for k, v := range output.Metadata { + metadata[k] = v + } + + contentType := "" + if output.ContentType != nil { + contentType = *output.ContentType + } + + fileInfo := &FileInfo{ + Key: key, + Size: aws.ToInt64(output.ContentLength), + ETag: strings.Trim(aws.ToString(output.ETag), "\""), // 去除引号 + ContentType: contentType, + LastModified: aws.ToTime(output.LastModified), + Metadata: metadata, + 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(从S3下载并计算) +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] + } + + // 下载文件 + output, err := c.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return "", fmt.Errorf("下载文件失败: %w", err) + } + defer func() { _ = output.Body.Close() }() + + // 计算MD5 + hash := md5.New() + if _, err := io.Copy(hash, output.Body); 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.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + // 检查是否是 NoSuchKey 错误 + if c.isNotFoundError(err) { + 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.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + 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) { + _, err := c.client.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: aws.String(bucketName), + }) + if err != nil { + // 检查是否是 NotFound 错误 + if c.isNotFoundError(err) { + return false, nil + } + return false, fmt.Errorf("检查桶失败: %w", err) + } + return true, nil +} + +// CreateBucket 创建桶 +func (c *Client) CreateBucket(ctx context.Context, bucketName string) error { + input := &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + } + + // 如果不是 us-east-1 区域,需要设置 LocationConstraint + if c.config.Region != "" && c.config.Region != "us-east-1" { + input.CreateBucketConfiguration = &types.CreateBucketConfiguration{ + LocationConstraint: types.BucketLocationConstraint(c.config.Region), + } + } + + _, err := c.client.CreateBucket(ctx, input) + 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.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(bucketName), + Policy: aws.String(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" + } + + if c.config.Endpoint != "" { + return fmt.Sprintf("%s://%s/%s/%s", protocol, c.config.Endpoint, bucket, key) + } + + // AWS S3 默认URL格式 + return fmt.Sprintf("%s://%s.s3.%s.amazonaws.com/%s", protocol, bucket, c.config.Region, 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 +} + +// isNotFoundError 检查是否为NotFound错误 +func (c *Client) isNotFoundError(err error) bool { + if err == nil { + return false + } + + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + code := apiErr.ErrorCode() + return code == "NotFound" || code == "NoSuchKey" || code == "NoSuchBucket" + } + + return false +}