实现 MinIO 对象存储图片流水线(可迁移 OSS)
- 新增 S3 兼容 Storage 抽象与 MinIO 实现,compose 启动 MinIO - 上传接口:校验 → 缩放 → WebP → 主图/缩略图入库 - 消息图片 content 改为对象 URL,拒绝 base64 - 工作台/访客端改为先上传再发消息
This commit is contained in:
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -9,6 +10,7 @@ type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
Storage StorageConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -30,6 +32,20 @@ type JWTConfig struct {
|
||||
ExpireTime time.Duration
|
||||
}
|
||||
|
||||
// StorageConfig S3 兼容对象存储(MinIO / OSS / COS / S3 共用字段)
|
||||
type StorageConfig struct {
|
||||
Endpoint string // 如 localhost:9000 或 oss-cn-hangzhou.aliyuncs.com
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
UseSSL bool
|
||||
Region string
|
||||
PublicBase string // 浏览器可访问前缀,如 http://localhost:9000/kefu
|
||||
MaxUploadMB int
|
||||
MaxImageEdge int // 最长边像素
|
||||
WebPQuality float32
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
@@ -38,7 +54,7 @@ func Load() *Config {
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "5432"),
|
||||
Port: getEnv("DB_PORT", "5433"),
|
||||
User: getEnv("DB_USER", "postgres"),
|
||||
Password: getEnv("DB_PASSWORD", "postgres"),
|
||||
Name: getEnv("DB_NAME", "kefu_sys"),
|
||||
@@ -48,6 +64,18 @@ func Load() *Config {
|
||||
Secret: getEnv("JWT_SECRET", "kefu-sys-secret-key"),
|
||||
ExpireTime: 24 * time.Hour,
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
Endpoint: getEnv("STORAGE_ENDPOINT", "localhost:9000"),
|
||||
AccessKey: getEnv("STORAGE_ACCESS_KEY", "minioadmin"),
|
||||
SecretKey: getEnv("STORAGE_SECRET_KEY", "minioadmin"),
|
||||
Bucket: getEnv("STORAGE_BUCKET", "kefu"),
|
||||
UseSSL: getEnvBool("STORAGE_USE_SSL", false),
|
||||
Region: getEnv("STORAGE_REGION", "us-east-1"),
|
||||
PublicBase: getEnv("STORAGE_PUBLIC_BASE_URL", "http://localhost:9000/kefu"),
|
||||
MaxUploadMB: getEnvInt("STORAGE_MAX_UPLOAD_MB", 10),
|
||||
MaxImageEdge: getEnvInt("STORAGE_MAX_IMAGE_EDGE", 1920),
|
||||
WebPQuality: float32(getEnvInt("STORAGE_WEBP_QUALITY", 80)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,3 +90,27 @@ func getEnv(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"kefu-sys/server/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTextMessageLength = 2000
|
||||
maxImageMessageSize = 5 * 1024 * 1024
|
||||
)
|
||||
|
||||
// imagePublicBase 由 SetupRoutes 注入,用于校验图片消息 URL 归属
|
||||
var imagePublicBase string
|
||||
|
||||
func SetImagePublicBase(base string) {
|
||||
imagePublicBase = storage.NormalizePublicBase(base)
|
||||
}
|
||||
|
||||
func validateMessageContent(messageType, content string) (string, error) {
|
||||
switch messageType {
|
||||
case "text":
|
||||
@@ -24,19 +31,22 @@ func validateMessageContent(messageType, content string) (string, error) {
|
||||
}
|
||||
return content, nil
|
||||
case "image":
|
||||
parts := strings.SplitN(content, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", errors.New("图片格式无效")
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", errors.New("图片地址不能为空")
|
||||
}
|
||||
if parts[0] != "data:image/jpeg;base64" && parts[0] != "data:image/png;base64" && parts[0] != "data:image/gif;base64" {
|
||||
return "", errors.New("仅支持 jpg、png、gif 图片")
|
||||
// 开发阶段直接切换为对象存储 URL,不再接受 base64
|
||||
if strings.HasPrefix(content, "data:") {
|
||||
return "", errors.New("请先上传图片,勿直接发送 base64")
|
||||
}
|
||||
bytes, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil || len(bytes) == 0 {
|
||||
return "", errors.New("图片内容无效")
|
||||
if !strings.HasPrefix(content, "http://") && !strings.HasPrefix(content, "https://") {
|
||||
return "", errors.New("图片地址无效")
|
||||
}
|
||||
if len(bytes) > maxImageMessageSize {
|
||||
return "", errors.New("图片不能超过 5 MB")
|
||||
if imagePublicBase != "" && !storage.IsAllowedObjectURL(imagePublicBase, content) {
|
||||
return "", errors.New("图片地址不在允许的存储域名下")
|
||||
}
|
||||
if len(content) > 2000 {
|
||||
return "", errors.New("图片地址过长")
|
||||
}
|
||||
return content, nil
|
||||
default:
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestValidateMessageContent(t *testing.T) {
|
||||
validImage := "data:image/png;base64,aGVsbG8="
|
||||
if content, err := validateMessageContent("image", validImage); err != nil || content != validImage {
|
||||
t.Fatalf("合法图片校验失败: content=%q err=%v", content, err)
|
||||
SetImagePublicBase("http://localhost:9000/kefu")
|
||||
|
||||
if _, err := validateMessageContent("text", "hello"); err != nil {
|
||||
t.Fatalf("文本消息应通过: %v", err)
|
||||
}
|
||||
if _, err := validateMessageContent("image", "data:image/webp;base64,aGVsbG8="); err == nil {
|
||||
t.Fatal("不支持的图片格式未被拦截")
|
||||
if _, err := validateMessageContent("text", ""); err == nil {
|
||||
t.Fatal("空文本应失败")
|
||||
}
|
||||
if _, err := validateMessageContent("text", " "); err == nil {
|
||||
t.Fatal("空白文本未被拦截")
|
||||
|
||||
validURL := "http://localhost:9000/kefu/tenants/1/chat/a.webp"
|
||||
if content, err := validateMessageContent("image", validURL); err != nil || content != validURL {
|
||||
t.Fatalf("合法图片 URL 校验失败: content=%q err=%v", content, err)
|
||||
}
|
||||
if _, err := validateMessageContent("text", strings.Repeat("字", maxTextMessageLength+1)); err == nil {
|
||||
t.Fatal("超长文本未被拦截")
|
||||
if _, err := validateMessageContent("image", "data:image/png;base64,aGVsbG8="); err == nil {
|
||||
t.Fatal("base64 图片应被拒绝")
|
||||
}
|
||||
if _, err := validateMessageContent("image", "https://evil.example.com/a.webp"); err == nil {
|
||||
t.Fatal("外域图片 URL 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-sys/server/internal/config"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/storage"
|
||||
)
|
||||
|
||||
func SetupRoutes(r *gin.Engine) {
|
||||
func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.StorageConfig) {
|
||||
auth := NewAuthHandler()
|
||||
session := NewSessionHandler()
|
||||
customer := NewCustomerHandler()
|
||||
@@ -16,6 +18,9 @@ func SetupRoutes(r *gin.Engine) {
|
||||
settings := NewSettingsHandler()
|
||||
ws := NewWsHandler()
|
||||
widget := NewWidgetHandler()
|
||||
upload := NewUploadHandler(store, storageCfg)
|
||||
|
||||
SetImagePublicBase(storageCfg.PublicBase)
|
||||
|
||||
api := r.Group("/api")
|
||||
|
||||
@@ -31,6 +36,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
widgetApi.GET("/messages", widget.GetMessages)
|
||||
widgetApi.GET("/ws", widget.Connect)
|
||||
widgetApi.POST("/rating", widget.SubmitRating)
|
||||
widgetApi.POST("/upload", upload.WidgetUploadImage)
|
||||
|
||||
// 需要认证的接口
|
||||
authRequired := api.Group("")
|
||||
@@ -39,6 +45,9 @@ func SetupRoutes(r *gin.Engine) {
|
||||
// WebSocket
|
||||
authRequired.GET("/ws", ws.Connect)
|
||||
|
||||
// 上传
|
||||
authRequired.POST("/uploads", upload.UploadImage)
|
||||
|
||||
// 会话管理
|
||||
authRequired.GET("/agents/available", session.ListAvailableAgents)
|
||||
sessions := authRequired.Group("/sessions")
|
||||
|
||||
@@ -2,8 +2,10 @@ package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -13,9 +15,11 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"kefu-sys/server/internal/config"
|
||||
"kefu-sys/server/internal/handler"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
"kefu-sys/server/internal/storage"
|
||||
)
|
||||
|
||||
func setupRouter(t *testing.T) *gin.Engine {
|
||||
@@ -32,10 +36,45 @@ func setupRouter(t *testing.T) *gin.Engine {
|
||||
model.DB = db
|
||||
middleware.InitJWT("test-secret")
|
||||
router := gin.New()
|
||||
handler.SetupRoutes(router)
|
||||
mem := storage.NewMemory("http://localhost:9000/kefu")
|
||||
handler.SetupRoutes(router, mem, config.StorageConfig{
|
||||
PublicBase: "http://localhost:9000/kefu",
|
||||
MaxUploadMB: 10,
|
||||
MaxImageEdge: 1920,
|
||||
WebPQuality: 80,
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
// tinyPNG 1x1 像素 PNG
|
||||
func tinyPNG() []byte {
|
||||
b, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
||||
return b
|
||||
}
|
||||
|
||||
func multipartImageRequest(t *testing.T, method, target string, fileField string, filename string, data []byte, user model.User) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
part, err := w.CreateFormFile(fileField, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 form file 失败: %v", err)
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
t.Fatalf("写入文件失败: %v", err)
|
||||
}
|
||||
_ = w.Close()
|
||||
req := httptest.NewRequest(method, target, &body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role)
|
||||
if err != nil {
|
||||
t.Fatalf("生成令牌失败: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
func createTenant(t *testing.T, name, status string) model.Tenant {
|
||||
t.Helper()
|
||||
tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)}
|
||||
@@ -417,8 +456,22 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
||||
t.Fatalf("转接后原客服仍可回复: %d %s", oldAgentMessageRecorder.Code, oldAgentMessageRecorder.Body.String())
|
||||
}
|
||||
|
||||
uploadRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(uploadRecorder, multipartImageRequest(t, http.MethodPost, "/api/uploads", "file", "dot.png", tinyPNG(), agentTwo))
|
||||
if uploadRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("上传图片失败: %d %s", uploadRecorder.Code, uploadRecorder.Body.String())
|
||||
}
|
||||
var uploadResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil || uploadResp.Data.URL == "" {
|
||||
t.Fatalf("解析上传响应失败: %v body=%s", err, uploadRecorder.Body.String())
|
||||
}
|
||||
|
||||
imageRecorder := httptest.NewRecorder()
|
||||
imageBody := []byte(`{"content":"data:image/png;base64,aGVsbG8=","type":"image"}`)
|
||||
imageBody := []byte(fmt.Sprintf(`{"content":%q,"type":"image"}`, uploadResp.Data.URL))
|
||||
router.ServeHTTP(imageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), imageBody, agentTwo))
|
||||
if imageRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("发送图片消息失败: %d %s", imageRecorder.Code, imageRecorder.Body.String())
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-sys/server/internal/config"
|
||||
"kefu-sys/server/internal/media"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/storage"
|
||||
)
|
||||
|
||||
type UploadHandler struct {
|
||||
store storage.ObjectStorage
|
||||
cfg config.StorageConfig
|
||||
}
|
||||
|
||||
func NewUploadHandler(store storage.ObjectStorage, cfg config.StorageConfig) *UploadHandler {
|
||||
return &UploadHandler{store: store, cfg: cfg}
|
||||
}
|
||||
|
||||
// UploadImage 客服端上传:multipart file → 处理 → 存对象存储
|
||||
func (h *UploadHandler) UploadImage(c *gin.Context) {
|
||||
h.handleUpload(c, fmt.Sprintf("tenants/%d/chat", middleware.GetTenantID(c)))
|
||||
}
|
||||
|
||||
// WidgetUploadImage 访客端上传:需 visitor token + session_id
|
||||
func (h *UploadHandler) WidgetUploadImage(c *gin.Context) {
|
||||
sessionID := c.PostForm("session_id")
|
||||
if sessionID == "" {
|
||||
sessionID = c.Query("session_id")
|
||||
}
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少 session_id"})
|
||||
return
|
||||
}
|
||||
token := visitorTokenFromRequest(c, c.PostForm("visitor_token"))
|
||||
var sid uint
|
||||
if _, err := fmt.Sscanf(sessionID, "%d", &sid); err != nil || sid == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"})
|
||||
return
|
||||
}
|
||||
session, ok := loadVisitorSession(c, sid, token)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"})
|
||||
return
|
||||
}
|
||||
h.handleUpload(c, fmt.Sprintf("tenants/%d/widget/%d", session.TenantID, session.ID))
|
||||
}
|
||||
|
||||
func (h *UploadHandler) handleUpload(c *gin.Context, keyPrefix string) {
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "对象存储未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
maxBytes := int64(h.cfg.MaxUploadMB) * 1024 * 1024
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 10 * 1024 * 1024
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+512)
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请上传文件字段 file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("文件不能超过 %d MB", h.cfg.MaxUploadMB)})
|
||||
return
|
||||
}
|
||||
|
||||
// 嗅探 MIME
|
||||
head := make([]byte, 512)
|
||||
n, _ := io.ReadFull(file, head)
|
||||
contentType := http.DetectContentType(head[:n])
|
||||
// 复位读取:拼回 head + 剩余
|
||||
reader := io.MultiReader(bytes.NewReader(head[:n]), file)
|
||||
|
||||
// 部分浏览器 Content-Type 不准,也允许 header 中的 type
|
||||
if !media.AllowedUploadMIME(contentType) {
|
||||
ct := header.Header.Get("Content-Type")
|
||||
if media.AllowedUploadMIME(ct) {
|
||||
contentType = ct
|
||||
} else {
|
||||
// 按扩展名兜底
|
||||
ext := strings.ToLower(path.Ext(header.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".gif":
|
||||
contentType = "image/gif"
|
||||
case ".webp":
|
||||
contentType = "image/webp"
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅支持 jpg/png/gif/webp 图片"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = contentType
|
||||
|
||||
raw, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
if int64(len(raw)) > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "文件过大"})
|
||||
return
|
||||
}
|
||||
|
||||
processed, err := media.ProcessImage(bytes.NewReader(raw), media.ProcessOptions{
|
||||
MaxEdge: h.cfg.MaxImageEdge,
|
||||
ThumbEdge: 400,
|
||||
WebPQuality: h.cfg.WebPQuality,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
mainKey := storage.NewObjectKey(keyPrefix, processed.MainExt)
|
||||
thumbKey := storage.NewObjectKey(keyPrefix+"/thumbs", processed.ThumbExt)
|
||||
|
||||
mainURL, err := h.store.Put(c.Request.Context(), mainKey, bytes.NewReader(processed.Main), int64(len(processed.Main)), processed.MainType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "上传失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
thumbURL, err := h.store.Put(c.Request.Context(), thumbKey, bytes.NewReader(processed.Thumb), int64(len(processed.Thumb)), processed.ThumbType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "缩略图上传失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
middleware.JSON(c, gin.H{
|
||||
"url": mainURL,
|
||||
"thumb_url": thumbURL,
|
||||
"content_type": processed.MainType,
|
||||
"width": processed.Width,
|
||||
"height": processed.Height,
|
||||
"size": len(processed.Main),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxEdge = 1920
|
||||
DefaultThumbEdge = 400
|
||||
DefaultQuality = 80
|
||||
)
|
||||
|
||||
type ProcessOptions struct {
|
||||
MaxEdge int
|
||||
ThumbEdge int
|
||||
WebPQuality float32
|
||||
}
|
||||
|
||||
type ProcessedImage struct {
|
||||
Main []byte
|
||||
MainType string // image/webp
|
||||
MainExt string // .webp
|
||||
Thumb []byte
|
||||
ThumbType string
|
||||
ThumbExt string
|
||||
Width int
|
||||
Height int
|
||||
ThumbWidth int
|
||||
ThumbHeight int
|
||||
}
|
||||
|
||||
// ProcessImage 解码 → 等比缩放 → WebP 主图 + 缩略图
|
||||
func ProcessImage(r io.Reader, opt ProcessOptions) (*ProcessedImage, error) {
|
||||
if opt.MaxEdge <= 0 {
|
||||
opt.MaxEdge = DefaultMaxEdge
|
||||
}
|
||||
if opt.ThumbEdge <= 0 {
|
||||
opt.ThumbEdge = DefaultThumbEdge
|
||||
}
|
||||
if opt.WebPQuality <= 0 || opt.WebPQuality > 100 {
|
||||
opt.WebPQuality = DefaultQuality
|
||||
}
|
||||
|
||||
src, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法解码图片: %w", err)
|
||||
}
|
||||
|
||||
mainImg := fitWithin(src, opt.MaxEdge)
|
||||
thumbImg := fitWithin(src, opt.ThumbEdge)
|
||||
|
||||
mainBuf := &bytes.Buffer{}
|
||||
if err := webp.Encode(mainBuf, mainImg, &webp.Options{Quality: opt.WebPQuality}); err != nil {
|
||||
return nil, fmt.Errorf("webp 编码失败: %w", err)
|
||||
}
|
||||
thumbBuf := &bytes.Buffer{}
|
||||
if err := webp.Encode(thumbBuf, thumbImg, &webp.Options{Quality: opt.WebPQuality}); err != nil {
|
||||
return nil, fmt.Errorf("缩略图编码失败: %w", err)
|
||||
}
|
||||
|
||||
bMain := mainImg.Bounds()
|
||||
bThumb := thumbImg.Bounds()
|
||||
return &ProcessedImage{
|
||||
Main: mainBuf.Bytes(),
|
||||
MainType: "image/webp",
|
||||
MainExt: ".webp",
|
||||
Thumb: thumbBuf.Bytes(),
|
||||
ThumbType: "image/webp",
|
||||
ThumbExt: ".webp",
|
||||
Width: bMain.Dx(),
|
||||
Height: bMain.Dy(),
|
||||
ThumbWidth: bThumb.Dx(),
|
||||
ThumbHeight: bThumb.Dy(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fitWithin(img image.Image, maxEdge int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxEdge && h <= maxEdge {
|
||||
return img
|
||||
}
|
||||
if w >= h {
|
||||
return imaging.Resize(img, maxEdge, 0, imaging.Lanczos)
|
||||
}
|
||||
return imaging.Resize(img, 0, maxEdge, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// AllowedUploadMIME 上传阶段允许的源格式
|
||||
func AllowedUploadMIME(contentType string) bool {
|
||||
switch contentType {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user