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
+279
View File
@@ -0,0 +1,279 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
"everypublish/server/internal/task"
"everypublish/server/internal/ws"
)
// TaskHandler 发布任务处理器(状态机见 internal/task)
type TaskHandler struct {
DB *gorm.DB
Dispatcher *ws.Dispatcher
}
type taskReq struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
Tags []string `json:"tags"`
AccountIDs []uint64 `json:"accountIds"`
MaterialIDs []uint64 `json:"materialIds"`
ScheduleAt *int64 `json:"scheduleAt"`
Priority int `json:"priority"`
}
func marshalJSONArray[T any](arr []T) string {
raw, _ := json.Marshal(arr)
if raw == nil {
return "[]"
}
return string(raw)
}
// List 任务列表(状态筛选 + 分页)
func (h *TaskHandler) 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.Task{}).Where("workspace_id = ?", c.GetUint64("wsid"))
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
if s := c.Query("schedule"); s != "" {
if s == "scheduled" {
q = q.Where("schedule_at is not null and schedule_at > ?", time.Now())
} else if s == "immediate" {
q = q.Where("schedule_at is null or schedule_at <= ?", time.Now())
}
}
var total int64
q.Count(&total)
var list []models.Task
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})
}
// Create 新建任务(draft)
func (h *TaskHandler) Create(c *gin.Context) {
var req taskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法:"+err.Error())
return
}
if len(req.AccountIDs) == 0 {
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个平台账号")
return
}
if len(req.MaterialIDs) == 0 {
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个素材")
return
}
priority := req.Priority
if priority < 1 || priority > 10 {
priority = 5
}
row := models.Task{
WorkspaceID: c.GetUint64("wsid"),
Title: req.Title,
Content: req.Content,
Tags: marshalJSONArray(req.Tags),
AccountIDs: marshalJSONArray(req.AccountIDs),
MaterialIDs: marshalJSONArray(req.MaterialIDs),
Status: task.Draft,
Priority: priority,
MaxRetry: 3,
CreatedBy: c.GetUint64("uid"),
}
if req.ScheduleAt != nil {
ts := time.UnixMilli(*req.ScheduleAt)
row.ScheduleAt = &ts
}
if err := h.DB.Create(&row).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
return
}
response.Audit(c, "task.create", "task:"+itoa(row.ID), gin.H{"title": req.Title})
response.OK(c, row)
}
// Get 任务详情
func (h *TaskHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return
}
response.OK(c, row)
}
// Update 编辑草稿(仅 draft/rejected)
func (h *TaskHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req taskReq
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return
}
if row.Status != task.Draft && row.Status != task.Rejected {
response.Fail(c, http.StatusConflict, 3005, "仅草稿或已驳回任务可编辑")
return
}
updates := map[string]interface{}{
"title": req.Title,
"content": req.Content,
"tags": marshalJSONArray(req.Tags),
"account_ids": marshalJSONArray(req.AccountIDs),
"material_ids": marshalJSONArray(req.MaterialIDs),
}
if req.ScheduleAt != nil {
ts := time.UnixMilli(*req.ScheduleAt)
updates["schedule_at"] = &ts
}
if err = h.DB.Model(&row).Updates(updates).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "task.update", "task:"+itoa(id), gin.H{"title": req.Title})
response.OK(c, nil)
}
// Delete 删除草稿
func (h *TaskHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return
}
if row.Status != task.Draft {
response.Fail(c, http.StatusConflict, 3005, "仅草稿可删除")
return
}
if err = h.DB.Delete(&row).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "删除失败")
return
}
response.Audit(c, "task.delete", "task:"+itoa(id), nil)
response.OK(c, nil)
}
// apply 状态转移并落库
func (h *TaskHandler) apply(c *gin.Context, action task.Action, extra map[string]interface{}) *models.Task {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return nil
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return nil
}
next, ok := task.Next(row.Status, action)
if !ok {
response.Fail(c, http.StatusConflict, 3005, "当前状态("+row.Status+")不允许该操作("+string(action)+")")
return nil
}
updates := map[string]interface{}{"status": next}
for k, v := range extra {
updates[k] = v
}
if err = h.DB.Model(&row).Updates(updates).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return nil
}
row.Status = next
return &row
}
// Submit 提交审核
func (h *TaskHandler) Submit(c *gin.Context) {
if row := h.apply(c, task.ActionSubmit, nil); row != nil {
response.Audit(c, "task.submit", "task:"+itoa(row.ID), nil)
response.OK(c, row)
}
}
// Approve 审核通过 → queued
func (h *TaskHandler) Approve(c *gin.Context) {
if row := h.apply(c, task.ActionApprove, nil); row != nil {
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{row.CreatedBy}, "task", "任务已通过审核", row.Title)
response.Audit(c, "task.approve", "task:"+itoa(row.ID), nil)
if h.Dispatcher != nil {
_ = h.Dispatcher.TryDispatch(row.ID) // 在线设备立即下发
}
response.OK(c, row)
}
}
// Reject 驳回(附批注)
func (h *TaskHandler) Reject(c *gin.Context) {
var req struct {
Note string `json:"note"`
}
_ = c.ShouldBindJSON(&req)
if row := h.apply(c, task.ActionReject, map[string]interface{}{"review_note": req.Note, "reviewed_by": c.GetUint64("uid")}); row != nil {
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{row.CreatedBy}, "task", "任务被驳回", req.Note)
response.Audit(c, "task.reject", "task:"+itoa(row.ID), gin.H{"note": req.Note})
response.OK(c, row)
}
}
// Resubmit 驳回后重新提交
func (h *TaskHandler) Resubmit(c *gin.Context) {
if row := h.apply(c, task.ActionResubmit, nil); row != nil {
response.Audit(c, "task.resubmit", "task:"+itoa(row.ID), nil)
response.OK(c, row)
}
}
// Retry 失败重试
func (h *TaskHandler) Retry(c *gin.Context) {
if row := h.apply(c, task.ActionRetry, map[string]interface{}{"error_message": ""}); row != nil {
response.Audit(c, "task.retry", "task:"+itoa(row.ID), nil)
if h.Dispatcher != nil {
_ = h.Dispatcher.TryDispatch(row.ID)
}
response.OK(c, row)
}
}
// Cancel 取消
func (h *TaskHandler) Cancel(c *gin.Context) {
if row := h.apply(c, task.ActionCancel, nil); row != nil {
response.Audit(c, "task.cancel", "task:"+itoa(row.ID), nil)
response.OK(c, row)
}
}