实现 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
@@ -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())