29. 定时任务实战
一句话定位:Admin.NET 的定时任务基于 Sundial / Furion.Schedule,用 [JobDetail] + 触发器特性(如 [Daily])声明作业,配合持久化(DbJobPersistence 写 SysJobDetail/SysJobTrigger)、集群锁(JobMonitor)与动态脚本作业(DynamicJobCompiler),还有一个 /schedule 看板。
调度组件与 Cron
任务调度集成组件 Sundial,功能齐全的开源分布式作业调度系统,简单好用。Cron 表达式解析库采用 TimeCrontab,支持 Cron 所有特性。编写 Cron 表达式推荐使用在线生成器 https://cron.qqe2.com/。
创建一个定时任务非常容易:定义作业并实现 IJob 接口即可,不需要手动注册。框架启动时会自动扫描注册所有 IJob 实现类,并做数据库持久化(把定时任务存到库里)。建议每个作业类都贴 [JobDetail] + 触发器特性。
一、骨架
App.json → "JobSchedule": { "Enabled": true }
│
▼
JobDetailAttribute([JobDetail(jobId)]) + 触发器特性([Daily]/[Cron]/...)
│ 启动时 App.EffectiveTypes.ScanToBuilders()
▼
ISchedulerFactory
├── IJobPersistence = DbJobPersistence → SysJobDetail / SysJobTrigger / SysJobTriggerRecord
├── IJobMonitor = JobMonitor → 集群 redis 锁 + 异常邮件/企微 + SysLogEx
└── IJobClusterServer = JobClusterServer → SysJobCluster(集群心跳)
│
▼
IServiceScopeFactory.CreateScope() + CopyNew() ← 作业内访问数据库的唯一正确方式二、[JobDetail] 参数全表与触发器特性
[JobDetail]
| 参数 | 说明 | 示例 |
|---|---|---|
jobId(构造第 1 参) | 作业唯一编号,落库主键也是它 | "clean_sys_log_job" |
GroupName | 作业组名 | "default" |
Description | 描述,看板展示 | "清理系统日志" |
Concurrent | 是否允许并发执行 | false(推荐) |
常用触发器特性(Admin.NET.Core/Job/CleanSysLogJob.cs 用的是 [Daily])
| 特性 | 说明 | 关键参数 |
|---|---|---|
[Secondly] | 每秒触发 | TriggerId、Description |
[Minutely] | 每分钟触发 | 同上 |
[Hourly] | 每小时触发 | 同上 |
[Daily] | 每天触发(默认 00:00:00) | TriggerId、Description、At(...) |
[Weekly] | 每周触发 | 同上 |
[Monthly] | 每月触发 | 同上 |
[Cron] | Cron 表达式 | Cron = "0 0/5 * * * ?" |
[Trigger](+ [Period] 等) | 组合式自定义 | 见下 |
触发器特性完整清单
除了上表的常用项,Sundial 还提供了「周期型」与「定点型」两大类,覆盖几乎所有排班需求:
| 类别 | 特性 | 说明 |
|---|---|---|
| 周期 | [Period(5000)] | 毫秒周期(间隔)触发器 |
| 周期 | [PeriodSeconds(5)] | 秒周期(间隔)触发器 |
| 周期 | [PeriodMinutes(5)] | 分钟周期(间隔)触发器 |
| 周期 | [PeriodHours(5)] | 小时周期(间隔)触发器 |
| 定点 | [Secondly] / [Minutely] / [Hourly] | 每秒 / 每分 / 每小时开始触发 |
| 定点 | [Daily] / [Weekly] / [Monthly] / [Yearly] | 每天(午夜)/ 每周日 / 每月 1 号 / 每年 1 月 1 号触发 |
| 定点 | [Workday] | 每周一至周五(午夜)触发 |
| 定点 | [SecondlyAt] | 特定秒开始触发 |
| 定点 | [MinutelyAt] | 每分钟特定秒开始触发 |
| 定点 | [HourlyAt] | 每小时特定分钟开始触发 |
| 定点 | [DailyAt] | 每天特定小时开始触发 |
| 定点 | [MonthlyAt] | 每月特定天(午夜)开始触发 |
| 定点 | [WeeklyAt] | 每周特定星期几(午夜)开始触发 |
| 定点 | [YearlyAt] | 每年特定月 1 号(午夜)开始触发 |
所有触发器特性都有
TriggerId(必填)与Description;同一个作业可以配多个触发器。区分技巧:不带
At的是"每 X 的开始时刻"(如[Daily]= 每天 00:00:00),带At的是"指定时刻"(如[DailyAt]可指定 08:30:00)。需要"每隔 N 秒固定间隔跑"用[Period*]系列,需要"整点/整分对齐"用不带At的系列。
组合写法示意
[JobDetail("biz_report_job", Description = "业务日报", GroupName = "report", Concurrent = false)]
[Trigger(TriggerId = "biz_report_trigger", Description = "每天 8:30",
Schedule = "Daily", Args = new object[] { "08:30:00" })]
public class BizReportJob : IJob { ... }三、完整作业示例
框架自带的 CleanSysLogJob 是范本:
// Admin.NET.Core/Job/CleanSysLogJob.cs
[JobDetail("clean_sys_log_job", Description = "清理系统日志", GroupName = "default", Concurrent = false)]
[Daily(TriggerId = "clean_sys_log_trigger", Description = "清理系统日志")]
public class CleanSysLogJob(IServiceScopeFactory serviceScopeFactory) : IJob
{
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
using var serviceScope = _serviceScopeFactory.CreateScope();
// ⚠️ 每个仓储都 CopyNew(),拿到独立连接
var logVisRep = serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogVis>>().CopyNew();
var logOpRep = serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogOp>>().CopyNew();
var logDiffRep = serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogDiff>>().CopyNew();
var logTriggerRep= serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysJobTriggerRecord>>().CopyNew();
var sysConfigService = serviceScope.ServiceProvider.GetRequiredService<SysConfigService>();
var daysAgo = await sysConfigService.GetConfigValueByCode<int>(ConfigConst.SysLogRetentionDays);
await logVisRep.AsDeleteable().Where(u => u.CreateTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
await logOpRep.AsDeleteable().Where(u => u.CreateTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
await logDiffRep.AsDeleteable().Where(u => u.CreateTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
await logTriggerRep.AsDeleteable().Where(u => u.CreatedTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
Console.WriteLine($"【定时任务】清理系统日志成功,清理 {daysAgo} 天前的日志数据 {DateTime.Now}");
// 清理临时目录 & Tus 过期分片
DeleteAllFiles(Path.Combine(App.WebHostEnvironment.WebRootPath, "temp"));
var tempStorePath = Path.Combine(App.WebHostEnvironment.WebRootPath, "upload", "tempStore");
if (Directory.Exists(tempStorePath))
{
var tusDiskStore = new tusdotnet.Stores.TusDiskStore(tempStorePath);
if (tusDiskStore is ITusExpirationStore expirationStore)
await expirationStore.RemoveExpiredFilesAsync(stoppingToken);
}
}
// ...DeleteAllFiles 递归删除
}自己写一个:订单超时自动关单
namespace Admin.NET.Application.Job;
/// <summary>
/// 订单超时自动取消(每 5 分钟)
/// </summary>
[JobDetail("order_timeout_job", Description = "订单超时自动取消", GroupName = "biz", Concurrent = false)]
[Cron("0 0/5 * * * ?", TriggerId = "order_timeout_trigger", Description = "每5分钟")]
public class OrderTimeoutJob(IServiceScopeFactory scopeFactory) : IJob
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
using var scope = _scopeFactory.CreateScope();
var rep = scope.ServiceProvider.GetRequiredService<SqlSugarRepository<BizOrder>>().CopyNew();
var config = scope.ServiceProvider.GetRequiredService<SysConfigService>();
var minutes = await config.GetConfigValueByCode<int>("SYS_ORDER_TIMEOUT_MINUTES");
var deadline = DateTime.Now.AddMinutes(-minutes);
var rows = await rep.AsUpdateable()
.SetColumns(u => u.Status == OrderStatusEnum.Canceled)
.SetColumns(u => u.Remark == "超时未支付自动取消")
.Where(u => u.Status == OrderStatusEnum.Unpaid && u.CreateTime < deadline)
.ExecuteCommandAsync(stoppingToken);
Log.Information($"【定时任务】超时关单 {rows} 笔,截止时间 {deadline}");
}
}变体:带自定义日志分类的清理作业
如果希望作业日志单独归类(便于日志平台按类别检索),可以注入 ILoggerFactory 并指定分类名:
namespace Admin.NET.Core;
/// <summary>
/// 清理日志作业任务
/// </summary>
[JobDetail("job_log", Description = "清理操作日志", GroupName = "default", Concurrent = false)]
[Daily(TriggerId = "trigger_log", Description = "清理操作日志")]
public class LogJob : IJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
public LogJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
{
_scopeFactory = scopeFactory;
_logger = loggerFactory.CreateLogger(CommonConst.SysLogCategoryName);
}
public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
using var serviceScope = _scopeFactory.CreateScope();
var logVisRep = serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogVis>>();
var logOpRep = serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogOp>>();
var logDiffRep = serviceScope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogDiff>>();
var daysAgo = 30; // 删除30天以前
await logVisRep.CopyNew().AsDeleteable().Where(u => (DateTime)u.CreateTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
await logOpRep.CopyNew().AsDeleteable().Where(u => (DateTime)u.CreateTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
await logDiffRep.CopyNew().AsDeleteable().Where(u => (DateTime)u.CreateTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken);
Console.WriteLine($"【{DateTime.Now}】清理系统日志(30天前)");
// 自定义日志分类
_logger.LogInformation($"【{DateTime.Now}】清理系统日志...");
}
}提示
作业里若要操作数据库,仓储必须 CopyNew(),否则会出现线程异常错误;同时整个任务体应包裹在 using var serviceScope = _scopeFactory.CreateScope() 作用域内。
四、IServiceScopeFactory 与 CopyNew
这是作业开发最容易出错的地方,四条铁律:
① 作业的生命周期是 Singleton 级别(常驻)
→ 不能直接构造函数注入 Scoped 的 SqlSugarRepository<T>、UserManager
② 每个作业需自建作用域
using var scope = _serviceScopeFactory.CreateScope();
③ 从 Scope 里取服务后必须 CopyNew()
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>().CopyNew();
var rep = scope.ServiceProvider.GetRequiredService<SqlSugarRepository<T>>().CopyNew();
④ 没有 App.User
→ 全局过滤器(租户、软删除、数据范围)全部不生效
→ 需要全量扫描时这是"特性";不该全量时要自己加 Where对比一下正确/错误:
// ❌ 错误:Scoped 注入进常驻作业
public class BadJob(SqlSugarRepository<SysLogVis> rep) : IJob { ... }
// ✅ 正确写法一:仓储 + CopyNew
using var scope = _scopeFactory.CreateScope();
var rep = scope.ServiceProvider.GetRequiredService<SqlSugarRepository<SysLogVis>>().CopyNew();
// ✅ 正确写法二:DbJobPersistence / AppEventSubscriber 的风格
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>().CopyNew();同一个坑在第 16 篇 MQTT、第 17 篇 RabbitMQ、第 18 篇事件总线里都存在,写法完全一致。
五、持久化到库
Admin.NET.Core/Service/Job/DbJobPersistence.cs 实现 Furion 的 IJobPersistence:
| 方法 | 何时触发 | 动作 |
|---|---|---|
PreloadAsync | 调度服务启动 | 读 SysJobDetail/SysJobTrigger 覆盖编译期定义;把 CreateType != BuiltIn 的脚本/http 作业动态编译后加进调度器 |
OnLoadingAsync | 作业计划初始化通知 | 原样返回 builder |
OnChangedAsync | JobDetail 变更 | Appended/Updated/Removed → SysJobDetail 增删改 |
OnTriggerChangedAsync | Trigger 变更 | Appended/Updated/Removed → SysJobTrigger 增删改 |
OnExecutionRecordAsync | 触发器运行结束 | 写 SysJobTriggerRecord(对应 /api/sysJob/jobTriggerRecordPage) |
涉及的表:SysJobDetail、SysJobTrigger、SysJobTriggerRecord、SysJobCluster。
脚本作业动态加载的关键几行:
// 获取数据库所有通过脚本创建的作业
var allDbScriptJobs = await db.Queryable<SysJobDetail>()
.Where(u => u.CreateType != JobCreateTypeEnum.BuiltIn).ToListAsync(stoppingToken);
foreach (var dbDetail in allDbScriptJobs)
{
Type jobType = dbDetail.CreateType switch
{
JobCreateTypeEnum.Script => dynamicJobCompiler.BuildJob(dbDetail.ScriptCode),
JobCreateTypeEnum.Http => typeof(HttpJob),
_ => throw new NotSupportedException(),
};
// 动态构建的 jobType 的程序集名称为随机名称,需重新设置
dbDetail.AssemblyName = jobType.Assembly.FullName!.Split(',')[0];
dbDetail.JobType = jobType.FullName;
var jobBuilder = JobBuilder.Create(jobType).LoadFrom(dbDetail);
// 强行设置为不扫描 IJob 实现类 [Trigger] 特性触发器,否则会重复添加同名触发器
jobBuilder.SetIncludeAnnotations(false);
var dbTriggers = await db.Queryable<SysJobTrigger>().Where(u => u.JobId == jobBuilder.JobId).ToListAsync();
var triggerBuilders = dbTriggers.Select(u => TriggerBuilder.Create(u.TriggerId).LoadFrom(u).Updated());
var schedulerBuilder = SchedulerBuilder.Create(jobBuilder, triggerBuilders.ToArray());
schedulerBuilder.Updated();
allJobs.Add(schedulerBuilder);
}六、集群分布式锁
Admin.NET.Core/Service/Job/JobMonitor.cs:
public class JobMonitor : IJobMonitor
{
public Task OnExecutingAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var sysCacheService = scope.ServiceProvider.GetRequiredService<SysCacheService>();
var key = $"{CacheConst.KeyDisLockJob}{context.JobId}"; // dis_lock_job:{jobId}
using var dis = sysCacheService.BeginCacheLock(key, throwOnFailure: false);
if (dis == null || sysCacheService.ExistKey(key)) return Task.CompletedTask;
// 标记作业正在运行,防止重复执行
sysCacheService.Set($"{key}:running", 1, TimeSpan.FromMinutes(60));
return Task.CompletedTask;
}
public async Task OnExecutedAsync(JobExecutedContext context, CancellationToken stoppingToken)
{
var key = $"{CacheConst.KeyDisLockJob}{context.JobId}";
sysCacheService.Remove($"{key}:running");
// 作业信息推企业微信(受 SysErrorWeCom 控制)
if (await sysConfigService.GetConfigValueByCode<bool>(ConfigConst.SysErrorWeCom))
await sysWeComService.PushScheduledTask(new() { TaskDesc = context.JobDetail.Description, ... });
if (context.Exception == null) return;
var exception = $"定时任务【{context.Trigger.Description}】错误:{context.Exception}";
_logger.LogError(exception);
await _eventPublisher.PublishAsync(nameof(AppEventSubscriber.SendErrorMail), exception, stoppingToken);
await _eventPublisher.PublishAsync(new ChannelEventSource(nameof(AppEventSubscriber.CreateExLog), sysLogEx));
}
}缓存:CacheConst.KeyDisLockJob = "dis_lock_job:"。集群下多节点竞争同一把 Redis 锁,抢不到就跳过本次执行;同时写 dis_lock_job:{jobId}:running 标记 60 分钟(超过 60 分钟的作业要留意这个 TTL)。
七、动态脚本任务 DynamicJobCompiler
// Admin.NET.Core/Job/DynamicJobCompiler.cs
public class DynamicJobCompiler : ISingleton
{
/// <summary>编译代码并返回其中实现 IJob 的类型</summary>
public Type BuildJob(string script)
{
using var jobAssembly = App.CompileCSharpClassCode(script);
var jobType = jobAssembly.Assembly.GetTypes().FirstOrDefault(u => typeof(IJob).IsAssignableFrom(u));
return jobType;
}
}使用入口 SysJobService.AddJobDetail(JobCreateTypeEnum.Script):
case JobCreateTypeEnum.Script when string.IsNullOrEmpty(input.ScriptCode):
throw Oops.Oh(ErrorCodeEnum.D1701); // 脚本代码不能为空
case JobCreateTypeEnum.Script:
jobType = _dynamicJobCompiler.BuildJob(input.ScriptCode);
if (jobType.GetCustomAttributes<JobDetailAttribute>().FirstOrDefault() is not JobDetailAttribute attr)
throw Oops.Oh(ErrorCodeEnum.D1702); // 缺少 [JobDetail]
if (attr.JobId != input.JobId)
throw Oops.Oh(ErrorCodeEnum.D1703); // JobId 与脚本里的不一致
break;
case JobCreateTypeEnum.Http:
jobType = typeof(HttpJob);
break;对应的错误码:
| 错误码 | 文案 |
|---|---|
D1701 | 脚本代码不能为空 |
D1702 | 脚本代码中的作业类,需要定义 [JobDetail] 特性 |
D1703 | 作业编号需要与脚本代码中的作业类 [JobDetail('jobId')] 一致 |
D1704 | 禁止修改作业编号 |
D1705 | 执行作业失败 |
脚本任务模板(在 定时任务管理 → 新增 界面里贴进去,作业编号填 hello_job):
// 注意:脚本里只能用 App.GetRequiredService<...>() 或注入的构造函数
// 不能使用 using / namespace 之外的复杂依赖(编译时只会引用已加载的程序集)
using Furion.Schedule;
public class HelloJob : IJob
{
private readonly IServiceScopeFactory _scopeFactory;
public HelloJob(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
[JobDetail("hello_job", Description = "脚本问候任务", GroupName = "script", Concurrent = false)]
[Period(5, TriggerId = "hello_trigger", Description = "每5秒")]
public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
Console.WriteLine($"【脚本任务】Hello from {context.JobId} @ {DateTime.Now}");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>().CopyNew();
// ... 业务逻辑
await Task.CompletedTask;
}
}HTTP 类型作业:把 CreateType 选成 Http,走 HttpJob(Admin.NET.Core/Job/Http/),向指定 URL 发起请求,适合做跨系统定时回调。
八、管理接口与 /schedule 看板
SysJobService(Admin.NET.Core/Service/Job/SysJobService.cs)暴露的接口:
| 作用 | 方法 / 路由 |
|---|---|
| 作业分页 | PageJobDetail → /api/sysJob/pageJobDetail |
| 作业组集合 | ListJobGroup |
| 新增/更新/删除作业 | AddJobDetail / UpdateJobDetail / DeleteJobDetail |
| 触发器列表/增删改 | GetJobTriggerList / AddJobTrigger / UpdateJobTrigger / DeleteJobTrigger |
| 暂停/启动/取消作业 | PauseAllJob / StartAllJob / PauseJob / StartJob / CancelJob |
| 立即执行作业 | RunJob |
| 暂停/启动触发器 | PauseTrigger / StartTrigger |
| 强制唤醒调度器 | CancelSleep |
| 强制持久化 | PersistAll |
| 集群列表 | GetJobClusterList |
| 运行记录分页 / 清空 | PageJobTriggerRecord / ClearJobTriggerRecord |
看板与开关:
// Admin.NET.Application/Configuration/App.json
"JobSchedule": {
"Enabled": true // 关掉后整个调度服务不启动(作业不再执行,但表数据还在)
}前端菜单:系统管理 → 定时任务
内嵌看板:/schedule(Furion.Schedule 自带 UI,展示作业/触发器/最近执行的 timeline)九、步骤清单
App.json确认JobSchedule.Enabled = true- 建作业类:实现
IJob,构造函数注入IServiceScopeFactory - 贴
[JobDetail("xxx_job", Description=..., GroupName=..., Concurrent=false)] - 贴触发器特性(
[Daily]/[Cron]/...),务必带TriggerId ExecuteAsync里:CreateScope()→ 取服务 →CopyNew()- 重启后 自动生成/更新
SysJobDetail、SysJobTrigger(PreloadAsync同步) - 在
/schedule或 系统管理 → 定时任务 里微调 cron、暂停/启动、点"执行一次"验证 - 需要临时脚本:新建时
CreateType = Script,贴脚本代码,作业编号必须与脚本里[JobDetail]的 jobId 完全一致
⚠️ 注意事项 / 常见坑
- Scoped 注入必炸:作业是常驻的,直接注入
SqlSugarRepository<T>/UserManager会得到已释放对象或跨线程争用。用IServiceScopeFactory+CopyNew()。 Concurrent = false不等于集群不重复:它只管单进程内并发;跨节点要靠JobMonitor的 Redis 锁。注意BeginCacheLock(key, throwOnFailure: false)失败返回 null 时 JobMonitor 是 return(不拦截执行),所以 Redis 不可用时作业仍可能被多节点执行。重要作业请在业务层做幂等。dis_lock_job:{jobId}:runningTTL 60 分钟:超过 1 小时的长作业,标记会过期;同时作业结束才 Remove,所以崩溃退出会残留 running 标记。- 脚本任务的
[JobDetail]jobId 必须和界面填的作业编号一致,否则报D1703;也禁止事后改作业编号(D1704)。 - 动态编译的程序集名是随机的,
AssemblyName/JobType会在PreloadAsync里被重写,别手工改这两列。 SetIncludeAnnotations(false)的原因:脚本作业若允许扫描[Trigger]特性会重复添加同名触发器,导致重复执行。- 改了脚本代码必须走
UpdateJobDetail(里面有_dynamicJobCompiler.BuildJob重新编译),只改数据库记录不生效。 AddJobDetail里有await Task.Delay(500):等持久化写完再补更新CreateType/ScriptCode,别觉得它慢就删掉。- 没有
App.User:作业里查询没有租户/软删除/数据范围过滤,IsDelete = true的数据也会被查出来。删除/统计类作业务必自己补.Where(u => u.IsDelete == false)。 - 时区/Cron 语义:
[Daily]默认 00:00:00,服务器时区为准;容器部署要确认TZ环境变量。 - 异常会被
JobMonitor吞掉并转邮件/企微:作业本身不会导致进程崩溃,但也意味着"跑挂了你可能只看日志",务必配SysErrorMail/SysErrorWeCom系统参数。 - 日志增长很快:
SysJobTriggerRecord会持续增长,靠CleanSysLogJob清理,并受ConfigConst.SysLogRetentionDays控制。
小结
- 声明式:
[JobDetail(jobId, ...)]+ 一个触发器特性,ExecuteAsync写业务 - 作用域:必须
IServiceScopeFactory.CreateScope()+CopyNew(),且无App.User(过滤器全失效) - 持久化:
DbJobPersistence把 JobDetail/Trigger/运行记录写进SysJobDetail、SysJobTrigger、SysJobTriggerRecord - 集群:
JobMonitor用dis_lock_job:{jobId}Redis 锁 + 异常走事件总线发邮件/企微 - 脚本作业:
DynamicJobCompiler.BuildJob(script)+CreateType=Script,jobId 要与脚本[JobDetail]一致(错误码 D1701~D1705) - 看板
/schedule+SysJobService十六个管理接口,App.json的JobSchedule.Enabled是总开关