定时任务添加http模式

This commit is contained in:
lmk
2026-07-10 14:25:27 +08:00
parent b1cc63f883
commit 4cc52da4f0
10 changed files with 423 additions and 79 deletions
+38 -18
View File
@@ -83,7 +83,10 @@ func autoInitDB(ctx context.Context) {
group_id INTEGER NOT NULL DEFAULT 0, server_ids TEXT NOT NULL DEFAULT '',
server_type INTEGER NOT NULL DEFAULT 0, task_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '', cron_spec TEXT NOT NULL DEFAULT '',
concurrent INTEGER NOT NULL DEFAULT 0, command TEXT NOT NULL DEFAULT '',
concurrent INTEGER NOT NULL DEFAULT 0, task_type TEXT NOT NULL DEFAULT 'shell',
command TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT 'GET', headers TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
timeout INTEGER NOT NULL DEFAULT 0, execute_times INTEGER NOT NULL DEFAULT 0,
prev_time INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 2,
is_notify INTEGER NOT NULL DEFAULT 0, notify_type INTEGER NOT NULL DEFAULT 0,
@@ -119,6 +122,20 @@ func autoInitDB(ctx context.Context) {
}
}
// 迁移:为旧表添加新列(忽略重复添加的错误)
migrations := []string{
"ALTER TABLE pp_task ADD COLUMN task_type TEXT NOT NULL DEFAULT 'shell'",
"ALTER TABLE pp_task ADD COLUMN url TEXT NOT NULL DEFAULT ''",
"ALTER TABLE pp_task ADD COLUMN method TEXT NOT NULL DEFAULT 'GET'",
"ALTER TABLE pp_task ADD COLUMN headers TEXT NOT NULL DEFAULT ''",
"ALTER TABLE pp_task ADD COLUMN body TEXT NOT NULL DEFAULT ''",
}
for _, sql := range migrations {
if _, err := g.DB().Exec(ctx, sql); err != nil {
// 列已存在则忽略错误
}
}
var adminCount int
_ = g.DB().GetScan(ctx, &adminCount, "SELECT COUNT(*) FROM pp_uc_admin WHERE id=?", 1)
if adminCount == 0 {
@@ -138,26 +155,29 @@ func autoInitDB(ctx context.Context) {
func seedAuth(ctx context.Context) {
now := time.Now().Unix()
type row struct{ id, pid, sort, isShow int; name, url, icon string }
type row struct {
id, pid, sort, isShow int
name, url, icon string
}
rows := []row{
// pid=0 = 一级菜单
{1, 0, 1, 1, "任务管理", " ", "fa-tasks"},
{2, 0, 2, 1, "服务器管理", " ", "fa-server"},
{3, 0, 3, 1, "系统设置", " ", "fa-cog"},
{4, 0, 4, 1, "日志管理", " ", "fa-file-text"},
{5, 0, 5, 1, "权限管理", " ", "fa-lock"},
{1, 0, 1, 1, "任务管理", " ", "fa-tasks"},
{2, 0, 2, 1, "服务器管理", " ", "fa-server"},
{3, 0, 3, 1, "系统设置", " ", "fa-cog"},
{4, 0, 4, 1, "日志管理", " ", "fa-file-text"},
{5, 0, 5, 1, "权限管理", " ", "fa-lock"},
// 二级菜单(pid=父菜单的id
{14, 1, 1, 1, "任务列表", "/task/list", "fa-tasks"},
{15, 1, 2, 1, "任务审核", "/task/audit_list", "fa-check"},
{16, 2, 1, 1, "服务器列表", "/server/list", "fa-server"},
{6, 3, 1, 1, "任务分组", "/group/list", "fa-folder"},
{7, 3, 2, 1, "资源分组", "/server_group/list","fa-sitemap"},
{8, 3, 3, 1, "禁用命令", "/ban/list", "fa-ban"},
{9, 3, 4, 1, "通知模板", "/notify_tpl/list", "fa-bullhorn"},
{10, 4, 1, 1, "执行日志", "/task_log/list", "fa-file"},
{11, 5, 1, 1, "权限因子", "/auth/index", "fa-key"},
{12, 5, 2, 1, "角色管理", "/role/list", "fa-group"},
{13, 5, 3, 1, "管理员管理","/admin/list", "fa-user"},
{14, 1, 1, 1, "任务列表", "/task/list", "fa-tasks"},
{15, 1, 2, 1, "任务审核", "/task/audit_list", "fa-check"},
{16, 2, 1, 1, "服务器列表", "/server/list", "fa-server"},
{6, 3, 1, 1, "任务分组", "/group/list", "fa-folder"},
{7, 3, 2, 1, "资源分组", "/server_group/list", "fa-sitemap"},
{8, 3, 3, 1, "禁用命令", "/ban/list", "fa-ban"},
{9, 3, 4, 1, "通知模板", "/notify_tpl/list", "fa-bullhorn"},
{10, 4, 1, 1, "执行日志", "/task_log/list", "fa-file"},
{11, 5, 1, 1, "权限因子", "/auth/index", "fa-key"},
{12, 5, 2, 1, "角色管理", "/role/list", "fa-group"},
{13, 5, 3, 1, "管理员管理", "/admin/list", "fa-user"},
}
for _, r := range rows {
g.DB().Exec(ctx,
+44 -10
View File
@@ -84,7 +84,7 @@ func TaskTable(r *ghttp.Request) {
if t.UpdateTime > 0 {
ut = time.Unix(t.UpdateTime, 0).Format("2006-01-02 15:04:05")
}
rows = append(rows, g.Map{"id": t.Id, "task_name": t.TaskName, "group_id": t.GroupId, "cron_spec": t.CronSpec, "command": t.Command, "status": t.Status, "status_text": st, "prev_time": t.PrevTime, "prev_time_str": pt, "next_time": nt, "execute_times": t.ExecuteTimes, "server_ids": t.ServerIds, "create_time_str": ct, "update_time_str": ut})
rows = append(rows, g.Map{"id": t.Id, "task_name": t.TaskName, "group_id": t.GroupId, "cron_spec": t.CronSpec, "task_type": t.TaskType, "command": t.Command, "url": t.Url, "method": t.Method, "headers": t.Headers, "body": t.Body, "status": t.Status, "status_text": st, "prev_time": t.PrevTime, "prev_time_str": pt, "next_time": nt, "execute_times": t.ExecuteTimes, "server_ids": t.ServerIds, "create_time_str": ct, "update_time_str": ut})
}
ajaxList(r, "成功", 0, total, rows)
}
@@ -94,7 +94,12 @@ func TaskAjaxSave(r *ghttp.Request) {
id := r.Get("id", 0).Int()
taskName := r.Get("task_name").String()
cronSpec := r.Get("cron_spec").String()
taskType := r.Get("task_type", "shell").String()
command := r.Get("command").String()
url := r.Get("url").String()
method := r.Get("method", "GET").String()
headers := r.Get("headers").String()
body := r.Get("body").String()
groupId := r.Get("group_id", 0).Int()
serverIds := r.Get("server_ids").String()
serverType := r.Get("server_type", 0).Int()
@@ -105,20 +110,31 @@ func TaskAjaxSave(r *ghttp.Request) {
notifyTplId := r.Get("notify_tpl_id", 0).Int()
notifyUserIds := r.Get("notify_user_ids").String()
description := r.Get("description").String()
if taskName == "" || cronSpec == "" || command == "" {
ajaxMsg(r, "任务名称、Cron表达式、命令不能为空", consts.MsgErr)
if taskName == "" || cronSpec == "" {
ajaxMsg(r, "任务名称、Cron表达式不能为空", consts.MsgErr)
return
}
if taskType == "http" {
if url == "" {
ajaxMsg(r, "URL不能为空", consts.MsgErr)
return
}
} else {
if command == "" {
ajaxMsg(r, "命令不能为空", consts.MsgErr)
return
}
if err := scheduler.Scheduler.CheckCommand(ctx, command); err != nil {
ajaxMsg(r, err.Error(), consts.MsgErr)
return
}
}
// 验证 Cron 表达式(临时注册再移除,仅用于校验格式)
if _, err := gcron.AddSingleton(ctx, cronSpec, func(ctx context.Context) {}, "cron_validator"); err != nil {
ajaxMsg(r, "Cron表达式格式错误: "+err.Error(), consts.MsgErr)
return
}
gcron.Remove("cron_validator")
if err := scheduler.Scheduler.CheckCommand(ctx, command); err != nil {
ajaxMsg(r, err.Error(), consts.MsgErr)
return
}
now := time.Now().Unix()
userId := r.GetCtxVar("userId", 0).Int()
if id > 0 {
@@ -134,7 +150,12 @@ func TaskAjaxSave(r *ghttp.Request) {
task.ServerType = serverType
task.CronSpec = cronSpec
task.Concurrent = concurrent
task.TaskType = taskType
task.Command = command
task.Url = url
task.Method = method
task.Headers = headers
task.Body = body
task.Timeout = timeout
task.IsNotify = isNotify
task.NotifyType = notifyType
@@ -158,7 +179,9 @@ func TaskAjaxSave(r *ghttp.Request) {
t := &entity.Task{
TaskName: taskName, Description: description, GroupId: groupId,
ServerIds: serverIds, ServerType: serverType, CronSpec: cronSpec,
Concurrent: concurrent, Command: command, Timeout: timeout,
Concurrent: concurrent, TaskType: taskType,
Command: command, Url: url, Method: method,
Headers: headers, Body: body, Timeout: timeout,
Status: status, IsNotify: isNotify, NotifyType: notifyType,
NotifyTplId: notifyTplId, NotifyUserIds: notifyUserIds,
CreateId: userId, UpdateId: userId, CreateTime: now, UpdateTime: now,
@@ -493,6 +516,16 @@ func TaskLogTable(r *ghttp.Request) {
return
}
// 构建任务ID→名称映射
taskNameMap := make(map[int]string)
for _, l := range list {
if _, ok := taskNameMap[l.TaskId]; !ok {
if t, _ := dao.Task.GetById(ctx, l.TaskId); t != nil {
taskNameMap[l.TaskId] = t.TaskName
}
}
}
rows := make([]g.Map, 0)
for _, l := range list {
st := "成功"
@@ -511,8 +544,9 @@ func TaskLogTable(r *ghttp.Request) {
sizeText = fmt.Sprintf("%.1f KB", float64(outputSize)/1024)
}
rows = append(rows, g.Map{
"id": l.Id, "task_id": l.TaskId, "server_name": l.ServerName,
"status": l.Status, "statusText": st,
"id": l.Id, "task_id": l.TaskId, "task_name": taskNameMap[l.TaskId],
"server_name": l.ServerName,
"status": l.Status, "statusText": st,
"process_time": l.ProcessTime,
"start_time": startTime,
"output_size": sizeText,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>定时任务管理器</title>
<script type="module" crossorigin src="/app/assets/index-LEpjxP6Q.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-CDatsf8P.css">
<script type="module" crossorigin src="/app/assets/index-9ppaOJEB.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-PhAJ6uiJ.css">
</head>
<body>
<div id="app"></div>
+44 -4
View File
@@ -40,9 +40,38 @@
</template>
</el-input>
</el-form-item>
<el-form-item label="命令脚本" prop="command">
<el-form-item label="任务类型">
<el-radio-group v-model="form.task_type">
<el-radio value="shell">Shell 命令</el-radio>
<el-radio value="http">HTTP 请求</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.task_type === 'shell'" label="命令脚本" prop="command">
<el-input v-model="form.command" type="textarea" :rows="4" />
</el-form-item>
<template v-if="form.task_type === 'http'">
<el-form-item label="请求 URL" prop="url">
<el-input v-model="form.url" placeholder="https://example.com/api" />
</el-form-item>
<el-form-item label="请求方法">
<el-select v-model="form.method" style="width: 140px">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
<el-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
<el-form-item label="请求头">
<el-input v-model="form.headers" type="textarea" :rows="3" placeholder='{"Content-Type": "application/json"}' />
</el-form-item>
<el-form-item label="请求体">
<el-input v-model="form.body" type="textarea" :rows="4" placeholder='{"key": "value"}' />
</el-form-item>
</template>
<el-form-item label="超时设置">
<el-input-number v-model="form.timeout" :min="0" :max="86400" />
</el-form-item>
@@ -96,7 +125,8 @@ const submitting = ref(false)
const form = reactive<any>({
id: 0, task_name: '', group_id: '', server_ids: [], server_type: 0,
description: '', concurrent: 0, cron_spec: '', command: '',
description: '', concurrent: 0, cron_spec: '', task_type: 'shell',
command: '', url: '', method: 'GET', headers: '', body: '',
timeout: 0, is_notify: 0, notify_type: 0, notify_tpl_id: 0,
notify_user_ids: [],
})
@@ -108,7 +138,6 @@ const formData = reactive<any>({
const rules = {
task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
cron_spec: [{ required: true, message: '请输入Cron表达式', trigger: 'blur' }],
command: [{ required: true, message: '请输入命令', trigger: 'blur' }],
}
async function loadFormData() {
@@ -124,7 +153,10 @@ async function loadFormData() {
server_ids: d.task.serverIds ? String(d.task.serverIds).split(',') : [],
server_type: d.task.serverType, description: d.task.description,
concurrent: d.task.concurrent, cron_spec: d.task.cronSpec,
command: d.task.command, timeout: d.task.timeout,
task_type: d.task.taskType || 'shell',
command: d.task.command, url: d.task.url || '',
method: d.task.method || 'GET', headers: d.task.headers || '',
body: d.task.body || '', timeout: d.task.timeout,
is_notify: d.task.isNotify, notify_type: d.task.notifyType,
notify_tpl_id: d.task.notifyTplId,
notify_user_ids: d.task.notifyUserIds ? String(d.task.notifyUserIds).split(',') : [],
@@ -140,6 +172,14 @@ async function loadFormData() {
}
async function handleSubmit() {
if (form.task_type === 'shell' && !form.command) {
ElMessage.error('请输入命令')
return
}
if (form.task_type === 'http' && !form.url) {
ElMessage.error('请输入URL')
return
}
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
+1
View File
@@ -22,6 +22,7 @@
<el-table-column prop="id" label="ID" width="60" sortable="custom" />
<el-table-column prop="task_name" label="任务名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="cron_spec" label="时间表达式" width="110" />
<el-table-column prop="task_type" label="类型" width="80" />
<el-table-column label="下次执行" width="150"><template #default="{ row }"><span v-if="row.next_time">{{ row.next_time }}</span><span v-else class="text-muted">-</span></template></el-table-column>
<el-table-column label="上次执行" width="150"><template #default="{ row }">{{ row.prev_time_str || '-' }}</template></el-table-column>
<el-table-column prop="status_text" label="状态" width="80" />
@@ -8,6 +8,7 @@
<el-table :data="list" v-loading="loading" stripe size="small" @selection-change="rows => selectedIds = rows.map(r => r.id)">
<el-table-column type="selection" width="45" />
<el-table-column prop="task_id" label="任务ID" width="70" />
<el-table-column prop="task_name" label="任务名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="server_name" label="服务器" />
<el-table-column prop="start_time" label="开始时间" width="160" />
<el-table-column prop="process_time" label="执行耗时" width="80" />
+36 -25
View File
@@ -10,7 +10,12 @@ type Task struct {
Description string `orm:"description" json:"description"`
CronSpec string `orm:"cron_spec" json:"cronSpec"`
Concurrent int `orm:"concurrent" json:"concurrent"`
TaskType string `orm:"task_type" json:"taskType"`
Command string `orm:"command" json:"command"`
Url string `orm:"url" json:"url"`
Method string `orm:"method" json:"method"`
Headers string `orm:"headers" json:"headers"`
Body string `orm:"body" json:"body"`
Timeout int `orm:"timeout" json:"timeout"`
ExecuteTimes int `orm:"execute_times" json:"executeTimes"`
PrevTime int64 `orm:"prev_time" json:"prevTime"`
@@ -27,31 +32,37 @@ type Task struct {
// TaskCols 字段常量
var TaskCols = struct {
Id, GroupId, ServerIds, ServerType, TaskName string
Description, CronSpec, Concurrent, Command string
Timeout, ExecuteTimes, PrevTime, Status string
Id, GroupId, ServerIds, ServerType, TaskName string
Description, CronSpec, Concurrent, TaskType string
Command, Url, Method, Headers, Body string
Timeout, ExecuteTimes, PrevTime, Status string
IsNotify, NotifyType, NotifyTplId, NotifyUserIds string
CreateId, UpdateId, CreateTime, UpdateTime string
CreateId, UpdateId, CreateTime, UpdateTime string
}{
Id: "id",
GroupId: "group_id",
ServerIds: "server_ids",
ServerType: "server_type",
TaskName: "task_name",
Description: "description",
CronSpec: "cron_spec",
Concurrent: "concurrent",
Command: "command",
Timeout: "timeout",
ExecuteTimes: "execute_times",
PrevTime: "prev_time",
Status: "status",
IsNotify: "is_notify",
NotifyType: "notify_type",
NotifyTplId: "notify_tpl_id",
NotifyUserIds: "notify_user_ids",
CreateId: "create_id",
UpdateId: "update_id",
CreateTime: "create_time",
UpdateTime: "update_time",
Id: "id",
GroupId: "group_id",
ServerIds: "server_ids",
ServerType: "server_type",
TaskName: "task_name",
Description: "description",
CronSpec: "cron_spec",
Concurrent: "concurrent",
TaskType: "task_type",
Command: "command",
Url: "url",
Method: "method",
Headers: "headers",
Body: "body",
Timeout: "timeout",
ExecuteTimes: "execute_times",
PrevTime: "prev_time",
Status: "status",
IsNotify: "is_notify",
NotifyType: "notify_type",
NotifyTplId: "notify_tpl_id",
NotifyUserIds: "notify_user_ids",
CreateId: "create_id",
UpdateId: "update_id",
CreateTime: "create_time",
UpdateTime: "update_time",
}
+101 -20
View File
@@ -1,8 +1,12 @@
package scheduler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"runtime"
"strings"
@@ -31,28 +35,35 @@ func (s *schedulerService) executeTask(ctx context.Context, task *entity.Task) {
serverName := "本地服务器"
var serverId int
serverIds := strings.Split(task.ServerIds, ",")
for _, sid := range serverIds {
sid = strings.TrimSpace(sid)
if sid == "" {
continue
}
var result *TaskResult
if sid == "0" {
serverName = "本地服务器"
serverId = 0
result = s.execLocal(ctx, task.Command, task.Timeout)
} else {
if srv, _ := dao.TaskServer.GetById(ctx, parseInt(sid)); srv != nil {
serverName = srv.ServerName
serverId = srv.Id
if task.TaskType == "http" {
// HTTP 任务:直接从调度器所在机器发起请求
result := s.execHTTP(ctx, task)
finalResult = result
} else {
// Shell 任务:按原有逻辑执行(本地/远程)
serverIds := strings.Split(task.ServerIds, ",")
for _, sid := range serverIds {
sid = strings.TrimSpace(sid)
if sid == "" {
continue
}
result = s.execRemote(ctx, task.Command, task.Timeout, parseInt(sid))
}
if finalResult == nil || !result.IsOk {
finalResult = result
var result *TaskResult
if sid == "0" {
serverName = "本地服务器"
serverId = 0
result = s.execLocal(ctx, task.Command, task.Timeout)
} else {
if srv, _ := dao.TaskServer.GetById(ctx, parseInt(sid)); srv != nil {
serverName = srv.ServerName
serverId = srv.Id
}
result = s.execRemote(ctx, task.Command, task.Timeout, parseInt(sid))
}
if finalResult == nil || !result.IsOk {
finalResult = result
}
}
}
@@ -174,6 +185,76 @@ func (s *schedulerService) execRemote(ctx context.Context, command string, timeo
return result
}
// execHTTP 执行 HTTP 请求任务
func (s *schedulerService) execHTTP(ctx context.Context, task *entity.Task) *TaskResult {
result := &TaskResult{}
client := &http.Client{Timeout: time.Duration(task.Timeout) * time.Second}
if task.Timeout <= 0 {
client.Timeout = 30 * time.Second
}
method := strings.ToUpper(task.Method)
if method == "" {
method = "GET"
}
var bodyReader io.Reader
if task.Body != "" && (method == "POST" || method == "PUT" || method == "PATCH") {
bodyReader = bytes.NewBufferString(task.Body)
}
req, err := http.NewRequest(method, task.Url, bodyReader)
if err != nil {
result.Error = "创建请求失败: " + err.Error()
return result
}
// 解析并设置自定义 Header
if task.Headers != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(task.Headers), &headers); err == nil {
for k, v := range headers {
req.Header.Set(k, v)
}
} else {
result.Error = "Headers 格式错误(需为 JSON 对象)"
return result
}
}
// 默认 Content-Type
if req.Header.Get("Content-Type") == "" && bodyReader != nil {
req.Header.Set("Content-Type", "application/json")
}
g.Log().Infof(ctx, "HTTP请求 -> %s %s", method, task.Url)
resp, err := client.Do(req)
if err != nil {
result.Error = "请求失败: " + err.Error()
return result
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
bodyStr := string(bodyBytes)
// 截断过长的输出
if len(bodyStr) > 10000 {
bodyStr = bodyStr[:10000] + "\n... (truncated)"
}
statusLine := fmt.Sprintf("[HTTP %d]", resp.StatusCode)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
result.IsOk = true
result.Output = statusLine + " " + bodyStr
} else {
result.Error = statusLine + " " + bodyStr
}
g.Log().Infof(ctx, "HTTP响应 -> %s %s -> %s", method, task.Url, statusLine)
return result
}
// parseInt 简单字符串转整数
func parseInt(s string) int {
id := 0