382 lines
12 KiB
Go
382 lines
12 KiB
Go
package handlers
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"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)
|
||
}
|
||
|
||
func (h *TaskHandler) validateTargets(wsid uint64, accountIDs, materialIDs []uint64) error {
|
||
var accounts []models.Account
|
||
if err := h.DB.Where("workspace_id = ? and id in ? and status = ?", wsid, accountIDs, "active").Find(&accounts).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(accounts) != len(uniqueIDs(accountIDs)) {
|
||
return fmt.Errorf("账号不存在、不属于当前工作区或尚未完成登录")
|
||
}
|
||
var materials []models.Material
|
||
if err := h.DB.Where("workspace_id = ? and id in ? and status = ?", wsid, materialIDs, "ready").Find(&materials).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(materials) != len(uniqueIDs(materialIDs)) {
|
||
return fmt.Errorf("素材不存在、未就绪或不属于当前工作区")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func uniqueIDs(values []uint64) []uint64 {
|
||
seen := make(map[uint64]struct{}, len(values))
|
||
result := make([]uint64, 0, len(values))
|
||
for _, value := range values {
|
||
if _, ok := seen[value]; ok {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func recordTaskEvent(db *gorm.DB, row *models.Task, eventType, message string, data interface{}) {
|
||
raw := ""
|
||
if data != nil {
|
||
if encoded, err := json.Marshal(data); err == nil {
|
||
raw = string(encoded)
|
||
}
|
||
}
|
||
_ = db.Create(&models.TaskEvent{
|
||
WorkspaceID: row.WorkspaceID,
|
||
TaskID: row.ID,
|
||
Type: eventType,
|
||
Status: row.Status,
|
||
Message: message,
|
||
Data: raw,
|
||
}).Error
|
||
}
|
||
|
||
// 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 > 300 {
|
||
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
|
||
}
|
||
if err := h.validateTargets(c.GetUint64("wsid"), req.AccountIDs, req.MaterialIDs); err != nil {
|
||
response.Fail(c, http.StatusBadRequest, 1004, err.Error())
|
||
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)
|
||
if !ts.After(time.Now()) {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "定时发布时间必须晚于当前时间")
|
||
return
|
||
}
|
||
row.ScheduleAt = &ts
|
||
}
|
||
if err := h.DB.Create(&row).Error; err != nil {
|
||
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
|
||
return
|
||
}
|
||
recordTaskEvent(h.DB, &row, "created", "任务已创建", nil)
|
||
response.Audit(c, "task.create", "task:"+itoa(row.ID), gin.H{"title": req.Title})
|
||
response.OK(c, row)
|
||
}
|
||
|
||
// Events 返回任务时间线,并强制按 workspace 隔离。
|
||
func (h *TaskHandler) Events(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||
return
|
||
}
|
||
var events []models.TaskEvent
|
||
if err = h.DB.Where("task_id = ? and workspace_id = ?", id, c.GetUint64("wsid")).Order("id asc").Find(&events).Error; err != nil {
|
||
response.Fail(c, http.StatusInternalServerError, 5000, "任务时间线加载失败")
|
||
return
|
||
}
|
||
response.OK(c, gin.H{"list": events})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if len(req.AccountIDs) == 0 || len(req.MaterialIDs) == 0 {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个账号和素材")
|
||
return
|
||
}
|
||
if err := h.validateTargets(c.GetUint64("wsid"), req.AccountIDs, req.MaterialIDs); err != nil {
|
||
response.Fail(c, http.StatusBadRequest, 1004, err.Error())
|
||
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)
|
||
if !ts.After(time.Now()) {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "定时发布时间必须晚于当前时间")
|
||
return
|
||
}
|
||
updates["schedule_at"] = &ts
|
||
} else {
|
||
// nil represents an explicit immediate schedule in the JSON contract.
|
||
// The update endpoint has no meaningful partial-update semantics.
|
||
updates["schedule_at"] = nil
|
||
}
|
||
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
|
||
recordTaskEvent(h.DB, &row, string(action), "状态变更为 "+next, extra)
|
||
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)
|
||
if h.Dispatcher != nil && h.Dispatcher.Hub != nil {
|
||
h.Dispatcher.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务已通过审核", "content": row.Title})
|
||
}
|
||
response.Audit(c, "task.approve", "task:"+itoa(row.ID), nil)
|
||
if h.Dispatcher != nil {
|
||
_ = h.Dispatcher.TryDispatch(row.ID)
|
||
// TryDispatch can synchronously advance a Web-only mock task. Return
|
||
// the persisted state instead of the stale queued copy from apply.
|
||
_ = h.DB.Where("id = ? and workspace_id = ?", row.ID, row.WorkspaceID).First(row).Error
|
||
}
|
||
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)
|
||
if h.Dispatcher != nil && h.Dispatcher.Hub != nil {
|
||
h.Dispatcher.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务被驳回", "content": 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 {
|
||
if h.Dispatcher != nil {
|
||
h.Dispatcher.Cancel(row.ID)
|
||
}
|
||
response.Audit(c, "task.cancel", "task:"+itoa(row.ID), nil)
|
||
response.OK(c, row)
|
||
}
|
||
}
|