定时任务
定时任务
定时任务用于数据比对、报表生成、过期清理、订单超时关闭等场景。系统基于 Quartz.NET 提供 Web 管理界面,支持新增、启动、停止、立即执行、运行监控与日志查看。任务定义存数据库,调度状态只在内存。
一、能力概览
| 能力 | 说明 |
|---|---|
| 三种任务类型 | 程序集(反射执行代码)、HTTP(调接口)、存储过程 / SQL |
| 两种触发器 | Cron 表达式(TriggerType=1)、固定间隔按秒(TriggerType=0) |
| 错过执行策略 | 按任务配置 MisfirePolicy:不补 / 立即补一次 / 按计划补全 |
| 统一分发 | 所有任务在 Quartz 里都注册为 Job_Dispatcher,运行时按 TaskType 路由 |
| 多租户展开 | 单租户、多个租户(a,b)、全部启用租户(*),逐租户独立日志 |
| 多实例防重 | 同一次计划触发只允许一个节点执行(依赖 Redis 才能跨节点) |
| 超时与重试 | 全局配置超时秒数与重试次数,超时的执行不再重试 |
| 运行监控 | GET /system/Tasks/runtime 返回调度器状态与各任务的运行态 |
| 失败通知 | 企业微信 / 邮件,带冷却时间,防刷屏 |
| 内置任务 | 租户到期提醒/停服、商城订单超时/自动收货、工作流超时/Webhook 重试 |
二、新建任务
菜单:系统监控 → 定时任务 → 新增。
| 字段 | 必填 | 说明 |
|---|---|---|
任务名称 Name | ✅ | 自定义,如「清理过期日志」 |
任务分组 JobGroup | ✅ | 取自字典 sys_job_group,如 system / default |
任务类型 TaskType | ✅ | 1 程序集 / 2 网络请求 / 3 存储过程或 SQL |
触发器类型 TriggerType | ✅ | 1 Cron 表达式 / 0 固定间隔 |
Cron Cron | TriggerType=1 时必填 | 表达式,可在线生成或让 AI 生成 |
间隔秒数 IntervalSecond | TriggerType=0 时必填 | 每 N 秒执行一次 |
错过执行策略 MisfirePolicy | 0 不补(默认)/ 1 立即补一次 / 2 按计划补全 | |
程序集名称 AssemblyName | 程序集任务必填 | 默认 ZR.Tasks;内置任务可能是 ZR.ServiceCore / ZR.Mall / ZR.Workflow |
任务类名 ClassName | 程序集任务必填 | 如 TaskScheduler.Job_SyncTest |
请求地址 ApiUrl | HTTP 任务必填 | 必须是 http / https |
请求方式 RequestMethod | 只认 POST,其它值一律按 GET 处理 | |
请求参数 JobParams | POST 时是请求体;GET 时拼到 query;存储过程任务时是参数 | |
SqlText | TaskType=3 必填 | 存储过程名(schema.Proc)或 SQL 语句;字段名沿用历史命名 |
租户 TenantId | 单租户 ID / a,b / *;非主租户自动锁定为本租户 | |
开始 / 结束时间 BeginTime / EndTime | 调度有效区间,留空表示立即开始、永不过期 |
六条必须知道的规则
- 创建后默认关闭(
IsStart=0),要点「更多 → 启动」才会按 Cron 调度; - 未启动也能执行一次:「运行一次」会临时创建一个一次性触发器,执行完即丢弃,不会改变
IsStart; - 运行中的任务不能修改:
IsStart=1时保存会提示「该任务正在运行中,请先停止再更新」,流程是 停止 → 修改 → 启动; - 修改不会自动重建调度:控制器有意不重建,避免把已停止的任务重新拉起来;
- 手动执行记录
IsManual=1、TriggerSource=manual,调度触发是IsManual=0、TriggerSource=cron; - SaaS 下非主租户只能操作本租户任务,
TenantId会被强制改写为当前租户。
三、Cron 表达式
[秒] [分] [小时] [日] [月] [周] [年]
| 名称 | 必填 | 值范围 | 允许的通配符 |
|---|---|---|---|
| 秒 | 是 | 0-59 | , - * / |
| 分 | 是 | 0-59 | , - * / |
| 时 | 是 | 0-23 | , - * / |
| 日 | 是 | 1-31 | , - * ? / L W |
| 月 | 是 | 1-12 | , - * / |
| 周 | 是 | 1-7 | , - * ? / L # |
| 年 | 否 | 1970-2099 | , - * / |
常用示例:
| 表达式 | 说明 |
|---|---|
0 0 2 * * ? | 每天凌晨 2 点 |
0 0/5 * * * ? | 每 5 分钟 |
0 0 9 ? * MON-FRI | 工作日早 9 点 |
0 30 10 1 * ? | 每月 1 号 10:30 |
0 0 0 L * ? | 每月最后一天 0 点 |
通配符说明:
*所有值;分字段设*表示每分钟触发;?不指定值。日和周互斥,写了一个另一个建议写?,如「每月 10 号」:0 0 0 10 * ?;-区间,小时设10-12表示 10、11、12 点;,枚举,周字段MON,WED,FRI表示周一、三、五;/递增,秒字段5/15表示第 5、20、35、50 秒;L最后,日字段为月末,周字段6L为本月最后一个周五;W最近工作日,15W为离 15 号最近的工作日;#第几个,6#3为每月第三个周六。
非法 Cron 的两处校验不一致
保存时控制器会用 CronExpression.IsValidExpression 校验,不合法直接报「cron表达式不正确」。
但调度层注册时若发现表达式非法,会静默降级为按间隔触发(用 IntervalSecond),不报错。所以不要绕过界面直接插库——写错的任务会表现成「按秒空跑」。
AI 生成 Cron
POST /system/Tasks/ai/cron(权限 monitor:job:ai):传入自然语言描述,返回 Cron 表达式与未来 3 次执行时间,服务端会用 Quartz 再校验一次。界面上对应定时任务弹窗里的 AI 按钮。
四、三种任务类型
4.1 程序集任务
代码放在 ZR.Tasks/TaskScheduler 目录(业务模块自带的放各自工程,如 ZR.Mall/TaskScheduler):
using Infrastructure.Attribute;
using Quartz;
using SqlSugar.IOC;
using ZR.Model.System;
namespace ZR.Tasks.TaskScheduler
{
/// <summary>
/// 定时任务示例:标注 [AppService] 后自动注入 DI,TasksExtension 里无需再注册
/// </summary>
[AppService(ServiceType = typeof(Job_SyncTest), ServiceLifetime = LifeTime.Scoped)]
public class Job_SyncTest : JobBase, IJob
{
public async Task Execute(IJobExecutionContext context)
{
await ExecuteJob(context, Run);
}
/// <summary>
/// 注意:内部所有方法都要 async,且不能漏 await
/// </summary>
public async Task Run()
{
await Task.Delay(1);
// 当前租户的库(分发器已切换好 TenantContext)
var db = DbScoped.SugarScope;
var info = await db.Queryable<SysDept>().FirstAsync();
// 访问指定库
// var db2 = DbScoped.SugarScope.GetConnectionScope(2);
}
}
}
- 继承
JobBase才能复用日志、耗时、异常上报;ExecuteJob有两个重载(固定返回success/ 保留字符串返回值); - 方法必须
async且不能漏await,否则异常不会被捕获,日志会记成功; - 取数据库用
DbScoped.SugarScope(已进入当前租户上下文),访问其它库用GetConnectionScope(租户ID); - 注册:优先用
[AppService]自动注册;也可以在TasksExtension.AddTaskSchedulers()里services.AddTransient<T>(); - 任务实际由
Job_Dispatcher反射调用,不是 Quartz 直接跑你的类,所以类名写错保存时不会拦,执行时才报错。
4.2 HTTP 任务
请求地址:https://api.example.com/task/clean
请求方式:POST / GET
请求参数:{"days": 30} 或 days=30&type=log
| 项 | 实际行为 |
|---|---|
| 协议限制 | 只允许 http / https,file://、gopher:// 等直接拒绝;不拦内网与回环地址(内网调用是正常用法) |
| 超时 | 30 秒(GET/POST 都是),超时按失败记录 |
| POST 请求体 | 以 text/plain; charset=utf-8 发送(代码未指定 contentType)。目标接口若要求 application/json,会返回 415 |
| GET 参数 | 直接拼到 URL 后;JobParams 为空时会拼出一个空的 ?(部分服务端会因此报错,建议留空就别填) |
| 成功判定 | 只看是否发生网络异常。目标返回 4xx/5xx 也算成功,返回体只写进日志供查 |
HTTP 任务不是「接口成功才算成功」
HttpHelper 不校验状态码,所以接口返回 500 时任务日志仍显示成功、失败率也不会升高。需要严格判定时,应让接口把结果落到可查询的地方,另配一个校验任务,或改用程序集任务直接调用业务服务。
4.3 存储过程任务
TaskType=3 且 SqlText 是带点的过程名(schema.Proc 或 db.schema.Proc)时,按存储过程执行:
SqlText dbo.sp_clean_expired_log
JobParams @days=30,@type='login'
或把参数写成 JSON:
{ "days": 30, "type": "login" }
| 规则 | 说明 |
|---|---|
| 过程名格式 | ^(?:标识符\.){1,2}标识符$,必须带 schema(或库名),不支持直接用无前缀的 proc |
| 参数名 | ^@?[A-Za-z_][A-Za-z0-9_]*$,最多 100 个,不允许重名 |
| 参数写法 | 内联 @a=1,@b='x'(支持单/双引号与 '' 转义)或 JobParams 里的 JSON 对象 |
| 二选一 | 内联与 JobParams 同时填会直接报错,避免参数来源不清 |
| 类型推断 | true/false → bool,整数 → long,小数 → decimal/double,null → DBNull,其余 → string |
| 安全校验 | 存储过程不经过 SqlTaskGuard;过程名与参数名由执行器单独校验,防注入 |
| 执行 | Ado.UseStoredProcedure().ExecuteCommand(过程名, 参数数组),在任务租户的库上执行 |
为什么复用 SqlText
历史版本 TaskType=3 表示「执行 SQL 语句」,SqlText 存语句。新增存储过程后仍沿用该字段(过程名带点即走存储过程分支),老任务的 SQL 语句不受影响,判断依据是「是否像 schema.Proc」。
4.4 SQL 任务
SqlText 是普通 SQL 时,在任务所属租户的库上执行(GetConnectionScope(tenantId).Ado):
update sys_logininfor set status = '1' where login_time < dateadd(day, -90, getdate())
执行前经 Infrastructure/Helper/SqlTaskGuard.cs 校验,不通过则任务失败并在日志写明原因:
| 规则 | 说明 |
|---|---|
| 剥离干扰项 | 先去掉字符串字面量与注释('a;b'、-- 注释里的分号不会误伤) |
| 禁多语句 | 骨架里出现 ; 即拒绝 |
| 禁批处理 | 出现独立词 GO 即拒绝 |
| 禁 DDL / 危险操作 | drop / alter / create / truncate / rename / grant / revoke / attach / backup / restore |
| 禁执行体 | exec / execute / declare / waitfor / shutdown |
| 禁系统存储过程 | 前缀 xp_ / sp_ |
| 禁全表写 | 以 update / delete 开头且顶层没有 WHERE 的语句(子查询里的 WHERE 不算) |
这是黑名单,不是白名单
SqlTaskGuard 只拦上表列出的东西,没有「首个关键字必须是 SELECT/INSERT/...」的白名单校验。因此 BULK INSERT、DBCC、OPENROWSET、USE、SET、KILL 等语句能通过;MERGE、WITH ... DELETE 也不受「顶层 WHERE」约束。
结论:SQL 任务只应开放给可信管理员。若存在配置被篡改的风险,应在数据库账号权限层收紧(任务连接账号不要有 DDL 权限)。
另外两条:
- 语句不要带结尾分号,否则被判为多语句拒绝;
- 不要写
use xxx;切库——库由任务的租户上下文决定,tenantId为空会直接报错。
4.5 内置任务
随初始化写入,幂等且不覆盖你在后台改过的 Cron。删除记录后普通重启不会恢复,只有重新执行初始化(--initdb / 建库流程)才会补回,所以不建议删除:
| 任务 | 分组 | Cron | 类型 | 默认状态 |
|---|---|---|---|---|
| 租户到期自动停服 | system | 0 0 2 * * ? | 程序集 ZR.ServiceCore.Job_TenantExpireSuspend | 停止 |
| 租户到期前提醒 | system | 0 0 9 * * ? | 程序集 Job_TenantExpireRemind(30/15/7/3/1 天阶梯提醒) | 停止 |
| 商城待付款订单超时自动取消 | mall | 0 0/5 * * * ? | 程序集 ZR.Mall.Job_ClosePendingOrder | 启动 |
| 商城发货后自动确认收货 | mall | 0 0 * * * ? | 程序集 Job_AutoConfirmReceipt(默认 7 天) | 启动 |
| 工作流超时自动处理 | workflow | 0 0/5 * * * ? | 程序集 ZR.Workflow.Job_WfTimeoutAutoProcess | 启动 |
| 工作流 Webhook 投递 | workflow | 0 0/2 * * * ? | 程序集 Job_WfWebhookRetry | 启动 |
- 系统两条由
SystemTaskSeedService幂等写入(经SeedDataService调用); - 商城 / 工作流任务随各自模块的
IModuleInitProvider.Seed写入,不会覆盖运营改过的 Cron; - 商城任务在 SaaS 下
TenantId为*(逐租户展开),非 SaaS 为主库 ID。
五、执行与治理
5.1 执行流程
Quartz 触发(系统里只注册一个 Job:Job_Dispatcher)
│
├─ 查任务模板
│ ├─ 不存在 → 清理孤儿调度(DeleteJob)+ 写一条失败日志
│ └─ 存在 → 继续
├─ 解析租户(单 ID / a,b / *)
├─ 防重占位(见第六节)→ 抢不到直接返回
└─ 逐租户:切换 TenantContext → 按 TaskType 执行 → 各自写一条日志
└─ 全部结束后统一更新任务统计
- 任务统计:
RunTimes/LastRunTime/LastRunStatus/LastSuccessTime/LastFailTime/LastErrorMsg每次触发统一更新一次(不是每租户一次); - 日志:每个租户一条,
InvokeTarget为类名 /HttpRequest/SqlExecute/StoredProcedure:过程名; - 防叠加:分发器标了
[DisallowConcurrentExecution],上一次没跑完时下一次触发会排队,不会并发; - 消息截断:任务消息超过 2000 字符截断,异常信息超过 5000 字符截断。
5.2 超时与重试(QuartzTask 配置节)
"QuartzTask": {
"Enabled": false, // 是否启动调度(见 9.1)
"TimeoutSeconds": 0, // 单次执行超时,0=不限
"MaxRetryCount": 0, // 失败重试次数,0=不重试
"RetryIntervalSeconds": 10, // 重试间隔
"NotifyOnFailure": false, // 失败是否通知
"NotifyChannels": [ "wx", "email" ],
"NotifyCooldownMinutes": 30, // 同一任务通知冷却
"NotifyEmails": "" // 邮件收件人,多个用逗号分隔
}
- 超时通过取消信号中断执行,抛
TaskExecutionTimeoutException;超时与主动取消不参与重试,避免长时间占用调度线程; - 重试次数与间隔只对「执行抛异常」生效;
- 通知走
JobFailureNotifier:企业微信需要配置WxCorp:CorpID,邮件需要Email节可用;NotifyCooldownMinutes内同一任务只通知一次,防止每分钟失败的任务刷屏。
5.3 错过执行策略(MisfirePolicy)
触发时刻到了但线程忙、或服务停机期间错过执行时按该策略处理:
| 取值 | 含义 | Cron 指令 | Simple 指令 |
|---|---|---|---|
0(默认) | 不补执行,等下一次计划 | DoNothing | NextWithRemainingCount |
1 | 立即补执行一次 | FireAndProceed | FireNow |
2 | 把错过的按计划补全 | IgnoreMisfires | IgnoreMisfires |
「运行一次」按钮固定按「立即执行」处理,与任务的 MisfirePolicy 无关。
六、多实例部署与执行防重
每个节点都会启动 Quartz 调度,因此框架内置「同一次计划触发只执行一次」:
Quartz 触发 → Job_Dispatcher.Execute()
│
├─ 占位键:zr:task:dispatch:{任务ID}:{计划触发时刻}
├─ CacheStore.Default.TrySet(键, 1, 30 分钟)
│ ├─ 成功 → 本节点执行
│ └─ 失败 → 记 Debug 日志后跳过(别的节点已接管)
└─ 执行完按租户分别写日志
- 依赖
TrySet的原子性:启用 Redis 时是跨节点原子占位(SET NX EX),能真正防重; - 未启用 Redis 时退化为进程内锁,只保证单实例内不重复,多节点仍会各跑一次 —— 多实例生产必须启用 Redis;
- 占位时长固定 30 分钟,只需覆盖单次执行耗时;
- 键里带计划触发时刻,所以同一任务的多次触发互不影响;
- 防重只保证「同一次触发不重复」,业务仍要自己幂等。
任务实例的 DI 作用域
JobFactory 为每个 Quartz Job 实例创建独立 DI 作用域,并用 ConditionalWeakTable 把「Job 实例 → 作用域」关联,ReturnJob 时移除并释放;解析失败也会立刻释放。长时间运行不会堆积 Scoped 依赖。
七、管理接口
基础路径:
/system/tasks(控制器声明为system/Tasks,路由不区分大小写)。权限前缀monitor:job:*。
| 操作 | 方法 | 路由 | 权限 |
|---|---|---|---|
| 任务列表 | GET | /list | monitor:job:list |
| 任务详情 | GET | /get?id= | 无(登录即可) |
| 运行监控 | GET | /runtime | monitor:job:list |
| 新增 | POST | /create | monitor:job:add |
| 修改 | POST | /update | monitor:job:edit |
| 删除 | DELETE | /delete?id= | monitor:job:delete |
| 启动 | GET | /start?id= | monitor:job:start |
| 停止 | GET | /stop?id= | monitor:job:stop |
| 立即执行 | GET | /run?id= | monitor:job:run |
| AI 生成 Cron | POST | /ai/cron | monitor:job:ai |
| 导出 | GET | /export | monitor:job:export |
列表响应的 extra 里带汇总统计:totalRuns / successRuns / failureRuns / successRate / failureRate。
- 启动:任务已在运行时返回「计划任务已经开启」;
- 停止:从 Quartz 中移除该 Job(不是暂停),停止后仍可用「运行一次」;
- 立即执行:任务不在调度器里时会临时创建一次性触发器,执行后自动丢弃。
运行监控 GET /runtime
返回两部分数据:
| 部分 | 字段 | 来源 |
|---|---|---|
| 调度器 | schedulerStarted | Quartz 调度器是否已启动 |
| 任务运行态 | isRunning / paused / nextFireTime / prevFireTime / triggerCount | Quartz 内存(GetCurrentlyExecutingJobs / GetTriggersOfJob) |
| 任务统计 | runTimes / lastRunStatus / lastRunTime / totalElapsedMs | 数据库(sys_tasks_log 的 Elapsed 求和) |
- 运行态不落库,服务重启后只有数据库统计还在;
- 暂不支持手动中断正在执行的任务(没有
Interrupt/Cancel实现),要终止只能停止调度或等本次执行结束。
日志接口
基础路径:
/monitor/jobLog。
| 操作 | 方法 | 路由 | 权限 |
|---|---|---|---|
| 日志列表 | GET | /list | 无(登录即可,默认查最近 7 天) |
| 删除指定日志 | DELETE | /{jobIds} | PRIV_JOBLOG_DELETE |
| 清空日志 | DELETE | /clean | PRIV_JOBLOG_REMOVE |
| 导出日志 | GET | /export | PRIV_JOBLOG_EXPORT |
日志字段
| 字段 | 说明 |
|---|---|
JobName / JobGroup | 任务名称与分组 |
Status | 0 成功、1 失败 |
JobMessage | 执行结果消息(超 2000 字符截断) |
Exception | 异常信息(超 5000 字符截断) |
Elapsed | 耗时(毫秒) |
InvokeTarget | 调用目标(类名 / HttpRequest / SqlExecute / StoredProcedure:xxx) |
Operator | 操作人(手动触发时是当前用户) |
IsManual | 0 系统触发、1 手动触发 |
TriggerSource | cron / manual |
TenantId | 所属租户 |
TraceId | 链路追踪 ID(无上游时取 Activity.Current) |
ServerName | 字段保留,当前未赋值,排查时不要依赖 |
日志不会自动清理
sys_tasks_log 没有清理任务、也没有保留天数配置,只有手动「清空」。高频任务(如每分钟)跑几个月就能积累百万行,且该表除主键外没有索引,列表查询会越来越慢。
建议:给高频任务配一个低频的清理任务(DELETE ... WHERE create_time < ...,注意租户),或定期在数据库侧归档。
八、SaaS 多租户
TenantId 值 | 行为 |
|---|---|
| 单个租户 ID | 只对该租户执行 |
a,b,c | 逗号分隔,对多个租户执行 |
* | 查主库 SysTenant 中「未删除且启用」的租户,逐个执行 |
- 每个租户的执行结果独立一条日志,分别记录耗时与结果;
- 所有租户跑完后统一更新任务统计(任一租户失败即整体失败);
- 非主租户的管理员看不到定时任务菜单:
monitor:job:在PlatformMenuPermPrefixes前缀限制内,任务管理属于平台级功能; - 非主租户即便直接调接口,控制器也会把
TenantId回落为当前租户,并校验任务归属。
九、注意事项与排查
9.1 调度默认不启动,由配置开关控制
"QuartzTask": { "Enabled": true } // 或环境变量 QuartzTask__Enabled=true
- 默认
false,即安装后不会自动跑任何任务,需要显式开启; - 不再按
IsProduction()判断环境 —— 开发、测试想验证调度,同样改这个开关; - 开关只影响「是否把
IsStart=1的任务注册进调度器」,不影响手动「运行一次」。
9.2 调度状态是内存态
Quartz 使用 RAMJobStore(appsettings.json 只配置了 quartz.serializer.type,没有 jobStore.type),所以:
- 重启后调度信息不在数据库里,是应用启动时按
IsStart=1的任务重新注册的; - 不要指望在
QRTZ_*表里看到任务 —— 本项目没有建这些表; - 集群部署时各节点独立加载,靠第六节的 Redis 占位避免重复执行。
序列化器固定为 newtonsoft:
"Quartz": {
"quartz.serializer.type": "newtonsoft" // binary 依赖的 BinaryFormatter 已被 .NET 9+ 移除
}
9.3 常见问题
| 现象 | 排查方向 |
|---|---|
| 任务到点不执行 | ① QuartzTask:Enabled 是否为 true(9.1);② IsStart 是否为 1;③ BeginTime/EndTime 是否已过期;④ 多实例下是否被别的节点占位(看 Debug 日志) |
| 任务偶尔少跑几次 | 看任务 MisfirePolicy(默认 0 不补跑),以及 QuartzTask:TimeoutSeconds 是否过小导致被中断 |
| 保存时报「cron表达式不正确」 | 表达式位数不对(Quartz 要 6~7 段),或日/周同时写了具体值 |
| 存储过程任务报「过程名格式不正确」 | 必须写 schema.Proc(如 dbo.sp_xxx),不能只写 sp_xxx |
| 存储过程参数不生效 | 内联参数与 JobParams 只能二选一;检查是否重名或超过 100 个 |
| 任务「执行成功」但数据没变 | SQL 任务检查租户库是否选对;HTTP 任务检查返回值(4xx/5xx 也算成功,见 4.2) |
| SQL 任务被拒 | 看日志里的具体原因:带分号、含禁词、UPDATE/DELETE 没有顶层 WHERE |
| 多实例重复执行 | Redis 是否启用(未启用只有单实例防重) |
| 没收到失败通知 | QuartzTask:NotifyOnFailure 是否为 true;企业微信要配 WxCorp:CorpID,邮件要 NotifyEmails 有值;冷却期内不会重复发 |
| 日志表越来越大 | 见第七节:无自动清理,需自建清理任务 |
| 任务类改了但行为没变 | 程序集任务是反射调用,需重新发布程序集;同名类变更后重启宿主 |
