Files
EveryPublish/server/internal/api/handlers/material.go
T

202 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
if strings.HasPrefix(header.Header.Get("Content-Type"), "video/") {
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})
}
// 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)
if !strings.HasPrefix(clean, filepath.Clean(h.Cfg.StorageDir)) {
response.Fail(c, http.StatusForbidden, 1003, "非法路径")
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)
}