修复定时任务的bug
This commit is contained in:
@@ -1 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
data/
|
||||
resource/log/
|
||||
*.exe~
|
||||
*.log
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
+60
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+16
-16
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+155
File diff suppressed because one or more lines are too long
+155
File diff suppressed because one or more lines are too long
+155
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user