feat: rebuild pet app and backend around core care flows
This commit is contained in:
@@ -4,9 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
@@ -14,94 +13,52 @@ import (
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// Storage MinIO 对象存储封装
|
||||
type Storage struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
publicBaseURL string
|
||||
client *minio.Client
|
||||
bucket string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// New 初始化 MinIO 客户端,并确保 bucket 存在且可公开下载
|
||||
func New(cfg config.MinIOConfig) (*Storage, error) {
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
endpoint, secure := cfg.Endpoint, cfg.UseSSL
|
||||
if strings.Contains(endpoint, "://") {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Path != "" {
|
||||
return nil, fmt.Errorf("invalid minio endpoint %q", cfg.Endpoint)
|
||||
}
|
||||
endpoint, secure = parsed.Host, parsed.Scheme == "https"
|
||||
}
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Secure: secure,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio new: %w", err)
|
||||
return nil, fmt.Errorf("init minio: %w", err)
|
||||
}
|
||||
|
||||
s := &Storage{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/"),
|
||||
}
|
||||
if err := s.ensureBucket(); err != nil {
|
||||
return nil, err
|
||||
scheme := "http"
|
||||
if secure {
|
||||
scheme = "https"
|
||||
}
|
||||
s := &Storage{client: client, bucket: cfg.Bucket, baseURL: scheme + "://" + endpoint}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ensureBucket() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
func (s *Storage) Bucket() string { return s.bucket }
|
||||
|
||||
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
|
||||
func (s *Storage) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
|
||||
_, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ContentType: contentType})
|
||||
return err
|
||||
}
|
||||
|
||||
// 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()
|
||||
func (s *Storage) Remove(ctx context.Context, key string) error {
|
||||
return s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
_, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("put object: %w", err)
|
||||
// PublicURL builds the URL stored on the file row; other models only store file IDs.
|
||||
func (s *Storage) PublicURL(key string) string {
|
||||
parts := strings.Split(key, "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
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)
|
||||
return s.baseURL + "/" + url.PathEscape(s.bucket) + "/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
func TestPublicURLUsesEndpoint(t *testing.T) {
|
||||
store, err := New(config.MinIOConfig{
|
||||
Endpoint: "https://s3.sundynix.cn", AccessKey: "test", SecretKey: "test", Bucket: "sundynix-pet",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := store.PublicURL("files/2026-10-01/corgi photo.png")
|
||||
want := "https://s3.sundynix.cn/sundynix-pet/files/2026-10-01/corgi%20photo.png"
|
||||
if got != want {
|
||||
t.Fatalf("PublicURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user