mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
优化任务管理
This commit is contained in:
@@ -93,9 +93,6 @@ log:
|
|||||||
max_backups: 10
|
max_backups: 10
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
# ─── Task Scheduler (Cron) ──────────────────────────────────────────────────────
|
|
||||||
scheduler:
|
|
||||||
cleanup_unused_uploads_task_cron: "0 */2 * * *" # Clean up orphaned upload records
|
|
||||||
|
|
||||||
# ─── Async Task Worker ──────────────────────────────────────────────────────────
|
# ─── Async Task Worker ──────────────────────────────────────────────────────────
|
||||||
worker:
|
worker:
|
||||||
|
|||||||
+363
@@ -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": {
|
"/api/v1/admin/tasks/types": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"model.SystemConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"task.DispatchTaskRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"template.CreateTemplateRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/admin/tasks/types": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"model.SystemConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"task.DispatchTaskRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"template.CreateTemplateRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -277,6 +277,26 @@ definitions:
|
|||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
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:
|
model.SystemConfig:
|
||||||
properties:
|
properties:
|
||||||
created_at:
|
created_at:
|
||||||
@@ -667,6 +687,24 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- value
|
- value
|
||||||
type: object
|
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:
|
task.DispatchTaskRequest:
|
||||||
properties:
|
properties:
|
||||||
end_time:
|
end_time:
|
||||||
@@ -727,6 +765,24 @@ definitions:
|
|||||||
description: 类型:string, text, number
|
description: 类型:string, text, number
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
template.CreateTemplateRequest:
|
||||||
properties:
|
properties:
|
||||||
content:
|
content:
|
||||||
@@ -1941,6 +1997,175 @@ paths:
|
|||||||
summary: 重试失败任务
|
summary: 重试失败任务
|
||||||
tags:
|
tags:
|
||||||
- admin
|
- 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:
|
/api/v1/admin/tasks/types:
|
||||||
get:
|
get:
|
||||||
description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
|
description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {TaskExecutionsManager} from "@/components/common/admin/task-executions"
|
|
||||||
|
|
||||||
/* 任务日志页面 */
|
|
||||||
export default function TaskExecutionsPage() {
|
|
||||||
return <TaskExecutionsManager />
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,56 @@
|
|||||||
"use client"
|
"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"
|
||||||
|
|
||||||
/* 任务管理页面 */
|
function TasksPageContent() {
|
||||||
export default function TasksPage() {
|
const searchParams = useSearchParams()
|
||||||
return <TaskManager />
|
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"
|
"use client"
|
||||||
|
|
||||||
import {useCallback, useEffect, useState} from "react"
|
import {useCallback, useEffect, useState} from "react"
|
||||||
import Link from "next/link"
|
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
import {format} from "date-fns"
|
import {format} from "date-fns"
|
||||||
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
|
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
|
||||||
@@ -29,6 +28,7 @@ const TRIGGER_LABELS: Record<string, string> = {
|
|||||||
system: "系统",
|
system: "系统",
|
||||||
manual: "手动",
|
manual: "手动",
|
||||||
retry: "重试",
|
retry: "重试",
|
||||||
|
schedule: "定时",
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string | null) {
|
function formatDateTime(value?: string | null) {
|
||||||
@@ -148,21 +148,10 @@ export function TaskExecutionsManager() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
|
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
|
||||||
|
|
||||||
return (
|
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 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">
|
<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")}>
|
<Select value={executionStatus} onValueChange={(value) => setExecutionStatus(value as TaskExecutionStatus | "all")}>
|
||||||
<SelectTrigger size="sm" className="w-[120px]">
|
<SelectTrigger size="sm" className="w-[120px]">
|
||||||
<SelectValue placeholder="状态" />
|
<SelectValue placeholder="状态" />
|
||||||
|
|||||||
+3
-14
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {useCallback, useEffect, useState} from "react"
|
import {useCallback, useEffect, useState} from "react"
|
||||||
import Link from "next/link"
|
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
import {Button} from "@/components/ui/button"
|
import {Button} from "@/components/ui/button"
|
||||||
import {Input} from "@/components/ui/input"
|
import {Input} from "@/components/ui/input"
|
||||||
@@ -9,7 +8,7 @@ import {Label} from "@/components/ui/label"
|
|||||||
import {Textarea} from "@/components/ui/textarea"
|
import {Textarea} from "@/components/ui/textarea"
|
||||||
import {Spinner} from "@/components/ui/spinner"
|
import {Spinner} from "@/components/ui/spinner"
|
||||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
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 {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services"
|
||||||
import {ErrorInline} from "@/components/layout/error"
|
import {ErrorInline} from "@/components/layout/error"
|
||||||
@@ -241,18 +240,8 @@ export function TaskManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
<br/>
|
||||||
<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">
|
<div className="space-y-6">
|
||||||
{error ? (
|
{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,
|
AdminUser,
|
||||||
AuthSource,
|
AuthSource,
|
||||||
AuthSourceRequest,
|
AuthSourceRequest,
|
||||||
|
CreateScheduleRequest,
|
||||||
CreateSystemConfigRequest,
|
CreateSystemConfigRequest,
|
||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
@@ -12,6 +13,7 @@ import type {
|
|||||||
ListTaskExecutionsResponse,
|
ListTaskExecutionsResponse,
|
||||||
ListUsersRequest,
|
ListUsersRequest,
|
||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
|
Schedule,
|
||||||
SystemConfig,
|
SystemConfig,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
TaskExecution,
|
TaskExecution,
|
||||||
@@ -19,6 +21,7 @@ import type {
|
|||||||
TaskTypeResponse,
|
TaskTypeResponse,
|
||||||
Template,
|
Template,
|
||||||
ToggleAuthSourceRequest,
|
ToggleAuthSourceRequest,
|
||||||
|
UpdateScheduleRequest,
|
||||||
UpdateSystemConfigRequest,
|
UpdateSystemConfigRequest,
|
||||||
UpdateTemplateRequest,
|
UpdateTemplateRequest,
|
||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
@@ -235,6 +238,36 @@ export class AdminService extends BaseService {
|
|||||||
return this.post<string>(`/tasks/executions/${ id }/retry`);
|
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 }`);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 用户管理 ====================
|
// ==================== 用户管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,4 +40,7 @@ export type {
|
|||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
DatabaseInfo,
|
DatabaseInfo,
|
||||||
|
Schedule,
|
||||||
|
CreateScheduleRequest,
|
||||||
|
UpdateScheduleRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
@@ -188,6 +188,42 @@ export interface ListTaskExecutionsResponse {
|
|||||||
page_size: number;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 用户管理 ====================
|
// ==================== 用户管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export type {
|
|||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
DatabaseInfo,
|
DatabaseInfo,
|
||||||
|
Schedule,
|
||||||
|
CreateScheduleRequest,
|
||||||
|
UpdateScheduleRequest,
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
// 用户服务
|
// 用户服务
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ package task
|
|||||||
|
|
||||||
// 任务管理相关错误消息
|
// 任务管理相关错误消息
|
||||||
const (
|
const (
|
||||||
InvalidTaskType = "无效的任务类型"
|
InvalidTaskType = "无效的任务类型"
|
||||||
InvalidTimeRange = "无效的时间范围"
|
InvalidTimeRange = "无效的时间范围"
|
||||||
TaskDispatchFailed = "任务下发失败"
|
TaskDispatchFailed = "任务下发失败"
|
||||||
UserIDRequired = "用户ID必填"
|
UserIDRequired = "用户ID必填"
|
||||||
TaskNotFound = "任务执行记录不存在"
|
TaskNotFound = "任务执行记录不存在"
|
||||||
TaskNotRetryable = "该任务不支持重试"
|
TaskNotRetryable = "该任务不支持重试"
|
||||||
TaskNotFailed = "只有失败的任务才能重试"
|
TaskNotFailed = "只有失败的任务才能重试"
|
||||||
TaskMaxRetryExceeded = "已达到最大重试次数"
|
TaskMaxRetryExceeded = "已达到最大重试次数"
|
||||||
TaskRetryFailed = "任务重试失败"
|
TaskRetryFailed = "任务重试失败"
|
||||||
|
InvalidCronExpression = "无效的 Cron 表达式"
|
||||||
|
ScheduleNotFound = "定时任务不存在"
|
||||||
|
ScheduleSaveFailed = "保存定时任务失败"
|
||||||
|
ScheduleDeleteFailed = "删除定时任务失败"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
"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/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/task"
|
"github.com/Rain-kl/Wavelet/internal/task"
|
||||||
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
|
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/Rain-kl/Wavelet/internal/util"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -194,3 +197,218 @@ func RetryTask(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(newTaskID))
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -246,14 +246,6 @@ func applyEnvOverrides(c *configModel) {
|
|||||||
{Name: "default", Priority: defaultQueuePriority},
|
{Name: "default", Priority: defaultQueuePriority},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scheduler ───
|
|
||||||
c.Scheduler.CleanupUnusedUploadsTaskCron = envStr(
|
|
||||||
"SCHEDULER_CLEANUP_CRON", c.Scheduler.CleanupUnusedUploadsTaskCron,
|
|
||||||
)
|
|
||||||
if c.Scheduler.CleanupUnusedUploadsTaskCron == "" {
|
|
||||||
c.Scheduler.CleanupUnusedUploadsTaskCron = "@daily"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// printConfig 打印配置内容
|
// printConfig 打印配置内容
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ type logConfig struct {
|
|||||||
|
|
||||||
// schedulerConfig 定时任务配置
|
// schedulerConfig 定时任务配置
|
||||||
type schedulerConfig struct {
|
type schedulerConfig struct {
|
||||||
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// workerConfig 工作配置
|
// 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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -244,6 +244,12 @@ func registerRoutes(r *gin.Engine) {
|
|||||||
adminRouter.GET("/tasks/executions/:id", admin_task.GetTaskExecution)
|
adminRouter.GET("/tasks/executions/:id", admin_task.GetTaskExecution)
|
||||||
adminRouter.POST("/tasks/executions/:id/retry", admin_task.RetryTask)
|
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
|
// Users
|
||||||
adminRouter.GET("/users", admin_user.ListUsers)
|
adminRouter.GET("/users", admin_user.ListUsers)
|
||||||
adminRouter.POST("/users", admin_user.CreateUser)
|
adminRouter.POST("/users", admin_user.CreateUser)
|
||||||
|
|||||||
@@ -111,3 +111,13 @@ func GetTaskMeta(taskType string) *TaskMeta {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTaskMetaByAsynqTask 根据 Asynq 任务名称获取元数据
|
||||||
|
func GetTaskMetaByAsynqTask(asynqTask string) *TaskMeta {
|
||||||
|
for _, t := range DispatchableTasks {
|
||||||
|
if t.AsynqTask == asynqTask {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+62
-36
@@ -218,25 +218,15 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
|
|||||||
return err
|
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()
|
now := time.Now()
|
||||||
execution.Status = model.TaskExecutionStatusRunning
|
execution, err := getOrCreateTaskExecution(ctx, taskID, t, now)
|
||||||
execution.StartedAt = &now
|
if err == nil && execution != nil && execution.TriggeredBy != "schedule" {
|
||||||
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
execution.Status = model.TaskExecutionStatusRunning
|
||||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, err)
|
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())
|
result, execErr := handler.Execute(ctx, t.Payload())
|
||||||
|
|
||||||
// 计算耗时
|
// 计算耗时并归档记录
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
finishTime := time.Now()
|
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.Duration = duration.Milliseconds()
|
||||||
execution.FinishedAt = &finishTime
|
execution.FinishedAt = &finishTime
|
||||||
|
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
// 执行失败
|
|
||||||
execution.Status = model.TaskExecutionStatusFailed
|
execution.Status = model.TaskExecutionStatusFailed
|
||||||
execution.ErrorMessage = execErr.Error()
|
execution.ErrorMessage = execErr.Error()
|
||||||
|
logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr)
|
||||||
logger.ErrorF(ctx,
|
|
||||||
"[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v",
|
|
||||||
t.Type(), taskID, duration.Milliseconds(), execErr,
|
|
||||||
)
|
|
||||||
|
|
||||||
span.SetStatus(codes.Error, execErr.Error())
|
span.SetStatus(codes.Error, execErr.Error())
|
||||||
span.RecordError(execErr)
|
span.RecordError(execErr)
|
||||||
} else {
|
} else {
|
||||||
// 执行成功
|
|
||||||
execution.Status = model.TaskExecutionStatusSucceeded
|
execution.Status = model.TaskExecutionStatusSucceeded
|
||||||
if result != nil {
|
if result != nil {
|
||||||
execution.Result = result.Message
|
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)
|
execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.InfoF(ctx, "[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds())
|
||||||
logger.InfoF(ctx,
|
|
||||||
"[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms",
|
|
||||||
t.Type(), taskID, duration.Milliseconds(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新执行记录
|
|
||||||
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
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
|
// generateTaskID 生成任务 ID
|
||||||
|
|||||||
@@ -1,67 +1,127 @@
|
|||||||
// Copyright 2025 linux.do
|
|
||||||
// Copyright 2026 Arctel.net
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/Rain-kl/Wavelet/internal/task"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
cleanupDedupWindow = 23 * time.Hour // 清理任务去重窗口
|
|
||||||
cleanupMaxRetry = 3 // 清理任务最大重试次数
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
scheduler *asynq.Scheduler
|
activeScheduler *asynq.Scheduler
|
||||||
schedulerOnce sync.Once
|
schedulerMutex sync.Mutex
|
||||||
|
quitChan chan struct{}
|
||||||
|
schedulerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
|
||||||
// AsynqClient 已在 task 包中初始化
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAsynqClient 获取全局 AsynqClient
|
// GetAsynqClient 获取全局 AsynqClient
|
||||||
func GetAsynqClient() *asynq.Client {
|
func GetAsynqClient() *asynq.Client {
|
||||||
return task.AsynqClient
|
return task.AsynqClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartScheduler 启动调度器
|
// StartScheduler 启动调度器 (该函数阻塞,直到调度器退出)
|
||||||
func StartScheduler() error {
|
func StartScheduler() error {
|
||||||
var err error
|
var err error
|
||||||
schedulerOnce.Do(func() {
|
schedulerOnce.Do(func() {
|
||||||
location, locErr := time.LoadLocation("Asia/Shanghai")
|
quitChan = make(chan struct{})
|
||||||
if locErr != nil {
|
|
||||||
err = fmt.Errorf(errLoadLocationFailed, locErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduler = asynq.NewScheduler(
|
|
||||||
task.RedisOpt,
|
|
||||||
&asynq.SchedulerOpts{
|
|
||||||
Location: location,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 清理未使用的上传文件任务
|
// 初始化并运行首次调度
|
||||||
if _, err = scheduler.Register(
|
if err = ReloadScheduler(); err != nil {
|
||||||
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
|
err = fmt.Errorf("initial reload failed: %w", err)
|
||||||
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
|
|
||||||
asynq.Unique(cleanupDedupWindow),
|
|
||||||
asynq.MaxRetry(cleanupMaxRetry),
|
|
||||||
); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动调度器
|
// 阻塞等待
|
||||||
err = scheduler.Run()
|
<-quitChan
|
||||||
})
|
})
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user