diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 3f42692..ed1e23b 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -106,7 +106,7 @@ func main() { handler.RegisterVideoClipRoutes(api, db, cfg) handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg) handler.RegisterStorageRoutes(api, db) - handler.RegisterKnowledgeRoutes(api, db) + handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages) // 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态) go startDeviceStatusSync(db, mediaSvc) diff --git a/server-go/internal/config/config.go b/server-go/internal/config/config.go index f491b6e..a6fee71 100644 --- a/server-go/internal/config/config.go +++ b/server-go/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { S3AccessKey string `env:"S3_ACCESS_KEY" envDefault:"silk-app"` S3SecretKey string `env:"S3_SECRET_KEY" envDefault:"Silk-App-Secret-2026!"` 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"` WVPAPIBase string `env:"WVP_API_BASE" envDefault:"http://localhost:18978"` WVPUsername string `env:"WVP_USERNAME" envDefault:"admin"` diff --git a/server-go/internal/handler/knowledge.go b/server-go/internal/handler/knowledge.go index 7ce7d25..f9a1df7 100644 --- a/server-go/internal/handler/knowledge.go +++ b/server-go/internal/handler/knowledge.go @@ -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 过滤) diff --git a/server-go/internal/handler/knowledge_image.go b/server-go/internal/handler/knowledge_image.go new file mode 100644 index 0000000..e103d2a --- /dev/null +++ b/server-go/internal/handler/knowledge_image.go @@ -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)) +} diff --git a/server-go/internal/handler/knowledge_image_test.go b/server-go/internal/handler/knowledge_image_test.go new file mode 100644 index 0000000..1417a89 --- /dev/null +++ b/server-go/internal/handler/knowledge_image_test.go @@ -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) + } +} diff --git a/server-go/internal/service/s3.go b/server-go/internal/service/s3.go index 405a5b0..8ee4fbf 100644 --- a/server-go/internal/service/s3.go +++ b/server-go/internal/service/s3.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "io" "net/url" "os" "strings" @@ -151,6 +152,33 @@ func (s *S3Service) UploadFile(bucket, key, filePath string) error { 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 十六进制摘要 func sha256Hex(data string) string { h := sha256.Sum256([]byte(data))