优化任务管理

This commit is contained in:
ryan
2026-06-10 19:27:51 +08:00
parent 69edb1252b
commit db163034c0
24 changed files with 2063 additions and 131 deletions
-3
View File
@@ -93,9 +93,6 @@ log:
max_backups: 10
compress: true
# ─── Task Scheduler (Cron) ──────────────────────────────────────────────────────
scheduler:
cleanup_unused_uploads_task_cron: "0 */2 * * *" # Clean up orphaned upload records
# ─── Async Task Worker ──────────────────────────────────────────────────────────
worker:
+363
View File
@@ -1659,6 +1659,287 @@ const docTemplate = `{
}
}
},
"/api/v1/admin/tasks/schedules": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "获取定时任务列表",
"responses": {
"200": {
"description": "定时任务列表",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Schedule"
}
}
}
}
]
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "创建定时任务",
"parameters": [
{
"description": "创建定时任务请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/task.CreateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "创建成功的定时任务信息",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Schedule"
}
}
}
]
}
},
"400": {
"description": "Cron 表达式无效、异步任务类型不存在或参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "保存定时任务失败",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/tasks/schedules/{id}": {
"put": {
"security": [
{
"SessionCookie": []
}
],
"description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "修改定时任务",
"parameters": [
{
"type": "integer",
"description": "定时任务 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "修改定时任务请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/task.UpdateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "修改后的定时任务信息",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Schedule"
}
}
}
]
}
},
"400": {
"description": "Cron 表达式无效、参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"404": {
"description": "定时任务不存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "修改定时任务失败",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
},
"delete": {
"security": [
{
"SessionCookie": []
}
],
"description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "删除定时任务",
"parameters": [
{
"type": "integer",
"description": "定时任务 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "删除结果",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "删除定时任务失败",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/tasks/types": {
"get": {
"security": [
@@ -4362,6 +4643,36 @@ const docTemplate = `{
}
}
},
"model.Schedule": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"cron": {
"type": "string"
},
"id": {
"type": "string",
"example": "0"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"payload": {
"type": "string"
},
"task_type": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"model.SystemConfig": {
"type": "object",
"properties": {
@@ -4933,6 +5244,32 @@ const docTemplate = `{
}
}
},
"task.CreateScheduleRequest": {
"type": "object",
"required": [
"cron",
"is_active",
"name",
"task_type"
],
"properties": {
"cron": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"payload": {
"type": "string"
},
"task_type": {
"type": "string"
}
}
},
"task.DispatchTaskRequest": {
"type": "object",
"required": [
@@ -5021,6 +5358,32 @@ const docTemplate = `{
}
}
},
"task.UpdateScheduleRequest": {
"type": "object",
"required": [
"cron",
"is_active",
"name",
"task_type"
],
"properties": {
"cron": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"payload": {
"type": "string"
},
"task_type": {
"type": "string"
}
}
},
"template.CreateTemplateRequest": {
"type": "object",
"required": [
+363
View File
@@ -1652,6 +1652,287 @@
}
}
},
"/api/v1/admin/tasks/schedules": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "获取定时任务列表",
"responses": {
"200": {
"description": "定时任务列表",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Schedule"
}
}
}
}
]
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "创建定时任务",
"parameters": [
{
"description": "创建定时任务请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/task.CreateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "创建成功的定时任务信息",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Schedule"
}
}
}
]
}
},
"400": {
"description": "Cron 表达式无效、异步任务类型不存在或参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "保存定时任务失败",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/tasks/schedules/{id}": {
"put": {
"security": [
{
"SessionCookie": []
}
],
"description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "修改定时任务",
"parameters": [
{
"type": "integer",
"description": "定时任务 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "修改定时任务请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/task.UpdateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "修改后的定时任务信息",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Schedule"
}
}
}
]
}
},
"400": {
"description": "Cron 表达式无效、参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"404": {
"description": "定时任务不存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "修改定时任务失败",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
},
"delete": {
"security": [
{
"SessionCookie": []
}
],
"description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "删除定时任务",
"parameters": [
{
"type": "integer",
"description": "定时任务 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "删除结果",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "删除定时任务失败",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/tasks/types": {
"get": {
"security": [
@@ -4355,6 +4636,36 @@
}
}
},
"model.Schedule": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"cron": {
"type": "string"
},
"id": {
"type": "string",
"example": "0"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"payload": {
"type": "string"
},
"task_type": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"model.SystemConfig": {
"type": "object",
"properties": {
@@ -4926,6 +5237,32 @@
}
}
},
"task.CreateScheduleRequest": {
"type": "object",
"required": [
"cron",
"is_active",
"name",
"task_type"
],
"properties": {
"cron": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"payload": {
"type": "string"
},
"task_type": {
"type": "string"
}
}
},
"task.DispatchTaskRequest": {
"type": "object",
"required": [
@@ -5014,6 +5351,32 @@
}
}
},
"task.UpdateScheduleRequest": {
"type": "object",
"required": [
"cron",
"is_active",
"name",
"task_type"
],
"properties": {
"cron": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"payload": {
"type": "string"
},
"task_type": {
"type": "string"
}
}
},
"template.CreateTemplateRequest": {
"type": "object",
"required": [
+225
View File
@@ -277,6 +277,26 @@ definitions:
id:
type: integer
type: object
model.Schedule:
properties:
created_at:
type: string
cron:
type: string
id:
example: "0"
type: string
is_active:
type: boolean
name:
type: string
payload:
type: string
task_type:
type: string
updated_at:
type: string
type: object
model.SystemConfig:
properties:
created_at:
@@ -667,6 +687,24 @@ definitions:
required:
- value
type: object
task.CreateScheduleRequest:
properties:
cron:
type: string
is_active:
type: boolean
name:
type: string
payload:
type: string
task_type:
type: string
required:
- cron
- is_active
- name
- task_type
type: object
task.DispatchTaskRequest:
properties:
end_time:
@@ -727,6 +765,24 @@ definitions:
description: 类型:string, text, number
type: string
type: object
task.UpdateScheduleRequest:
properties:
cron:
type: string
is_active:
type: boolean
name:
type: string
payload:
type: string
task_type:
type: string
required:
- cron
- is_active
- name
- task_type
type: object
template.CreateTemplateRequest:
properties:
content:
@@ -1941,6 +1997,175 @@ paths:
summary: 重试失败任务
tags:
- admin
/api/v1/admin/tasks/schedules:
get:
description: 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
produces:
- application/json
responses:
"200":
description: 定时任务列表
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
items:
$ref: '#/definitions/model.Schedule'
type: array
type: object
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 获取定时任务列表
tags:
- admin
post:
consumes:
- application/json
description: 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限
parameters:
- description: 创建定时任务请求参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/task.CreateScheduleRequest'
produces:
- application/json
responses:
"200":
description: 创建成功的定时任务信息
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/model.Schedule'
type: object
"400":
description: Cron 表达式无效、异步任务类型不存在或参数错误
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限
schema:
$ref: '#/definitions/util.ResponseAny'
"500":
description: 保存定时任务失败
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 创建定时任务
tags:
- admin
/api/v1/admin/tasks/schedules/{id}:
delete:
description: 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限
parameters:
- description: 定时任务 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 删除结果
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限
schema:
$ref: '#/definitions/util.ResponseAny'
"500":
description: 删除定时任务失败
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 删除定时任务
tags:
- admin
put:
consumes:
- application/json
description: 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限
parameters:
- description: 定时任务 ID
in: path
name: id
required: true
type: integer
- description: 修改定时任务请求参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/task.UpdateScheduleRequest'
produces:
- application/json
responses:
"200":
description: 修改后的定时任务信息
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/model.Schedule'
type: object
"400":
description: Cron 表达式无效、参数错误
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限
schema:
$ref: '#/definitions/util.ResponseAny'
"404":
description: 定时任务不存在
schema:
$ref: '#/definitions/util.ResponseAny'
"500":
description: 修改定时任务失败
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 修改定时任务
tags:
- admin
/api/v1/admin/tasks/types:
get:
description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
@@ -1,8 +0,0 @@
"use client"
import {TaskExecutionsManager} from "@/components/common/admin/task-executions"
/* 任务日志页面 */
export default function TaskExecutionsPage() {
return <TaskExecutionsManager />
}
+52 -4
View File
@@ -1,8 +1,56 @@
"use client"
import { TaskManager } from "@/components/common/admin/tasks"
import {Suspense} from "react"
import {useRouter, useSearchParams} from "next/navigation"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {TaskManager} from "@/components/common/admin/task-manager"
import {TaskSchedulesManager} from "@/components/common/admin/task-schedules"
import {TaskExecutionsManager} from "@/components/common/admin/task-executions"
import {Spinner} from "@/components/ui/spinner"
/* 任务管理页面 */
export default function TasksPage() {
return <TaskManager />
function TasksPageContent() {
const searchParams = useSearchParams()
const router = useRouter()
const activeTab = searchParams.get("tab") || "tasks"
const handleTabChange = (value: string) => {
router.push(`/admin/tasks?tab=${value}`)
}
return (
<div className="py-6 space-y-6">
<div className="flex flex-col gap-1 pb-2">
<h1 className="text-2xl font-bold tracking-tight">任务管理</h1>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList variant="line">
<TabsTrigger value="tasks">任务管理</TabsTrigger>
<TabsTrigger value="schedules">定时任务</TabsTrigger>
<TabsTrigger value="executions">任务日志</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="space-y-4 outline-none">
<TaskManager />
</TabsContent>
<TabsContent value="schedules" className="space-y-4 outline-none">
<TaskSchedulesManager />
</TabsContent>
<TabsContent value="executions" className="space-y-4 outline-none">
<TaskExecutionsManager />
</TabsContent>
</Tabs>
</div>
)
}
export default function TasksPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<Spinner className="h-8 w-8" />
</div>
}>
<TasksPageContent />
</Suspense>
)
}
@@ -1,7 +1,6 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import Link from "next/link"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
@@ -29,6 +28,7 @@ const TRIGGER_LABELS: Record<string, string> = {
system: "系统",
manual: "手动",
retry: "重试",
schedule: "定时",
}
function formatDateTime(value?: string | null) {
@@ -148,21 +148,10 @@ export function TaskExecutionsManager() {
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
return (
<div className="py-6 space-y-6">
<div className="space-y-6">
<br/>
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Activity className="size-5 text-muted-foreground" />
<div>
<div className="text-2xl font-semibold tracking-tight">任务日志</div>
<div className="text-xs text-muted-foreground">查看异步任务执行状况,点击任务记录查看详情</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/tasks">
返回
</Link>
</Button>
<Select value={executionStatus} onValueChange={(value) => setExecutionStatus(value as TaskExecutionStatus | "all")}>
<SelectTrigger size="sm" className="w-[120px]">
<SelectValue placeholder="状态" />
@@ -1,7 +1,6 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import Link from "next/link"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
@@ -9,7 +8,7 @@ import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Calendar as CalendarIcon, Clock, FileText, Info, Layers, Play} from "lucide-react"
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
@@ -241,18 +240,8 @@ export function TaskManager() {
}
return (
<div className="py-6 space-y-6">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/tasks/executions">
<FileText className="size-4" />
任务日志
</Link>
</Button>
</div>
<div className="space-y-6">
<br/>
<div className="space-y-6">
{error ? (
@@ -0,0 +1,464 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
export function TaskSchedulesManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [schedules, setSchedules] = useState<Schedule[]>([])
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
// Modal States
const [dialogOpen, setDialogOpen] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
// Delete States
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// Form Fields
const [name, setName] = useState("")
const [selectedTaskType, setSelectedTaskType] = useState("")
const [cron, setCron] = useState("")
const [isActive, setIsActive] = useState(true)
const [paramValues, setParamValues] = useState<Record<string, string>>({})
// Fetch Schedules & Task Types
const fetchData = useCallback(async () => {
try {
setLoading(true)
setError(null)
const [schedulesData, taskTypesData] = await Promise.all([
AdminService.listSchedules(),
AdminService.getTaskTypes()
])
setSchedules(schedulesData || [])
setTaskTypes(taskTypesData || [])
} catch (err) {
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Track task type selection to initialize parameters
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
if (editingSchedule && editingSchedule.task_type === selectedTaskType) {
// Parse existing payload when editing the same task type
try {
const parsed = JSON.parse(editingSchedule.payload || "{}")
const values: Record<string, string> = {}
targetTask?.params?.forEach(p => {
values[p.name] = String(parsed[p.name] ?? "")
})
setParamValues(values)
} catch {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
}
} else {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
}
} else {
setParamValues({})
}
}, [selectedTaskType, taskTypes, editingSchedule])
const handleToggleActive = async (schedule: Schedule) => {
try {
const updated = await AdminService.updateSchedule(schedule.id, {
name: schedule.name,
task_type: schedule.task_type,
cron: schedule.cron,
payload: schedule.payload,
is_active: !schedule.is_active
})
setSchedules(prev => prev.map(s => s.id === schedule.id ? updated : s))
toast.success(updated.is_active ? `已启用定时任务:${updated.name}` : `已停用定时任务:${updated.name}`)
} catch (err) {
toast.error("修改任务状态失败", {
description: err instanceof Error ? err.message : "未知错误"
})
}
}
const openCreateDialog = () => {
setEditingSchedule(null)
setName("")
setSelectedTaskType(taskTypes[0]?.type || "")
setCron("0 */2 * * *")
setIsActive(true)
setParamValues({})
setDialogOpen(true)
}
const openEditDialog = (schedule: Schedule) => {
setEditingSchedule(schedule)
setName(schedule.name)
setSelectedTaskType(schedule.task_type)
setCron(schedule.cron)
setIsActive(schedule.is_active)
setDialogOpen(true)
}
const handleSubmit = async () => {
if (!name.trim()) {
toast.error("任务名称不能为空")
return
}
if (!cron.trim()) {
toast.error("Cron 表达式不能为空")
return
}
try {
setSubmitLoading(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const payloadData: Record<string, string> = {}
if (targetTask?.params) {
for (const param of targetTask.params) {
const val = (paramValues[param.name] || "").trim()
if (param.required && !val) {
toast.error(`${param.label}不能为空`)
setSubmitLoading(false)
return
}
payloadData[param.name] = val
}
}
const payloadStr = JSON.stringify(payloadData)
if (editingSchedule) {
const req: UpdateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const updated = await AdminService.updateSchedule(editingSchedule.id, req)
setSchedules(prev => prev.map(s => s.id === editingSchedule.id ? updated : s))
toast.success("定时任务更新成功")
} else {
const req: CreateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const created = await AdminService.createSchedule(req)
setSchedules(prev => [created, ...prev])
toast.success("定时任务创建成功")
}
setDialogOpen(false)
} catch (err) {
toast.error("提交失败", {
description: err instanceof Error ? err.message : "未知错误"
})
} finally {
setSubmitLoading(false)
}
}
const openDeleteDialog = (id: string) => {
setDeletingId(id)
setDeleteOpen(true)
}
const handleDelete = async () => {
if (!deletingId) return
try {
setDeleteLoading(true)
await AdminService.deleteSchedule(deletingId)
setSchedules(prev => prev.filter(s => s.id !== deletingId))
toast.success("定时任务删除成功")
setDeleteOpen(false)
} catch (err) {
toast.error("删除失败", {
description: err instanceof Error ? err.message : "未知错误"
})
} finally {
setDeleteLoading(false)
}
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
const getTaskName = (type: string) => {
const meta = taskTypes.find(t => t.type === type)
return meta ? meta.name : type
}
return (
<div className="space-y-6">
<br/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新
</Button>
<Button size="sm" onClick={openCreateDialog} disabled={taskTypes.length === 0} variant={'secondary'}>
<Plus className="size-4 mr-1" />
新增定时任务
</Button>
</div>
</div>
{error ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={error} onRetry={fetchData} className="justify-center" />
</div>
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
) : schedules.length === 0 ? (
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[800px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[180px]">任务名称</TableHead>
<TableHead className="w-[160px]">关联异步任务</TableHead>
<TableHead className="w-[120px]">Cron 表达式</TableHead>
<TableHead className="min-w-[200px]">执行参数 (Payload)</TableHead>
<TableHead className="w-[100px] text-center">启用状态</TableHead>
<TableHead className="w-[120px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">{schedule.name}</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">{getTaskName(schedule.task_type)}</span>
<span className="font-mono text-[10px] text-muted-foreground">{schedule.task_type}</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold">
{schedule.cron}
</TableCell>
<TableCell className="max-w-[300px] truncate">
<code className="text-xs bg-muted/60 px-1 py-0.5 rounded font-mono">
{schedule.payload || "{}"}
</code>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Switch
checked={schedule.is_active}
onCheckedChange={() => handleToggleActive(schedule)}
/>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => openEditDialog(schedule)}
>
<Edit2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/90"
onClick={() => openDeleteDialog(schedule.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="sched-name">任务名称</Label>
<Input
id="sched-name"
placeholder="例如:每日数据清理"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-xs"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-type">关联异步任务</Label>
<select
id="sched-type"
value={selectedTaskType}
onChange={(e) => setSelectedTaskType(e.target.value)}
disabled={!!editingSchedule}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{taskTypes.map(t => (
<option key={t.type} value={t.type}>
{t.name} ({t.type})
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-cron">Cron 表达式</Label>
<Input
id="sched-cron"
placeholder="e.g. 0 */2 * * * 或 @daily"
value={cron}
onChange={(e) => setCron(e.target.value)}
className="text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="sched-active">启用状态</Label>
<p className="text-[10px] text-muted-foreground">决定此定时任务是否会定时触发执行。</p>
</div>
<Switch
id="sched-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4 shrink-0" />
<span>所选任务类型不需要运行参数参数。</span>
</div>
)
}
return (
<div className="space-y-4 pt-2 border-t">
<Label className="text-xs font-semibold">运行参数配置 (Payload)</Label>
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2 pl-2 border-l-2 border-muted">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1 text-xs">
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[70px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
)}
</div>
))}
</div>
)
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitLoading} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
{submitLoading && <Spinner className="size-3 mr-1" />}
{editingSchedule ? '保存修改' : '确认创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => setDeleteOpen(false)} disabled={deleteLoading} className="h-8 text-xs">
取消
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
{deleteLoading && <Spinner className="size-3 mr-1" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -3,6 +3,7 @@ import type {
AdminUser,
AuthSource,
AuthSourceRequest,
CreateScheduleRequest,
CreateSystemConfigRequest,
CreateTemplateRequest,
CreateUserRequest,
@@ -12,6 +13,7 @@ import type {
ListTaskExecutionsResponse,
ListUsersRequest,
ListUsersResponse,
Schedule,
SystemConfig,
SystemStatus,
TaskExecution,
@@ -19,6 +21,7 @@ import type {
TaskTypeResponse,
Template,
ToggleAuthSourceRequest,
UpdateScheduleRequest,
UpdateSystemConfigRequest,
UpdateTemplateRequest,
UpdateUserStatusRequest,
@@ -235,6 +238,36 @@ export class AdminService extends BaseService {
return this.post<string>(`/tasks/executions/${ id }/retry`);
}
// ==================== 定时任务管理 ====================
/**
* 获取定时任务列表
*/
static async listSchedules(): Promise<Schedule[]> {
return this.get<Schedule[]>('/tasks/schedules');
}
/**
* 创建定时任务
*/
static async createSchedule(request: CreateScheduleRequest): Promise<Schedule> {
return this.post<Schedule>('/tasks/schedules', request);
}
/**
* 更新定时任务
*/
static async updateSchedule(id: string, request: UpdateScheduleRequest): Promise<Schedule> {
return this.put<Schedule>(`/tasks/schedules/${ id }`, request);
}
/**
* 删除定时任务
*/
static async deleteSchedule(id: string): Promise<void> {
return this.delete<void>(`/tasks/schedules/${ id }`);
}
// ==================== 用户管理 ====================
/**
+3
View File
@@ -40,4 +40,7 @@ export type {
UpdateUserStatusRequest,
SystemStatus,
DatabaseInfo,
Schedule,
CreateScheduleRequest,
UpdateScheduleRequest,
} from './types';
+36
View File
@@ -188,6 +188,42 @@ export interface ListTaskExecutionsResponse {
page_size: number;
}
/**
* 定时任务配置信息
*/
export interface Schedule {
id: string;
name: string;
task_type: string;
cron: string;
payload: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
/**
* 创建定时任务请求参数
*/
export interface CreateScheduleRequest {
name: string;
task_type: string;
cron: string;
payload: string;
is_active: boolean;
}
/**
* 修改定时任务请求参数
*/
export interface UpdateScheduleRequest {
name: string;
task_type: string;
cron: string;
payload: string;
is_active: boolean;
}
// ==================== 用户管理 ====================
/**
+3
View File
@@ -110,6 +110,9 @@ export type {
UpdateUserStatusRequest,
SystemStatus,
DatabaseInfo,
Schedule,
CreateScheduleRequest,
UpdateScheduleRequest,
} from './admin';
// 用户服务
+13 -9
View File
@@ -7,13 +7,17 @@ package task
// 任务管理相关错误消息
const (
InvalidTaskType = "无效的任务类型"
InvalidTimeRange = "无效的时间范围"
TaskDispatchFailed = "任务下发失败"
UserIDRequired = "用户ID必填"
TaskNotFound = "任务执行记录不存在"
TaskNotRetryable = "该任务不支持重试"
TaskNotFailed = "只有失败的任务才能重试"
TaskMaxRetryExceeded = "已达到最大重试次数"
TaskRetryFailed = "任务重试失败"
InvalidTaskType = "无效的任务类型"
InvalidTimeRange = "无效的时间范围"
TaskDispatchFailed = "任务下发失败"
UserIDRequired = "用户ID必填"
TaskNotFound = "任务执行记录不存在"
TaskNotRetryable = "该任务不支持重试"
TaskNotFailed = "只有失败的任务才能重试"
TaskMaxRetryExceeded = "已达到最大重试次数"
TaskRetryFailed = "任务重试失败"
InvalidCronExpression = "无效的 Cron 表达式"
ScheduleNotFound = "定时任务不存在"
ScheduleSaveFailed = "保存定时任务失败"
ScheduleDeleteFailed = "删除定时任务失败"
)
+218
View File
@@ -12,11 +12,14 @@ import (
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin"
"github.com/Rain-kl/Wavelet/internal/logger"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
"github.com/Rain-kl/Wavelet/internal/task/scheduler"
"github.com/Rain-kl/Wavelet/internal/util"
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
)
func init() {
@@ -194,3 +197,218 @@ func RetryTask(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(newTaskID))
}
// ListSchedules 获取定时任务列表
// @Summary 获取定时任务列表
// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]model.Schedule} "定时任务列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Router /api/v1/admin/tasks/schedules [get]
func ListSchedules(c *gin.Context) {
schedules, err := model.ListSchedules(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.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} util.ResponseAny{data=model.Schedule} "创建成功的定时任务信息"
// @Failure 400 {object} util.ResponseAny "Cron 表达式无效、异步任务类型不存在或参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "保存定时任务失败"
// @Router /api/v1/admin/tasks/schedules [post]
func CreateSchedule(c *gin.Context) {
var req CreateScheduleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 校验 Cron 表达式
if _, err := cron.ParseStandard(req.Cron); err != nil {
c.JSON(http.StatusBadRequest, util.Err(InvalidCronExpression))
return
}
// 校验关联的异步任务类型
meta := task.GetTaskMeta(req.TaskType)
if meta == nil {
c.JSON(http.StatusBadRequest, util.Err(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 {
c.JSON(http.StatusBadRequest, util.Err(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 {
c.JSON(http.StatusInternalServerError, util.Err(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, util.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} util.ResponseAny{data=model.Schedule} "修改后的定时任务信息"
// @Failure 400 {object} util.ResponseAny "Cron 表达式无效、参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 404 {object} util.ResponseAny "定时任务不存在"
// @Failure 500 {object} util.ResponseAny "修改定时任务失败"
// @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 {
c.JSON(http.StatusBadRequest, util.Err("无效的定时任务ID"))
return
}
var req UpdateScheduleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查定时任务是否存在
schedule, err := model.GetScheduleByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, util.Err(ScheduleNotFound))
return
}
// 校验 Cron 表达式
if _, err := cron.ParseStandard(req.Cron); err != nil {
c.JSON(http.StatusBadRequest, util.Err(InvalidCronExpression))
return
}
// 校验关联的异步任务类型
meta := task.GetTaskMeta(req.TaskType)
if meta == nil {
c.JSON(http.StatusBadRequest, util.Err(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 {
c.JSON(http.StatusBadRequest, util.Err(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 {
c.JSON(http.StatusInternalServerError, util.Err(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, util.OK(schedule))
}
// DeleteSchedule 删除定时任务
// @Summary 删除定时任务
// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "定时任务 ID"
// @Success 200 {object} util.ResponseAny{data=string} "删除结果"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "删除定时任务失败"
// @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 {
c.JSON(http.StatusBadRequest, util.Err("无效的定时任务ID"))
return
}
if err := model.DeleteSchedule(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(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, util.OKNil())
}
-8
View File
@@ -246,14 +246,6 @@ func applyEnvOverrides(c *configModel) {
{Name: "default", Priority: defaultQueuePriority},
}
}
// ─── Scheduler ───
c.Scheduler.CleanupUnusedUploadsTaskCron = envStr(
"SCHEDULER_CLEANUP_CRON", c.Scheduler.CleanupUnusedUploadsTaskCron,
)
if c.Scheduler.CleanupUnusedUploadsTaskCron == "" {
c.Scheduler.CleanupUnusedUploadsTaskCron = "@daily"
}
}
// printConfig 打印配置内容
-1
View File
@@ -120,7 +120,6 @@ type logConfig struct {
// schedulerConfig 定时任务配置
type schedulerConfig struct {
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
}
// workerConfig 工作配置
@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
-- Seed initial cleanup task
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS schedules;
@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
-- Seed initial cleanup task
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS schedules;
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/db/idgen"
)
// Schedule 定时任务配置表
type Schedule struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:128;not null"`
TaskType string `json:"task_type" gorm:"size:64;not null"`
Cron string `json:"cron" gorm:"size:64;not null"`
Payload string `json:"payload" gorm:"type:text"`
IsActive bool `json:"is_active" gorm:"not null;default:true"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (Schedule) TableName() string {
return "schedules"
}
// CreateSchedule 创建定时任务
func CreateSchedule(ctx context.Context, schedule *Schedule) error {
schedule.ID = idgen.NextUint64ID()
return db.DB(ctx).Create(schedule).Error
}
// UpdateSchedule 更新定时任务
func UpdateSchedule(ctx context.Context, schedule *Schedule) error {
return db.DB(ctx).Save(schedule).Error
}
// DeleteSchedule 删除定时任务
func DeleteSchedule(ctx context.Context, id uint64) error {
return db.DB(ctx).Delete(&Schedule{}, id).Error
}
// GetScheduleByID 根据 ID 获取定时任务
func GetScheduleByID(ctx context.Context, id uint64) (*Schedule, error) {
var schedule Schedule
if err := db.DB(ctx).Where("id = ?", id).First(&schedule).Error; err != nil {
return nil, err
}
return &schedule, nil
}
// ListSchedules 获取所有定时任务
func ListSchedules(ctx context.Context) ([]Schedule, error) {
var schedules []Schedule
if err := db.DB(ctx).Order("id DESC").Find(&schedules).Error; err != nil {
return nil, err
}
return schedules, nil
}
// ListActiveSchedules 获取所有启用的定时任务
func ListActiveSchedules(ctx context.Context) ([]Schedule, error) {
var schedules []Schedule
if err := db.DB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil {
return nil, err
}
return schedules, nil
}
+6
View File
@@ -244,6 +244,12 @@ func registerRoutes(r *gin.Engine) {
adminRouter.GET("/tasks/executions/:id", admin_task.GetTaskExecution)
adminRouter.POST("/tasks/executions/:id/retry", admin_task.RetryTask)
// Task schedules
adminRouter.GET("/tasks/schedules", admin_task.ListSchedules)
adminRouter.POST("/tasks/schedules", admin_task.CreateSchedule)
adminRouter.PUT("/tasks/schedules/:id", admin_task.UpdateSchedule)
adminRouter.DELETE("/tasks/schedules/:id", admin_task.DeleteSchedule)
// Users
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.POST("/users", admin_user.CreateUser)
+10
View File
@@ -111,3 +111,13 @@ func GetTaskMeta(taskType string) *TaskMeta {
}
return nil
}
// GetTaskMetaByAsynqTask 根据 Asynq 任务名称获取元数据
func GetTaskMetaByAsynqTask(asynqTask string) *TaskMeta {
for _, t := range DispatchableTasks {
if t.AsynqTask == asynqTask {
return &t
}
}
return nil
}
+62 -36
View File
@@ -218,25 +218,15 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
return err
}
// 从数据库加载执行记录
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
if err != nil {
logger.ErrorF(ctx, "[TaskExecutor] 查询执行记录失败 taskID=%s: %v", taskID, err)
// 执行记录不存在,仍然执行任务但不记录状态
_, execErr := handler.Execute(ctx, t.Payload())
if execErr != nil {
span.SetStatus(codes.Error, execErr.Error())
return execErr
}
return nil
}
// 更新状态为 running
// 加载或动态创建执行记录
now := time.Now()
execution.Status = model.TaskExecutionStatusRunning
execution.StartedAt = &now
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, err)
execution, err := getOrCreateTaskExecution(ctx, taskID, t, now)
if err == nil && execution != nil && execution.TriggeredBy != "schedule" {
execution.Status = model.TaskExecutionStatusRunning
execution.StartedAt = &now
if updateErr := model.UpdateTaskExecution(ctx, execution); updateErr != nil {
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, updateErr)
}
}
// 开始计时
@@ -245,26 +235,69 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
// 执行业务逻辑
result, execErr := handler.Execute(ctx, t.Payload())
// 计算耗时
// 计算耗时并归档记录
duration := time.Since(start)
finishTime := time.Now()
completeTaskExecution(ctx, execution, t, duration, finishTime, result, execErr, span)
if execution == nil && execErr != nil {
span.SetStatus(codes.Error, execErr.Error())
return execErr
}
return execErr
}
// getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录
func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, now time.Time) (*model.TaskExecution, error) {
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
if err == nil {
return execution, nil
}
meta := GetTaskMetaByAsynqTask(t.Type())
if meta == nil {
return nil, err
}
execution = &model.TaskExecution{
TaskID: taskID,
TaskType: meta.AsynqTask,
TaskName: meta.Name,
Status: model.TaskExecutionStatusRunning,
Retryable: meta.Retryable,
MaxRetry: meta.MaxRetry,
RetryCount: 0,
Payload: string(t.Payload()),
TriggeredBy: "schedule",
StartedAt: &now,
}
if createErr := model.CreateTaskExecution(ctx, execution); createErr != nil {
logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr)
return nil, createErr
}
return execution, nil
}
// completeTaskExecution 完成并更新任务执行记录的状态和执行结果
func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, finishTime time.Time, result *TaskResult, execErr error, span trace.Span) {
if execution == nil {
return
}
execution.Duration = duration.Milliseconds()
execution.FinishedAt = &finishTime
if execErr != nil {
// 执行失败
execution.Status = model.TaskExecutionStatusFailed
execution.ErrorMessage = execErr.Error()
logger.ErrorF(ctx,
"[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v",
t.Type(), taskID, duration.Milliseconds(), execErr,
)
logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr)
span.SetStatus(codes.Error, execErr.Error())
span.RecordError(execErr)
} else {
// 执行成功
execution.Status = model.TaskExecutionStatusSucceeded
if result != nil {
execution.Result = result.Message
@@ -272,19 +305,12 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail)
}
}
logger.InfoF(ctx,
"[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms",
t.Type(), taskID, duration.Milliseconds(),
)
logger.InfoF(ctx, "[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds())
}
// 更新执行记录
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", taskID, err)
logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err)
}
return execErr
}
// generateTaskID 生成任务 ID
+94 -34
View File
@@ -1,67 +1,127 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/logger"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/hibiken/asynq"
)
const (
cleanupDedupWindow = 23 * time.Hour // 清理任务去重窗口
cleanupMaxRetry = 3 // 清理任务最大重试次数
)
var (
scheduler *asynq.Scheduler
schedulerOnce sync.Once
activeScheduler *asynq.Scheduler
schedulerMutex sync.Mutex
quitChan chan struct{}
schedulerOnce sync.Once
)
func init() {
// AsynqClient 已在 task 包中初始化
}
// GetAsynqClient 获取全局 AsynqClient
func GetAsynqClient() *asynq.Client {
return task.AsynqClient
}
// StartScheduler 启动调度器
// StartScheduler 启动调度器 (该函数阻塞,直到调度器退出)
func StartScheduler() error {
var err error
schedulerOnce.Do(func() {
location, locErr := time.LoadLocation("Asia/Shanghai")
if locErr != nil {
err = fmt.Errorf(errLoadLocationFailed, locErr)
return
}
scheduler = asynq.NewScheduler(
task.RedisOpt,
&asynq.SchedulerOpts{
Location: location,
},
)
quitChan = make(chan struct{})
// 清理未使用的上传文件任务
if _, err = scheduler.Register(
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
asynq.Unique(cleanupDedupWindow),
asynq.MaxRetry(cleanupMaxRetry),
); err != nil {
// 初始化并运行首次调度
if err = ReloadScheduler(); err != nil {
err = fmt.Errorf("initial reload failed: %w", err)
return
}
// 启动调度器
err = scheduler.Run()
// 阻塞等待
<-quitChan
})
return err
}
// StopScheduler 停止调度服务并解除 StartScheduler 阻塞
func StopScheduler() {
schedulerMutex.Lock()
defer schedulerMutex.Unlock()
if activeScheduler != nil {
activeScheduler.Shutdown()
activeScheduler = nil
}
if quitChan != nil {
close(quitChan)
quitChan = nil
}
}
// ReloadScheduler 重载调度器配置 (线程安全)
func ReloadScheduler() error {
schedulerMutex.Lock()
defer schedulerMutex.Unlock()
// 1. 如果有运行中的调度器,先关闭它
if activeScheduler != nil {
activeScheduler.Shutdown()
activeScheduler = nil
}
// 2. 从数据库载入启用的定时任务配置
schedules, err := model.ListActiveSchedules(context.Background())
if err != nil {
return fmt.Errorf("load schedules from db failed: %w", err)
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return fmt.Errorf(errLoadLocationFailed, err)
}
// 3. 实例化新的调度器
newScheduler := asynq.NewScheduler(
task.RedisOpt,
&asynq.SchedulerOpts{
Location: location,
},
)
// 4. 遍历并注册任务
for _, s := range schedules {
meta := task.GetTaskMeta(s.TaskType)
if meta == nil {
continue // 忽略排程配置中无效的任务类型
}
// 构造 Asynq 载荷。定时任务使用对应 Meta 中的 Asynq 标识,同时将数据库中保存的 json 作为参数
t := asynq.NewTask(meta.AsynqTask, []byte(s.Payload))
if _, err := newScheduler.Register(
s.Cron,
t,
asynq.MaxRetry(meta.MaxRetry),
asynq.Queue(meta.Queue),
); err != nil {
// 定时任务配置可能有误(如 Cron 格式不被 Asynq 识别),记录日志并跳过
logger.ErrorF(context.Background(), "[Scheduler] 注册定时任务失败 id=%d name=%s: %v", s.ID, s.Name, err)
continue
}
}
// 5. 替换全局调度器并异步启动
activeScheduler = newScheduler
go func() {
if err := activeScheduler.Run(); err != nil {
logger.ErrorF(context.Background(), "[Scheduler] 调度器运行错误: %v", err)
}
}()
logger.InfoF(context.Background(), "[Scheduler] 成功重新加载定时任务,共注册 %d 个活动任务", len(schedules))
return nil
}