mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 06:56:36 +08:00
refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory. - Relocate agent, relay, and flared subprojects from internal/ to internal/apps/. - Combine docker-compose files and update build context paths to root. - Update GitHub workflows and Dockerfiles to refer to new directories and package names. - Rewrite Go package imports across all files. - Resolve database renew test race condition and clean up docs.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package task 提供任务管理接口
|
||||
package task
|
||||
|
||||
// 任务管理相关错误消息
|
||||
const (
|
||||
InvalidTaskType = "无效的任务类型"
|
||||
InvalidTimeRange = "无效的时间范围"
|
||||
TaskDispatchFailed = "任务下发失败"
|
||||
UserIDRequired = "用户ID必填"
|
||||
TaskNotFound = "任务执行记录不存在"
|
||||
TaskNotRetryable = "该任务不支持重试"
|
||||
TaskNotFailed = "只有失败的任务才能重试"
|
||||
TaskMaxRetryExceeded = "已达到最大重试次数"
|
||||
TaskRetryFailed = "任务重试失败"
|
||||
InvalidCronExpression = "无效的 Cron 表达式"
|
||||
ScheduleNotFound = "定时任务不存在"
|
||||
ScheduleSaveFailed = "保存定时任务失败"
|
||||
ScheduleDeleteFailed = "删除定时任务失败"
|
||||
)
|
||||
@@ -0,0 +1,416 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/task/scheduler"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
)
|
||||
|
||||
// ListTaskTypes 获取支持的任务类型列表
|
||||
// @Summary 获取支持的任务类型
|
||||
// @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} response.Any{data=[]task.TaskMeta} "任务类型列表"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Router /api/v1/admin/tasks/types [get]
|
||||
func ListTaskTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(task.GetDispatchableTasks()))
|
||||
}
|
||||
|
||||
// DispatchTaskRequest 下发任务请求
|
||||
type DispatchTaskRequest struct {
|
||||
TaskType string `json:"task_type" binding:"required"`
|
||||
StartTime *time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
UserID *uint64 `json:"user_id"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// DispatchTask 下发任务
|
||||
// @Summary 下发异步任务
|
||||
// @Description 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body DispatchTaskRequest true "任务请求参数"
|
||||
// @Success 200 {object} response.Any{data=string} "任务已入队"
|
||||
// @Failure 400 {object} response.Any "任务类型不存在或参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Failure 500 {object} response.Any "任务入队失败"
|
||||
// @Router /api/v1/admin/tasks/dispatch [post]
|
||||
func DispatchTask(c *gin.Context) {
|
||||
var req DispatchTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
meta := task.GetTaskMeta(req.TaskType)
|
||||
if meta == nil {
|
||||
response.AbortBadRequest(c, InvalidTaskType)
|
||||
return
|
||||
}
|
||||
|
||||
var payloadBytes []byte
|
||||
if strings.TrimSpace(req.Payload) != "" {
|
||||
payloadBytes = []byte(req.Payload)
|
||||
}
|
||||
|
||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
taskID, err := task.DispatchTask(c.Request.Context(), req.TaskType, validated, "manual")
|
||||
if err != nil {
|
||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskDispatchFailed, err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(taskID))
|
||||
}
|
||||
|
||||
// ListTaskExecutions 查询任务执行记录列表
|
||||
// @Summary 查询任务执行记录
|
||||
// @Description 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param status query string false "状态筛选 (pending/running/succeeded/failed)"
|
||||
// @Param task_type query string false "任务类型筛选"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页条数" default(20)
|
||||
// @Success 200 {object} response.Any{data=object} "任务执行记录列表"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Router /api/v1/admin/tasks/executions [get]
|
||||
func ListTaskExecutions(c *gin.Context) {
|
||||
var req model.ListTaskExecutionsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.TaskType != "" {
|
||||
if meta := task.GetTaskMeta(req.TaskType); meta != nil {
|
||||
req.TaskType = meta.AsynqTask
|
||||
}
|
||||
}
|
||||
|
||||
executions, total, err := model.ListTaskExecutions(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.AbortInternal(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{
|
||||
"items": executions,
|
||||
"total": total,
|
||||
"page": req.Page,
|
||||
"page_size": req.PageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetTaskExecution 查询单条任务执行详情
|
||||
// @Summary 查询任务执行详情
|
||||
// @Description 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "任务执行记录 ID"
|
||||
// @Success 200 {object} response.Any{data=model.TaskExecution} "任务执行详情"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Failure 404 {object} response.Any "记录不存在"
|
||||
// @Router /api/v1/admin/tasks/executions/{id} [get]
|
||||
func GetTaskExecution(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, admin.InvalidTaskExecutionID)
|
||||
return
|
||||
}
|
||||
|
||||
execution, err := model.GetTaskExecutionByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.AbortNotFound(c, TaskNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(execution))
|
||||
}
|
||||
|
||||
// RetryTask 重试失败的任务
|
||||
// @Summary 重试失败任务
|
||||
// @Description 重新下发一条失败的任务,创建新的执行记录,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "任务执行记录 ID"
|
||||
// @Success 200 {object} response.Any{data=string} "新任务的 TaskID"
|
||||
// @Failure 400 {object} response.Any "任务不支持重试或参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Failure 404 {object} response.Any "记录不存在"
|
||||
// @Failure 500 {object} response.Any "重试失败"
|
||||
// @Router /api/v1/admin/tasks/executions/{id}/retry [post]
|
||||
func RetryTask(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, admin.InvalidTaskExecutionID)
|
||||
return
|
||||
}
|
||||
|
||||
newTaskID, err := task.RetryTask(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(errMsg, "不存在"):
|
||||
response.AbortNotFound(c, errMsg)
|
||||
case strings.Contains(errMsg, "只有失败的任务") || strings.Contains(errMsg, "不支持重试") || strings.Contains(errMsg, "已达到最大重试"):
|
||||
response.AbortBadRequest(c, errMsg)
|
||||
default:
|
||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskRetryFailed, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(newTaskID))
|
||||
}
|
||||
|
||||
// ListSchedules 获取定时任务列表
|
||||
// @Summary 获取定时任务列表
|
||||
// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} response.Any{data=[]model.Schedule} "定时任务列表"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Router /api/v1/admin/tasks/schedules [get]
|
||||
func ListSchedules(c *gin.Context) {
|
||||
schedules, err := model.ListSchedules(c.Request.Context())
|
||||
if err != nil {
|
||||
response.AbortInternal(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(schedules))
|
||||
}
|
||||
|
||||
// CreateScheduleRequest 创建定时任务请求
|
||||
type CreateScheduleRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
TaskType string `json:"task_type" binding:"required"`
|
||||
Cron string `json:"cron" binding:"required"`
|
||||
Payload string `json:"payload"`
|
||||
IsActive *bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateSchedule 创建定时任务
|
||||
// @Summary 创建定时任务
|
||||
// @Description 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body CreateScheduleRequest true "创建定时任务请求参数"
|
||||
// @Success 200 {object} response.Any{data=model.Schedule} "创建成功的定时任务信息"
|
||||
// @Failure 400 {object} response.Any "Cron 表达式无效、异步任务类型不存在或参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Failure 500 {object} response.Any "保存定时任务失败"
|
||||
// @Router /api/v1/admin/tasks/schedules [post]
|
||||
func CreateSchedule(c *gin.Context) {
|
||||
var req CreateScheduleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 Cron 表达式
|
||||
if _, err := cron.ParseStandard(req.Cron); err != nil {
|
||||
response.AbortBadRequest(c, InvalidCronExpression)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验关联的异步任务类型
|
||||
meta := task.GetTaskMeta(req.TaskType)
|
||||
if meta == nil {
|
||||
response.AbortBadRequest(c, InvalidTaskType)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验并规范化 Payload
|
||||
var payloadBytes []byte
|
||||
if strings.TrimSpace(req.Payload) != "" {
|
||||
payloadBytes = []byte(req.Payload)
|
||||
}
|
||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
schedule := &model.Schedule{
|
||||
Name: req.Name,
|
||||
TaskType: req.TaskType,
|
||||
Cron: req.Cron,
|
||||
Payload: string(validated),
|
||||
IsActive: *req.IsActive,
|
||||
}
|
||||
|
||||
if err := model.CreateSchedule(c.Request.Context(), schedule); err != nil {
|
||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleSaveFailed, err))
|
||||
return
|
||||
}
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(schedule))
|
||||
}
|
||||
|
||||
// UpdateScheduleRequest 修改定时任务请求
|
||||
type UpdateScheduleRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
TaskType string `json:"task_type" binding:"required"`
|
||||
Cron string `json:"cron" binding:"required"`
|
||||
Payload string `json:"payload"`
|
||||
IsActive *bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateSchedule 修改定时任务
|
||||
// @Summary 修改定时任务
|
||||
// @Description 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "定时任务 ID"
|
||||
// @Param request body UpdateScheduleRequest true "修改定时任务请求参数"
|
||||
// @Success 200 {object} response.Any{data=model.Schedule} "修改后的定时任务信息"
|
||||
// @Failure 400 {object} response.Any "Cron 表达式无效、参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Failure 404 {object} response.Any "定时任务不存在"
|
||||
// @Failure 500 {object} response.Any "修改定时任务失败"
|
||||
// @Router /api/v1/admin/tasks/schedules/{id} [put]
|
||||
func UpdateSchedule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "无效的定时任务ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateScheduleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查定时任务是否存在
|
||||
schedule, err := model.GetScheduleByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.AbortNotFound(c, ScheduleNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 Cron 表达式
|
||||
if _, err := cron.ParseStandard(req.Cron); err != nil {
|
||||
response.AbortBadRequest(c, InvalidCronExpression)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验关联的异步任务类型
|
||||
meta := task.GetTaskMeta(req.TaskType)
|
||||
if meta == nil {
|
||||
response.AbortBadRequest(c, InvalidTaskType)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验并规范化 Payload
|
||||
var payloadBytes []byte
|
||||
if strings.TrimSpace(req.Payload) != "" {
|
||||
payloadBytes = []byte(req.Payload)
|
||||
}
|
||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
schedule.Name = req.Name
|
||||
schedule.TaskType = req.TaskType
|
||||
schedule.Cron = req.Cron
|
||||
schedule.Payload = string(validated)
|
||||
schedule.IsActive = *req.IsActive
|
||||
|
||||
if err := model.UpdateSchedule(c.Request.Context(), schedule); err != nil {
|
||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleSaveFailed, err))
|
||||
return
|
||||
}
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(schedule))
|
||||
}
|
||||
|
||||
// DeleteSchedule 删除定时任务
|
||||
// @Summary 删除定时任务
|
||||
// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "定时任务 ID"
|
||||
// @Success 200 {object} response.Any{data=string} "删除结果"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Failure 500 {object} response.Any "删除定时任务失败"
|
||||
// @Router /api/v1/admin/tasks/schedules/{id} [delete]
|
||||
func DeleteSchedule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "无效的定时任务ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeleteSchedule(c.Request.Context(), id); err != nil {
|
||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleDeleteFailed, err))
|
||||
return
|
||||
}
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
uploadtask "github.com/Rain-kl/Wavelet/internal/apps/upload/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||
"github.com/Rain-kl/Wavelet/internal/bootstrap"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
)
|
||||
|
||||
func setupTaskTestEnvironment(t *testing.T) func() {
|
||||
_, mr, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
bootstrap.RegisterTasks()
|
||||
task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
return func() {
|
||||
if task.AsynqClient != nil {
|
||||
_ = task.AsynqClient.Close()
|
||||
task.AsynqClient = nil
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestRouter(authUser *model.User) *gin.Engine {
|
||||
r := testhelper.NewTestGinEngine()
|
||||
adminGroup := r.Group("/api/v1/admin")
|
||||
|
||||
// Mock authentication middleware
|
||||
adminGroup.Use(func(c *gin.Context) {
|
||||
if authUser != nil {
|
||||
oauth.SetToContext(c, oauth.UserObjKey, authUser)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
adminGroup.GET("/tasks/types", ListTaskTypes)
|
||||
adminGroup.POST("/tasks/dispatch", DispatchTask)
|
||||
adminGroup.GET("/tasks/executions", ListTaskExecutions)
|
||||
adminGroup.GET("/tasks/executions/:id", GetTaskExecution)
|
||||
adminGroup.POST("/tasks/executions/:id/retry", RetryTask)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestListTaskTypes(t *testing.T) {
|
||||
cleanup := setupTaskTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/types", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 OK, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp response.Any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var taskMetas []task.TaskMeta
|
||||
_ = json.Unmarshal(dataBytes, &taskMetas)
|
||||
|
||||
if len(taskMetas) == 0 {
|
||||
t.Error("expected at least one dispatchable task type")
|
||||
}
|
||||
|
||||
foundCleanup := false
|
||||
foundWarmImageCache := false
|
||||
for _, m := range taskMetas {
|
||||
if m.Type == uploadtask.TaskTypeSystemCleanup {
|
||||
foundCleanup = true
|
||||
}
|
||||
if m.Type == uploadtask.TaskTypeWarmImageCache {
|
||||
foundWarmImageCache = true
|
||||
}
|
||||
}
|
||||
if !foundCleanup {
|
||||
t.Errorf("expected task type %s to be listed", uploadtask.TaskTypeSystemCleanup)
|
||||
}
|
||||
if !foundWarmImageCache {
|
||||
t.Errorf("expected task type %s to be listed", uploadtask.TaskTypeWarmImageCache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchTask(t *testing.T) {
|
||||
cleanup := setupTaskTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("dispatch valid task successfully", func(t *testing.T) {
|
||||
payload := DispatchTaskRequest{
|
||||
TaskType: uploadtask.TaskTypeSystemCleanup,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Body: %s", w.Body.String())
|
||||
|
||||
var resp response.Any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Empty(t, resp.ErrorMsg)
|
||||
assert.NotNil(t, resp.Data)
|
||||
|
||||
// 返回的 data 应该是 taskID
|
||||
taskID, ok := resp.Data.(string)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, taskID)
|
||||
})
|
||||
|
||||
t.Run("dispatch send_email task successfully with valid payload", func(t *testing.T) {
|
||||
payload := DispatchTaskRequest{
|
||||
TaskType: user.TaskTypeSendEmail,
|
||||
Payload: `{"to":"receiver@example.com","subject":"Test Subject","body":"Test Body"}`,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Body: %s", w.Body.String())
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Empty(t, resp.ErrorMsg)
|
||||
assert.NotNil(t, resp.Data)
|
||||
})
|
||||
|
||||
t.Run("dispatch send_email task failure with invalid payload json", func(t *testing.T) {
|
||||
payload := DispatchTaskRequest{
|
||||
TaskType: user.TaskTypeSendEmail,
|
||||
Payload: `{"to":`,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Contains(t, resp.ErrorMsg, "无效的 JSON 格式")
|
||||
})
|
||||
|
||||
t.Run("dispatch send_email task failure with missing fields", func(t *testing.T) {
|
||||
payload := DispatchTaskRequest{
|
||||
TaskType: user.TaskTypeSendEmail,
|
||||
Payload: `{"to":"","subject":"Test","body":"Test"}`,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Contains(t, resp.ErrorMsg, "不能为空")
|
||||
})
|
||||
|
||||
t.Run("dispatch invalid task type failure", func(t *testing.T) {
|
||||
payload := DispatchTaskRequest{
|
||||
TaskType: "invalid_task_type",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(t, InvalidTaskType, resp.ErrorMsg)
|
||||
})
|
||||
|
||||
t.Run("dispatch with empty body failure", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListTaskExecutions(t *testing.T) {
|
||||
cleanup := setupTaskTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
ctx := context.Background()
|
||||
|
||||
// 准备测试数据
|
||||
now := time.Now()
|
||||
records := []*model.TaskExecution{
|
||||
{TaskID: "exec_001", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "manual", Duration: 1500, Result: "清理完成", StartedAt: &now, FinishedAt: &now},
|
||||
{TaskID: "exec_002", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusFailed, TriggeredBy: "system", ErrorMessage: "连接超时", StartedAt: &now, FinishedAt: &now},
|
||||
{TaskID: "exec_003", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusPending, TriggeredBy: "manual", Retryable: true, MaxRetry: 3},
|
||||
}
|
||||
for _, r := range records {
|
||||
err := model.CreateTaskExecution(ctx, r)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("list all executions", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
})
|
||||
|
||||
t.Run("filter by status", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?status=failed", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(1), data["total"])
|
||||
})
|
||||
|
||||
t.Run("filter by task_type (asynq task name)", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=system:cleanup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
})
|
||||
|
||||
t.Run("filter by task_type (management task type)", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=system_cleanup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
})
|
||||
|
||||
t.Run("pagination", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?page=1&page_size=2", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTaskExecution(t *testing.T) {
|
||||
cleanup := setupTaskTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建测试记录
|
||||
execution := &model.TaskExecution{
|
||||
TaskID: "detail_001",
|
||||
TaskType: "system:cleanup",
|
||||
TaskName: "系统垃圾清理",
|
||||
Status: model.TaskExecutionStatusSucceeded,
|
||||
Log: "[10:00:01] 开始扫描\n[10:00:02] 找到 50 个文件\n[10:00:03] 清理完成",
|
||||
Result: "共清理 50 个文件",
|
||||
Duration: 2000,
|
||||
Retryable: true,
|
||||
MaxRetry: 3,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("get existing execution", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d", execution.ID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var detail model.TaskExecution
|
||||
json.Unmarshal(dataBytes, &detail)
|
||||
|
||||
assert.Equal(t, "detail_001", detail.TaskID)
|
||||
assert.Equal(t, model.TaskExecutionStatusSucceeded, detail.Status)
|
||||
assert.Contains(t, detail.Log, "开始扫描")
|
||||
assert.Contains(t, detail.Log, "清理完成")
|
||||
assert.Equal(t, int64(2000), detail.Duration)
|
||||
})
|
||||
|
||||
t.Run("get non-existent execution", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions/99999999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid ID format", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions/invalid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRetryTask(t *testing.T) {
|
||||
cleanup := setupTaskTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("retry failed task successfully", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
execution := &model.TaskExecution{
|
||||
TaskID: "retry_api_001",
|
||||
TaskType: "system:cleanup",
|
||||
TaskName: "系统垃圾清理",
|
||||
Status: model.TaskExecutionStatusFailed,
|
||||
ErrorMessage: "S3 连接超时",
|
||||
Retryable: true,
|
||||
MaxRetry: 3,
|
||||
RetryCount: 0,
|
||||
TriggeredBy: "manual",
|
||||
StartedAt: &now,
|
||||
FinishedAt: &now,
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||
req, _ := http.NewRequest("POST", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Empty(t, resp.ErrorMsg)
|
||||
assert.NotNil(t, resp.Data)
|
||||
|
||||
// 验证新记录
|
||||
newTaskID, ok := resp.Data.(string)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, newTaskID)
|
||||
|
||||
newExecution, err := model.GetTaskExecutionByTaskID(ctx, newTaskID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, newExecution.RetryCount)
|
||||
assert.Equal(t, "retry", newExecution.TriggeredBy)
|
||||
})
|
||||
|
||||
t.Run("retry succeeded task fails", func(t *testing.T) {
|
||||
execution := &model.TaskExecution{
|
||||
TaskID: "retry_succeeded_001",
|
||||
TaskType: "system:cleanup",
|
||||
TaskName: "系统垃圾清理",
|
||||
Status: model.TaskExecutionStatusSucceeded,
|
||||
Retryable: true,
|
||||
MaxRetry: 3,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||
req, _ := http.NewRequest("POST", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("retry non-retryable task fails", func(t *testing.T) {
|
||||
execution := &model.TaskExecution{
|
||||
TaskID: "retry_not_allowed_001",
|
||||
TaskType: "system:cleanup",
|
||||
TaskName: "系统垃圾清理",
|
||||
Status: model.TaskExecutionStatusFailed,
|
||||
Retryable: false,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||
req, _ := http.NewRequest("POST", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("retry non-existent task", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/executions/99999999/retry", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("retry with invalid ID", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/executions/invalid/retry", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user