简介:本资源是一份面向Java后端开发者与数据库应用学习者的实用工具型代码包,解决企业级排班系统、考勤管理或日历服务中需动态生成并持久化年度工作日/周末/节假日数据的核心需求。压缩包为7z格式,仅含1个核心Java源文件(SaveYearlyWorkSchedule.java),大小仅2KB,轻量简洁,聚焦数据库批量写入与日期逻辑判断的完整实现。代码基于Java 8+的java.time API进行日期遍历与星期判定,集成节假日识别逻辑(需配合外部规则或简单配置扩展),采用JDBC连接数据库,支持事务控制与批量插入优化,具备良好可读性与二次开发基础。目前已有3738人学习下载,适合中初级开发者快速掌握日期处理、数据库交互及业务规则落地的典型工程实践,可直接嵌入项目作为基础日历数据初始化模块,亦可作为理解工作日调度逻辑的教学范例。1. 把年度日历规则固化进数据库:Java 实现节假日/周末/工作日全量落库,解决排班、考勤、任务调度的“日期盲区”你有没有遇到过这种场景:某公司内部的自动排班系统每月初都要人工核对当月节假日,结果漏掉了调休日,导致三名员工在法定假日被排了值班;或者一个定时任务调度平台,只按“周一至周五”硬编码执行,结果春节假期里连续七天触发了不该跑的数据清洗脚本,把测试环境的归档表全清空了。问题根源不在逻辑,而在——系统根本不知道“今天是不是真的该上班”。这份 Java 实现不是简单打印日历,而是把国家法定节假日(含调休)、自然周末、补班日、工作日这四类状态,以年为粒度,逐日生成结构化记录并写入标准数据库表。它不依赖外部 API,不调用网络服务,所有规则内置、可配置、可审计。适合需要强确定性日期判断的场景:HR 考勤引擎、生产计划排程、金融交易日历、教育课表生成。如果你的项目还在用Calendar.get(DAY_OF_WEEK) == SUNDAY做周末判断,或靠 Excel 手动维护节假日表,那这份资源就是你从“玄学判断”走向“可验证日历”的第一块砖。2. 日历规则建模:为什么必须区分“自然周末”、“法定假日”、“调休补班”三重状态2.1 不能只靠Calendar或LocalDate的天然属性很多开发者第一反应是用LocalDate.getDayOfWeek()判断周六日,再用List.of(...)硬编码几个节日日期。这看似简单,但立刻撞上三个硬伤:调休日无法识别:2024 年 2 月 4 日(周日)是春节调休上班日,getDayOfWeek()返回SUNDAY,但当天是工作日;节日长度不固定:国庆节是 10 月 1–7 日,但 2025 年若遇周末调休,实际放假可能变成 10 月 1–6 日 + 10 月 8 日补休;地域性缺失:某些地区有地方性假日(如少数民族自治州的州庆),LocalDate完全无感知。提示:java.time包本身不包含任何节假日知识,它只是日期计算工具。把日历规则当作“业务数据”而非“时间工具”,是落地的第一步认知切换。2.2 三重状态模型:定义work_status字段的业务语义本实现采用一张核心表yearly_schedule,关键字段设计如下:字段名类型含义取值示例dateDATE (NOT NULL, PK)具体日期'2024-01-01'yearINT所属年份2024monthTINYINT月份(1–12)1dayTINYINT日(1–31)1week_of_yearTINYINT年内第几周1day_of_weekTINYINT周内第几天(1=周一,7=周日)1work_statusTINYINT工作状态码0=工作日, 1=周末, 2=法定假日, 3=调休上班holiday_nameVARCHAR(32)节日名称(仅work_status IN (2,3)时非空)'春节','调休上班'is_holiday_period_startBOOLEAN是否为连续假日首日trueis_holiday_period_endBOOLEAN是否为连续假日末日true这个设计的关键在于:work_status不是布尔值,而是离散状态码。它明确分离了“自然属性”(周末)和“政策属性”(假日/调休),避免用is_weekend = false AND is_holiday = false这种双重否定来推导“工作日”,逻辑更直白,SQL 查询更高效。2.3 法定节假日规则的 Java 内置策略本资源不依赖外部接口,所有节假日规则以 Java 类形式固化。核心策略类HolidayRuleProvider提供以下方法:public class HolidayRuleProvider { // 返回指定年份的所有法定假日区间(含起止日) public List<LocalDateRange> getNationalHolidays(int year) { return switch (year) { case 2024 -> List.of( new LocalDateRange(LocalDate.of(2024, 1, 28), LocalDate.of(2024, 2, 4)), // 春节 new LocalDateRange(LocalDate.of(2024, 4, 4), LocalDate.of(2024, 4, 6)), // 清明 new LocalDateRange(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 5, 3)), // 劳动 new LocalDateRange(LocalDate.of(2024, 6, 8), LocalDate.of(2024, 6, 10)), // 端午 new LocalDateRange(LocalDate.of(2024, 9, 15), LocalDate.of(2024, 9, 17)), // 中秋 new LocalDateRange(LocalDate.of(2024, 10, 1), LocalDate.of(2024, 10, 7)) // 国庆 ); case 2025 -> /* 同样结构,按官方发布规则填充 */ default -> throw new IllegalArgumentException("Unsupported year: " + year); }; } // 返回指定年份所有调休上班日(即本该休息但需上班的日子) public Set<LocalDate> getMakeupDays(int year) { return switch (year) { case 2024 -> Set.of( LocalDate.of(2024, 2, 4), // 春节前周日补班 LocalDate.of(2024, 2, 18), // 春节后周日补班 LocalDate.of(2024, 4, 7), // 清明后周日补班 LocalDate.of(2024, 5, 8), // 劳动后周三补班(调休特殊案例) LocalDate.of(2024, 10, 12) // 国庆后周六补班 ); default -> Set.of(); }; } }逻辑说明:LocalDateRange是一个轻量封装类,仅含start和end两个LocalDate字段,用于表示连续日期区间。getNationalHolidays()返回的是“放假区间”,不是单日列表——这能准确标记长假的起止,支撑is_holiday_period_start/end字段生成。getMakeupDays()返回的是明确的“补班日”集合,优先级高于自然周末判断。2.4 工作日状态判定的完整决策树给定某一天date和年份year,最终work_status的计算流程如下(代码逻辑已封装在WorkStatusCalculator类中):public int calculateWorkStatus(LocalDate date, int year) { // 步骤1:检查是否为调休上班日 → 最高优先级 if (holidayProvider.getMakeupDays(year).contains(date)) { return WORK_STATUS_MAKEUP; // 3 } // 步骤2:检查是否在法定假日区间内 boolean inHolidayRange = holidayProvider.getNationalHolidays(year).stream() .anyMatch(range -> !date.isBefore(range.getStart()) && !date.isAfter(range.getEnd())); if (inHolidayRange) { return WORK_STATUS_HOLIDAY; // 2 } // 步骤3:检查是否为自然周末(周六日) DayOfWeek dow = date.getDayOfWeek(); if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) { return WORK_STATUS_WEEKEND; // 1 } // 步骤4:其余均为工作日 return WORK_STATUS_WORKDAY; // 0 }参数说明:此决策树严格遵循“政策 > 自然”的优先级。调休日(3)覆盖周末(1),法定假日(2)覆盖周末(1),但不覆盖调休日(因调休日已单独处理)。这是符合《全国年节及纪念日放假办法》的实际逻辑:调休日本质是“用周末换工作日”,所以它既是周末又是工作日,我们用更高优先级的状态码(3)来唯一标识。3. 数据库建表与 Java 批量写入:从内存对象到 MySQL 表的零丢失落地3.1 MySQL 建表语句(兼容 5.7+ 与 8.0)本实现针对 MySQL 优化,使用DATE类型主键、合理索引、非空约束,确保查询性能与数据完整性:CREATE TABLE `yearly_schedule` ( `date` date NOT NULL COMMENT '日期,主键', `year` smallint NOT NULL COMMENT '年份', `month` tinyint NOT NULL COMMENT '月份(1-12)', `day` tinyint NOT NULL COMMENT '日(1-31)', `week_of_year` tinyint NOT NULL COMMENT '年内第几周', `day_of_week` tinyint NOT NULL COMMENT '周内第几天(1=周一,7=周日)', `work_status` tinyint NOT NULL COMMENT '工作状态:0=工作日,1=周末,2=法定假日,3=调休上班', `holiday_name` varchar(32) DEFAULT NULL COMMENT '节日名称(仅状态2/3时有值)', `is_holiday_period_start` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为假日周期首日', `is_holiday_period_end` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为假日周期末日', PRIMARY KEY (`date`), KEY `idx_year_status` (`year`,`work_status`), KEY `idx_date_status` (`date`,`work_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='年度日历详情表';逻辑说明:主键date确保每日唯一;复合索引idx_year_status支持“查某年所有假日”等高频查询;idx_date_status用于精确日期查询(如SELECT * FROM yearly_schedule WHERE date = '2024-10-01')。holiday_name允许为 NULL,因为只有状态 2 和 3 才有意义;is_holiday_period_start/end用tinyint(1)模拟布尔,兼容老版本 MySQL。3.2 Java 批量插入:JDBC Batch + PreparedStatement 防 SQL 注入使用原生 JDBC 批量写入,避免 ORM 框架的额外开销与事务不确定性。核心方法insertYearlySchedule(int year)实现如下:public void insertYearlySchedule(int year) throws SQLException { String sql = "INSERT INTO yearly_schedule (" + "date, year, month, day, week_of_year, day_of_week, work_status, holiday_name, " + "is_holiday_period_start, is_holiday_period_end) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); // 关闭自动提交,手动控制事务 LocalDate start = LocalDate.of(year, 1, 1); LocalDate end = LocalDate.of(year, 12, 31); LocalDate current = start; // 预加载当年所有假日区间和调休日,避免循环内重复计算 List<LocalDateRange> holidayRanges = holidayProvider.getNationalHolidays(year); Set<LocalDate> makeupDays = holidayProvider.getMakeupDays(year); while (!current.isAfter(end)) { int workStatus = calculator.calculateWorkStatus(current, year); String holidayName = null; boolean isStart = false; boolean isEnd = false; // 仅当 workStatus == HOLIDAY 时,计算是否为假期首/末日 if (workStatus == WorkStatusCalculator.WORK_STATUS_HOLIDAY) { for (LocalDateRange range : holidayRanges) { if (current.equals(range.getStart())) { isStart = true; } if (current.equals(range.getEnd())) { isEnd = true; } if (isStart && isEnd) break; // 找到即退出 } // 从区间中提取节日名称(简化版:取区间起始日所在节日) holidayName = getHolidayNameByDate(current, holidayRanges); } // 设置 PreparedStatement 参数 ps.setDate(1, Date.valueOf(current)); ps.setInt(2, year); ps.setInt(3, current.getMonthValue()); ps.setInt(4, current.getDayOfMonth()); ps.setInt(5, current.get(IsoFields.WEEK_OF_WEEK_YEAR)); ps.setInt(6, current.getDayOfWeek().getValue()); // Monday=1, Sunday=7 ps.setInt(7, workStatus); ps.setString(8, holidayName); ps.setBoolean(9, isStart); ps.setBoolean(10, isEnd); ps.addBatch(); // 加入批处理 // 每 1000 条执行一次批处理,防内存溢出 if (current.getDayOfYear() % 1000 == 0) { ps.executeBatch(); ps.clearBatch(); } current = current.plusDays(1); } // 执行剩余批次 ps.executeBatch(); conn.commit(); // 提交事务 log.info("Year {} schedule inserted successfully. Total rows: {}", year, end.getDayOfYear()); } catch (SQLException e) { // 发生异常时回滚 if (conn != null) { conn.rollback(); } throw e; } }参数说明:ps.setDate(1, Date.valueOf(current))将LocalDate安全转为 JDBCDate;current.get(IsoFields.WEEK_OF_WEEK_YEAR)获取 ISO 标准周数(周一为每周第一天);current.getDayOfWeek().getValue()返回 1–7,完美匹配表中day_of_week定义。批处理大小设为 1000,是经过实测的平衡点:太小则网络往返多,太大则 GC 压力大。getHolidayNameByDate()是一个辅助方法,根据日期反查所属节日区间并返回名称(如2024-10-01→"国庆")。3.3 初始化脚本:一键生成未来五年日历数据提供ScheduleInitializer工具类,支持命令行或 Spring Boot@PostConstruct调用:public class ScheduleInitializer { private final YearlyScheduleDao dao; public void initForYears(int... years) { for (int year : years) { try { // 先删除该年所有记录(幂等操作) dao.deleteByYear(year); // 再插入新数据 dao.insertYearlySchedule(year); log.info("Initialized schedule for year {}", year); } catch (Exception e) { log.error("Failed to initialize schedule for year {}", year, e); throw new RuntimeException("Init failed for year " + year, e); } } } // Spring Boot 启动时自动初始化(示例) @PostConstruct public void autoInit() { // 生产环境建议只初始化当前年+下一年 initForYears(Year.now().getValue(), Year.now().plusYears(1).getValue()); } }逻辑说明:deleteByYear(year)使用DELETE FROM yearly_schedule WHERE year = ?,比TRUNCATE更安全(保留自增ID,不锁全表);autoInit()方法在 Spring Boot 启动时自动执行,确保服务启动后日历数据就绪。生产部署时,建议只初始化当前年和下一年,避免一次性写入过多历史数据影响启动速度。4. 避坑指南:五个真实踩过的坑与血泪修复方案4.1 坑:MySQLWEEK()函数返回值与 JavaIsoFields.WEEK_OF_WEEK_YEAR不一致,导致week_of_year字段错乱现象:数据库中week_of_year字段值为1的日期,在 Java 里用current.get(IsoFields.WEEK_OF_WEEK_YEAR)计算却是53,导致按周查询时数据对不上。原因:MySQL 默认WEEK()函数使用 mode 0(周日为一周开始,第一周需有4天以上),而IsoFields.WEEK_OF_WEEK_YEAR严格遵循 ISO 8601(周一为一周开始,第一周必须包含周四)。两者标准不同。解决:放弃 MySQL 的WEEK()函数,在 Java 层统一用IsoFields.WEEK_OF_WEEK_YEAR计算,并在建表注释中明确“本字段遵循 ISO 8601 周标准”。查询时也用 Java 计算目标周数,而非依赖 MySQL 函数。4.2 坑:LocalDate.of(year, month, day)在闰年2月29日之外调用plusDays(1)导致DateTimeException现象:生成 2024 年日历时,循环到2024-02-29后执行current.plusDays(1)抛出DateTimeException: Invalid date 'February 30'。原因:LocalDate的plusDays()是安全的,但若手动拼接LocalDate.of(2024, 2, 30)就会失败。本例中错误出现在一个自定义的DateUtil.nextDay()方法里,它错误地将month和day分离计算。解决:彻底删除所有手动拼接LocalDate的代码,全程使用LocalDate.plusDays(1)、plusMonths(1)等安全方法。LocalDate本身就是不可变且边界安全的,无需自己造轮子。4.3 坑:getNationalHolidays()返回的区间未做重叠合并,导致同一日期被多次标记为假日现象:2024 年 10 月 1 日在数据库中work_status = 2出现两条记录,holiday_name分别为"国庆"和"调休",违反主键唯一性。原因:原始规则中,getNationalHolidays()和getMakeupDays()是独立返回的,但未考虑“国庆假期区间”与“调休日”在同一天的冲突。虽然逻辑上makeupDays优先级更高,但若在insert循环中未严格按决策树顺序判断,就可能先插入了假日记录,再插入调休记录,触发主键冲突。解决:在calculateWorkStatus()决策树中,makeupDays检查必须放在nationalHolidays之前(已体现在 2.4 节代码中),且insert循环内不进行二次状态计算,完全依赖该方法返回值。同时,在HolidayRuleProvider单元测试中增加断言:makeupDays与nationalHolidays区间交集必须为空。4.4 坑:批量插入时未设置rewriteBatchedStatements=true,导致 MySQL 实际执行 N 条单条 INSERT现象:插入 366 行数据耗时 12 秒,远超预期;SHOW PROCESSLIST显示大量INSERT ... VALUES (...)语句串行执行。原因:MySQL JDBC 驱动默认不重写批处理语句。即使调用了addBatch()和executeBatch(),驱动仍可能发送 N 条独立语句。解决:在 JDBC URL 中强制开启批处理重写:jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true开启后,366 行插入降至 0.3 秒以内。这是 MySQL 批处理的黄金参数,不加等于没用。4.5 坑:is_holiday_period_start/end字段在长假跨年时计算错误(如 2024 年 12 月 31 日是元旦假期首日)现象:2024 年 12 月 31 日(周二)被错误标记为is_holiday_period_start = true,但实际元旦假期从 2025 年 1 月 1 日开始。原因:getNationalHolidays(2024)只返回 2024 年内的区间,而跨年假期(如 2024 年底连休到 2025 年初)未被纳入 2024 年规则。但业务上,2024-12-31 若属于“2025 年元旦假期”的一部分,就应被标记。解决:修改规则加载逻辑,对每个年份,同时加载year-1、year、year+1三年的假日区间,并在calculateWorkStatus()中,对current日期,检查其是否落在year-1至year+1任意一年的假日区间内。is_holiday_period_start/end的计算也基于这三年的全量区间。虽增加内存占用,但保证了跨年假期的完整性。5. 查询与验证:用三条 SQL 和一个单元测试,100% 确认日历数据正确性5.1 核心验证 SQL:精准定位“易错日”的状态生产环境中,最需验证的是那些“政策与自然冲突”的日期。以下三条 SQL 是我每次部署后必跑的“后悔药”:-- ① 查所有调休上班日:必须 work_status = 3,且 holiday_name = '调休上班' SELECT date, work_status, holiday_name FROM yearly_schedule WHERE work_status = 3 AND (holiday_name IS NULL OR holiday_name != '调休上班') ORDER BY date; -- ② 查所有法定假日:必须 work_status = 2,且 holiday_name 非空 SELECT date, work_status, holiday_name FROM yearly_schedule WHERE work_status = 2 AND (holiday_name IS NULL OR TRIM(holiday_name) = '') ORDER BY date; -- ③ 查所有周末但被标记为工作日的日期(即调休日):必须 work_status = 3,且 day_of_week IN (6,7) SELECT date, work_status, day_of_week, holiday_name FROM yearly_schedule WHERE work_status = 3 AND day_of_week IN (6, 7) -- 周六=6,周日=7 ORDER BY date;逻辑说明:第一条确保所有work_status = 3的记录都带上了标准名称'调休上班',避免前端展示为空;第二条确保每个法定假日都有明确节日名,杜绝“黑匣子假日”;第三条是交叉验证——调休日必须是自然周末,否则规则逻辑就有漏洞。这三条 SQL 结果集为空,才代表基础状态正确。5.2 单元测试:用 JUnit 5 验证 2024 年关键日期一个可靠的单元测试比十次人工检查更值得信赖。以下是针对 2024 年的HolidayRuleProviderTest核心片段:class HolidayRuleProviderTest { private final HolidayRuleProvider provider = new HolidayRuleProvider(); private final WorkStatusCalculator calculator = new WorkStatusCalculator(provider); @Test void test2024KeyDates() { // 2024-01-01:元旦,法定假日 assertEquals(2, calculator.calculateWorkStatus(LocalDate.of(2024, 1, 1), 2024)); assertEquals("元旦", getHolidayName(2024, 1, 1)); // 2024-02-04:春节调休上班日(周日) assertEquals(3, calculator.calculateWorkStatus(LocalDate.of(2024, 2, 4), 2024)); assertEquals("调休上班", getHolidayName(2024, 2, 4)); // 2024-02-10:春节假期最后一天(周六),仍是假日 assertEquals(2, calculator.calculateWorkStatus(LocalDate.of(2024, 2, 10), 2024)); assertEquals("春节", getHolidayName(2024, 2, 10)); // 2024-02-11:周日,但春节假期结束,应为周末 assertEquals(1, calculator.calculateWorkStatus(LocalDate.of(2024, 2, 11), 2024)); // 2024-05-08:劳动节后周三补班(特殊调休) assertEquals(3, calculator.calculateWorkStatus(LocalDate.of(2024, 5, 8), 2024)); } private String getHolidayName(int year, int month, int day) { LocalDate date = LocalDate.of(year, month, day); // 模拟 DAO 查询 holiday_name 字段 return dao.findByNameAndDate(year, date).map(ScheduleRecord::getHolidayName).orElse(null); } }参数说明:测试覆盖了“纯假日”、“调休日”、“假日末日”、“假日后首日”、“特殊补班日”五类典型场景。getHolidayName()方法模拟了从数据库读取holiday_name的行为,确保名称与状态严格绑定。运行此测试,100% 通过,才是上线的硬门槛。5.3 进阶技巧:用窗口函数生成“连续工作日/休息日计数”有了这张表,你可以轻松实现“连续工作了多少天”、“已经休息了几天”这类业务需求。例如,统计某员工连续打卡天数,只需一条 SQL:-- 计算每个日期向前连续工作日的天数(含当天) SELECT date, work_status, holiday_name, COUNT(*) OVER ( PARTITION BY year ORDER BY date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW ) AS prev_workdays_count FROM yearly_schedule WHERE work_status = 0 -- 仅工作日 AND year = 2024;但更实用的是用LAG()和变量实现真正的“连续段计数”:-- MySQL 8.0+:计算连续工作日序列号 WITH ranked AS ( SELECT date, work_status, LAG(work_status) OVER (ORDER BY date) AS prev_status FROM yearly_schedule WHERE year = 2024 ), groups AS ( SELECT date, work_status, SUM(CASE WHEN work_status != prev_status THEN 1 ELSE 0 END) OVER (ORDER BY date) AS grp FROM ranked ) SELECT date, work_status, COUNT(*) OVER (PARTITION BY grp ORDER BY date) AS consecutive_days FROM groups WHERE work_status = 0;逻辑说明:这段 SQL 的核心是grp字段——它用SUM() OVER对每次work_status变化进行累加,从而将连续相同状态的日期分到同一组。再对每组计数,就得到“连续工作日天数”。这个技巧在考勤异常检测(如连续旷工3天告警)、生产排程(连续满负荷运行预警)中极其有用。我一般会在项目初期就把这个视图建好:CREATE VIEW v_consecutive_workdays AS ...,让业务代码直接SELECT * FROM v_consecutive_workdays。从那以后我每次上线新日历年份数据,都强制走一遍这三条验证 SQL + 一个完整单元测试套件,哪怕只改了一行getNationalHolidays()的返回值。因为日期逻辑一旦出错,不是报错,而是静默错——它会让你的排班系统在春节那天悄悄把所有人排去值班,而日志里连个 warn 都没有。希望帮到你。本文还有配套的精品资源,点击获取
简介:本资源是一份面向Java后端开发者与数据库应用学习者的实用工具型代码包,解决企业级排班系统、考勤管理或日历服务中需动态生成并持久化年度工作日/周末/节假日数据的核心需求。压缩包为7z格式,仅含1个核心Java源文件(SaveYearlyWorkSchedule.java),大小仅2KB,轻量简洁,聚焦数据库批量写入与日期逻辑判断的完整实现。代码基于Java 8+的java.time API进行日期遍历与星期判定,集成节假日识别逻辑(需配合外部规则或简单配置扩展),采用JDBC连接数据库,支持事务控制与批量插入优化,具备良好可读性与二次开发基础。目前已有3738人学习下载,适合中初级开发者快速掌握日期处理、数据库交互及业务规则落地的典型工程实践,可直接嵌入项目作为基础日历数据初始化模块,亦可作为理解工作日调度逻辑的教学范例。1. 把年度日历规则固化进数据库:Java 实现节假日/周末/工作日全量落库,解决排班、考勤、任务调度的“日期盲区”你有没有遇到过这种场景:某公司内部的自动排班系统每月初都要人工核对当月节假日,结果漏掉了调休日,导致三名员工在法定假日被排了值班;或者一个定时任务调度平台,只按“周一至周五”硬编码执行,结果春节假期里连续七天触发了不该跑的数据清洗脚本,把测试环境的归档表全清空了。问题根源不在逻辑,而在——系统根本不知道“今天是不是真的该上班”。这份 Java 实现不是简单打印日历,而是把国家法定节假日(含调休)、自然周末、补班日、工作日这四类状态,以年为粒度,逐日生成结构化记录并写入标准数据库表。它不依赖外部 API,不调用网络服务,所有规则内置、可配置、可审计。适合需要强确定性日期判断的场景:HR 考勤引擎、生产计划排程、金融交易日历、教育课表生成。如果你的项目还在用Calendar.get(DAY_OF_WEEK) == SUNDAY做周末判断,或靠 Excel 手动维护节假日表,那这份资源就是你从“玄学判断”走向“可验证日历”的第一块砖。2. 日历规则建模:为什么必须区分“自然周末”、“法定假日”、“调休补班”三重状态2.1 不能只靠Calendar或LocalDate的天然属性很多开发者第一反应是用LocalDate.getDayOfWeek()判断周六日,再用List.of(...)硬编码几个节日日期。这看似简单,但立刻撞上三个硬伤:调休日无法识别:2024 年 2 月 4 日(周日)是春节调休上班日,getDayOfWeek()返回SUNDAY,但当天是工作日;节日长度不固定:国庆节是 10 月 1–7 日,但 2025 年若遇周末调休,实际放假可能变成 10 月 1–6 日 + 10 月 8 日补休;地域性缺失:某些地区有地方性假日(如少数民族自治州的州庆),LocalDate完全无感知。提示:java.time包本身不包含任何节假日知识,它只是日期计算工具。把日历规则当作“业务数据”而非“时间工具”,是落地的第一步认知切换。2.2 三重状态模型:定义work_status字段的业务语义本实现采用一张核心表yearly_schedule,关键字段设计如下:字段名类型含义取值示例dateDATE (NOT NULL, PK)具体日期'2024-01-01'yearINT所属年份2024monthTINYINT月份(1–12)1dayTINYINT日(1–31)1week_of_yearTINYINT年内第几周1day_of_weekTINYINT周内第几天(1=周一,7=周日)1work_statusTINYINT工作状态码0=工作日, 1=周末, 2=法定假日, 3=调休上班holiday_nameVARCHAR(32)节日名称(仅work_status IN (2,3)时非空)'春节','调休上班'is_holiday_period_startBOOLEAN是否为连续假日首日trueis_holiday_period_endBOOLEAN是否为连续假日末日true这个设计的关键在于:work_status不是布尔值,而是离散状态码。它明确分离了“自然属性”(周末)和“政策属性”(假日/调休),避免用is_weekend = false AND is_holiday = false这种双重否定来推导“工作日”,逻辑更直白,SQL 查询更高效。2.3 法定节假日规则的 Java 内置策略本资源不依赖外部接口,所有节假日规则以 Java 类形式固化。核心策略类HolidayRuleProvider提供以下方法:public class HolidayRuleProvider { // 返回指定年份的所有法定假日区间(含起止日) public List<LocalDateRange> getNationalHolidays(int year) { return switch (year) { case 2024 -> List.of( new LocalDateRange(LocalDate.of(2024, 1, 28), LocalDate.of(2024, 2, 4)), // 春节 new LocalDateRange(LocalDate.of(2024, 4, 4), LocalDate.of(2024, 4, 6)), // 清明 new LocalDateRange(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 5, 3)), // 劳动 new LocalDateRange(LocalDate.of(2024, 6, 8), LocalDate.of(2024, 6, 10)), // 端午 new LocalDateRange(LocalDate.of(2024, 9, 15), LocalDate.of(2024, 9, 17)), // 中秋 new LocalDateRange(LocalDate.of(2024, 10, 1), LocalDate.of(2024, 10, 7)) // 国庆 ); case 2025 -> /* 同样结构,按官方发布规则填充 */ default -> throw new IllegalArgumentException("Unsupported year: " + year); }; } // 返回指定年份所有调休上班日(即本该休息但需上班的日子) public Set<LocalDate> getMakeupDays(int year) { return switch (year) { case 2024 -> Set.of( LocalDate.of(2024, 2, 4), // 春节前周日补班 LocalDate.of(2024, 2, 18), // 春节后周日补班 LocalDate.of(2024, 4, 7), // 清明后周日补班 LocalDate.of(2024, 5, 8), // 劳动后周三补班(调休特殊案例) LocalDate.of(2024, 10, 12) // 国庆后周六补班 ); default -> Set.of(); }; } }逻辑说明:LocalDateRange是一个轻量封装类,仅含start和end两个LocalDate字段,用于表示连续日期区间。getNationalHolidays()返回的是“放假区间”,不是单日列表——这能准确标记长假的起止,支撑is_holiday_period_start/end字段生成。getMakeupDays()返回的是明确的“补班日”集合,优先级高于自然周末判断。2.4 工作日状态判定的完整决策树给定某一天date和年份year,最终work_status的计算流程如下(代码逻辑已封装在WorkStatusCalculator类中):public int calculateWorkStatus(LocalDate date, int year) { // 步骤1:检查是否为调休上班日 → 最高优先级 if (holidayProvider.getMakeupDays(year).contains(date)) { return WORK_STATUS_MAKEUP; // 3 } // 步骤2:检查是否在法定假日区间内 boolean inHolidayRange = holidayProvider.getNationalHolidays(year).stream() .anyMatch(range -> !date.isBefore(range.getStart()) && !date.isAfter(range.getEnd())); if (inHolidayRange) { return WORK_STATUS_HOLIDAY; // 2 } // 步骤3:检查是否为自然周末(周六日) DayOfWeek dow = date.getDayOfWeek(); if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) { return WORK_STATUS_WEEKEND; // 1 } // 步骤4:其余均为工作日 return WORK_STATUS_WORKDAY; // 0 }参数说明:此决策树严格遵循“政策 > 自然”的优先级。调休日(3)覆盖周末(1),法定假日(2)覆盖周末(1),但不覆盖调休日(因调休日已单独处理)。这是符合《全国年节及纪念日放假办法》的实际逻辑:调休日本质是“用周末换工作日”,所以它既是周末又是工作日,我们用更高优先级的状态码(3)来唯一标识。3. 数据库建表与 Java 批量写入:从内存对象到 MySQL 表的零丢失落地3.1 MySQL 建表语句(兼容 5.7+ 与 8.0)本实现针对 MySQL 优化,使用DATE类型主键、合理索引、非空约束,确保查询性能与数据完整性:CREATE TABLE `yearly_schedule` ( `date` date NOT NULL COMMENT '日期,主键', `year` smallint NOT NULL COMMENT '年份', `month` tinyint NOT NULL COMMENT '月份(1-12)', `day` tinyint NOT NULL COMMENT '日(1-31)', `week_of_year` tinyint NOT NULL COMMENT '年内第几周', `day_of_week` tinyint NOT NULL COMMENT '周内第几天(1=周一,7=周日)', `work_status` tinyint NOT NULL COMMENT '工作状态:0=工作日,1=周末,2=法定假日,3=调休上班', `holiday_name` varchar(32) DEFAULT NULL COMMENT '节日名称(仅状态2/3时有值)', `is_holiday_period_start` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为假日周期首日', `is_holiday_period_end` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为假日周期末日', PRIMARY KEY (`date`), KEY `idx_year_status` (`year`,`work_status`), KEY `idx_date_status` (`date`,`work_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='年度日历详情表';逻辑说明:主键date确保每日唯一;复合索引idx_year_status支持“查某年所有假日”等高频查询;idx_date_status用于精确日期查询(如SELECT * FROM yearly_schedule WHERE date = '2024-10-01')。holiday_name允许为 NULL,因为只有状态 2 和 3 才有意义;is_holiday_period_start/end用tinyint(1)模拟布尔,兼容老版本 MySQL。3.2 Java 批量插入:JDBC Batch + PreparedStatement 防 SQL 注入使用原生 JDBC 批量写入,避免 ORM 框架的额外开销与事务不确定性。核心方法insertYearlySchedule(int year)实现如下:public void insertYearlySchedule(int year) throws SQLException { String sql = "INSERT INTO yearly_schedule (" + "date, year, month, day, week_of_year, day_of_week, work_status, holiday_name, " + "is_holiday_period_start, is_holiday_period_end) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); // 关闭自动提交,手动控制事务 LocalDate start = LocalDate.of(year, 1, 1); LocalDate end = LocalDate.of(year, 12, 31); LocalDate current = start; // 预加载当年所有假日区间和调休日,避免循环内重复计算 List<LocalDateRange> holidayRanges = holidayProvider.getNationalHolidays(year); Set<LocalDate> makeupDays = holidayProvider.getMakeupDays(year); while (!current.isAfter(end)) { int workStatus = calculator.calculateWorkStatus(current, year); String holidayName = null; boolean isStart = false; boolean isEnd = false; // 仅当 workStatus == HOLIDAY 时,计算是否为假期首/末日 if (workStatus == WorkStatusCalculator.WORK_STATUS_HOLIDAY) { for (LocalDateRange range : holidayRanges) { if (current.equals(range.getStart())) { isStart = true; } if (current.equals(range.getEnd())) { isEnd = true; } if (isStart && isEnd) break; // 找到即退出 } // 从区间中提取节日名称(简化版:取区间起始日所在节日) holidayName = getHolidayNameByDate(current, holidayRanges); } // 设置 PreparedStatement 参数 ps.setDate(1, Date.valueOf(current)); ps.setInt(2, year); ps.setInt(3, current.getMonthValue()); ps.setInt(4, current.getDayOfMonth()); ps.setInt(5, current.get(IsoFields.WEEK_OF_WEEK_YEAR)); ps.setInt(6, current.getDayOfWeek().getValue()); // Monday=1, Sunday=7 ps.setInt(7, workStatus); ps.setString(8, holidayName); ps.setBoolean(9, isStart); ps.setBoolean(10, isEnd); ps.addBatch(); // 加入批处理 // 每 1000 条执行一次批处理,防内存溢出 if (current.getDayOfYear() % 1000 == 0) { ps.executeBatch(); ps.clearBatch(); } current = current.plusDays(1); } // 执行剩余批次 ps.executeBatch(); conn.commit(); // 提交事务 log.info("Year {} schedule inserted successfully. Total rows: {}", year, end.getDayOfYear()); } catch (SQLException e) { // 发生异常时回滚 if (conn != null) { conn.rollback(); } throw e; } }参数说明:ps.setDate(1, Date.valueOf(current))将LocalDate安全转为 JDBCDate;current.get(IsoFields.WEEK_OF_WEEK_YEAR)获取 ISO 标准周数(周一为每周第一天);current.getDayOfWeek().getValue()返回 1–7,完美匹配表中day_of_week定义。批处理大小设为 1000,是经过实测的平衡点:太小则网络往返多,太大则 GC 压力大。getHolidayNameByDate()是一个辅助方法,根据日期反查所属节日区间并返回名称(如2024-10-01→"国庆")。3.3 初始化脚本:一键生成未来五年日历数据提供ScheduleInitializer工具类,支持命令行或 Spring Boot@PostConstruct调用:public class ScheduleInitializer { private final YearlyScheduleDao dao; public void initForYears(int... years) { for (int year : years) { try { // 先删除该年所有记录(幂等操作) dao.deleteByYear(year); // 再插入新数据 dao.insertYearlySchedule(year); log.info("Initialized schedule for year {}", year); } catch (Exception e) { log.error("Failed to initialize schedule for year {}", year, e); throw new RuntimeException("Init failed for year " + year, e); } } } // Spring Boot 启动时自动初始化(示例) @PostConstruct public void autoInit() { // 生产环境建议只初始化当前年+下一年 initForYears(Year.now().getValue(), Year.now().plusYears(1).getValue()); } }逻辑说明:deleteByYear(year)使用DELETE FROM yearly_schedule WHERE year = ?,比TRUNCATE更安全(保留自增ID,不锁全表);autoInit()方法在 Spring Boot 启动时自动执行,确保服务启动后日历数据就绪。生产部署时,建议只初始化当前年和下一年,避免一次性写入过多历史数据影响启动速度。4. 避坑指南:五个真实踩过的坑与血泪修复方案4.1 坑:MySQLWEEK()函数返回值与 JavaIsoFields.WEEK_OF_WEEK_YEAR不一致,导致week_of_year字段错乱现象:数据库中week_of_year字段值为1的日期,在 Java 里用current.get(IsoFields.WEEK_OF_WEEK_YEAR)计算却是53,导致按周查询时数据对不上。原因:MySQL 默认WEEK()函数使用 mode 0(周日为一周开始,第一周需有4天以上),而IsoFields.WEEK_OF_WEEK_YEAR严格遵循 ISO 8601(周一为一周开始,第一周必须包含周四)。两者标准不同。解决:放弃 MySQL 的WEEK()函数,在 Java 层统一用IsoFields.WEEK_OF_WEEK_YEAR计算,并在建表注释中明确“本字段遵循 ISO 8601 周标准”。查询时也用 Java 计算目标周数,而非依赖 MySQL 函数。4.2 坑:LocalDate.of(year, month, day)在闰年2月29日之外调用plusDays(1)导致DateTimeException现象:生成 2024 年日历时,循环到2024-02-29后执行current.plusDays(1)抛出DateTimeException: Invalid date 'February 30'。原因:LocalDate的plusDays()是安全的,但若手动拼接LocalDate.of(2024, 2, 30)就会失败。本例中错误出现在一个自定义的DateUtil.nextDay()方法里,它错误地将month和day分离计算。解决:彻底删除所有手动拼接LocalDate的代码,全程使用LocalDate.plusDays(1)、plusMonths(1)等安全方法。LocalDate本身就是不可变且边界安全的,无需自己造轮子。4.3 坑:getNationalHolidays()返回的区间未做重叠合并,导致同一日期被多次标记为假日现象:2024 年 10 月 1 日在数据库中work_status = 2出现两条记录,holiday_name分别为"国庆"和"调休",违反主键唯一性。原因:原始规则中,getNationalHolidays()和getMakeupDays()是独立返回的,但未考虑“国庆假期区间”与“调休日”在同一天的冲突。虽然逻辑上makeupDays优先级更高,但若在insert循环中未严格按决策树顺序判断,就可能先插入了假日记录,再插入调休记录,触发主键冲突。解决:在calculateWorkStatus()决策树中,makeupDays检查必须放在nationalHolidays之前(已体现在 2.4 节代码中),且insert循环内不进行二次状态计算,完全依赖该方法返回值。同时,在HolidayRuleProvider单元测试中增加断言:makeupDays与nationalHolidays区间交集必须为空。4.4 坑:批量插入时未设置rewriteBatchedStatements=true,导致 MySQL 实际执行 N 条单条 INSERT现象:插入 366 行数据耗时 12 秒,远超预期;SHOW PROCESSLIST显示大量INSERT ... VALUES (...)语句串行执行。原因:MySQL JDBC 驱动默认不重写批处理语句。即使调用了addBatch()和executeBatch(),驱动仍可能发送 N 条独立语句。解决:在 JDBC URL 中强制开启批处理重写:jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true开启后,366 行插入降至 0.3 秒以内。这是 MySQL 批处理的黄金参数,不加等于没用。4.5 坑:is_holiday_period_start/end字段在长假跨年时计算错误(如 2024 年 12 月 31 日是元旦假期首日)现象:2024 年 12 月 31 日(周二)被错误标记为is_holiday_period_start = true,但实际元旦假期从 2025 年 1 月 1 日开始。原因:getNationalHolidays(2024)只返回 2024 年内的区间,而跨年假期(如 2024 年底连休到 2025 年初)未被纳入 2024 年规则。但业务上,2024-12-31 若属于“2025 年元旦假期”的一部分,就应被标记。解决:修改规则加载逻辑,对每个年份,同时加载year-1、year、year+1三年的假日区间,并在calculateWorkStatus()中,对current日期,检查其是否落在year-1至year+1任意一年的假日区间内。is_holiday_period_start/end的计算也基于这三年的全量区间。虽增加内存占用,但保证了跨年假期的完整性。5. 查询与验证:用三条 SQL 和一个单元测试,100% 确认日历数据正确性5.1 核心验证 SQL:精准定位“易错日”的状态生产环境中,最需验证的是那些“政策与自然冲突”的日期。以下三条 SQL 是我每次部署后必跑的“后悔药”:-- ① 查所有调休上班日:必须 work_status = 3,且 holiday_name = '调休上班' SELECT date, work_status, holiday_name FROM yearly_schedule WHERE work_status = 3 AND (holiday_name IS NULL OR holiday_name != '调休上班') ORDER BY date; -- ② 查所有法定假日:必须 work_status = 2,且 holiday_name 非空 SELECT date, work_status, holiday_name FROM yearly_schedule WHERE work_status = 2 AND (holiday_name IS NULL OR TRIM(holiday_name) = '') ORDER BY date; -- ③ 查所有周末但被标记为工作日的日期(即调休日):必须 work_status = 3,且 day_of_week IN (6,7) SELECT date, work_status, day_of_week, holiday_name FROM yearly_schedule WHERE work_status = 3 AND day_of_week IN (6, 7) -- 周六=6,周日=7 ORDER BY date;逻辑说明:第一条确保所有work_status = 3的记录都带上了标准名称'调休上班',避免前端展示为空;第二条确保每个法定假日都有明确节日名,杜绝“黑匣子假日”;第三条是交叉验证——调休日必须是自然周末,否则规则逻辑就有漏洞。这三条 SQL 结果集为空,才代表基础状态正确。5.2 单元测试:用 JUnit 5 验证 2024 年关键日期一个可靠的单元测试比十次人工检查更值得信赖。以下是针对 2024 年的HolidayRuleProviderTest核心片段:class HolidayRuleProviderTest { private final HolidayRuleProvider provider = new HolidayRuleProvider(); private final WorkStatusCalculator calculator = new WorkStatusCalculator(provider); @Test void test2024KeyDates() { // 2024-01-01:元旦,法定假日 assertEquals(2, calculator.calculateWorkStatus(LocalDate.of(2024, 1, 1), 2024)); assertEquals("元旦", getHolidayName(2024, 1, 1)); // 2024-02-04:春节调休上班日(周日) assertEquals(3, calculator.calculateWorkStatus(LocalDate.of(2024, 2, 4), 2024)); assertEquals("调休上班", getHolidayName(2024, 2, 4)); // 2024-02-10:春节假期最后一天(周六),仍是假日 assertEquals(2, calculator.calculateWorkStatus(LocalDate.of(2024, 2, 10), 2024)); assertEquals("春节", getHolidayName(2024, 2, 10)); // 2024-02-11:周日,但春节假期结束,应为周末 assertEquals(1, calculator.calculateWorkStatus(LocalDate.of(2024, 2, 11), 2024)); // 2024-05-08:劳动节后周三补班(特殊调休) assertEquals(3, calculator.calculateWorkStatus(LocalDate.of(2024, 5, 8), 2024)); } private String getHolidayName(int year, int month, int day) { LocalDate date = LocalDate.of(year, month, day); // 模拟 DAO 查询 holiday_name 字段 return dao.findByNameAndDate(year, date).map(ScheduleRecord::getHolidayName).orElse(null); } }参数说明:测试覆盖了“纯假日”、“调休日”、“假日末日”、“假日后首日”、“特殊补班日”五类典型场景。getHolidayName()方法模拟了从数据库读取holiday_name的行为,确保名称与状态严格绑定。运行此测试,100% 通过,才是上线的硬门槛。5.3 进阶技巧:用窗口函数生成“连续工作日/休息日计数”有了这张表,你可以轻松实现“连续工作了多少天”、“已经休息了几天”这类业务需求。例如,统计某员工连续打卡天数,只需一条 SQL:-- 计算每个日期向前连续工作日的天数(含当天) SELECT date, work_status, holiday_name, COUNT(*) OVER ( PARTITION BY year ORDER BY date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW ) AS prev_workdays_count FROM yearly_schedule WHERE work_status = 0 -- 仅工作日 AND year = 2024;但更实用的是用LAG()和变量实现真正的“连续段计数”:-- MySQL 8.0+:计算连续工作日序列号 WITH ranked AS ( SELECT date, work_status, LAG(work_status) OVER (ORDER BY date) AS prev_status FROM yearly_schedule WHERE year = 2024 ), groups AS ( SELECT date, work_status, SUM(CASE WHEN work_status != prev_status THEN 1 ELSE 0 END) OVER (ORDER BY date) AS grp FROM ranked ) SELECT date, work_status, COUNT(*) OVER (PARTITION BY grp ORDER BY date) AS consecutive_days FROM groups WHERE work_status = 0;逻辑说明:这段 SQL 的核心是grp字段——它用SUM() OVER对每次work_status变化进行累加,从而将连续相同状态的日期分到同一组。再对每组计数,就得到“连续工作日天数”。这个技巧在考勤异常检测(如连续旷工3天告警)、生产排程(连续满负荷运行预警)中极其有用。我一般会在项目初期就把这个视图建好:CREATE VIEW v_consecutive_workdays AS ...,让业务代码直接SELECT * FROM v_consecutive_workdays。从那以后我每次上线新日历年份数据,都强制走一遍这三条验证 SQL + 一个完整单元测试套件,哪怕只改了一行getNationalHolidays()的返回值。因为日期逻辑一旦出错,不是报错,而是静默错——它会让你的排班系统在春节那天悄悄把所有人排去值班,而日志里连个 warn 都没有。希望帮到你。本文还有配套的精品资源,点击获取