package storage import ( "context" "fmt" "io" "log" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/sundynix/pets-be/internal/config" ) // Storage MinIO 对象存储封装 type Storage struct { client *minio.Client bucket string publicBaseURL string } // New 初始化 MinIO 客户端,并确保 bucket 存在且可公开下载 func New(cfg config.MinIOConfig) (*Storage, error) { 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("minio new: %w", err) } s := &Storage{ client: client, bucket: cfg.Bucket, publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/"), } if err := s.ensureBucket(); err != nil { return nil, err } return s, nil } func (s *Storage) ensureBucket() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() exists, err := s.client.BucketExists(ctx, s.bucket) if err != nil { return fmt.Errorf("bucket exists: %w", err) } if exists { // 桶已存在:绝不碰它的策略。MinIO 可能是多个服务共用的, // 万一 bucket 配错(填成别的服务的桶),无条件改策略会把人家的桶 // 悄悄变成全网可读。已存在的桶由运维一次性配好读权限。 log.Printf("minio: 复用已存在的 bucket %q(不修改其访问策略)", s.bucket) return nil } if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { return fmt.Errorf("make bucket: %w", err) } // 仅对「本服务刚创建」的桶设公开只读:小程序要用 URL 直接加载图片。 policy := fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": {"AWS": ["*"]}, "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::%s/*"] }] }`, s.bucket) if err := s.client.SetBucketPolicy(ctx, s.bucket, policy); err != nil { return fmt.Errorf("set bucket policy: %w", err) } log.Printf("minio: 已创建 bucket %q 并设为公开只读(图片需可直链访问)", s.bucket) return nil } // Upload 上传对象,返回可公开访问的 URL func (s *Storage) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() _, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{ ContentType: contentType, }) if err != nil { return "", fmt.Errorf("put object: %w", err) } return s.PublicURL(objectName), nil } // Exists 对象在不在。小程序码有微信侧配额,同一只宠物的码存一次就够, // 生成前先查 func (s *Storage) Exists(objectName string) bool { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() _, err := s.client.StatObject(ctx, s.bucket, objectName, minio.StatObjectOptions{}) return err == nil } // PublicURL 由对象名拼出可访问 URL(用当前配置的 public_base_url,避免存旧地址) func (s *Storage) PublicURL(objectName string) string { return fmt.Sprintf("%s/%s/%s", s.publicBaseURL, s.bucket, objectName) }