实现 MinIO 对象存储图片流水线(可迁移 OSS)

- 新增 S3 兼容 Storage 抽象与 MinIO 实现,compose 启动 MinIO
- 上传接口:校验 → 缩放 → WebP → 主图/缩略图入库
- 消息图片 content 改为对象 URL,拒绝 base64
- 工作台/访客端改为先上传再发消息
This commit is contained in:
yml2213
2026-07-15 12:21:31 +08:00
parent 85b407fddf
commit ec2f12c6ed
20 changed files with 907 additions and 108 deletions
+55
View File
@@ -0,0 +1,55 @@
package storage
import (
"bytes"
"context"
"fmt"
"io"
"sync"
)
// MemoryStorage 测试用内存存储
type MemoryStorage struct {
mu sync.RWMutex
data map[string][]byte
base string
}
func NewMemory(publicBase string) *MemoryStorage {
return &MemoryStorage{
data: make(map[string][]byte),
base: NormalizePublicBase(publicBase),
}
}
func (m *MemoryStorage) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (string, error) {
b, err := io.ReadAll(r)
if err != nil {
return "", err
}
m.mu.Lock()
m.data[key] = b
m.mu.Unlock()
return m.base + "/" + key, nil
}
func (m *MemoryStorage) Delete(_ context.Context, key string) error {
m.mu.Lock()
delete(m.data, key)
m.mu.Unlock()
return nil
}
func (m *MemoryStorage) PublicBase() string { return m.base }
func (m *MemoryStorage) EnsureBucket(context.Context) error { return nil }
func (m *MemoryStorage) Get(key string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
b, ok := m.data[key]
if !ok {
return nil, fmt.Errorf("not found")
}
return bytes.Clone(b), nil
}
+104
View File
@@ -0,0 +1,104 @@
package storage
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"kefu-sys/server/internal/config"
)
// S3Storage 基于 minio-go 的 S3 兼容实现(MinIO / OSS / COS / AWS S3
type S3Storage struct {
client *minio.Client
bucket string
base string
}
func NewS3(cfg config.StorageConfig) (*S3Storage, error) {
endpoint := strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
})
if err != nil {
return nil, fmt.Errorf("init s3 client: %w", err)
}
base := NormalizePublicBase(cfg.PublicBase)
if base == "" {
scheme := "http"
if cfg.UseSSL {
scheme = "https"
}
base = fmt.Sprintf("%s://%s/%s", scheme, endpoint, cfg.Bucket)
}
return &S3Storage{client: client, bucket: cfg.Bucket, base: base}, nil
}
func (s *S3Storage) EnsureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
return err
}
if !exists {
if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
return err
}
}
// 开发环境尽量开放公共读;生产用 CDN/桶策略配置
policy := fmt.Sprintf(`{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Principal":{"AWS":["*"]},
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::%s/*"]
}]
}`, s.bucket)
_ = s.client.SetBucketPolicy(ctx, s.bucket, policy)
return nil
}
func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (string, error) {
if contentType == "" {
contentType = "application/octet-stream"
}
_, err := s.client.PutObject(ctx, s.bucket, key, r, size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return "", err
}
return s.objectURL(key), nil
}
func (s *S3Storage) Delete(ctx context.Context, key string) error {
return s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
}
func (s *S3Storage) PublicBase() string { return s.base }
func (s *S3Storage) objectURL(key string) string {
// PublicBase 形如 http://localhost:9000/kefu
return s.base + "/" + strings.TrimPrefix(key, "/")
}
// ParseKeyFromURL 从公网 URL 还原 object key(删除时用)
func (s *S3Storage) ParseKeyFromURL(publicURL string) (string, error) {
if !strings.HasPrefix(publicURL, s.base+"/") {
return "", fmt.Errorf("url not in bucket")
}
key := strings.TrimPrefix(publicURL, s.base+"/")
if key == "" {
return "", fmt.Errorf("empty key")
}
if u, err := url.PathUnescape(key); err == nil {
return u, nil
}
return key, nil
}
+34
View File
@@ -0,0 +1,34 @@
package storage
import (
"context"
"io"
"time"
)
// ObjectStorage 对象存储抽象。MinIO / 阿里云 OSS(S3 兼容) / 腾讯云 COS 等共用此接口,
// 业务层只依赖 Put/Delete/URL,迁移云厂商时只改配置与驱动初始化。
type ObjectStorage interface {
// Put 上传对象,返回公网可访问 URL
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (publicURL string, err error)
// Delete 删除对象(可选清理)
Delete(ctx context.Context, key string) error
// PublicBase 浏览器访问前缀(用于校验消息中的图片 URL)
PublicBase() string
// EnsureBucket 开发环境自动建桶
EnsureBucket(ctx context.Context) error
}
// PutResult 上传结果
type PutResult struct {
Key string
URL string
ContentType string
Size int64
}
// NewObjectKey 生成带日期前缀的对象键,避免冲突
func NewObjectKey(prefix, ext string) string {
now := time.Now()
return prefix + "/" + now.Format("2006/01/02") + "/" + now.Format("150405") + "-" + randomHex(8) + ext
}
+27
View File
@@ -0,0 +1,27 @@
package storage
import (
"crypto/rand"
"encoding/hex"
"strings"
)
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// NormalizePublicBase 去掉末尾斜杠
func NormalizePublicBase(base string) string {
return strings.TrimRight(strings.TrimSpace(base), "/")
}
// IsAllowedObjectURL 判断 url 是否属于当前存储公网前缀
func IsAllowedObjectURL(publicBase, url string) bool {
base := NormalizePublicBase(publicBase)
if base == "" || url == "" {
return false
}
return strings.HasPrefix(url, base+"/") || url == base
}