Quartz 定时任务「新增功能」完整踩坑记:从 Cron 格式到事务回滚
一、背景介绍在开发定时任务管理系统时前端需要支持用户新增定时任务。功能包括任务名称、调用方法、Cron 表达式、并发策略、状态等。本文将从零到一记录完整的开发过程包括前端校验和后端逻辑的所有踩坑经历希望能帮助遇到类似问题的开发者。技术栈前端Vue 2 Element UI后端Spring Boot MyBatis Quartz数据库SQL Server二、最终效果预览text┌─────────────────────────────────────────────────────────────┐ │ Add Job │ ├─────────────────────────────────────────────────────────────┤ │ Job Name: [测试任务 ] │ │ Job Group: [System ▼] │ │ Invoke Method: [taskService.sync() ] │ │ Format: beanName.methodName() │ │ Cron Expression: [0 0 0 * * ? ] [Generate] │ │ ✓ Valid cron expression │ │ Concurrent: ● Allow Concurrent ○ Disallow │ │ Status: ● Enabled ○ Disabled │ ├─────────────────────────────────────────────────────────────┤ │ [Cancel] [OK] │ └─────────────────────────────────────────────────────────────┘三、踩坑历程坑1Cron 表达式格式错误现象用户输入0 0 * * ?5位后端 Quartz 需要 6 位秒 分 时 日 月 星期textCronExpression 0 0 * * ? is invalid原因前端没有校验 Cron 格式解决方案添加前端格式校验javascriptcheckCron() { const cron this.formData.cronExpression if (!cron) return const parts cron.trim().split(/\s/) if (parts.length ! 6) { this.cronValid false this.cronErrorMessage Cron must have 6 fields: second minute hour day month week return } // 秒、分、时范围校验 const second parseInt(parts[0]) const minute parseInt(parts[1]) const hour parseInt(parts[2]) if (second 0 || second 59) { this.cronValid false this.cronErrorMessage Second must be between 0 and 59 return } // ... 分钟、小时类似校验 }坑2用户不知道 Cron 怎么写现象用户面对空输入框不知道填什么格式原因缺少提示和示例解决方案添加 placeholder 和提示文字htmlel-input v-modelformData.cronExpression placeholdere.g., 0 0 0 * * ? / div classcron-hintFormat: second minute hour day month week/div div classcron-examples spanExamples: 0 0 0 * * ? (daily 00:00)/span span0 0 2 ? * SUN (weekly Sunday 02:00)/span /div坑3手写 Cron 容易出错现象用户写0 0 0 * * 中文问号或0 0 0 * *少字段原因纯手工输入没有辅助工具解决方案添加 Cron 生成器弹窗htmlel-popover triggerclick v-modelcronPopoverVisible placementbottom width400 div classcron-generator div classcron-row span classcron-labelSecond (0-59):/span el-select v-modelcronSecond sizemini clearable el-option v-fori in 59 :keyi :labeli :valuei / /el-select /div div classcron-row span classcron-labelMinute (0-59):/span el-select v-modelcronMinute sizemini clearable el-option v-fori in 59 :keyi :labeli :valuei / /el-select /div !-- 小时、日期、月份、星期选择器... -- div classcron-preview strongCron:/strong {{ generatedCron }} /div div styletext-align: right; el-button sizemini clickcronPopoverVisible falseCancel/el-button el-button typeprimary sizemini clickapplyCronApply/el-button /div /div el-button slotreference typetext iconel-icon-timeGenerate/el-button /el-popoverjavascriptcomputed: { generatedCron() { return ${this.cronSecond} ${this.cronMinute} ${this.cronHour} ${this.cronDay} ${this.cronMonth} ${this.cronWeek} } }, methods: { applyCron() { this.formData.cronExpression this.generatedCron this.cronPopoverVisible false this.checkCron() } }坑4日期和星期冲突现象用户同时选择了日期如 15 号和星期如 MondayQuartz 报错text? can only be specified for Day-of-Month or Day-of-Week原因Cron 规则中日期和星期不能同时指定解决方案添加互斥逻辑javascriptdata() { return { cronDay: *, cronWeek: ? } }, methods: { onDayChange() { if (this.cronDay ! ? this.cronDay ! *) { this.cronWeek ? } }, onWeekChange() { if (this.cronWeek ! ? this.cronWeek ! *) { this.cronDay ? } } }htmldiv classcron-row span classcron-labelDay of Month (1-31):/span el-select v-modelcronDay changeonDayChange el-option labelEvery day value* / el-option label? value? / el-option v-fori in 31 :keyi :labeli :valuei / /el-select span classcron-hint v-ifcronDay ! ? cronDay ! *(Week will be set to ?)/span /div坑5Bean 名大小写问题现象用户输入TaskService.sync()首字母大写后端找不到 BeantextBean not found: TaskService原因Spring Bean 默认首字母小写TaskService应该是taskService解决方案前端添加格式提示后端自动转换htmldiv classform-tip Format: beanName.methodName() (lowercase first letter) brExample: taskService.syncMonthLeepaOrder() /divjava// 后端自动将首字母转小写 String className invokeTarget.substring(0, dotIndex); String beanName Character.toLowerCase(className.charAt(0)) className.substring(1); Object bean SpringUtils.getBean(beanName);坑6方法名错误现象用户输入taskService.syncOrder()但实际方法名是syncLeepaOrdertextMethod not found: syncOrder()原因用户不知道有哪些可用方法也没有实时校验解决方案添加实时校验输入框失焦时校验htmlel-input v-modelformData.invokeTarget placeholdere.g., taskService.syncMonthLeepaOrder() blurcheckInvokeTarget / div v-ifinvokeTargetValid false classerror-tip {{ invokeTargetError }} /divjavascriptasync checkInvokeTarget() { const invokeTarget this.formData.invokeTarget if (!invokeTarget) { this.invokeTargetValid null return } // 格式校验 const dotIndex invokeTarget.indexOf(.) const paramStart invokeTarget.indexOf(() const paramEnd invokeTarget.lastIndexOf()) if (dotIndex -1 || paramStart -1 || paramEnd -1) { this.invokeTargetValid false this.invokeTargetError Invalid format. Expected: beanName.methodName() return } // 调用后端校验接口 const res await this.axios.post(/scheduler/job/validate, { invokeTarget: invokeTarget }) if (res.data res.data.valid) { this.invokeTargetValid true this.invokeTargetError } else { this.invokeTargetValid false this.invokeTargetError res.data.message } }坑7id 为 null 导致 Quartz 创建失败重点现象新增任务时Quartz 创建失败textjava.lang.NullPointerException at SchedulerJobService.createScheduleJob(SchedulerJobService.java:62)原因代码顺序错误先创建 Quartz 任务后插入数据库。此时job.id还是null错误代码java// ❌ 错误顺序 jobService.createScheduleJob(job); // job.id 还是 null jobMapper.insertJob(job);正确顺序java// ✅ 正确顺序 jobMapper.insertJob(job); // 先插入数据库获取自增 ID jobService.createScheduleJob(job); // 此时 job.id 已有值关键点MyBatis 的Options(useGeneratedKeys true)会在插入后自动回填 IDjavaInsert(INSERT INTO ELAB_SCHEDULER_JOB (jobName, jobGroup, invokeTarget, cronExpression, concurrent, status, createBy, createTime) VALUES (#{jobName}, #{jobGroup}, #{invokeTarget}, #{cronExpression}, #{concurrent}, #{status}, #{createBy}, GETDATE())) Options(useGeneratedKeys true, keyProperty id) int insertJob(SchedulerJob job);坑8Quartz 创建失败时数据库记录没有回滚现象Quartz 创建失败如方法名错误但数据库已经有记录了原因没有事务控制数据库插入成功但 Quartz 任务创建失败解决方案添加事务失败时自动回滚javaPostMapping(/add) Transactional(transactionManager eLab_TransactionManager, rollbackFor Exception.class) public WebResult add(RequestBody SchedulerJob job) { WebResult result new WebResult(); job.setCreateBy(getCurrentUser()); // 1. 插入数据库 int insertResult jobMapper.insertJob(job); if (insertResult 0) { result.error(Failed to insert job); return result; } // 2. 创建 Quartz 任务失败时事务自动回滚数据库 boolean success jobService.createScheduleJob(job); if (success) { result.setMessage(Job added successfully); } else { result.error(Failed to schedule job); } return result; }坑9重复提交现象用户快速连续点击 OK 按钮发送了多个请求原因没有防重复提交机制解决方案添加submitting状态javascriptdata() { return { submitting: false } }, methods: { async submitJob() { if (this.submitting) return this.$refs.jobForm.validate(async (valid) { if (!valid) return if (this.cronValid ! true) { this.$message.error(Please enter a valid cron expression) return } this.submitting true try { const res await this.axios.post(/scheduler/job/add, this.formData) if (res.data res.data.status 200) { this.$message.success(Job added) this.dialogVisible false this.loadJobList() } else { this.$message.error(res.data.message || Operation failed) } } catch (err) { this.$message.error(Operation failed) } finally { this.submitting false } }) } }htmlel-button typeprimary clicksubmitJob :loadingsubmittingOK/el-button坑10编辑时传了不该传的字段现象编辑任务时后端报错textFailed to convert property value of type java.lang.String to required type java.util.Date for property createTime原因{ ...row }把createTime、updateTime也复制了错误代码javascript// ❌ 错误写法 this.formData { ...row }正确写法javascript// ✅ 正确写法 this.formData { id: row.id, jobName: row.jobName, jobGroup: row.jobGroup, invokeTarget: row.invokeTarget, cronExpression: row.cronExpression, concurrent: row.concurrent, status: row.status }四、最终校验流程图text用户点击 OK │ ▼ submitting 检查 ───► 如果正在提交忽略 │ ▼ 表单验证 ───► 失败 ───► 显示错误提示 │ ▼ 通过 Cron 格式校验 ───► 失败 ───► 显示红色错误 │ ▼ 通过 显示绿色 ✓ Valid cron expression │ ▼ Invoke Method 校验 ───► 失败 ───► 显示错误 │ ▼ 通过 提交请求 │ ├──► 成功 ───► 关闭弹窗刷新列表 │ └──► 失败 ───► 显示错误信息 │ ▼ submitting false恢复按钮五、完整代码前端TaskSchedule.vue核心部分vuetemplate el-dialog :titledialogTitle :visible.syncdialogVisible width560px closeresetForm el-form refjobForm :modelformData :rulesformRules label-width120px sizesmall el-form-item labelJob Name propjobName el-input v-modelformData.jobName placeholderEnter job name / /el-form-item el-form-item labelJob Group propjobGroup el-select v-modelformData.jobGroup placeholderSelect job group el-option labelSystem valueDEFAULT / el-option labelCustom valueCUSTOM / /el-select /el-form-item el-form-item labelInvoke Method propinvokeTarget el-input v-modelformData.invokeTarget placeholdere.g., taskService.syncMonthLeepaOrder() blurcheckInvokeTarget / div classform-tip Format: beanName.methodName() (lowercase first letter) /div div v-ifinvokeTargetValid false classerror-tip {{ invokeTargetError }} /div /el-form-item el-form-item labelCron Expression propcronExpression el-input v-modelformData.cronExpression placeholdere.g., 0 0 0 * * ? blurcheckCron stylewidth: 100% / el-button typetext sizemini clickcronPopoverVisible true iconel-icon-time Generate /el-button div v-ifcronValid false classerror-tip {{ cronErrorMessage }} /div div v-ifcronValid true classsuccess-tip ✓ Valid cron expression /div !-- Cron 生成器弹窗 -- el-popover placementbottom width400 triggerclick v-modelcronPopoverVisible div classcron-generator div classcron-row span classcron-labelSecond (0-59):/span el-select v-modelcronSecond sizemini clearable el-option label0 value0 / el-option v-fori in 59 :keyi :labeli :valuei / /el-select /div div classcron-row span classcron-labelMinute (0-59):/span el-select v-modelcronMinute sizemini clearable el-option labelEvery minute value* / el-option v-fori in 59 :keyi :labeli :valuei / /el-select /div div classcron-row span classcron-labelHour (0-23):/span el-select v-modelcronHour sizemini clearable el-option labelEvery hour value* / el-option v-fori in 23 :keyi :labeli :valuei / /el-select /div div classcron-row span classcron-labelDay of Month (1-31):/span el-select v-modelcronDay sizemini clearable changeonDayChange el-option labelEvery day value* / el-option label? value? / el-option v-fori in 31 :keyi :labeli :valuei / /el-select span classcron-hint v-ifcronDay ! ? cronDay ! *(Week will be ?)/span /div div classcron-row span classcron-labelMonth (1-12):/span el-select v-modelcronMonth sizemini clearable el-option labelEvery month value* / el-option v-fori in 12 :keyi :labeli :valuei / /el-select /div div classcron-row span classcron-labelDay of Week (0-7):/span el-select v-modelcronWeek sizemini clearable changeonWeekChange el-option labelEvery day value* / el-option label? value? / el-option labelSunday value0 / el-option labelMonday value1 / el-option labelTuesday value2 / el-option labelWednesday value3 / el-option labelThursday value4 / el-option labelFriday value5 / el-option labelSaturday value6 / /el-select span classcron-hint v-ifcronWeek ! ? cronWeek ! *(Day will be ?)/span /div div classcron-preview strongCron:/strong {{ generatedCron }} /div div styletext-align: right; margin-top: 10px; el-button sizemini clickcronPopoverVisible falseCancel/el-button el-button typeprimary sizemini clickapplyCronApply/el-button /div /div el-button slotreference typetext sizemini iconel-icon-timeGenerate/el-button /el-popover /el-form-item el-form-item labelConcurrent propconcurrent el-radio-group v-modelformData.concurrent el-radio label1Allow Concurrent/el-radio el-radio label0Disallow Concurrent/el-radio /el-radio-group /el-form-item el-form-item labelStatus propstatus el-radio-group v-modelformData.status el-radio label0Enabled/el-radio el-radio label1Disabled/el-radio /el-radio-group /el-form-item /el-form span slotfooter classdialog-footer el-button clickdialogVisible falseCancel/el-button el-button typeprimary clicksubmitJob :loadingsubmittingOK/el-button /span /el-dialog /template script import cronParser from cron-parser export default { name: TaskSchedule, data() { return { submitting: false, dialogVisible: false, dialogTitle: , cronValid: null, cronErrorMessage: , invokeTargetValid: null, invokeTargetError: , cronPopoverVisible: false, cronSecond: 0, cronMinute: 0, cronHour: 0, cronDay: *, cronMonth: *, cronWeek: ?, formData: { id: null, jobName: , jobGroup: DEFAULT, invokeTarget: , cronExpression: , concurrent: 1, status: 0 }, formRules: { jobName: [{ required: true, message: Job name is required, trigger: blur }], jobGroup: [{ required: true, message: Job group is required, trigger: change }], invokeTarget: [{ required: true, message: Invoke method is required, trigger: blur }], cronExpression: [{ required: true, message: Cron expression is required, trigger: blur }] } } }, computed: { generatedCron() { return ${this.cronSecond} ${this.cronMinute} ${this.cronHour} ${this.cronDay} ${this.cronMonth} ${this.cronWeek} } }, methods: { checkCron() { const cron this.formData.cronExpression if (!cron) { this.cronValid null this.cronErrorMessage return } try { cronParser.parseExpression(cron) this.cronValid true this.cronErrorMessage } catch (err) { this.cronValid false this.cronErrorMessage err.message } }, checkInvokeTarget() { const invokeTarget this.formData.invokeTarget if (!invokeTarget) { this.invokeTargetValid null this.invokeTargetError return } const dotIndex invokeTarget.indexOf(.) const paramStart invokeTarget.indexOf(() const paramEnd invokeTarget.lastIndexOf()) if (dotIndex -1 || paramStart -1 || paramEnd -1) { this.invokeTargetValid false this.invokeTargetError Invalid format. Expected: beanName.methodName() return } const className invokeTarget.substring(0, dotIndex) const methodName invokeTarget.substring(dotIndex 1, paramStart) if (!className || !methodName) { this.invokeTargetValid false this.invokeTargetError Class name and method name cannot be empty return } this.invokeTargetValid true this.invokeTargetError }, applyCron() { this.formData.cronExpression this.generatedCron this.cronPopoverVisible false this.checkCron() }, onDayChange() { if (this.cronDay ! ? this.cronDay ! *) { this.cronWeek ? } }, onWeekChange() { if (this.cronWeek ! ? this.cronWeek ! *) { this.cronDay ? } }, handleAdd() { this.dialogTitle Add Job this.formData { id: null, jobName: , jobGroup: DEFAULT, invokeTarget: , cronExpression: , concurrent: 1, status: 0 } this.cronValid null this.cronErrorMessage this.invokeTargetValid null this.invokeTargetError this.dialogVisible true }, async submitJob() { if (this.submitting) return this.$refs.jobForm.validate(async (valid) { if (!valid) return if (!this.formData.cronExpression) { this.$message.error(Please enter cron expression) return } if (this.cronValid ! true) { this.$message.error(Please enter a valid cron expression) return } this.submitting true const isEdit !!this.formData.id const url isEdit ? /scheduler/job/update : /scheduler/job/add const submitData { jobName: this.formData.jobName, jobGroup: this.formData.jobGroup, invokeTarget: this.formData.invokeTarget, cronExpression: this.formData.cronExpression, concurrent: this.formData.concurrent, status: this.formData.status } if (isEdit) { submitData.id this.formData.id } try { const res await this.axios.post(url, submitData) if (res.data res.data.status 200) { this.$message.success(isEdit ? Job updated : Job added) this.dialogVisible false this.loadJobList() } else { this.$message.error(res.data.message || Operation failed) } } catch (err) { console.error(err) this.$message.error(Operation failed) } finally { this.submitting false } }) }, resetForm() { if (this.$refs.jobForm) { this.$refs.jobForm.resetFields() } this.cronValid null this.cronErrorMessage this.invokeTargetValid null this.invokeTargetError } } } /script style langscss scoped .form-tip { color: #909399; font-size: 12px; margin-top: 4px; } .error-tip { color: #f56c6c; font-size: 12px; margin-top: 4px; } .success-tip { color: #67c23a; font-size: 12px; margin-top: 4px; } .cron-generator { .cron-row { display: flex; align-items: center; margin-bottom: 10px; .cron-label { width: 130px; font-size: 12px; } .el-select { width: 120px; } .cron-hint { margin-left: 10px; font-size: 11px; color: #909399; } } .cron-preview { margin-top: 10px; padding: 8px; background-color: #f5f7fa; border-radius: 4px; font-size: 12px; word-break: break-all; } } /style后端 Controller核心部分javaRestController RequestMapping(/scheduler/job) public class SchedulerJobController { Autowired private SchedulerJobService jobService; Autowired private SchedulerJobMapper jobMapper; /** * 新增任务 */ PostMapping(/add) Transactional(transactionManager eLab_TransactionManager, rollbackFor Exception.class) public WebResult add(RequestBody SchedulerJob job) { WebResult result new WebResult(); // 1. 设置创建人 job.setCreateBy(getCurrentUser()); // 2. 先插入数据库获取自增 ID int insertResult jobMapper.insertJob(job); if (insertResult 0) { result.error(Failed to insert job); return result; } // 3. 创建 Quartz 任务如果失败事务自动回滚数据库 boolean success jobService.createScheduleJob(job); if (success) { result.setMessage(Job added successfully); } else { result.error(Failed to schedule job); } return result; } private String getCurrentUser() { // 从 token 获取当前登录用户 return system; } }ServiceQuartz 任务创建javaService public class SchedulerJobService { Autowired private Scheduler scheduler; public boolean createScheduleJob(SchedulerJob job) throws SchedulerException { // 1. 校验 invokeTarget if (!isValidateInvokeTarget(job.getInvokeTarget())) { return false; } // 2. 创建 JobDetail JobDetail jobDetail JobBuilder.newJob(QuartzJobExecution.class) .withIdentity(job.getId().toString(), job.getJobGroup()) .build(); jobDetail.getJobDataMap().put(JOB_PARAM_KEY, job); // 3. 创建 Trigger CronScheduleBuilder cronBuilder CronScheduleBuilder.cronSchedule(job.getCronExpression()); CronTrigger trigger TriggerBuilder.newTrigger() .withIdentity(job.getId().toString(), job.getJobGroup()) .withSchedule(cronBuilder) .build(); // 4. 调度任务 scheduler.scheduleJob(jobDetail, trigger); // 5. 如果任务是暂停状态 if (1.equals(job.getStatus())) { scheduler.pauseJob(jobDetail.getKey()); } return true; } private boolean isValidateInvokeTarget(String invokeTarget) { int dotIndex invokeTarget.indexOf(.); int paramStart invokeTarget.indexOf((); if (dotIndex -1 || paramStart -1) { log.info(Invalid format. Expected: beanName.methodName()); return false; } String className invokeTarget.substring(0, dotIndex); String methodName invokeTarget.substring(dotIndex 1, paramStart); String beanName Character.toLowerCase(className.charAt(0)) className.substring(1); // 校验 Bean 是否存在 Object bean; try { bean SpringUtils.getBean(beanName); } catch (Exception e) { log.info(Bean not found: beanName); return false; } // 校验方法是否存在 try { bean.getClass().getDeclaredMethod(methodName); return true; } catch (NoSuchMethodException e) { log.info(Method not found: methodName ()); return false; } } }六、总结坑问题解决方案1Cron 格式错误添加格式校验2用户不会写 Cron添加 placeholder 提示3手写容易出错添加 Cron 生成器4日期星期冲突添加互斥逻辑5Bean 名大小写后端自动转换首字母小写6方法名错误添加实时校验7id 为 null先插入数据库再创建 Quartz 任务8事务回滚添加Transactional9重复提交添加submitting状态10编辑传多余字段只复制需要的字段核心原则提前校验实时反馈先存数据再建任务事务保证一致性希望这篇文章能帮助到遇到类似问题的开发者。如有疑问欢迎评论区交流