修复定时任务的bug

This commit is contained in:
lmk
2026-07-21 10:28:54 +08:00
parent 4cc52da4f0
commit 3cee24c114
18 changed files with 930 additions and 48 deletions
+8
View File
@@ -1 +1,9 @@
.git
.gitignore
node_modules
data/
resource/log/
*.exe~
*.log
.idea/
.vscode/
+60
View File
@@ -0,0 +1,60 @@
# ========== 构建前端 ==========
FROM docker.m.daocloud.io/node:18-alpine AS frontend-builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install --registry=https://registry.npmmirror.com
COPY frontend/ .
RUN npm run build
# ========== 构建 Go 后端 ==========
FROM golang:alpine AS backend-builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=auto
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 复制前端构建产物
COPY --from=frontend-builder /app/dist/ ./frontend/dist/
RUN go build -ldflags="-s -w" -o ppgo_job ./main.go
# ========== 运行阶段 ==========
FROM alpine:latest
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=backend-builder /build/ppgo_job .
COPY --from=backend-builder /build/config.yml .
COPY --from=backend-builder /build/frontend/dist ./frontend/dist
EXPOSE 8082
VOLUME ["/app/data"]
CMD ["./ppgo_job"]
+8 -10
View File
@@ -51,21 +51,19 @@ func ApiDashboard(r *ghttp.Request) {
"totalLog": logNum,
}
// 即将执行的任务(Vue 前端期望字段名
// 即将执行的任务(基于 cron 表达式计算下次执行时间
upcomingJobs := make([]g.Map, 0)
for _, entry := range scheduler.Scheduler.GetEntries() {
var taskId int
if _, err := fmt.Sscanf(entry.Name, "task_%d", &taskId); err == nil && taskId > 0 {
if t, _ := dao.Task.GetById(ctx, taskId); t != nil && len(upcomingJobs) < 10 {
nextTime := ""
if entry.RegisterTime.Unix() > 0 {
nextTime = entry.RegisterTime.Format("2006-01-02 15:04:05")
}
upcomingJobs = append(upcomingJobs, g.Map{
"task_name": t.TaskName,
"next_time": nextTime,
})
t, _ := dao.Task.GetById(ctx, taskId)
if t == nil || len(upcomingJobs) >= 10 {
continue
}
upcomingJobs = append(upcomingJobs, g.Map{
"task_name": t.TaskName,
"next_time": scheduler.GetTaskNextTime(t.CronSpec),
})
}
}
+10 -5
View File
@@ -50,12 +50,13 @@ func TaskTable(r *ghttp.Request) {
return
}
// 从调度器获取下次执行时间
// 计算各任务的下次执行时间(基于 cron 表达式)
nextTimeMap := make(map[int]string)
for _, entry := range scheduler.Scheduler.GetEntries() {
var tid int
if _, err := fmt.Sscanf(entry.Name, "task_%d", &tid); err == nil && tid > 0 {
nextTimeMap[tid] = entry.RegisterTime.Format("2006-01-02 15:04:05")
for _, t := range list {
if t.Status == 1 && t.CronSpec != "" {
if nt := scheduler.GetTaskNextTime(t.CronSpec); nt != "" {
nextTimeMap[t.Id] = nt
}
}
}
@@ -503,10 +504,14 @@ func TaskLogTable(r *ghttp.Request) {
page := r.Get("page", 1).Int()
limit := r.Get("limit", 20).Int()
taskId := r.Get("task_id", 0).Int()
status := r.Get("status", 9).Int()
var filters []interface{}
if taskId > 0 {
filters = append(filters, "task_id", taskId)
}
if status != 9 {
filters = append(filters, "status", status)
}
if sortStr := parseSort(r); sortStr != "" {
filters = append(filters, "@order", sortStr)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-9ppaOJEB.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-PhAJ6uiJ.css">
<script type="module" crossorigin src="/app/assets/index-B1r_eA9y.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BVLzGyHU.css">
</head>
<body>
<div id="app"></div>
+4 -1
View File
@@ -3,6 +3,9 @@
<template #header>{{ isEdit ? '编辑管理员' : '新增管理员' }}</template>
<el-form :model="form" ref="formRef" label-width="120px" size="small" style="max-width: 600px">
<el-form-item label="登录账号"><el-input v-model="form.login_name" :disabled="isEdit" /></el-form-item>
<el-form-item label="登录密码">
<el-input v-model="form.password" type="password" show-password :placeholder="isEdit ? '留空则不修改密码' : '默认 123456'" />
</el-form-item>
<el-form-item label="真实姓名"><el-input v-model="form.real_name" /></el-form-item>
<el-form-item label="手机号码"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
@@ -23,7 +26,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAdminEditData, saveAdmin } from '@/api/admin'
const route = useRoute(); const router = useRouter(); const isEdit = computed(() => route.name === 'AdminEdit'); const isSuperAdmin = computed(() => form.id === 1)
const roleList = ref<any[]>([]); const form = reactive<any>({ id: 0, login_name: '', real_name: '', phone: '', email: '', dingtalk: '', wechat: '', role_ids: [] })
const roleList = ref<any[]>([]); const form = reactive<any>({ id: 0, login_name: '', password: '', real_name: '', phone: '', email: '', dingtalk: '', wechat: '', role_ids: [] })
async function loadData() {
if (isEdit.value) { const id = Number(route.params.id); const r = await getAdminEditData(id); if (r.code === 0) { const d = r.data; roleList.value = d.roleList || []; if (d.admin) { Object.assign(form, { id: d.admin.id, login_name: d.admin.loginName, real_name: d.admin.realName, phone: d.admin.phone, email: d.admin.email, dingtalk: d.admin.dingtalk, wechat: d.admin.wechat }); form.role_ids = d.admin.roleIds ? d.admin.roleIds.split(',') : [] } } }
else { roleList.value = [] }
+12 -9
View File
@@ -3,7 +3,7 @@
<!-- 统计卡片 -->
<el-row :gutter="16" class="stat-cards">
<el-col :span="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card">
<el-card shadow="hover" class="stat-card" :style="item.path ? { cursor: 'pointer' } : {}" @click="item.path && router.push(item.path)">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
@@ -59,6 +59,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
@@ -66,6 +67,8 @@ import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, ToolboxComponent } from 'echarts/components'
import { getDashboard } from '@/api/dashboard'
const router = useRouter()
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent, ToolboxComponent])
const stats = ref<any[]>([])
@@ -101,14 +104,14 @@ onMounted(async () => {
if (res.code === 0 && res.data) {
const d = res.data
stats.value = [
{ label: '即将执行的任务', value: d.stats.startJob },
{ label: '待审核任务', value: d.stats.totalAuditTask },
{ label: '近期执行成功', value: d.stats.successNum },
{ label: '近期执行失败', value: d.stats.errorNum },
{ label: '用户总数', value: d.stats.userNum },
{ label: '任务总数', value: d.stats.totalJob },
{ label: '累计运行次数', value: d.stats.totalRunNum },
{ label: '日志总量', value: d.stats.totalLog },
{ label: '即将执行的任务', value: d.stats.startJob, path: '/task/list' },
{ label: '待审核任务', value: d.stats.totalAuditTask, path: '/task/audit_list' },
{ label: '近期执行成功', value: d.stats.successNum, path: '/task_log/list?status=0' },
{ label: '近期执行失败', value: d.stats.errorNum, path: '/task_log/list?status=-1' },
{ label: '用户总数', value: d.stats.userNum, path: '/admin/list' },
{ label: '任务总数', value: d.stats.totalJob, path: '/task/list' },
{ label: '累计运行次数', value: d.stats.totalRunNum, path: '/task_log/list' },
{ label: '日志总量', value: d.stats.totalLog, path: '/task_log/list' },
]
chartData.value = d.chartData
upcomingJobs.value = d.upcomingJobs || []
+10 -1
View File
@@ -41,5 +41,14 @@ async function fetchData() {
function search() { page.value = 1; fetchData() }
async function handleDelete(id: number) { await ElMessageBox.confirm('确认删除此日志?'); const r = await deleteTaskLog(id); if (r.code === 0) { ElMessage.success('删除成功'); fetchData() } else ElMessage.error(r.message) }
async function handleBatchDel() { if (!selectedIds.value.length) return; await ElMessageBox.confirm('确认删除选中的日志?'); for (const id of selectedIds.value) await deleteTaskLog(id); ElMessage.success('批量删除成功'); fetchData() }
onMounted(fetchData)
onMounted(() => {
const qStatus = route.query.status
if (qStatus !== undefined && qStatus !== null) {
const s = String(qStatus)
if (['0', '-1', '-2'].includes(s)) {
status.value = s
}
}
fetchData()
})
</script><style scoped>.pagination-wrap { margin-top: 12px; display: flex; justify-content: flex-end; }</style>
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
golang.org/x/crypto v0.54.0
)
require (
@@ -34,7 +35,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
+197
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"ppgo_job/dao"
"ppgo_job/model/entity"
@@ -103,3 +104,199 @@ func (s *schedulerService) CheckCommand(ctx context.Context, command string) err
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
// parseCronFields 解析 crontab 表达式字段,返回 [秒, 分, 时, 日, 月, 周] 的允许值集合。
// 支持 GoFrame gcron 格式(6 字段)和标准 5 字段。
// everySeconds > 0 表示 @every N 模式,此时 fields 为 nil。
func parseCronFields(pattern string) (fields []map[int]struct{}, everySeconds int64, err error) {
p := strings.TrimSpace(pattern)
// @every 语法
if strings.HasPrefix(p, "@every ") {
d, err := time.ParseDuration(strings.TrimPrefix(p, "@every "))
if err != nil {
return nil, 0, fmt.Errorf("解析 @every 失败: %w", err)
}
return nil, int64(d.Seconds()), nil
}
// 预定义模式
switch strings.ToLower(p) {
case "@yearly", "@annually":
p = "0 0 0 1 1 *"
case "@monthly":
p = "0 0 0 1 * *"
case "@weekly":
p = "0 0 0 * * 0"
case "@daily", "@midnight":
p = "0 0 0 * * *"
case "@hourly":
p = "0 0 * * * *"
}
flds := strings.Fields(p)
var parts []string
switch len(flds) {
case 5:
// 5 字段: 分 时 日 月 周 → 秒补 0
parts = append([]string{"0"}, flds...)
case 6:
if flds[0] == "#" {
// # 开头表示 5 字段(忽略秒)
parts = append([]string{"0"}, flds[1:]...)
} else {
parts = flds
}
default:
return nil, 0, fmt.Errorf("cron 表达式字段数不对: %d (期望 5 或 6)", len(flds))
}
if len(parts) != 6 {
return nil, 0, fmt.Errorf("解析后字段数不对: %d", len(parts))
}
// 字段范围: 秒(0-59), 分(0-59), 时(0-23), 日(1-31), 月(1-12), 周(0-6)
ranges := [][2]int{{0, 59}, {0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
result := make([]map[int]struct{}, 6)
for i, field := range parts {
m, err := parseCronField(field, ranges[i][0], ranges[i][1])
if err != nil {
return nil, 0, fmt.Errorf("第 %d 字段解析失败: %w", i+1, err)
}
result[i] = m
}
return result, 0, nil
}
// parseCronField 解析单个 crontab 字段
func parseCronField(field string, min, max int) (map[int]struct{}, error) {
m := make(map[int]struct{})
if field == "*" {
for i := min; i <= max; i++ {
m[i] = struct{}{}
}
return m, nil
}
for _, item := range strings.Split(field, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
interval := 1
if parts := strings.SplitN(item, "/", 2); len(parts) == 2 {
if n, err := fmt.Sscanf(parts[1], "%d", &interval); err != nil || n != 1 {
return nil, fmt.Errorf("步长解析失败: %s", parts[1])
}
item = parts[0]
}
start, end := min, max
if item != "*" {
if parts := strings.SplitN(item, "-", 2); len(parts) == 2 {
if n, err := fmt.Sscanf(parts[0], "%d", &start); err != nil || n != 1 {
return nil, fmt.Errorf("范围起点解析失败: %s", parts[0])
}
if n, err := fmt.Sscanf(parts[1], "%d", &end); err != nil || n != 1 {
return nil, fmt.Errorf("范围终点解析失败: %s", parts[1])
}
} else {
if n, err := fmt.Sscanf(item, "%d", &start); err != nil || n != 1 {
return nil, fmt.Errorf("数值解析失败: %s", item)
}
end = start
}
}
for i := start; i <= end; i += interval {
m[i] = struct{}{}
}
}
return m, nil
}
// NextCronTime 根据 crontab 表达式计算下次执行时间。
// 支持 GoFrame gcron 格式,也可用于标准 5 字段 cron 表达式。
// after 为起始时间(通常传 time.Now())。
func NextCronTime(pattern string, after time.Time) (time.Time, error) {
fields, everySec, err := parseCronFields(pattern)
if err != nil {
return time.Time{}, err
}
if everySec > 0 {
return after.Add(time.Duration(everySec) * time.Second), nil
}
secMap, minMap, hourMap, dayMap, monthMap, weekMap := fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]
// 从 after 下一秒开始查找
yearLimit := after.Year() + 5
loc := after.Location()
t := after.Add(time.Second)
for t.Year() <= yearLimit {
// 检查月,不匹配则跳到下个月1号
if _, ok := monthMap[int(t.Month())]; !ok {
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc)
continue
}
// 检查日,不匹配则跳到明天0点
if _, ok := dayMap[t.Day()]; !ok {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, loc)
continue
}
// 检查周,不匹配则跳到明天0点
if _, ok := weekMap[int(t.Weekday())]; !ok {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, loc)
continue
}
// 检查时,不匹配则跳到下小时0分0秒
if _, ok := hourMap[t.Hour()]; !ok {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, loc)
continue
}
// 检查分,不匹配则跳到下一分钟0秒
if _, ok := minMap[t.Minute()]; !ok {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute()+1, 0, 0, loc)
continue
}
// 检查秒,不匹配则加1秒
if _, ok := secMap[t.Second()]; !ok {
t = t.Add(time.Second)
continue
}
return t, nil
}
return time.Time{}, fmt.Errorf("cron 表达式 %q 在 5 年内找不到匹配时间", pattern)
}
// GetEntriesWithNextTime 获取所有调度任务的下次执行时间
func (s *schedulerService) GetEntriesWithNextTime() map[int]string {
entries := gcron.Entries()
result := make(map[int]string, len(entries))
for _, entry := range entries {
var tid int
if _, err := fmt.Sscanf(entry.Name, "task_%d", &tid); err == nil && tid > 0 {
// 从调度器取 pattern 计算下次时间
result[tid] = "" // 由 controller 从任务数据中计算
}
}
return result
}
// GetTaskNextTime 计算任务的下次执行时间(基于 cron 表达式)
func GetTaskNextTime(cronSpec string) string {
t, err := NextCronTime(cronSpec, time.Now())
if err != nil || t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
+134 -2
View File
@@ -245,16 +245,148 @@ func (s *schedulerService) execHTTP(ctx context.Context, task *entity.Task) *Tas
statusLine := fmt.Sprintf("[HTTP %d]", resp.StatusCode)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
result.IsOk = true
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", method, task.Url, statusLine)
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 ""
}
// parseInt 简单字符串转整数
func parseInt(s string) int {
id := 0