|
|
@@ -139,6 +139,9 @@ public class RepeatTaskGenerator {
|
|
|
}
|
|
|
log.info("每小时重复任务生成完成,处理{}个模板", hourlyTasks.size());
|
|
|
|
|
|
+ // 6. 自定义周期任务(frequency=custom,每 N 个 DAY/WEEK/MONTH),SC-P-12 task.frequency.config
|
|
|
+ generateCustomPeriodTasks(todayCal);
|
|
|
+
|
|
|
} catch (Exception e) {
|
|
|
log.error("生成重复任务失败", e);
|
|
|
}
|
|
|
@@ -154,6 +157,137 @@ public class RepeatTaskGenerator {
|
|
|
return diffDays % 2 == 0;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SC-P-12 task.frequency.config: 自定义周期(每 N 个 DAY/WEEK/MONTH)任务实例生成。
|
|
|
+ *
|
|
|
+ * <p>只处理 {@code frequency=custom} 且未暂停({@code is_paused=0} 或 NULL)的模板;
|
|
|
+ * 今天是否落在周期上由 {@link #isCustomCycleDue(Task, Date)} 按锚点判定,
|
|
|
+ * 判定的推进计算由 {@link #computeNextOccurrence(Task, Date)} 提供(跨月/跨年不漂移)。
|
|
|
+ * 暂停的模板被查询条件排除,恢复后下一轮自动按原锚点继续。</p>
|
|
|
+ */
|
|
|
+ private void generateCustomPeriodTasks(Calendar todayCal) {
|
|
|
+ Date today = startOfDay(todayCal.getTime());
|
|
|
+ List<Task> customTasks = taskMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Task>()
|
|
|
+ .eq(Task::getIsTemplate, 1)
|
|
|
+ .eq(Task::getFrequency, "custom")
|
|
|
+ .and(w -> w.eq(Task::getIsPaused, 0).or().isNull(Task::getIsPaused))
|
|
|
+ );
|
|
|
+ for (Task template : customTasks) {
|
|
|
+ if (isCustomCycleDue(template, today) && !hasTaskForDate(template, today, 86400000L)) {
|
|
|
+ createTaskFromTemplate(template, today, false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("自定义周期任务生成完成,处理{}个模板", customTasks.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断今天是否落在自定义周期上(以 cycleAnchor 为第 0 个周期,逐周期推进到 >= 今天)。
|
|
|
+ */
|
|
|
+ private boolean isCustomCycleDue(Task template, Date today) {
|
|
|
+ Date anchor = cycleAnchorOf(template);
|
|
|
+ if (anchor == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Date cursor = startOfDay(anchor);
|
|
|
+ // 防御性上限:避免异常配置(间隔为 0/负数)导致死循环
|
|
|
+ int guard = 0;
|
|
|
+ while (cursor.before(today) && guard++ < 20000) {
|
|
|
+ cursor = startOfDay(computeNextOccurrence(template, cursor));
|
|
|
+ }
|
|
|
+ return sameDay(cursor, today);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SC-P-12 边界「周期计算跨月/跨年正确」:按周期锚点 + 间隔计算 {@code from} 之后的下一个周期日。
|
|
|
+ *
|
|
|
+ * <p>采用「锚点 + k×间隔」而非逐次累加,避免月末日期漂移
|
|
|
+ * (如 1 月 31 日按 1 个月推进:2 月 28 日 → 3 月 31 日,而非 3 月 28 日)。
|
|
|
+ * MONTH/YEAR 使用 {@link Calendar} 按月进位处理跨月/跨年,目标月天数不足时收敛到当月最后一天。</p>
|
|
|
+ */
|
|
|
+ private Date computeNextOccurrence(Task template, Date from) {
|
|
|
+ Date anchor = startOfDay(cycleAnchorOf(template));
|
|
|
+ Date fromDay = startOfDay(from);
|
|
|
+ String unit = normalizeUnit(template.getFrequencyUnit());
|
|
|
+ int interval = template.getFrequencyInterval() != null && template.getFrequencyInterval() > 0
|
|
|
+ ? template.getFrequencyInterval() : 1;
|
|
|
+
|
|
|
+ if ("MONTH".equals(unit) || "YEAR".equals(unit)) {
|
|
|
+ int stepMonths = "YEAR".equals(unit) ? 12 * interval : interval;
|
|
|
+ Calendar anchorCal = Calendar.getInstance();
|
|
|
+ anchorCal.setTime(anchor);
|
|
|
+ Calendar fromCal = Calendar.getInstance();
|
|
|
+ fromCal.setTime(fromDay);
|
|
|
+ int monthsDiff = (fromCal.get(Calendar.YEAR) - anchorCal.get(Calendar.YEAR)) * 12
|
|
|
+ + (fromCal.get(Calendar.MONTH) - anchorCal.get(Calendar.MONTH));
|
|
|
+ int k = monthsDiff > 0 ? monthsDiff / stepMonths : 0;
|
|
|
+ Calendar occurrence = monthOffset(anchorCal, k * stepMonths);
|
|
|
+ while (!occurrence.getTime().after(fromDay)) {
|
|
|
+ k++;
|
|
|
+ occurrence = monthOffset(anchorCal, k * stepMonths);
|
|
|
+ }
|
|
|
+ return occurrence.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ long stepDays = "WEEK".equals(unit) ? 7L * interval : interval;
|
|
|
+ long diffDays = (fromDay.getTime() - anchor.getTime()) / 86400000L;
|
|
|
+ long k = diffDays > 0 ? diffDays / stepDays : 0;
|
|
|
+ Calendar occurrence = Calendar.getInstance();
|
|
|
+ occurrence.setTime(anchor);
|
|
|
+ occurrence.add(Calendar.DAY_OF_MONTH, (int) (k * stepDays));
|
|
|
+ while (!occurrence.getTime().after(fromDay)) {
|
|
|
+ k++;
|
|
|
+ occurrence.setTime(anchor);
|
|
|
+ occurrence.add(Calendar.DAY_OF_MONTH, (int) (k * stepDays));
|
|
|
+ }
|
|
|
+ return occurrence.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 锚点月 + N 个月,日期收敛到目标月最后一天(1 月 31 日 + 1 个月 = 2 月 28/29 日)。
|
|
|
+ */
|
|
|
+ private Calendar monthOffset(Calendar anchorCal, int months) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(anchorCal.getTime());
|
|
|
+ int anchorDay = anchorCal.get(Calendar.DAY_OF_MONTH);
|
|
|
+ cal.set(Calendar.DAY_OF_MONTH, 1);
|
|
|
+ cal.add(Calendar.MONTH, months);
|
|
|
+ int lastDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
|
|
|
+ cal.set(Calendar.DAY_OF_MONTH, Math.min(anchorDay, lastDay));
|
|
|
+ return cal;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 周期锚点:优先 cycleAnchor,其次模板截止时间,缺省回退模板创建时间。
|
|
|
+ */
|
|
|
+ private Date cycleAnchorOf(Task template) {
|
|
|
+ if (template.getCycleAnchor() != null) {
|
|
|
+ return template.getCycleAnchor();
|
|
|
+ }
|
|
|
+ if (template.getDeadline() != null) {
|
|
|
+ return template.getDeadline();
|
|
|
+ }
|
|
|
+ return template.getCreatedAt();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 周期单位归一(day/daily → DAY,week/weekly → WEEK,month/monthly → MONTH,year/yearly → YEAR)。
|
|
|
+ */
|
|
|
+ private String normalizeUnit(String unit) {
|
|
|
+ if (unit == null || unit.trim().isEmpty()) {
|
|
|
+ return "DAY";
|
|
|
+ }
|
|
|
+ String u = unit.trim().toUpperCase(java.util.Locale.ROOT);
|
|
|
+ if ("WEEKLY".equals(u)) return "WEEK";
|
|
|
+ if ("MONTHLY".equals(u)) return "MONTH";
|
|
|
+ if ("YEARLY".equals(u)) return "YEAR";
|
|
|
+ return u;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean sameDay(Date a, Date b) {
|
|
|
+ return a != null && b != null && startOfDay(a).getTime() == startOfDay(b).getTime();
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 检查指定时间窗口内是否已存在该模板的任务实例
|
|
|
* @param windowMs 去重窗口(毫秒):86400000=当天,3600000=最近1小时
|