修复CronPatternUtil.nextDateAfter当日为L时计算错误问题。(issue#4056@Github)

This commit is contained in:
Looly
2025-09-06 02:42:02 +08:00
parent 69fad96b30
commit 170492957f
10 changed files with 395 additions and 74 deletions

View File

@@ -2,7 +2,7 @@
# 🚀Changelog # 🚀Changelog
------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------
# 5.8.41(2025-09-04) # 5.8.41(2025-09-06)
### 🐣新特性 ### 🐣新特性
* 【core 】 增加`WeakKeyValueConcurrentMap`及其关联类,同时废弃`WeakConcurrentMap`并替换issue#4039@Github * 【core 】 增加`WeakKeyValueConcurrentMap`及其关联类,同时废弃`WeakConcurrentMap`并替换issue#4039@Github
@@ -20,6 +20,7 @@
* 【db 】 修复`Condition``Condition("discount_end_time", "!=", (String) null)`方法生成SQL时生成SQL不符合预期要求的错误pr#4042@Github * 【db 】 修复`Condition``Condition("discount_end_time", "!=", (String) null)`方法生成SQL时生成SQL不符合预期要求的错误pr#4042@Github
* 【core 】 修复`IoUtil``closeIfPosible`拼写错误,新建一个`closeIfPossible`方法原方法标记deprecatedissue#4047@Github * 【core 】 修复`IoUtil``closeIfPosible`拼写错误,新建一个`closeIfPossible`方法原方法标记deprecatedissue#4047@Github
* 【http 】 修复`HttpRequest.sendRedirectIfPossible`未对308做判断问题。issue#4053@Github * 【http 】 修复`HttpRequest.sendRedirectIfPossible`未对308做判断问题。issue#4053@Github
* 【cron 】 修复`CronPatternUtil.nextDateAfter`当日为L时计算错误问题。issue#4056@Github
------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------
# 5.8.40(2025-08-26) # 5.8.40(2025-08-26)

View File

@@ -36,6 +36,12 @@
<artifactId>hutool-setting</artifactId> <artifactId>hutool-setting</artifactId>
<version>${project.parent.version}</version> <version>${project.parent.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.4.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -6,11 +6,7 @@ import cn.hutool.cron.pattern.matcher.PatternMatcher;
import cn.hutool.cron.pattern.parser.PatternParser; import cn.hutool.cron.pattern.parser.PatternParser;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.*;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.TimeZone;
/** /**
* 定时任务表达式<br> * 定时任务表达式<br>

View File

@@ -23,7 +23,7 @@ public enum Part {
SECOND(Calendar.SECOND, 0, 59), SECOND(Calendar.SECOND, 0, 59),
MINUTE(Calendar.MINUTE, 0, 59), MINUTE(Calendar.MINUTE, 0, 59),
HOUR(Calendar.HOUR_OF_DAY, 0, 23), HOUR(Calendar.HOUR_OF_DAY, 0, 23),
DAY_OF_MONTH(Calendar.DAY_OF_MONTH, 1, 31), DAY_OF_MONTH(Calendar.DAY_OF_MONTH, 1, 32),
MONTH(Calendar.MONTH, Month.JANUARY.getValueBaseOne(), Month.DECEMBER.getValueBaseOne()), MONTH(Calendar.MONTH, Month.JANUARY.getValueBaseOne(), Month.DECEMBER.getValueBaseOne()),
DAY_OF_WEEK(Calendar.DAY_OF_WEEK, Week.SUNDAY.ordinal(), Week.SATURDAY.ordinal()), DAY_OF_WEEK(Calendar.DAY_OF_WEEK, Week.SUNDAY.ordinal(), Week.SATURDAY.ordinal()),
YEAR(Calendar.YEAR, 1970, 2099); YEAR(Calendar.YEAR, 1970, 2099);

View File

@@ -18,7 +18,12 @@ public class BoolArrayMatcher implements PartMatcher {
* 用户定义此字段的最小值 * 用户定义此字段的最小值
*/ */
private final int minValue; private final int minValue;
private final boolean[] bValues; /**
* 用户定义此字段的最大值
* @since 5.8.41
*/
private final int maxValue;
protected final boolean[] bValues;
/** /**
* 构造 * 构造
@@ -29,26 +34,37 @@ public class BoolArrayMatcher implements PartMatcher {
Assert.isTrue(CollUtil.isNotEmpty(intValueList), "Values must be not empty!"); Assert.isTrue(CollUtil.isNotEmpty(intValueList), "Values must be not empty!");
bValues = new boolean[Collections.max(intValueList) + 1]; bValues = new boolean[Collections.max(intValueList) + 1];
int min = Integer.MAX_VALUE; int min = Integer.MAX_VALUE;
int max = 0;
for (Integer value : intValueList) { for (Integer value : intValueList) {
min = Math.min(min, value); min = Math.min(min, value);
max = Math.max(max, value);
bValues[value] = true; bValues[value] = true;
} }
this.minValue = min; this.minValue = min;
this.maxValue = max;
} }
@Override @Override
public boolean match(Integer value) { public boolean match(Integer value) {
if (null == value || value >= bValues.length) { if(null != value && value >= minValue && value <= maxValue){
return false; return bValues[value];
} }
return bValues[value]; return false;
} }
@Override @Override
public int nextAfter(int value) { public int nextAfter(int value) {
if(value > minValue){ final int maxValue = this.maxValue;
while(value < bValues.length){ if(value == maxValue){
if(bValues[value]){ return value;
}
final int minValue = this.minValue;
if(value > minValue && value < maxValue){
final boolean[] bValues = this.bValues;
// 最大值永远小于数组长度,只需判断最大值边界
while(value <= maxValue){
if(value == maxValue || bValues[value]){
// 达到最大值或达到第一个匹配值
return value; return value;
} }
value++; value++;
@@ -70,6 +86,16 @@ public class BoolArrayMatcher implements PartMatcher {
return this.minValue; return this.minValue;
} }
/**
* 获取表达式定义的最大值
*
* @return 最大值
* @since 5.8.41
*/
public int getMaxValue() {
return this.maxValue;
}
@Override @Override
public String toString() { public String toString() {
return StrUtil.format("Matcher:{}", new Object[]{this.bValues}); return StrUtil.format("Matcher:{}", new Object[]{this.bValues});

View File

@@ -11,6 +11,12 @@ import java.util.List;
* @author Looly * @author Looly
*/ */
public class DayOfMonthMatcher extends BoolArrayMatcher { public class DayOfMonthMatcher extends BoolArrayMatcher {
/**
* 最后一天
*/
private static final int LAST_DAY = 32;
/** /**
* 构造 * 构造
* *
@@ -23,47 +29,135 @@ public class DayOfMonthMatcher extends BoolArrayMatcher {
/** /**
* 给定的日期是否匹配当前匹配器 * 给定的日期是否匹配当前匹配器
* *
* @param value 被检查的值,此处为日 * @param dayValue 被检查的值,此处为日
* @param month 实际的月份从1开始 * @param month 实际的月份从1开始
* @param isLeapYear 是否闰年 * @param isLeapYear 是否闰年
* @return 是否匹配 * @return 是否匹配
*/ */
public boolean match(int value, int month, boolean isLeapYear) { public boolean match(int dayValue, int month, boolean isLeapYear) {
return (super.match(value) // 在约定日范围内的某一天 return (super.match(dayValue) // 在约定日范围内的某一天
//匹配器中用户定义了最后一天31表示最后一天) //匹配器中用户定义了最后一天32表示最后一天)
|| (value > 27 && match(31) && isLastDayOfMonth(value, month, isLeapYear))); || matchLastDay(dayValue, month, isLeapYear));
} }
/** /**
* 是否为本月最后一天,规则如下: * 获取指定日之后的匹配值,也可以是其本身<br>
* <pre> * 如果表达式中存在最后一天(如使用"L"),则:
* 1、闰年2月匹配是否为29 * <ul>
* 2、其它月份是否匹配最后一天的日期可能为30或者31 * <li>4月、6月、9月、11月最多匹配到30日</li>
* </pre> * <li>4月闰年匹配到29日非闰年28日</li>
* </ul>
* *
* @param value 被检查的 * @param dayValue 指定的天
* @param month 月份从1开始 * @param month 月份从1开始
* @param isLeapYear 是否闰年 * @param isLeapYear 是否闰年
* @return 是否为本月最后一天 * @return 匹配到的值或之后的值
* @since 5.8.41
*/ */
private static boolean isLastDayOfMonth(int value, int month, boolean isLeapYear) { public int nextAfter(int dayValue, final int month, final boolean isLeapYear) {
return value == Month.getLastDay(month - 1, isLeapYear); final int maxValue = getMaxValue(month, isLeapYear);
final int minValue = getMinValue(month, isLeapYear);
if (dayValue > minValue) {
final boolean[] bValues = this.bValues;
// 最大值永远小于数组长度,只需判断最大值边界
while (dayValue <= maxValue) {
// 匹配到有效值
if (bValues[dayValue] ||
// 如果最大值不在有效值中,这个最大值表示最后一天,则在包含了最后一天的情况下返回最后一天
(dayValue == maxValue && match(LAST_DAY))) {
return dayValue;
}
dayValue++;
}
}
// 两种情况返回最小值
// 一是给定值小于最小值,那下一个匹配值就是最小值
// 二是给定值大于最大值,那下一个匹配值也是下一轮的最小值
return minValue;
} }
/**
* 是否包含最后一天
*
* @return 包含最后一天
*/
public boolean isLast() { public boolean isLast() {
return match(31); return match(32);
} }
/** /**
* 检查value是这个月的最后一天 * 检查value是这个月的最后一天
*
* @param value 被检查的值 * @param value 被检查的值
* @return * @param month 月份从1开始
* @param isLeapYear 是否闰年
* @return 是否是这个月的最后
*/ */
public boolean isLastDay(Integer value,Integer month, boolean isLeapYear) { public boolean isLastDay(Integer value, Integer month, boolean isLeapYear) {
if(isLastDayOfMonth(value, month, isLeapYear)) { return matchLastDay(value, month, isLeapYear);
return match(31);
}
return false;
} }
/**
* 获取表达式定义中指定月的最小日的值
*
* @param month 月base1
* @param isLeapYear 是否闰年
* @return 匹配的最小值
* @since 5.8.41
*/
public int getMinValue(final int month, final boolean isLeapYear) {
final int minValue = super.getMinValue();
if (LAST_DAY == minValue) {
// 用户指定了 L 等表示最后一天
return getLastDay(month, isLeapYear);
}
return minValue;
}
/**
* 获取表达式定义中指定月的最大日的值<br>
* 首先获取表达式定义的最大值,如果这个值大于本月最后一天,则返回最后一天,否则返回用户定义的最大值<br>
* 注意最后一天可能不是表达式中定义的有效值
*
* @param month 月base1
* @param isLeapYear 是否闰年
* @return 匹配的最大值
* @since 5.8.41
*/
public int getMaxValue(final int month, final boolean isLeapYear) {
return Math.min(super.getMaxValue(), getLastDay(month, isLeapYear));
}
/**
* 是否匹配本月最后一天,规则如下:
* <pre>
* 1、闰年2月匹配是否为29
* 2、其它月份是否匹配最后一天的日期可能为30或者31
* 3、表达式包含最后一天使用31表示
* </pre>
*
* @param dayValue 被检查的值
* @param month 月base1
* @param isLeapYear 是否闰年
* @return 是否为本月最后一天
*/
private boolean matchLastDay(final int dayValue, final int month, final boolean isLeapYear) {
return dayValue > 27
// 表达式中定义包含了最后一天
&& match(LAST_DAY)
// 用户指定的日正好是最后一天
&& dayValue == getLastDay(month, isLeapYear);
}
/**
* 获取最后一天
*
* @param month 月base1
* @param isLeapYear 是否闰年
* @return 最后一天
*/
private static int getLastDay(final int month, final boolean isLeapYear) {
return Month.getLastDay(month - 1, isLeapYear);
}
} }

View File

@@ -177,8 +177,6 @@ public class PatternMatcher {
* @return {@link Calendar}毫秒数为0 * @return {@link Calendar}毫秒数为0
*/ */
private int[] nextMatchValuesAfter(int[] values) { private int[] nextMatchValuesAfter(int[] values) {
final int[] newValues = values.clone();
int i = Part.YEAR.ordinal(); int i = Part.YEAR.ordinal();
// 新值,-1表示标识为回退 // 新值,-1表示标识为回退
int nextValue = 0; int nextValue = 0;
@@ -189,30 +187,20 @@ public class PatternMatcher {
continue; continue;
} }
// pr#1189 nextValue = getNextMatch(values, i, 0);
if (i == Part.DAY_OF_MONTH.ordinal()
&& matchers[i] instanceof DayOfMonthMatcher
&& ((DayOfMonthMatcher) matchers[i]).isLastDay(values[i],values[i+1],DateUtil.isLeapYear(values[Part.YEAR.ordinal()]))) {
int newMonth = newValues[Part.MONTH.ordinal()];
int newYear = newValues[Part.YEAR.ordinal()];
nextValue = getLastDay(newMonth, newYear);
} else {
nextValue = matchers[i].nextAfter(values[i]);
}
if (nextValue > values[i]) { if (nextValue > values[i]) {
// 此部分正常获取新值,结束循环,后续的部分置最小值 // 此部分正常获取新值,结束循环,后续的部分置最小值
newValues[i] = nextValue; values[i] = nextValue;
i--; i--;
break; break;
} else if (nextValue < values[i]) { } else if (nextValue < values[i]) {
// 回退前保存最新值
newValues[i] = nextValue;
// 此部分下一个值获取到的值产生回退,回到上一个部分,继续获取新值 // 此部分下一个值获取到的值产生回退,回到上一个部分,继续获取新值
i++; i++;
nextValue = -1;// 标记回退查找 nextValue = -1;// 标记回退查找
break; break;
} }
// 值不变,检查下一个部分 // 值不变,检查下一个部分
i--; i--;
} }
@@ -224,17 +212,12 @@ public class PatternMatcher {
// 周不参与计算 // 周不参与计算
i++; i++;
continue; continue;
} else if (i == Part.DAY_OF_MONTH.ordinal()
&& matchers[i] instanceof DayOfMonthMatcher
&& ((DayOfMonthMatcher) matchers[i]).isLastDay(values[i],values[i+1],DateUtil.isLeapYear(values[Part.YEAR.ordinal()]))) {
int newMonth = newValues[Part.MONTH.ordinal()];
int newYear = newValues[Part.YEAR.ordinal()];
nextValue = getLastDay(newMonth, newYear);
} else {
nextValue = matchers[i].nextAfter(values[i] + 1);
} }
nextValue = getNextMatch(values, i, 1);
if (nextValue > values[i]) { if (nextValue > values[i]) {
newValues[i] = nextValue; values[i] = nextValue;
i--; i--;
break; break;
} }
@@ -243,8 +226,32 @@ public class PatternMatcher {
} }
// 修改值以下的字段全部归最小值 // 修改值以下的字段全部归最小值
setToMin(newValues, i); setToMin(values, i);
return newValues; return values;
}
/**
* 获取指定部分的下一个匹配值,三种结果:
* <ul>
* <li>结果值大于原值:此部分已更新,后续部分取匹配的最小值。</li>
* <li>结果值小于原值:此部分获取到了最小值,上一个部分需要继续取下一个值。</li>
* <li>结果值等于原值此部分匹配获取下一个部分的next值</li>
* </ul>
*
* @param newValues 时间字段值,{second, minute, hour, dayOfMonth, monthBase1, dayOfWeekBase0, year}
* @param partOrdinal 序号
* @param plusValue 获取的偏移值
* @return 下一个值
*/
private int getNextMatch(final int[] newValues, final int partOrdinal, final int plusValue) {
if (partOrdinal == Part.DAY_OF_MONTH.ordinal() && matchers[partOrdinal] instanceof DayOfMonthMatcher) {
// 对于日需要考虑月份和闰年,单独处理
final boolean isLeapYear = DateUtil.isLeapYear(newValues[Part.YEAR.ordinal()]);
final int month = newValues[Part.MONTH.ordinal()];
return ((DayOfMonthMatcher) matchers[partOrdinal]).nextAfter(newValues[partOrdinal] + plusValue, month, isLeapYear);
}
return matchers[partOrdinal].nextAfter(newValues[partOrdinal] + plusValue);
} }
/** /**
@@ -253,19 +260,21 @@ public class PatternMatcher {
* @param values 值数组 * @param values 值数组
* @param toPart 截止的部分 * @param toPart 截止的部分
*/ */
private void setToMin(int[] values, int toPart) { private void setToMin(final int[] values, final int toPart) {
Part part; Part part;
for (int i = 0; i <= toPart; i++) { for (int i = toPart; i >= 0; i--) {
part = Part.of(i); part = Part.of(i);
if (part == Part.DAY_OF_MONTH if (part == Part.DAY_OF_MONTH) {
&& get(part) instanceof DayOfMonthMatcher final boolean isLeapYear = DateUtil.isLeapYear(values[Part.YEAR.ordinal()]);
&& ((DayOfMonthMatcher) get(part)).isLast()) { final int month = values[Part.MONTH.ordinal()];
int newMonth = values[Part.MONTH.ordinal()]; final PartMatcher partMatcher = get(part);
int newYear = values[Part.YEAR.ordinal()]; if (partMatcher instanceof DayOfMonthMatcher) {
values[i] = getLastDay(newMonth, newYear); values[i] = ((DayOfMonthMatcher) partMatcher).getMinValue(month, isLeapYear);
} else { continue;
values[i] = getMin(part); }
} }
values[i] = getMin(part);
} }
} }

View File

@@ -3,11 +3,14 @@ package cn.hutool.cron.pattern;
import cn.hutool.core.date.DateField; import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import static org.junit.jupiter.api.Assertions.*; import cn.hutool.core.lang.Console;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Calendar; import java.util.Calendar;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class CronPatternNextMatchTest { public class CronPatternNextMatchTest {
@Test @Test
@@ -182,6 +185,16 @@ public class CronPatternNextMatchTest {
} }
} }
@Test
public void testLastDayOfMonthForEveryYear3() {
DateTime date = DateUtil.parse("2022-03-08 07:44:16");
DateTime result = DateUtil.parse("2023-02-28 03:02:01");
// 匹配每一年2月的最后一天
CronPattern pattern = new CronPattern("1 2 3 L 2 ?");
Calendar calendar = pattern.nextMatchAfter(date.toCalendar());
Console.log(DateUtil.date(calendar));
}
@Test @Test
public void testEveryHour() { public void testEveryHour() {
DateTime date = DateUtil.parse("2022-02-28 07:44:16"); DateTime date = DateUtil.parse("2022-02-28 07:44:16");

View File

@@ -1,12 +1,15 @@
package cn.hutool.cron.pattern; package cn.hutool.cron.pattern;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CronPatternUtilTest { public class CronPatternUtilTest {
@Test @Test
@@ -44,4 +47,37 @@ public class CronPatternUtilTest {
assertEquals("2018-10-31 03:00:00", matchedDates.get(3).toString()); assertEquals("2018-10-31 03:00:00", matchedDates.get(3).toString());
assertEquals("2018-10-31 04:00:00", matchedDates.get(4).toString()); assertEquals("2018-10-31 04:00:00", matchedDates.get(4).toString());
} }
@Test
public void issue4056Test() {
// "*/5"和"1/5"意义相同从1号开始每5天一个匹配则匹配的天为
// 2025-02-01, 2025-02-06, 2025-02-11, 2025-02-16, 2025-02-21, 2025-02-26
// 2025-03-01, 2025-03-06, 2025-03-11, 2025-03-16, 2025-03-21, 2025-03-26, 2025-03-31
final String cron = "0 0 0 */5 * ? *";
final CronPattern cronPattern = new CronPattern(cron);
// 2025-02-28不应该在匹配之列
boolean match = cronPattern.match(DateUtil.parse("2025-02-28 00:00:00").toCalendar(), true);
Assertions.assertFalse( match);
match = cronPattern.match(DateUtil.parse("2025-03-01 00:00:00").toCalendar(), true);
Assertions.assertTrue( match);
match = cronPattern.match(DateUtil.parse("2025-03-31 00:00:00").toCalendar(), true);
Assertions.assertTrue( match);
}
@Test
public void issue4056Test2() {
final String cron = "0 0 0 */5 * ? *";
final CronPattern cronPattern = new CronPattern(cron);
final DateTime judgeTime = DateUtil.parse("2025-02-27 23:59:59");
final Date nextDate = CronPatternUtil.nextDateAfter(cronPattern, judgeTime);
// "*/5"和"1/5"意义相同从1号开始每5天一个匹配则匹配的天为
// 2025-02-01, 2025-02-06, 2025-02-11, 2025-02-16, 2025-02-21, 2025-02-26
// 2025-03-01, 2025-03-06, 2025-03-11, 2025-03-16, 2025-03-21, 2025-03-26, 2025-03-31
// 下一个匹配日期应为2025-03-01
Assertions.assertEquals("2025-03-01 00:00:00", nextDate.toString());
}
} }

View File

@@ -0,0 +1,140 @@
package cn.hutool.cron.pattern;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.quartz.CronExpression;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
public class Issue4056Test {
/**
* 见https://github.com/quartz-scheduler/quartz/issues/1298
* Quartz-2.5.0这块有bug只能使用2.4.0测认
*
* @throws ParseException 解析错误
*/
@Test
void testCronAll() throws ParseException {
final ArrayList<String> cronsList = new ArrayList<>();
final ArrayList<DateTime> judgeTimes = new ArrayList<>();
// 1. Cron 表达式40个
cronsList.add("0 0 0 * * ? *"); // 每天00:00
cronsList.add("0 0 12 * * ? *"); // 每天中午12:00
cronsList.add("0 0 18 * * ? *"); // 每天傍晚18:00
cronsList.add("0 0 6,12,18 * * ? *"); // 每天6点、12点、18点
cronsList.add("0 0 */6 * * ? *"); // 每6小时
cronsList.add("0 30 */8 * * ? *"); // 每8小时的30分
cronsList.add("0 */15 * * * ? *"); // 每15分钟
cronsList.add("0 */5 9-17 * * ? *"); // 工作时间内每5分钟
cronsList.add("0 0 0-23/2 * * ? *"); // 每2小时
cronsList.add("0 0 0 */8 * ? *"); // 每8天的00:00
cronsList.add("0 0 12 15 * ? *"); // 每月15日12:00
cronsList.add("0 0 0 L * ? *"); // 每月最后一天00:00
cronsList.add("0 0 0 29 2 ? *"); // 2月29日00:00闰年
cronsList.add("0 0 0 1 1 ? *"); // 每年1月1日00:00
cronsList.add("0 0/30 * * * ? *"); // 每小时0分和30分
cronsList.add("0 0 */4 * * ? *"); // 每4小时
cronsList.add("0 0 0 1/3 * ? *"); // 每3天00:00
cronsList.add("0 0 2 28-31 * ? *"); // 每月最后几天2:00
cronsList.add("0 0 0 1,15 * ? *"); // 每月1日和15日00:00
cronsList.add("0 0 0 1/5 * ? *"); // 每5天00:00
cronsList.add("0 0 0 1/10 * ? *"); // 每10天00:00
cronsList.add("0 0 0 1 */3 ? *"); // 每3个月的第1天00:00
cronsList.add("0 0 0 25 12 ? *"); // 圣诞节00:00
cronsList.add("0 0 12 31 12 ? *"); // 新年前夜12:00
cronsList.add("0 0 0 14 2 ? *"); // 情人节00:00
cronsList.add("0 0 10 1 5 ? *"); // 劳动节10:00
cronsList.add("0 0 9 8 3 ? *"); // 妇女节09:00
cronsList.add("0 0 0 1 4 ? *"); // 愚人节00:00
cronsList.add("0 0 12 4 7 ? *"); // 美国独立日12:00
cronsList.add("0 0 0 31 10 ? *"); // 万圣节00:00
cronsList.add("0 7,19,31,43,55 * * * ? *"); // 特定分钟
cronsList.add("0 */7 * * * ? *"); // 每7分钟
cronsList.add("0 15-45/5 * * * ? *"); // 每小时的15-45分之间每5分钟
cronsList.add("0 0-30/2 * * * ? *"); // 每小时前30分钟每2分钟
cronsList.add("0 45 23 * * ? *"); // 每天23:45
cronsList.add("0 59 23 * * ? *"); // 每天23:59
cronsList.add("0 0 */3 * * ? *"); // 每3小时
cronsList.add("0 0 9-18/2 * * ? *"); // 9点到18点每2小时
cronsList.add("0 0 22-2 * * ? *"); // 22点到次日2点每小时
cronsList.add("0 30 16 L * ? *"); // 每月最后一天16:30
// 2. 测试时间 (50个)
judgeTimes.add(DateUtil.parse("2025-02-01 18:20:10"));
judgeTimes.add(DateUtil.parse("2024-02-29 10:00:00"));
judgeTimes.add(DateUtil.parse("2025-12-31 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-01-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-06-15 12:00:00"));
judgeTimes.add(DateUtil.parse("2025-03-30 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-02-28 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-03-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-01-31 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-04-30 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-06-30 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-09-30 23:59:59"));
judgeTimes.add(DateUtil.parse("2026-01-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2024-02-28 00:00:00"));
judgeTimes.add(DateUtil.parse("2024-02-29 00:00:00"));
judgeTimes.add(DateUtil.parse("2024-02-29 23:59:59"));
judgeTimes.add(DateUtil.parse("2023-02-28 23:59:59"));
judgeTimes.add(DateUtil.parse("2028-02-29 12:00:00"));
judgeTimes.add(DateUtil.parse("2025-06-15 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-06-15 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-03-31 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-04-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-07-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-10-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-01-06 09:00:00"));
judgeTimes.add(DateUtil.parse("2025-01-10 17:00:00"));
judgeTimes.add(DateUtil.parse("2025-01-11 12:00:00"));
judgeTimes.add(DateUtil.parse("2025-01-12 12:00:00"));
judgeTimes.add(DateUtil.parse("2025-03-09 01:59:59"));
judgeTimes.add(DateUtil.parse("2025-03-09 03:00:00"));
judgeTimes.add(DateUtil.parse("2025-11-02 01:59:59"));
judgeTimes.add(DateUtil.parse("2025-11-02 01:00:00"));
judgeTimes.add(DateUtil.parse("2024-12-31 23:59:59"));
judgeTimes.add(DateUtil.parse("2024-01-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2026-12-31 23:59:59"));
judgeTimes.add(DateUtil.parse("2026-01-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-05-15 08:45:30"));
judgeTimes.add(DateUtil.parse("2025-08-22 14:20:15"));
judgeTimes.add(DateUtil.parse("2025-11-03 19:10:45"));
judgeTimes.add(DateUtil.parse("2025-02-14 09:30:00"));
judgeTimes.add(DateUtil.parse("2025-07-07 07:07:07"));
judgeTimes.add(DateUtil.parse("2025-09-09 09:09:09"));
judgeTimes.add(DateUtil.parse("2025-10-10 10:10:10"));
judgeTimes.add(DateUtil.parse("2025-12-12 12:12:12"));
judgeTimes.add(DateUtil.parse("2025-03-03 03:03:03"));
judgeTimes.add(DateUtil.parse("2025-06-06 06:06:06"));
judgeTimes.add(DateUtil.parse("2025-04-16 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-04-30 23:59:59"));
judgeTimes.add(DateUtil.parse("2025-05-01 00:00:00"));
judgeTimes.add(DateUtil.parse("2025-05-01 00:00:01"));
// 3. 计算并比对结果
for (final String cron : cronsList) {
final CronPattern hutoolCorn = new CronPattern(cron);
final CronExpression quartzCorn = new CronExpression(cron);
for (final DateTime judgeTime : judgeTimes) {
final Date quartzDate = quartzCorn.getNextValidTimeAfter(judgeTime);
final Date hutoolDate = CronPatternUtil.nextDateAfter(hutoolCorn, judgeTime);
Assertions.assertEquals(quartzDate, hutoolDate);
}
}
}
@Test
void issue4056Test() {
final String cron = "0 0 0 1/3 * ? *";
final CronPattern hutoolCorn = new CronPattern(cron);
final Date hutoolDate = CronPatternUtil.nextDateAfter(hutoolCorn, DateUtil.parse("2025-02-28 00:00:00"));
System.out.println(DateUtil.formatDateTime(hutoolDate));
}
}