fix(pages): 增加部署包孤儿补偿

按项目、来源、运行时与上传记录锁序补偿异常中断遗留的部署包。\n同时隐藏并保护系统内部排程,避免通用任务管理入口修改 scanner。
This commit is contained in:
deqiying
2026-07-19 19:08:43 +08:00
parent c39a3edcc3
commit 848884d8cd
9 changed files with 1119 additions and 5 deletions
+20 -2
View File
@@ -202,7 +202,7 @@ func RetryTask(c *gin.Context) {
// ListSchedules 获取定时任务列表
// @Summary 获取定时任务列表
// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
// @Description 返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
@@ -216,7 +216,15 @@ func ListSchedules(c *gin.Context) {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(schedules))
visible := make([]model.Schedule, 0, len(schedules))
for _, schedule := range schedules {
meta := task.GetTaskMeta(schedule.TaskType)
if meta != nil && meta.InternalOnly {
continue
}
visible = append(visible, schedule)
}
c.JSON(http.StatusOK, response.OK(visible))
}
// CreateScheduleRequest 创建定时任务请求
@@ -405,6 +413,7 @@ func getAdminTaskMeta(taskType string) *task.TaskMeta {
// @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/schedules/{id} [delete]
func DeleteSchedule(c *gin.Context) {
@@ -413,6 +422,15 @@ func DeleteSchedule(c *gin.Context) {
response.AbortBadRequest(c, "无效的定时任务ID")
return
}
schedule, err := model.GetScheduleByID(c.Request.Context(), id)
if err != nil {
response.AbortNotFound(c, ScheduleNotFound)
return
}
if meta := task.GetTaskMeta(schedule.TaskType); meta != nil && meta.InternalOnly {
response.AbortBadRequest(c, InvalidTaskType)
return
}
if err := model.DeleteSchedule(c.Request.Context(), id); err != nil {
response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleDeleteFailed, err))
+103
View File
@@ -75,8 +75,10 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
adminGroup.GET("/tasks/executions", ListTaskExecutions)
adminGroup.GET("/tasks/executions/:id", GetTaskExecution)
adminGroup.POST("/tasks/executions/:id/retry", RetryTask)
adminGroup.GET("/tasks/schedules", ListSchedules)
adminGroup.POST("/tasks/schedules", CreateSchedule)
adminGroup.PUT("/tasks/schedules/:id", UpdateSchedule)
adminGroup.DELETE("/tasks/schedules/:id", DeleteSchedule)
return r
}
@@ -137,6 +139,39 @@ func TestInternalOnlyTaskAdminBoundaries(t *testing.T) {
router := setupTestRouter(adminUser)
ctx := context.Background()
t.Run("list hides internal-only schedule", func(t *testing.T) {
internalSchedule := &model.Schedule{
Name: "隐藏的系统内部排程",
TaskType: testInternalOnlyTaskType,
Cron: "*/5 * * * *",
Payload: "{}",
IsActive: true,
}
publicSchedule := &model.Schedule{
Name: "可见的公开排程",
TaskType: uploadtask.TaskTypeSystemCleanup,
Cron: "0 * * * *",
Payload: "{}",
IsActive: true,
}
require.NoError(t, model.CreateSchedule(ctx, internalSchedule))
require.NoError(t, model.CreateSchedule(ctx, publicSchedule))
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/tasks/schedules", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
data, err := json.Marshal(resp.Data)
require.NoError(t, err)
var schedules []model.Schedule
require.NoError(t, json.Unmarshal(data, &schedules))
assert.NotContains(t, scheduleIDs(schedules), internalSchedule.ID)
assert.Contains(t, scheduleIDs(schedules), publicSchedule.ID)
})
t.Run("dispatch rejects internal-only task", func(t *testing.T) {
body, err := json.Marshal(DispatchTaskRequest{TaskType: testInternalOnlyTaskType})
require.NoError(t, err)
@@ -239,6 +274,74 @@ func TestInternalOnlyTaskAdminBoundaries(t *testing.T) {
assert.Equal(t, "公开排程", unchanged.Name)
assert.Equal(t, uploadtask.TaskTypeSystemCleanup, unchanged.TaskType)
})
t.Run("delete rejects internal-only schedule", func(t *testing.T) {
schedule := &model.Schedule{
Name: "不可删除的系统内部排程",
TaskType: testInternalOnlyTaskType,
Cron: "*/5 * * * *",
IsActive: true,
}
require.NoError(t, model.CreateSchedule(ctx, schedule))
req := httptest.NewRequest(
http.MethodDelete,
fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID),
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, InvalidTaskType, resp.ErrorMsg)
preserved, err := model.GetScheduleByID(ctx, schedule.ID)
require.NoError(t, err)
assert.Equal(t, testInternalOnlyTaskType, preserved.TaskType)
})
t.Run("delete missing schedule returns not found", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/tasks/schedules/999999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, ScheduleNotFound, resp.ErrorMsg)
})
t.Run("delete public schedule remains allowed", func(t *testing.T) {
schedule := &model.Schedule{
Name: "可删除的公开排程",
TaskType: uploadtask.TaskTypeSystemCleanup,
Cron: "0 * * * *",
IsActive: false,
}
require.NoError(t, model.CreateSchedule(ctx, schedule))
req := httptest.NewRequest(
http.MethodDelete,
fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID),
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
_, err := model.GetScheduleByID(ctx, schedule.ID)
assert.Error(t, err)
})
}
func scheduleIDs(schedules []model.Schedule) []uint64 {
ids := make([]uint64, 0, len(schedules))
for _, schedule := range schedules {
ids = append(ids, schedule.ID)
}
return ids
}
func TestDispatchTask(t *testing.T) {