feat(knowledge): 图片上传接口(S3 存储,限 5MB jpg/png/webp)

This commit is contained in:
weijuesen
2026-08-12 12:12:28 +08:00
parent 8bb93ad5c3
commit c8aa213626
6 changed files with 201 additions and 2 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ func main() {
handler.RegisterVideoClipRoutes(api, db, cfg) handler.RegisterVideoClipRoutes(api, db, cfg)
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg) handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
handler.RegisterStorageRoutes(api, db) handler.RegisterStorageRoutes(api, db)
handler.RegisterKnowledgeRoutes(api, db) handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
// 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态) // 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态)
go startDeviceStatusSync(db, mediaSvc) go startDeviceStatusSync(db, mediaSvc)
+1
View File
@@ -16,6 +16,7 @@ type Config struct {
S3AccessKey string `env:"S3_ACCESS_KEY" envDefault:"silk-app"` S3AccessKey string `env:"S3_ACCESS_KEY" envDefault:"silk-app"`
S3SecretKey string `env:"S3_SECRET_KEY" envDefault:"Silk-App-Secret-2026!"` S3SecretKey string `env:"S3_SECRET_KEY" envDefault:"Silk-App-Secret-2026!"`
S3Bucket string `env:"S3_BUCKET" envDefault:"silk-video-events"` S3Bucket string `env:"S3_BUCKET" envDefault:"silk-video-events"`
S3BucketImages string `env:"S3_BUCKET_IMAGES" envDefault:"silk-images"`
S3Region string `env:"S3_REGION" envDefault:"us-east-1"` S3Region string `env:"S3_REGION" envDefault:"us-east-1"`
WVPAPIBase string `env:"WVP_API_BASE" envDefault:"http://localhost:18978"` WVPAPIBase string `env:"WVP_API_BASE" envDefault:"http://localhost:18978"`
WVPUsername string `env:"WVP_USERNAME" envDefault:"admin"` WVPUsername string `env:"WVP_USERNAME" envDefault:"admin"`
+4 -1
View File
@@ -5,13 +5,14 @@ import (
"silk-server-go/internal/middleware" "silk-server-go/internal/middleware"
"silk-server-go/internal/model" "silk-server-go/internal/model"
"silk-server-go/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
) )
// RegisterKnowledgeRoutes 注册知识库路由 // 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") read := middleware.RequirePermission(db, "knowledge:read")
write := middleware.RequirePermission(db, "knowledge:write") 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.POST("/knowledge/articles", write, createArticle(db))
rg.PATCH("/knowledge/articles/:id", write, updateArticle(db)) rg.PATCH("/knowledge/articles/:id", write, updateArticle(db))
rg.DELETE("/knowledge/articles/:id", write, deleteArticle(db)) rg.DELETE("/knowledge/articles/:id", write, deleteArticle(db))
registerKnowledgeImageRoute(rg, db, s3, imageBucket)
} }
// listDiseases 蚕病百科列表(支持 category/keyword/enabled 过滤) // 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)
}
}
+28
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io"
"net/url" "net/url"
"os" "os"
"strings" "strings"
@@ -151,6 +152,33 @@ func (s *S3Service) UploadFile(bucket, key, filePath string) error {
return err return err
} }
// EnsureBucket 确保 bucket 存在,不存在则创建(幂等)
func (s *S3Service) EnsureBucket(bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}); err == nil {
return nil
}
_, err := s.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)})
return err
}
// UploadImage 上传图片对象(支持指定 Content-Type
func (s *S3Service) UploadImage(bucket, key string, body io.Reader, contentType string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
})
return err
}
// Endpoint 返回 S3 endpoint(用于拼接对象公开 URL)
func (s *S3Service) Endpoint() string { return s.endpoint }
// sha256Hex 计算 SHA256 十六进制摘要 // sha256Hex 计算 SHA256 十六进制摘要
func sha256Hex(data string) string { func sha256Hex(data string) string {
h := sha256.Sum256([]byte(data)) h := sha256.Sum256([]byte(data))