package handler import ( "bytes" "fmt" "io" "net/http" "path" "strings" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/config" "kefu-cloud/server/internal/media" "kefu-cloud/server/internal/middleware" "kefu-cloud/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), }) }