package handlers import ( "crypto/sha256" "encoding/hex" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "everypublish/server/internal/api/response" "everypublish/server/internal/config" "everypublish/server/internal/models" ) // MaterialHandler 素材处理器(服务器本地盘一期;StorageDriver 二期 OSS) type MaterialHandler struct { DB *gorm.DB Cfg *config.Config } // List 素材列表(分页 + 筛选) func (h *MaterialHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } q := h.DB.Model(&models.Material{}).Where("workspace_id = ? and status = ?", c.GetUint64("wsid"), "ready") if k := c.Query("kind"); k != "" { q = q.Where("kind = ?", k) } if g := c.Query("group"); g != "" { q = q.Where("`group` = ?", g) } var total int64 q.Count(&total) var list []models.Material q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list) response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) } // Upload multipart 上传:流式计算 sha256,同工作区按 sha256 去重 func (h *MaterialHandler) Upload(c *gin.Context) { if h.Cfg.MaxUploadBytes > 0 { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.Cfg.MaxUploadBytes) } file, header, err := c.Request.FormFile("file") if err != nil { response.Fail(c, http.StatusBadRequest, 1001, "文件过大或缺少文件字段 file") return } defer file.Close() wsid := c.GetUint64("wsid") dir := filepath.Join(h.Cfg.StorageDir, itoa(wsid)) if err = os.MkdirAll(dir, 0o755); err != nil { response.Fail(c, http.StatusInternalServerError, 5000, "存储初始化失败") return } ext := strings.ToLower(filepath.Ext(header.Filename)) if ext == "" || len(ext) > 16 { ext = ".bin" } key := filepath.Join(itoa(wsid), uuid.NewString()+ext) abspath := filepath.Join(h.Cfg.StorageDir, key) dst, err := os.Create(abspath) if err != nil { response.Fail(c, http.StatusInternalServerError, 5000, "写盘失败") return } hasher := sha256.New() size, err := io.Copy(io.MultiWriter(dst, hasher), file) dst.Close() if err != nil { _ = os.Remove(abspath) response.Fail(c, http.StatusInternalServerError, 5000, "写入失败") return } sum := hex.EncodeToString(hasher.Sum(nil)) // 去重:同工作区同 sha256 直接复用 var existing models.Material if err = h.DB.Where("workspace_id = ? and sha256 = ? and status = ?", wsid, sum, "ready").First(&existing).Error; err == nil { _ = os.Remove(abspath) response.OK(c, gin.H{"material": existing, "dedup": true}) return } kind := "image" contentType := strings.ToLower(header.Header.Get("Content-Type")) if strings.HasPrefix(contentType, "video/") || isVideoExtension(ext) { kind = "video" } group := strings.TrimSpace(c.PostForm("group")) tags := strings.TrimSpace(c.PostForm("tags")) if tags == "" { tags = "[]" } material := models.Material{ WorkspaceID: wsid, Name: header.Filename, Kind: kind, Size: size, SHA256: sum, Mime: header.Header.Get("Content-Type"), StorageKey: key, Group: group, Tags: tags, Status: "ready", CreatedBy: c.GetUint64("uid"), } if err = h.DB.Create(&material).Error; err != nil { _ = os.Remove(abspath) response.Fail(c, http.StatusInternalServerError, 5000, "入库失败") return } response.Audit(c, "material.upload", "material:"+itoa(material.ID), gin.H{"name": header.Filename, "size": size}) response.OK(c, gin.H{"material": material, "dedup": false}) } func isVideoExtension(ext string) bool { switch strings.ToLower(ext) { case ".mp4", ".mov", ".avi", ".mkv", ".m4v", ".webm", ".flv", ".wmv": return true default: return false } } // URL 生成签名直链(10 分钟一次性) func (h *MaterialHandler) URL(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.Fail(c, http.StatusBadRequest, 1001, "参数不合法") return } var material models.Material if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&material).Error; err != nil { response.Fail(c, http.StatusNotFound, 1004, "素材不存在") return } token := models.TransferToken{ MaterialID: material.ID, Token: uuid.NewString(), ExpiresAt: time.Now().Add(10 * time.Minute), } if err = h.DB.Create(&token).Error; err != nil { response.Fail(c, http.StatusInternalServerError, 5000, "生成链接失败") return } response.OK(c, gin.H{"url": h.Cfg.BaseURL + "/api/v1/files/" + token.Token, "expiresAt": token.ExpiresAt}) } // ServeFile 凭 token 取文件(一次性,10 分钟有效) func (h *MaterialHandler) ServeFile(c *gin.Context) { tokenStr := c.Param("token") var token models.TransferToken if err := h.DB.Where("token = ?", tokenStr).First(&token).Error; err != nil { response.Fail(c, http.StatusNotFound, 1004, "链接无效") return } if time.Now().After(token.ExpiresAt) { h.DB.Delete(&token) response.Fail(c, http.StatusGone, 3004, "链接已过期") return } var material models.Material if err := h.DB.First(&material, token.MaterialID).Error; err != nil { response.Fail(c, http.StatusNotFound, 1004, "素材不存在") return } // 一次性:取用即吊销 _ = h.DB.Delete(&token).Error abspath := filepath.Join(h.Cfg.StorageDir, material.StorageKey) clean := filepath.Clean(abspath) root := filepath.Clean(h.Cfg.StorageDir) + string(os.PathSeparator) if !strings.HasPrefix(clean, root) { response.Fail(c, http.StatusForbidden, 1003, "非法路径") return } if c.Query("inline") == "1" { c.File(abspath) return } c.FileAttachment(abspath, material.Name) } // Delete 删除素材(含文件) func (h *MaterialHandler) Delete(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.Fail(c, http.StatusBadRequest, 1001, "参数不合法") return } var material models.Material if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&material).Error; err != nil { response.Fail(c, http.StatusNotFound, 1004, "素材不存在") return } abspath := filepath.Join(h.Cfg.StorageDir, material.StorageKey) _ = os.Remove(abspath) if err = h.DB.Delete(&material).Error; err != nil { response.Fail(c, http.StatusInternalServerError, 5000, "删除失败") return } response.Audit(c, "material.delete", "material:"+itoa(id), nil) response.OK(c, nil) }