package logic import ( "context" "fmt" "strings" "sync" "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/gclient" "github.com/gogf/gf/v2/os/gcron" "easygoadmin/internal/dao" "easygoadmin/utility/cache" ) // ============================================================================ // 定时任务调度器:基于 gcron 的常驻调度,启动时加载 status=1 的任务, // 按 cron_expression 触发 url+params(HTTP 调用本服务接口),结果写任务日志表。 // // 任务键:job:{id}(gcron 命名条目),暂停=Remove、恢复=重新 Add。 // ============================================================================ var ( daemonOnce sync.Once // 保证调度器仅初始化一次 daemonStart time.Time // 调度器启动时间 ) // jobLockTTLSeconds 任务执行锁 TTL(秒) // 兜底上限:执行实例崩溃未主动释放时锁自动过期;正常路径执行结束即释放 const jobLockTTLSeconds = 300 // jobTryLock 抢占任务执行锁 // // 键 job:lock:{id} 经 utility/cache(Redis 优先、内存降级)SET NX 实现, // 多实例部署共享同一把锁,防止同一任务被并发执行; // 抢占成功返回释放函数,失败返回 nil(调用方放弃本次执行) func jobTryLock(jobId int64) func() { key := fmt.Sprintf("job:lock:%d", jobId) ctx := context.Background() if !cache.SetNX(ctx, key, "1", jobLockTTLSeconds) { return nil } return func() { _ = cache.Del(ctx, key) } } // jobKey 生成 gcron 条目键 func jobKey(jobId int64) string { return fmt.Sprintf("job:%d", jobId) } // DaemonInit 启动时加载运行中的任务 // 幂等,多次调用仅首次生效 func DaemonInit(ctx context.Context) { daemonOnce.Do(func() { daemonStart = time.Now() rows, err := dao.Job.Ctx(ctx).Where("status", 1).All() if err != nil { g.Log().Warningf(ctx, "定时任务加载失败: %v", err) return } g.Log().Debugf(ctx, "定时任务待挂载: %d 条", len(rows)) loaded := 0 for _, r := range rows { if err := addJobToScheduler(r["id"].Int64(), r["cron_expression"].String(), r["execute_policy"].Int(), r["is_sync"].Int()); err != nil { g.Log().Warningf(ctx, "任务挂载失败 job=%d cron=%q: %v", r["id"].Int64(), r["cron_expression"].String(), err) continue } loaded++ } g.Log().Infof(ctx, "定时任务调度器已启动,加载 %d 个任务", loaded) }) } // addJobToScheduler 挂载任务到调度器 // // 执行策略(execute_policy): // - 1 立即执行:允许并发,tick 即启动新执行(gcron.Add) // - 2 执行一次(默认):单例调度,上次未结束则跳过本次(gcron.AddSingleton) // - 3 放弃执行:并发 tick 照常触发,但抢不到执行锁则放弃本次 // // 执行方式(is_sync):1 同步在调度协程内执行;0 异步 go 协程执行。 // 策略 2/3 均叠加执行锁(多实例共享),策略 1 按并发语义不加锁。 func addJobToScheduler(jobId int64, cronExpr string, executePolicy int, isSync int) error { if cronExpr == "" { return fmt.Errorf("cron表达式为空") } handler := func(c context.Context) { run := func(ctx context.Context) { if executePolicy != 1 { unlock := jobTryLock(jobId) if unlock == nil { g.Log().Debugf(ctx, "任务 %d 存在执行中的实例,本次放弃", jobId) return } defer unlock() } executeJob(ctx, jobId, false) } if isSync == 1 { run(c) } else { go run(context.Background()) } } var err error if executePolicy == 1 { _, err = gcron.Add(context.Background(), cronExpr, handler, jobKey(jobId)) } else { _, err = gcron.AddSingleton(context.Background(), cronExpr, handler, jobKey(jobId)) } return err } // removeJobFromScheduler 从调度器摘除任务 func removeJobFromScheduler(jobId int64) { gcron.Remove(jobKey(jobId)) } // hasJobInScheduler 判断调度器中是否包含指定任务 func hasJobInScheduler(jobId int64) bool { return gcron.Search(jobKey(jobId)) != nil } // executeJob 执行任务 // // 流程:读取任务定义 → HTTP 调用目标地址 → 写入执行日志; // 内部捕获 panic,避免异常外泄影响调度器。 // force 为 true 时跳过状态检查,用于手动"立即执行"场景,未发布任务也可执行。 func executeJob(ctx context.Context, jobId int64, force bool) { defer func() { if e := recover(); e != nil { g.Log().Errorf(ctx, "定时任务 %d 执行 panic: %v", jobId, e) } }() one, err := dao.Job.Ctx(ctx).Where("id", jobId).One() if err != nil || len(one) == 0 { removeJobFromScheduler(jobId) return } if !force && one["status"].Int() != 1 { removeJobFromScheduler(jobId) return } start := time.Now() status := 0 message := "执行成功" exception := "" // 任务执行目标分派: // http(s) 地址 → HTTP 调用;其余取值 → 按方法名查找本地处理方法执行 target := one["url"].String() if target == "" { status = 1 message = "执行失败" exception = "任务URL为空" } else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { client := gclient.New() client.SetContentType("application/json") client.SetTimeout(30 * time.Second) body := one["params"].String() var response *gclient.Response var rerr error if body == "" { response, rerr = client.Get(ctx, target) } else { response, rerr = client.Post(ctx, target, body) } if rerr != nil { status = 1 message = "执行失败" exception = truncStr(rerr.Error(), 1000) } else { respBody := truncStr(response.ReadAllString(), 1000) if response.StatusCode != 200 { status = 1 message = "执行失败" exception = fmt.Sprintf("HTTP %d: %s", response.StatusCode, respBody) } else { message = truncStr("执行成功: "+respBody, 500) } } } else { handler, ok := jobHandlers[target] if !ok { status = 1 message = "执行失败" exception = fmt.Sprintf("未找到本地任务处理方法: %s", truncStr(target, 200)) } else { summary, herr := handler(ctx, one["params"].String()) if herr != nil { status = 1 message = "执行失败" exception = truncStr(herr.Error(), 1000) } else if summary != "" { message = truncStr("执行成功: "+summary, 500) } } } end := time.Now() // 执行日志落库(后台 ctx,独立于任务 ctx) if _, werr := dao.JobLog.Ctx(ctx).Data(map[string]any{ "job_id": jobId, "job_name": one["job_name"].String(), "job_group": one["job_group"].String(), "job_trigger": one["job_trigger"].String(), "job_message": message, "cron_expression": one["cron_expression"].String(), "status": status, "start_time": start, "end_time": end, "consume_time": end.Sub(start).Milliseconds(), "exception_info": exception, "create_user": "daemon", }).Insert(); werr != nil { g.Log().Errorf(ctx, "任务日志写入失败 job=%d: %v", jobId, werr) } } // truncStr 按字符数截断字符串 // 长度未超限时原样返回,超限时按 rune 截断,避免截断多字节字符 func truncStr(s string, maxLen int) string { if len(s) <= maxLen { return s } runes := []rune(s) if len(runes) <= maxLen { return s } return string(runes[:maxLen]) }