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) } }