feat(knowledge): 图片上传接口(S3 存储,限 5MB jpg/png/webp)
This commit is contained in:
@@ -5,13 +5,14 @@ import (
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterKnowledgeRoutes 注册知识库路由
|
||||
func RegisterKnowledgeRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
func RegisterKnowledgeRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, imageBucket string) {
|
||||
read := middleware.RequirePermission(db, "knowledge:read")
|
||||
write := middleware.RequirePermission(db, "knowledge:write")
|
||||
|
||||
@@ -26,6 +27,8 @@ func RegisterKnowledgeRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.POST("/knowledge/articles", write, createArticle(db))
|
||||
rg.PATCH("/knowledge/articles/:id", write, updateArticle(db))
|
||||
rg.DELETE("/knowledge/articles/:id", write, deleteArticle(db))
|
||||
|
||||
registerKnowledgeImageRoute(rg, db, s3, imageBucket)
|
||||
}
|
||||
|
||||
// listDiseases 蚕病百科列表(支持 category/keyword/enabled 过滤)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// maxImageSize 知识库图片大小上限(5MB)
|
||||
const maxImageSize = 5 << 20
|
||||
|
||||
// imageContentType 根据扩展名返回图片 Content-Type
|
||||
func imageContentType(ext string) (string, bool) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg", true
|
||||
case ".png":
|
||||
return "image/png", true
|
||||
case ".webp":
|
||||
return "image/webp", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validateImageFile 校验图片文件名扩展名与大小
|
||||
func validateImageFile(filename string, size int64) error {
|
||||
if size <= 0 || size > maxImageSize {
|
||||
return errors.New("图片大小需在 1B~5MB 之间")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if _, ok := imageContentType(ext); !ok {
|
||||
return errors.New("仅支持 jpg/jpeg/png/webp 图片")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildImageKey 生成 knowledge/<日期>/<随机>.ext 的对象键
|
||||
func buildImageKey(filename string) (string, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if _, ok := imageContentType(ext); !ok {
|
||||
return "", "", errors.New("仅支持 jpg/jpeg/png/webp 图片")
|
||||
}
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
date := time.Now().Format("20060102")
|
||||
key := fmt.Sprintf("knowledge/%s/%s%s", date, hex.EncodeToString(buf), ext)
|
||||
return key, ext, nil
|
||||
}
|
||||
|
||||
// uploadKnowledgeImage 上传知识库图片(multipart 字段名 file)
|
||||
func uploadKnowledgeImage(s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件(字段名 file)"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := validateImageFile(header.Filename, header.Size); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
key, ext, err := buildImageKey(header.Filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
contentType, _ := imageContentType(ext)
|
||||
|
||||
if err := s3.EnsureBucket(bucket); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片存储不可用: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s3.UploadImage(bucket, key, file, contentType); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片上传失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s/%s", s3.Endpoint(), bucket, key)
|
||||
c.JSON(http.StatusOK, gin.H{"url": url, "name": header.Filename, "size": header.Size})
|
||||
}
|
||||
}
|
||||
|
||||
// uploadImageRoute 在知识库路由组中注册图片上传(由 RegisterKnowledgeRoutes 调用)
|
||||
func registerKnowledgeImageRoute(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, bucket string) {
|
||||
rg.POST("/knowledge/images", middleware.RequirePermission(db, "knowledge:write"), uploadKnowledgeImage(s3, bucket))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateImageFileRejectsOversize(t *testing.T) {
|
||||
if err := validateImageFile("a.png", maxImageSize+1); err == nil {
|
||||
t.Error("超过 5MB 的图片应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageFileRejectsEmptyAndBadExt(t *testing.T) {
|
||||
if err := validateImageFile("a.png", 0); err == nil {
|
||||
t.Error("空文件应被拒绝")
|
||||
}
|
||||
if err := validateImageFile("a.txt", 1024); err == nil {
|
||||
t.Error("非图片扩展名应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageFileAcceptsAllowed(t *testing.T) {
|
||||
for _, name := range []string{"a.jpg", "a.jpeg", "a.png", "a.webp"} {
|
||||
if err := validateImageFile(name, 1024); err != nil {
|
||||
t.Errorf("%s 应被允许: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
for ext, want := range cases {
|
||||
got, ok := imageContentType(ext)
|
||||
if !ok || got != want {
|
||||
t.Errorf("imageContentType(%s) = %q, %v; want %q", ext, got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := imageContentType(".gif"); ok {
|
||||
t.Error(".gif 不应被支持")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImageKey(t *testing.T) {
|
||||
key, ext, err := buildImageKey("病蚕照片.PNG")
|
||||
if err != nil {
|
||||
t.Fatalf("buildImageKey 失败: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "knowledge/") {
|
||||
t.Errorf("key 应以 knowledge/ 开头: %s", key)
|
||||
}
|
||||
if !strings.HasSuffix(key, ".png") {
|
||||
t.Errorf("key 应以 .png 结尾: %s", key)
|
||||
}
|
||||
if ext != ".png" {
|
||||
t.Errorf("ext = %s, want .png", ext)
|
||||
}
|
||||
if len(key) <= len("knowledge/")+8 {
|
||||
t.Errorf("key 应包含日期与随机部分: %s", key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user