feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+32 -75
View File
@@ -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, "/")
}
+21
View File
@@ -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)
}
}