简介:这是一份面向Java后端开发者的实用代码资源,聚焦于将一年中的工作日、周末与法定节假日逐日识别并写入数据库表这一常见业务需求。资源包内仅含1个Java源文件,采用7z压缩,体积约2KB,文件结构精简,便于直接阅读与二次改造。代码围绕java.time日期处理、节假日判定、数据库表字段设计、JDBC连接与批量插入、事务回滚等关键环节展开,展示了从日期遍历到持久化的完整实现思路,并兼顾闰年、调休等边界情况的处理。目前已有3738人学习下载,适合需要构建考勤、排班或工时统计模块的开发者参考。通过阅读该实现,读者可掌握工作日与节假日数据的建模方式、批量写入的性能优化手段,以及提升代码可维护性与扩展性的组织方法,快速迁移到自身项目中。
1. 节假日表不是日历翻版:Java 落库前先想清楚三件事
很多团队做考勤、排班、计费系统时,都会遇到同一个需求:把每年的节假日、周末、工作日明细落到数据库表里。表面看只是「查日历、写数据」,真做起来才发现坑不少——法定节假日每年由相关部门调整,调休上班的周末和正常休息的周末混在一起,跨年时还要处理上一年年末发布下一年安排的时间差。用 Java 实现这套逻辑,核心不是写多少代码,而是先想清楚三件事:数据从哪来、表结构怎么设计、每年更新时怎么保证不重不漏。
这篇文章面向正在做考勤、排班、工时统计、计费系统的 Java 工程师,也适合需要把日历数据接入业务表的后端开发。我会按「数据来源选型 → 表结构设计 → Java 落库实现 → 年度更新与校验 → 避坑排查 → 进阶技巧」的顺序,把一套可复现的方案讲透。读完之后,你应该能直接在自己的 Spring Boot 或普通 Java 项目里落地这套逻辑,并且知道哪些参数必须可配置、哪些边界必须写测试。
2. 数据从哪来:三种节假日数据源的选型与取舍
2.1 硬编码、公开接口与手工维护的对比
做节假日落库,第一步永远是确定数据源。常见做法有三种:硬编码在代码里、调用公开节假日接口、人工整理后导入。三种方式没有绝对优劣,关键看你的业务对准确性和维护成本的容忍度。
硬编码最省事,把每年的日期写进枚举或常量类,启动时批量插入数据库。优点是零依赖、查询快、不担心接口挂掉;缺点是每年更新要改代码、重新发版,遇到临时调整(比如某年春节假期延长)就得紧急上线。适合内部工具、生命周期短的项目。
公开接口省去手工整理,按年拉取后解析入库。优点是更新及时、维护量小;缺点是依赖外部服务,接口格式可能变、可能限流、可能某天不可用。适合对时效性要求高、能接受定时任务重试的系统。
手工维护最可控,运营或 HR 在后台录入每年的安排,程序只负责校验和落库。优点是数据准确、可审计;缺点是人力成本高,年份多了容易漏。适合考勤、薪酬这类对准确性要求极高的场景。
我一般会采用「接口为主 + 本地缓存兜底 + 人工校正」的组合:定时任务从接口拉取,写入中间表,校验通过后合并到正式表;接口不可用时用上一年数据加人工确认。这样既保证时效,又不会因为接口抖动导致数据缺失。
2.2 表结构设计:一张主表加一张调整表
表结构直接决定后续查询和更新的复杂度。推荐拆成两张表:一张存每天的日期类型,一张存年度调整记录。
-- 日期明细表:每天一条记录 CREATE TABLE calendar_day ( id BIGINT PRIMARY KEY AUTO_INCREMENT, calendar_date DATE NOT NULL COMMENT '日期', year INT NOT NULL COMMENT '所属年份', day_type TINYINT NOT NULL COMMENT '1工作日 2周末 3法定节假日 4调休上班', holiday_name VARCHAR(64) DEFAULT NULL COMMENT '节假日名称,如春节', source TINYINT NOT NULL DEFAULT 1 COMMENT '1接口 2人工 3系统生成', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_date (calendar_date), KEY idx_year_type (year, day_type) ) COMMENT '日期明细表'; -- 年度调整表:记录调休、临时调整 CREATE TABLE calendar_adjust ( id BIGINT PRIMARY KEY AUTO_INCREMENT, year INT NOT NULL, adjust_date DATE NOT NULL COMMENT '被调整的日期', origin_type TINYINT NOT NULL COMMENT '原类型', target_type TINYINT NOT NULL COMMENT '调整后类型', reason VARCHAR(128) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_year_date (year, adjust_date) ) COMMENT '年度调整记录表';calendar_day的day_type用四个值区分工作日、周末、法定节假日、调休上班。注意「调休上班」必须单独一类,不能简单归为工作日,否则考勤统计时无法区分正常班和调休班。calendar_adjust记录每次调整的来源和原因,方便追溯。
提示:
calendar_date上加唯一索引,避免重复插入;year和day_type建联合索引,按年查询某类日期时走索引。
2.3 初始化一年的数据:从 1 月 1 日遍历到 12 月 31 日
有了表结构,先用 Java 生成基础数据:遍历全年日期,默认周六周日为周末,其余为工作日,再叠加法定节假日和调休。
public List<CalendarDay> buildBaseYear(int year) { List<CalendarDay> list = new ArrayList<>(); LocalDate start = LocalDate.of(year, 1, 1); LocalDate end = LocalDate.of(year, 12, 31); for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { CalendarDay day = new CalendarDay(); day.setCalendarDate(d); day.setYear(year); // 默认周六周日为周末,其余为工作日 DayOfWeek dow = d.getDayOfWeek(); if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) { day.setDayType(2); } else { day.setDayType(1); } day.setSource(3); list.add(day); } return list; }这段代码只生成基础框架,法定节假日和调休需要后续覆盖。day_type的默认值按周末和工作日区分,source标记为系统生成,方便后续识别哪些是人工修正过的。遍历时用LocalDate.plusDays而不是Calendar,避免时区和月份偏移问题。
参数上,year建议限制在合理范围(比如 2000 到 2100),防止误传导致生成大量无用数据。批量插入时用saveBatch或 JDBC 批处理,每批 500 到 1000 条,避免单条插入拖慢速度。
3. Java 落库实现:从接口解析到批量写入的完整链路
3.1 接口数据解析与字段映射
假设接口返回 JSON 数组,每条包含日期、类型、名称。解析时先定义 DTO,再映射到实体。
@Data public class HolidayDTO { private String date; // yyyy-MM-dd private Integer type; // 1工作日 2周末 3节假日 4调休 private String name; // 节假日名称 } public List<CalendarDay> parseFromApi(String json, int year) { List<HolidayDTO> dtos = JSON.parseArray(json, HolidayDTO.class); List<CalendarDay> list = new ArrayList<>(); for (HolidayDTO dto : dtos) { CalendarDay day = new CalendarDay(); day.setCalendarDate(LocalDate.parse(dto.getDate())); day.setYear(year); day.setDayType(dto.getType()); day.setHolidayName(dto.getName()); day.setSource(1); list.add(day); } return list; }解析时注意日期格式必须统一,接口返回yyyy-MM-dd还是yyyy/MM/dd要提前确认。type字段的取值要和数据库day_type对齐,如果接口用字符串(如"holiday"),需要加一层转换。name可能为空,落库时允许 NULL。
注意:接口返回的日期可能包含非本年数据,解析后要按
year过滤,避免污染其他年份。
3.2 批量写入与冲突处理:INSERT ON DUPLICATE KEY UPDATE
数据解析完,写入时用「插入或更新」语义,避免重复插入报错。
@Mapper public interface CalendarDayMapper { @Insert("<script>" + "INSERT INTO calendar_day (calendar_date, year, day_type, holiday_name, source) VALUES " + "<foreach collection='list' item='item' separator=','>" + "(#{item.calendarDate}, #{item.year}, #{item.dayType}, #{item.holidayName}, #{item.source})" + "</foreach>" + " ON DUPLICATE KEY UPDATE " + "day_type = VALUES(day_type), holiday_name = VALUES(holiday_name), " + "source = VALUES(source), update_time = NOW()" + "</script>") int batchUpsert(@Param("list") List<CalendarDay> list); }ON DUPLICATE KEY UPDATE依赖calendar_date的唯一索引。批量插入时每批控制在 500 到 1000 条,太大容易触发max_allowed_packet限制。如果用的是 PostgreSQL,对应写法是ON CONFLICT (calendar_date) DO UPDATE SET ...。
参数上,source字段很重要:接口来源标记为 1,人工修正标记为 2。更新时可以加条件,比如「人工修正过的数据不被接口覆盖」,避免运营改完又被定时任务冲掉。
3.3 年度更新任务:定时拉取与版本校验
每年安排通常在年末发布,定时任务建议在 12 月和 1 月各跑一次,拉取下一年数据。
@Scheduled(cron = "0 0 3 1 12,1 ?") public void syncNextYearCalendar() { int nextYear = LocalDate.now().getYear() + 1; // 先查是否已有数据,避免重复拉取 int count = calendarDayMapper.countByYear(nextYear); if (count >= 365) { log.info("{}年数据已存在,跳过", nextYear); return; } String json = holidayApi.fetch(nextYear); List<CalendarDay> list = parseFromApi(json, nextYear); // 分批写入 for (int i = 0; i < list.size(); i += 500) { int end = Math.min(i + 500, list.size()); calendarDayMapper.batchUpsert(list.subList(i, end)); } }cron表达式表示每年 12 月和 1 月的 1 日凌晨 3 点执行。先查数量再拉取,避免接口重复调用。分批写入时注意subList的边界,end取min防止越界。
如果接口返回的数据不完整(比如只有节假日没有调休),需要补一次人工确认流程。可以在calendar_adjust表里记录缺失项,由运营补录后再合并。
4. 避坑与排查:节假日落库最容易翻车的五个地方
4.1 调休上班被当成周末,考勤统计全错
现象:某年调休上班的周六,考勤系统显示为休息日,员工打卡被判定为加班。
原因:基础数据生成时只按DayOfWeek判断周末,没有叠加调休覆盖。
解决:在写入接口数据后,再执行一次调休覆盖逻辑,把day_type从 2 改为 4。查询时用day_type = 4单独识别调休上班。
4.2 跨年数据缺失,1 月 1 日查不到类型
现象:每年 1 月初,系统查询当天日期类型返回空,排班功能报错。
原因:下一年数据在 12 月才发布,定时任务如果失败或延迟,1 月 1 日就没有记录。
解决:定时任务加失败重试,同时在查询层做兜底——查不到时按DayOfWeek临时判断,并记录告警。人工补录后覆盖。
4.3 接口返回重复日期,唯一索引冲突导致整批失败
现象:批量插入时报Duplicate entry,整批数据回滚。
原因:接口返回的 JSON 里有重复日期,或者解析时没去重。
解决:解析后用Collectors.toMap按日期去重,保留最后一条。写入时用ON DUPLICATE KEY UPDATE而不是普通INSERT。
4.4 时区问题导致日期偏移一天
现象:数据库里存的日期比实际少一天或多一天。
原因:用java.util.Date或Calendar时受默认时区影响,LocalDate和java.sql.Date转换时也可能偏移。
解决:统一用LocalDate,JDBC 连接串加serverTimezone=Asia/Shanghai,MyBatis 映射时指定jdbcType=DATE。
4.5 人工修正被定时任务覆盖
现象:运营手动改了某天类型,第二天定时任务跑完又变回去了。
原因:接口数据source=1,人工修正source=2,但更新时没有区分优先级。
解决:在ON DUPLICATE KEY UPDATE里加条件,比如day_type = IF(source = 2, day_type, VALUES(day_type)),人工修正的数据不被接口覆盖。
5. 进阶技巧:用校验任务和查询缓存把日历表用起来
5.1 年度校验:数量、连续性和类型分布
数据落库后,加一个校验任务,检查每年记录数是否等于 365 或 366,日期是否连续,各类型数量是否合理。
public void validateYear(int year) { List<CalendarDay> list = calendarDayMapper.listByYear(year); int expected = Year.of(year).isLeap() ? 366 : 365; if (list.size() != expected) { log.error("{}年记录数异常:{},期望:{}", year, list.size(), expected); } for (int i = 1; i < list.size(); i++) { LocalDate prev = list.get(i - 1).getCalendarDate(); LocalDate curr = list.get(i).getCalendarDate(); if (!curr.equals(prev.plusDays(1))) { log.error("日期不连续:{} 到 {}", prev, curr); } } long holidayCount = list.stream().filter(d -> d.getDayType() == 3).count(); if (holidayCount < 10 || holidayCount > 20) { log.warn("{}年节假日数量异常:{}", year, holidayCount); } }校验任务建议每月跑一次,发现问题及时告警。数量校验能发现漏拉,连续性校验能发现解析错误,类型分布校验能发现接口格式变化。
5.2 查询缓存:按年加载到本地 Map
日历数据读多写少,适合缓存。启动时按年加载到ConcurrentHashMap,查询时直接命中。
@Component public class CalendarCache { private final Map<Integer, Map<LocalDate, CalendarDay>> cache = new ConcurrentHashMap<>(); @PostConstruct public void init() { int currentYear = LocalDate.now().getYear(); for (int y = currentYear - 1; y <= currentYear + 1; y++) { loadYear(y); } } public void loadYear(int year) { List<CalendarDay> list = calendarDayMapper.listByYear(year); Map<LocalDate, CalendarDay> map = list.stream() .collect(Collectors.toMap(CalendarDay::getCalendarDate, d -> d)); cache.put(year, map); } public CalendarDay get(LocalDate date) { Map<LocalDate, CalendarDay> map = cache.get(date.getYear()); return map == null ? null : map.get(date); } }缓存只加载前一年、当年、下一年,避免内存浪费。数据更新后调用loadYear刷新。查询时先走缓存,未命中再查库并回填。
5.3 一个具体技巧:用 SQL 直接生成全年日期序列
如果不想用 Java 遍历生成基础数据,可以用 SQL 的递归 CTE 直接生成。
WITH RECURSIVE dates AS ( SELECT DATE(CONCAT(2025, '-01-01')) AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM dates WHERE d < DATE(CONCAT(2025, '-12-31')) ) INSERT INTO calendar_day (calendar_date, year, day_type, source) SELECT d, 2025, CASE WHEN DAYOFWEEK(d) IN (1, 7) THEN 2 ELSE 1 END, 3 FROM dates ON DUPLICATE KEY UPDATE day_type = VALUES(day_type);这段 SQL 用递归 CTE 生成全年日期,DAYOFWEEK判断周末(1 是周日,7 是周六),直接插入。适合初始化历史年份数据,比 Java 遍历少一次网络往返。注意 MySQL 的cte_max_recursion_depth默认 1000,生成 365 条没问题,年份多了要调大。
我自己维护这套表时,最大的教训是:不要相信任何一年的数据能一次拉全。每年安排发布后,至少人工核对一遍节假日和调休,把校验任务跑通再上线。日历表看着简单,但它是考勤、排班、计费的底座,错一天就是一堆工单。希望帮到你。
本文还有配套的精品资源,点击获取