202 lines
6.2 KiB
Go
202 lines
6.2 KiB
Go
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)
|
||
}
|