📌 为什么要计算工作日后的日期?
在电商、金融、OA 系统开发中,我们经常需要计算若干个工作日(跳过周末和法定节假日)之后的日期。典型场景包括:
- 工单 SLA:承诺 5 个工作日内响应。
- 付款到期日:合同约定 3 个工作日内付款。
- 定时任务调度:只允许在交易日执行。
但问题在于:
- 周末固定(周六、日)容易处理。
- 法定节假日每年不同,需查阅国务院公告。
- 调休补班(如国庆前后周六上班)让简单的“周六日休息”规则失效。
因此,数据 + 算法缺一不可。本文提供一套基于 Java 8 java.time.LocalDate 的完整解决方案,核心算法采用“逐日递增 + 动态懒加载”策略,支持任意跨年(哪怕 workDays=300 也不怕),并附赠 Date ↔ LocalDate 互转工具,完美兼容老旧系统。
🧠 核心算法思路(简单粗暴,但有效)
算法本质是“一天一天地数”,直到数满指定的工作日数量。
判定逻辑(优先级从高到低)
对于每一天,我们根据它的“日期属性”判断是否为工作日:
| 周一 ~ 周五 | 默认是工作日,但如果该日期在“法定节假日黑名单”中,则不是工作日。 |
| 周六 ~ 周日 | 默认不是工作日,但如果该日期在“调休补班白名单”中,则是工作日(即调休上班)。 |
算法伪代码
输入:起始日期 startDate, 工作日数 workDays
输出:目标日期
current = startDate
已累计工作日 = 0
while (已累计工作日 < workDays) {
current = current + 1天
获取 current 的日期属性(是否周末、是否节假日、是否调休补班)
if (current 是工作日) {
已累计工作日 += 1
}
}
return current
这个算法简单、准确,且逐天递增的性能开销完全可以接受(即使 workDays=10000,也只是万次循环)。
⚡ 核心优化:动态懒加载(彻底解决跨年)
很多初学者会在代码中写:
if (startDate.getMonth() == DECEMBER && startDate.getDay() > 20) {
// 加载下一年的数据
}
这种写法有两大致命缺陷:
正确做法:在循环中按需加载。
维护一个 Map<Integer, Set<String>> 作为缓存,当 current 进入一个新的年份时,立即加载该年的节假日/补班数据。这样无论 workDays 多大,都能自动加载所有涉及的年份。
🌐 方案一:对接第三方节假日 API(零运维,开箱即用)
对于初创项目或不想维护数据表的场景,直接使用免费 API 是最快捷的方式。
这里以 ApiZero 为例(无需注册,每日 1000 次免费调用):
GET https://v1.apizero.cn/api/holiday?year=2026
返回 JSON 示例(字段说明):
{
"code": 200,
"data": {
"list": [
{
"holiday": "2026-10-01", // 法定假日(单日)
"vacation": "2026-10-01~2026-10-07", // 假期范围(或单日)
"remark": "2026-10-10" // 调休补班日期(周六/日上班)
}
]
}
}
1. Maven 依赖(用于解析 JSON)
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20230227</version>
</dependency>
2. 完整 Java 实现(包含动态懒加载)
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 工作日计算器(基于第三方 API,自动跨年)
*/
public class WorkDayCalculatorByAPI {
private static final String API_URL = "https://v1.apizero.cn/api/holiday";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
// 懒加载缓存:Key=年份, Value=该年的日期字符串集合(yyyy-MM-dd)
private static final Map<Integer, Set<String>> HOLIDAY_CACHE = new HashMap<>();
private static final Map<Integer, Set<String>> WORK_WEEKEND_CACHE = new HashMap<>();
/**
* 计算从 startDate 开始,经过 workDays 个工作日后的日期
*
* @param startDate 起始日期(当天不算,从第二天开始)
* @param workDays 需要累计的工作日数(正数)
* @return 目标日期
*/
public static LocalDate getDateAfterWorkDays(LocalDate startDate, int workDays) throws Exception {
LocalDate current = startDate;
int added = 0;
while (added < workDays) {
current = current.plusDays(1);
int year = current.getYear();
// 动态懒加载:当年数据尚未加载,则调用 API 拉取并放入缓存
if (!HOLIDAY_CACHE.containsKey(year)) {
HOLIDAY_CACHE.put(year, fetchHolidays(year));
WORK_WEEKEND_CACHE.put(year, fetchWorkWeekends(year));
}
String dateStr = current.format(FMT);
boolean isWeekend = current.getDayOfWeek().getValue() >= 6; // 6=周六, 7=周日
boolean isHoliday = HOLIDAY_CACHE.get(year).contains(dateStr);
boolean isWorkWeekend = WORK_WEEKEND_CACHE.get(year).contains(dateStr);
// 核心判定:周末仅在补班时算工作日,平日仅在不属于节假日时算工作日
boolean isWorkDay = isWeekend ? isWorkWeekend : !isHoliday;
if (isWorkDay) {
added++;
}
}
return current;
}
// ——————- API 交互 ——————-
/**
* 获取指定年份的法定节假日日期集合
*/
private static Set<String> fetchHolidays(int year) throws Exception {
JSONObject data = fetchApiData(year);
Set<String> holidays = new HashSet<>();
JSONArray list = data.optJSONArray("list");
if (list == null) return holidays;
for (int i = 0; i < list.length(); i++) {
JSONObject item = list.getJSONObject(i);
// 有些 API 用 holiday 字段,有些用 vacation,都取并集
String holiday = item.optString("holiday");
String vacation = item.optString("vacation");
if (holiday != null && !holiday.isEmpty()) {
holidays.addAll(expandDateRange(holiday));
}
if (vacation != null && !vacation.isEmpty()) {
holidays.addAll(expandDateRange(vacation));
}
}
return holidays;
}
/**
* 获取指定年份的调休补班日期集合(周六日上班)
*/
private static Set<String> fetchWorkWeekends(int year) throws Exception {
JSONObject data = fetchApiData(year);
Set<String> workWeekends = new HashSet<>();
JSONArray list = data.optJSONArray("list");
if (list == null) return workWeekends;
for (int i = 0; i < list.length(); i++) {
JSONObject item = list.getJSONObject(i);
// ApiZero 的补班字段为 remark,可能包含多个逗号分隔的日期
String remark = item.optString("remark");
if (remark != null && !remark.isEmpty()) {
for (String d : remark.split(",")) {
String trimmed = d.trim();
if (!trimmed.isEmpty()) {
workWeekends.add(trimmed);
}
}
}
}
return workWeekends;
}
/**
* 调用 API 获取原始 JSON 数据
*/
private static JSONObject fetchApiData(int year) throws Exception {
String urlStr = API_URL + "?year=" + year;
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int code = conn.getResponseCode();
if (code != 200) {
throw new RuntimeException("API 调用失败,HTTP 状态码:" + code);
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), "UTF-8"))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
JSONObject json = new JSONObject(sb.toString());
if (json.optInt("code") != 200) {
throw new RuntimeException("API 返回错误:" + json.optString("message"));
}
return json.getJSONObject("data");
} finally {
conn.disconnect();
}
}
/**
* 展开日期范围,例如 "2026-10-01~2026-10-07" 拆成每一天
*/
private static Set<String> expandDateRange(String range) {
Set<String> dates = new HashSet<>();
if (range == null || range.isEmpty()) return dates;
if (range.contains("~")) {
String[] parts = range.split("~");
if (parts.length == 2) {
try {
LocalDate start = LocalDate.parse(parts[0].trim(), FMT);
LocalDate end = LocalDate.parse(parts[1].trim(), FMT);
while (!start.isAfter(end)) {
dates.add(start.format(FMT));
start = start.plusDays(1);
}
} catch (Exception ignored) {
// 格式异常则忽略
}
}
} else {
dates.add(range);
}
return dates;
}
// ———- 测试 ———-
public static void main(String[] args) throws Exception {
LocalDate start = LocalDate.of(2026, 6, 1);
int workDays = 30;
LocalDate result = getDateAfterWorkDays(start, workDays);
System.out.println("从 " + start + " 起 " + workDays + " 个工作日后:" + result);
// 无论跨多少年,都会自动加载对应年份的数据
}
}
注意:workDays=300 虽然理论上可行,但现实中国家只公布未来一年的节假日安排,如果跨年太远(例如 2026 年要跨到 2027 年底),API 可能没有数据,此时需要人工介入或使用数据库方案。
🗄️ 方案二:自建数据库表(企业级推荐)
对于金融、政务等对数据主权和稳定性要求极高的场景,建议将节假日数据存储在自己的数据库中,每年根据国务院公告手动维护。
1. MySQL 表结构设计
CREATE TABLE `holiday` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`date` DATE NOT NULL COMMENT '日期(yyyy-MM-dd)',
`is_work_day` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0=休息日(法定节假日),1=工作日(调休补班)',
`year` INT NOT NULL COMMENT '年份(便于索引)',
`description` VARCHAR(100) DEFAULT NULL COMMENT '描述,例如“国庆节”、“调休补班”',
UNIQUE KEY `uk_date` (`date`),
KEY `idx_year` (`year`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. 示例数据(2026 年部分)
| 2026-01-01 | 0 | 元旦 |
| 2026-01-02 | 0 | 元旦调休 |
| 2026-01-03 | 0 | 元旦调休 |
| 2026-02-15 | 0 | 春节 |
| 2026-02-16 | 0 | 春节调休 |
| 2026-10-01 | 0 | 国庆节 |
| 2026-10-02 | 0 | 国庆调休 |
| 2026-10-07 | 0 | 国庆调休 |
| 2026-10-10 | 1 | 调休补班(周六上班) |
特别说明:
- 对于普通的周一至周五,无需插入任何记录,系统默认它们为工作日。
- 仅需插入两类“例外”:法定节假日(is_work_day=0)和调休补班(is_work_day=1,通常是周六日)。
3. Java 访问数据库(只写逻辑,不写具体连接代码)
在 Service 层,我们可以这样设计(省略 JDBC 细节,只展示核心流程):
public class WorkDayCalculatorByDB {
// 同样使用懒加载缓存
private static final Map<Integer, Set<String>> HOLIDAY_CACHE = new HashMap<>();
private static final Map<Integer, Set<String>> WORK_WEEKEND_CACHE = new HashMap<>();
public static LocalDate getDateAfterWorkDays(LocalDate startDate, int workDays) {
LocalDate current = startDate;
int added = 0;
while (added < workDays) {
current = current.plusDays(1);
int year = current.getYear();
// 如果缓存中没有该年的数据,则从数据库加载
if (!HOLIDAY_CACHE.containsKey(year)) {
HOLIDAY_CACHE.put(year, loadHolidaysFromDB(year));
WORK_WEEKEND_CACHE.put(year, loadWorkWeekendsFromDB(year));
}
String dateStr = current.format(DateTimeFormatter.ISO_LOCAL_DATE);
boolean isWeekend = current.getDayOfWeek().getValue() >= 6;
boolean isHoliday = HOLIDAY_CACHE.get(year).contains(dateStr);
boolean isWorkWeekend = WORK_WEEKEND_CACHE.get(year).contains(dateStr);
if (isWeekend ? isWorkWeekend : !isHoliday) {
added++;
}
}
return current;
}
// 从数据库加载指定年份的节假日(is_work_day=0)
private static Set<String> loadHolidaysFromDB(int year) {
// 执行 SQL: SELECT date FROM holiday WHERE year = ? AND is_work_day = 0
// 将结果集转为 Set<String> 并返回
return new HashSet<>(); // 伪代码
}
// 从数据库加载指定年份的调休补班(is_work_day=1)
private static Set<String> loadWorkWeekendsFromDB(int year) {
// 执行 SQL: SELECT date FROM holiday WHERE year = ? AND is_work_day = 1
return new HashSet<>(); // 伪代码
}
}
使用建议:
- 每年 11~12 月国务院公布次年节假日安排后,通过数据导入工具(如 Excel 导入或脚本)将数据插入 holiday 表。
- 应用启动时可将全量数据加载到内存缓存中,避免每次计算都查询数据库(数据量极小,每年不到 50 条)。
🔄 Date ↔ LocalDate 互转工具(兼容老旧系统)
如果你的项目仍在使用 java.util.Date,可以封装以下工具类无缝集成:
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
public class DateConverter {
/**
* java.util.Date → java.time.LocalDate
* 使用系统默认时区,如需指定时区可传入 ZoneId.of("Asia/Shanghai")
*/
public static LocalDate toLocalDate(Date date) {
return Instant.ofEpochMilli(date.getTime())
.atZone(ZoneId.systemDefault())
.toLocalDate();
}
/**
* java.time.LocalDate → java.util.Date
*/
public static Date toDate(LocalDate localDate) {
return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
}
使用示例:
Date oldStart = new Date();
LocalDate startLocal = DateConverter.toLocalDate(oldStart);
LocalDate resultLocal = WorkDayCalculatorByAPI.getDateAfterWorkDays(startLocal, 5);
Date resultDate = DateConverter.toDate(resultLocal);
📝 总结与最佳实践
| 跨年处理 | 务必使用本文的动态懒加载,在循环中按需加载年份数据,杜绝硬编码判断。 |
| 数据源选择 | 互联网项目可用 API(零维护),企业内网或对数据可控性要求高的项目必须用数据库。 |
| 缓存策略 | 节假日数据极少,建议使用 HashMap 做 JVM 内存缓存,避免重复调用 API 或查询数据库。 |
| 数据更新 | API 方案无需维护;数据库方案需每年根据国务院公告导入数据,并注意调休补班(标记 is_work_day=1)。 |
| 年份超限问题 | 若 workDays 过大(如 300),可能跨到未发布数据的未来年份。此时 API 可能返回空数据,数据库也可能无记录。建议业务上限制最大计算范围,或返回错误提示。 |
| 性能 | 逐日循环在 workDays 为几百时性能无影响,若达到数万次循环也仅需毫秒级,无需过度优化。 |
希望这篇博客能帮助你彻底解决“工作日计算”这一经典业务难题。 如果你有更好的思路或疑问,欢迎在评论区交流!
发布日期:2026-08-07
网硕互联帮助中心





评论前必须登录!
注册