Files
ppgo_job/service/scheduler/task_runner.go
T

427 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package scheduler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os/exec"
"runtime"
"strings"
"time"
"ppgo_job/dao"
"ppgo_job/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
// TaskResult 任务执行结果
type TaskResult struct {
Output string
Error string
IsOk bool
IsTimeout bool
}
// executeTask 执行任务主逻辑
func (s *schedulerService) executeTask(ctx context.Context, task *entity.Task) {
startTime := time.Now()
g.Log().Infof(ctx, "开始执行任务 [%d] %s", task.Id, task.TaskName)
var finalResult *TaskResult
serverName := "本地服务器"
var serverId int
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
}
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
}
}
}
if finalResult == nil {
finalResult = &TaskResult{IsOk: true}
}
elapsed := time.Since(startTime).Milliseconds()
// 记录日志
log := &entity.TaskLog{
TaskId: task.Id,
ServerId: serverId,
ServerName: serverName,
Output: finalResult.Output,
Error: finalResult.Error,
ProcessTime: int(elapsed),
CreateTime: time.Now().Unix(),
}
if finalResult.IsTimeout {
log.Status = -2
} else if !finalResult.IsOk {
log.Status = -1
} else {
log.Status = 0
}
_, _ = dao.TaskLog.Insert(ctx, log)
// 更新任务统计
_ = dao.Task.UpdateFields(ctx, task.Id, g.Map{"prev_time": startTime.Unix()})
_, _ = g.DB().Exec(ctx, "UPDATE pp_task SET execute_times = COALESCE(execute_times, 0) + 1 WHERE id = ?", task.Id)
// 失败时通知
if log.Status < 0 && task.IsNotify == 1 {
s.sendNotify(ctx, task, log)
}
g.Log().Infof(ctx, "任务 [%d] %s 执行完成, 耗时 %dms", task.Id, task.TaskName, elapsed)
}
// execLocal 本地执行命令
func (s *schedulerService) execLocal(ctx context.Context, command string, timeoutSec int) *TaskResult {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("CMD", "/C", command)
} else {
cmd = exec.Command("sh", "-c", command)
}
result := &TaskResult{}
done := make(chan error, 1)
go func() {
out, err := cmd.CombinedOutput()
result.Output = string(out)
done <- err
}()
timeout := time.Duration(timeoutSec) * time.Second
if timeoutSec <= 0 {
timeout = 24 * time.Hour
}
select {
case err := <-done:
result.IsOk = (err == nil)
if err != nil {
result.Error = err.Error()
}
case <-time.After(timeout):
_ = cmd.Process.Kill()
result.IsTimeout = true
result.Error = fmt.Sprintf("任务执行超过 %d 秒,已强制终止", timeoutSec)
}
return result
}
// execRemote 远程执行(SSH
func (s *schedulerService) execRemote(ctx context.Context, command string, timeoutSec int, serverId int) *TaskResult {
if serverId <= 0 {
return s.execLocal(ctx, command, timeoutSec)
}
server, err := dao.TaskServer.GetById(ctx, serverId)
if err != nil || server == nil {
return &TaskResult{IsOk: false, Error: fmt.Sprintf("服务器不存在: %d", serverId)}
}
g.Log().Infof(ctx, "远程执行 -> %s@%s:%d", server.ServerAccount, server.ServerIp, server.Port)
sshClient := &SSHClient{}
connectErr := sshClient.Connect(server.ServerIp, server.Port, server.ServerAccount, server.Password, server.PrivateKeySrc, server.Type)
if connectErr != nil {
return &TaskResult{IsOk: false, Error: "SSH连接失败: " + connectErr.Error()}
}
defer sshClient.Close()
result := &TaskResult{}
stdout, stderr, runErr := sshClient.Exec(command, timeoutSec)
result.Output = stdout
result.Error = stderr
if runErr != nil {
if strings.Contains(runErr.Error(), "timeout") {
result.IsTimeout = true
} else {
result.IsOk = false
}
if stderr == "" {
result.Error = runErr.Error()
}
} else {
result.IsOk = true
}
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 (超时=%ds)", method, task.Url, task.Timeout)
resp, err := client.Do(req)
if err != nil {
if isTimeoutError(err) {
result.IsTimeout = true
result.Error = fmt.Sprintf("请求超时(%d秒): %s", task.Timeout, task.Url)
} else {
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.Output = statusLine + " " + bodyStr
// 尝试解析 JSON 响应体,检测应用层错误
if appErr := detectJSONError(bodyBytes); appErr != "" {
result.IsOk = false
result.Error = fmt.Sprintf("%s %s", statusLine, appErr)
} else {
result.IsOk = true
}
} else {
result.IsOk = false
result.Error = statusLine + " " + bodyStr
}
g.Log().Infof(ctx, "HTTP响应 -> %s %s -> %s (ok=%v)", method, task.Url, statusLine, result.IsOk)
return result
}
// detectJSONError 解析 JSON 响应体,递归检测应用层错误。
// 支持常见 API 格式:
//
// {"code":1,...} — code ≠ 0 表示错误
// {"success":false,...} — success=false 表示错误
// {"status":"error",...} — status="error"/"fail" 表示错误
// {"errno":-1,...} — errno ≠ 0 表示错误
// {"error":"..."} — error 非空字符串表示错误
// {"data":{"success":false}} — 嵌套在 data 字段中的错误也会检测
//
// 返回空字符串表示无错误或无法解析。
func detectJSONError(body []byte) string {
body = bytes.TrimSpace(body)
if len(body) == 0 || body[0] != '{' {
return ""
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return ""
}
return detectJSONErrorMap(data)
}
// detectJSONErrorMap 递归检测 map 中的应用层错误。
// 优先级:error 字段 > success 字段 > code 字段 > status 字段 > errno 字段 > 嵌套 data。
func detectJSONErrorMap(data map[string]interface{}) string {
// 1. 检查 error 字段(字符串且非空)
if errVal, ok := data["error"]; ok {
switch v := errVal.(type) {
case string:
if v != "" {
return fmt.Sprintf("业务错误: %s", v)
}
case map[string]interface{}:
// error 可能是一个对象,如 {"error": {"message": "..."}}
if msg, hasMsg := v["message"]; hasMsg {
return fmt.Sprintf("业务错误: %v", msg)
}
}
}
// 2. 检查 success 字段(明确 false 表示错误,true 表示成功)
if success, ok := data["success"]; ok {
if b, ok := success.(bool); ok {
if !b {
msg := extractMsg(data, "message")
if msg != "" {
return fmt.Sprintf("业务错误(success=false): %s", msg)
}
return "业务错误(success=false)"
}
// success=true 明确表示成功,无需继续检查
return ""
}
}
// 3. 检查 code 字段(数字类型,非 0 表示错误 — 国内 API 最常见格式)
if code, ok := data["code"]; ok {
switch v := code.(type) {
case float64:
if v != 0 {
msg := extractMsg(data, "message")
if msg != "" {
return fmt.Sprintf("业务错误(code=%.0f): %s", v, msg)
}
return fmt.Sprintf("业务错误(code=%.0f)", v)
}
// code=0 可能是 GoFrame 等框架的全局成功码,
// 不返回 success,继续检查嵌套 data
}
}
// 4. 检查 status 字段
if status, ok := data["status"]; ok {
if s, ok := status.(string); ok {
switch s {
case "error", "fail", "failed":
msg := extractMsg(data, "message")
if msg != "" {
return fmt.Sprintf("业务错误(status=%s): %s", s, msg)
}
return fmt.Sprintf("业务错误(status=%s)", s)
}
}
}
// 5. 检查 errno 字段(部分 API 用 errno/errmsg 表示错误)
if errno, ok := data["errno"]; ok {
switch v := errno.(type) {
case float64:
if v != 0 {
msg := extractMsg(data, "errmsg", "message")
if msg != "" {
return fmt.Sprintf("业务错误(errno=%.0f): %s", v, msg)
}
return fmt.Sprintf("业务错误(errno=%.0f)", v)
}
// errno=0 明确表示成功
return ""
}
}
// 6. 递归检查嵌套的 data 字段
if nested, ok := data["data"].(map[string]interface{}); ok {
if errMsg := detectJSONErrorMap(nested); errMsg != "" {
return errMsg
}
}
return ""
}
// extractMsg 从 JSON 对象中按优先级提取消息字段的值。
func extractMsg(data map[string]interface{}, keys ...string) string {
for _, key := range keys {
if v, ok := data[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
}
// isTimeoutError 判断是否为超时错误
func isTimeoutError(err error) bool {
if err == nil {
return false
}
// Go http.Client 超时返回 context.DeadlineExceeded
if errors.Is(err, context.DeadlineExceeded) {
return true
}
// 也检查 net.Error.Timeout()
var netErr interface{ Timeout() bool }
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
// 兜底:关键字匹配
msg := err.Error()
return strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded")
}
// parseInt 简单字符串转整数
func parseInt(s string) int {
id := 0
for _, c := range s {
if c >= '0' && c <= '9' {
id = id*10 + int(c-'0')
} else {
break
}
}
return id
}