server: Go 服务器(REST+WSS 网关+JWT+Argon2+配对+素材直链+任务状态机);修复并发下线 send-on-closed-channel、下发查询 SQL 优先级、配对码原子占用、上传体积上限、JWT 默认密钥告警

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent bf25d8beac
commit cc8845dcec
44 changed files with 4995 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
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)
}