Java实现全年日期类型落库:节假日、调休与工作日的表设计实战
简介一份面向Java后端开发者的实用源码用于将一年内的工作日、周末与法定节假日明细自动计算并写入数据库表解决考勤、排班、值班等业务中需要按日期类型查询的常见需求。资源包为7z压缩格式大小仅2KB包含1个Java文件文件内封装了日期遍历、节假日判断、批量入库等核心方法代码结构清晰便于直接阅读与迁移。目前已有3738人学习下载。源码基于java.time和DayOfWeek判断日期属性同时提供节假日识别策略与JDBC数据库写入逻辑在数据量较大时实现采用集合暂存后批量提交的方式提升效率并借助事务回滚保证异常情况下数据一致还包含表结构设计思路与可配置扩展的说明便于二次开发适合需要快速实现年度日程数据落库的中级Java开发者参考。1. 为什么要把全年日期类型落库从一次排班翻车说起某公司考勤系统每月排班时HR 从网上找节假日安排手动填表有一年二月把调休上班日漏了导致二十多人被记旷工。后来我接到一个需求用 Java 把每年的节假日、周末、工作日详情yearly workschedule记录到数据库表中一次生成全年每天的类型供考勤、计薪、生产排程直接查询。这个资源解决的问题很明确把“今天是周几、是否法定假、是否调休上班”变成一张干净的表而不是每次现算。适合需要按日历做自动化业务、又不想被官方调休表反复折腾的团队。接下来会从表结构、算法、Java 实现、常见坑和核对方法这几个角度拆解。2. 表结构设计与节假日数据源先确定“每天一条记录”的粒度2.1 单表还是双表一张全年日程表能扛住查询吗我见过很多人上来先建一张“年历表”字段极多农历、黄历、节气、工作日类型。其实核心业务只需要每天一条记录包含日期、年份、月份、周几、类型和节假日名称。另一种是配置表加计算逻辑每次临时算但这会让下游系统无法索引、也不好做关联查询所以在数据量只有一年 365 条的场景下直接物化成一张 daily_calendar 表最划算。建表如下CREATE TABLE daily_calendar ( date_key DATE PRIMARY KEY COMMENT 自然日, year SMALLINT NOT NULL, month TINYINT NOT NULL, day_of_week TINYINT NOT NULL COMMENT 1周一 .. 7周日, day_type TINYINT NOT NULL COMMENT 0工作日,1周末,2节假日,3调休上班日, holiday_name VARCHAR(50) NULL, is_adjusted TINYINT DEFAULT 0 COMMENT 是否调休覆盖, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_year (year), KEY idx_month (month), KEY idx_day_type (day_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意day_of_week 这里明确写成 1周一、7周日下游查询时直接做 GROUP BY 不需要再翻译名字。day_type 用 0 到 3 四个值避免用字符串存枚举值后在代码里定义常量类。索引方面因为典型查询是“某月所有工作日”“某年所有调休日”最频繁的过滤条件是 year 和 month所以给这两个字段都加了普通索引date_key 本身是主键天然覆盖单点查询。这里没必要拆成两张表。曾经某团队把节假日配置单独存一张表然后查询时用 JOIN 和日期运算结果每来一个查询请求都要重复计算数据量虽然不大但 SQL 复杂度和维护成本上去了。物化成一张表还有一个隐藏好处可以把节假日名称直接冗余进去报表展示时不需要二次关联。2.2 节假日数据从哪来接口拉取与本地配置的取舍节假日安排的发布节奏是每年下半年公布次年安排。数据源有几类一类是直接使用某云厂商的日历 API优点是不用自己维护缺点是对接口稳定性有依赖而且有些厂商只提供节假日不提供调休上班日另一类是人工照着官方通知整理成一张配置表完全本地控制适合要保证入库数据权威的场景。常见做法是双轨制先用接口拉一次作为底稿再人工校验修正最后导入本地配置表。配置表结构可以这样设计CREATE TABLE holiday_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, holiday_date DATE NOT NULL UNIQUE, holiday_name VARCHAR(50) NOT NULL, is_off_day TINYINT DEFAULT 1 COMMENT 是否放假1放假0上班, is_workday TINYINT DEFAULT 0 COMMENT 是否调休上班1上班0不, year SMALLINT NOT NULL, source VARCHAR(30) DEFAULT manual, remark VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的核心是允许一个日期同时有“放假”和“上班”两个标志吗并不会。一个日期只会落在一个状态里要么正常休息、要么调休上班、要么正常工作日放假、要么周末放假日。所以更简洁的做法是只用一个枚举字段 day_status例如 1 表示放假、2 表示调休上班。但为什么还要保留两个标志因为有的场景需要知道“今天本来是周末但因为调休变成上班”或者“今天本来是工作日因为法定假变成休息”。在生成 daily_calendar 时这两个标志会一起参与到优先级判断。数据清洗方面接口返回的日期经常是 ISO8601 字符串比如“2025-02-10”但有的接口会带时区偏移甚至返回时间点。建议在导入层统一做一次 LocalDate.parse这里要特别提醒只要字符串不含时间就直接 Parse 成 LocalDate不要用 java.util.Date否则时区问题迟早会让你翻车。导入完成后最好把整张配置表打印出来人工扫一眼重点看每年 1 月和 10 月有没有缺失。2.3 数据的幂等导入与版本管理因为配置每年可能修订比如官方临时调整个别日期所以导入不能只做 insert。建议在导入 holiday_config 时对每一个日期使用“存在即更新”保留 source 字段。daily_calendar 生成时如果发现某天已经有了旧记录不能盲目覆盖不然调休规则回退时会产生脏数据。一个简单的办法是给 daily_calendar 加一个 generation_version 字段每次生成使用一个版本号历史版本归档到另一张表然后当前表只保留最新版本。如果不想加版本号也可以直接按 date_key 主键做 upsert但这会丢失“上一版是什么”的信息。我在表设计时加了 updated_at却没有版本号这在后来自查时吃过亏某一天的类型被覆盖后无法知道覆盖前的值是什么。所以有条件的话建议在每天记录里加上rule_version字段表示这行数据依据的是哪一版节假日配置。现在补 SQLALTER TABLE daily_calendar ADD COLUMN rule_version VARCHAR(20) NULL AFTER is_adjusted;这个字段配合 updated_at能回答“这个日期是谁在什么时候改的、依据什么规则改的”。后面做人工补偿更新时版本号能帮你区分是旧配置生成还是新配置生成不会出现“改了但不知道改没改上”的尴尬。3. 核心算法把“周末 节假日 调休”合并成最终类型3.1 先算基础类型再叠加调休覆盖算法非常直观但容易写错顺序。我之前见过一种写法先把法定假日设为节假日然后把所有周六日设为周末最后把调休上班日设为工作日结果是春节前两天被覆盖成工作日后假期连休信息丢失。正确的顺序是先初始化基础类型再应用配置覆盖且覆盖时要把“调休上班”和“法定放假”分开处理public int resolveDayType(LocalDate date, MapLocalDate, HolidayConfig configMap) { int dayType 0; DayOfWeek dow date.getDayOfWeek(); if (dow DayOfWeek.SATURDAY || dow DayOfWeek.SUNDAY) { dayType 1; // 先按周末处理 } HolidayConfig cfg configMap.get(date); if (cfg ! null) { if (cfg.isWorkday()) { dayType 3; // 调休上班覆盖周末 } else { dayType 2; // 法定节假日覆盖工作日 } } return dayType; }这段代码的关键在最后的三行覆盖顺序是“调休上班”优先于“周末”也就是说如果配置为调休上班日不管基础类型是什么都会被改成 3如果配置为节假日则会被改成 2。这里出现了一个陷阱如果某天是周六同时也是法定节假日的调休上班日配置表里 is_workday1 且 is_off_day0那么会被置为 3工作日这符合真实调休逻辑如果配置说周六放假则置为 2节假日。两种逻辑在配置表里是互斥的所以不会出现同时 is_workday1 和 is_off_day1 的数据导入时应该校验这一点。3.2 遍历一年日期用 LocalDate 而不是 Calendar生成全年日期时最常见的错误是从 1 月 1 日加到 12 月 31 日但很多人用 while 循环时条件写错导致 2 月 29 日的问题。推荐写法public ListDailyCalendar generateYear(int year, ListHolidayConfig configs) { MapLocalDate, HolidayConfig configMap configs.stream() .collect(Collectors.toMap(HolidayConfig::getHolidayDate, Function.identity())); ListDailyCalendar list new ArrayList(); LocalDate start LocalDate.of(year, 1, 1); LocalDate end start.withDayOfYear(start.lengthOfYear()); for (LocalDate date start; !date.isAfter(end); date date.plusDays(1)) { DayOfWeek dow date.getDayOfWeek(); int dowValue dow.getValue(); int dayType resolveDayType(date, configMap); DailyCalendar record buildRecord(date, year, dowValue, dayType); list.add(record); } return list; }这里start.lengthOfYear()返回当年总天数闰年是 366所以不需要手动判断 2 月 29。plusDays(1)会自动处理跨月、跨年。用LocalDate的好处是避开Calendar周几从 1周日开始的坑。代码里dowValue是 ISO 标准值周一到周日对应 1 到 7正好和我们表结构里的定义一样。3.3 让算法支持“跨年窗口”春节、国庆这类长假经常会出现“今年最后几天放假但调休上班日落在下一年一月初”的情况。生成某一年数据时如果只查该年的配置会漏掉跨年日期。解决方法是在加载配置时额外加载前一年 12 月和后一年 1 月的数据但只入库当前年份的日期。也就是说查询配置的范围要扩大到年 - 1到年 1生成后只保留Year.of(year)中的日期。下面是一个加载配置的片段public ListHolidayConfig loadConfigForGeneration(int year) { LocalDate start LocalDate.of(year - 1, 12, 1); LocalDate end LocalDate.of(year 1, 2, 28); return holidayConfigMapper.selectRange(start, end); }注意这里 2 月 28 是取一个大概范围足够覆盖春节调休。但如果下一年春节晚跨年调休可能出现在 2 月中旬所以更稳妥是取到year 1年 2 月底或者干脆取year 1年 3 月 31 日。这个参数可以根据需要调整。3.4 类型编码的扩展性与对外约定有人问为什么不用 0 和 1 两个状态因为现实中有“周末上班”这种并非常规工作日的状态报表需要区分。所以四个状态足够0 普通工作日1 普通周末2 法定节假日3 调休上班日。如果还想要“调休休假日”比如本来上班但因为调休放假可以把 2 也涵盖但如果要区分则加 4。我不建议一开始设计成 5 个状态因为下游需求还没明确时编码越复杂错误概率越高。对外接口约定方面日报/考勤模块通常只关心day_type in (0,3)是否是上班日所以可以提供一个 getter 方法public boolean isWorkDay(int dayType) { return dayType 0 || dayType 3; }这种做法比干脆把 day_type 存成字符串“工作日”更高效但代价是需要维护一份常量字典。在系统里用注释和枚举类说明每个值即可不要图省事直接用魔法数字。另外建议把is_adjusted字段当作冗余判断当day_type3时它一定为 1当day_type2且原本是工作日时它也应为 1。这样排查问题时一眼就能看出哪些日期是被调休规则改过的。4. Java 实现批量生成、幂等入库与定时触发4.1 Service 层生成逻辑与事务边界有了算法下一步实现 Service。实际项目中不需要一年一年去生成可以提供一个生成接口参数是年份列表。在 Spring Boot 下推荐把幂等和事务放在同一个方法内Service public class CalendarGeneratorService { Transactional(rollbackFor Exception.class) public void generateAndSave(int year) { ListHolidayConfig configs holidayConfigMapper.loadForGeneration(year); MapLocalDate, HolidayConfig configMap configs.stream() .collect(Collectors.toMap(HolidayConfig::getHolidayDate, Function.identity())); dailyCalendarMapper.deleteByYear(year); ListDailyCalendar records new ArrayList(366); LocalDate start LocalDate.of(year, 1, 1); LocalDate end LocalDate.of(year, 12, 31); for (LocalDate date start; !date.isAfter(end); date date.plusDays(1)) { records.add(buildRecord(date, configMap)); } dailyCalendarMapper.batchInsert(records); } }事务是关键deleteByYear和batchInsert必须在同一个事务里否则中途失败会留下空的一季度。Transactional意味着任何一步异常都会回滚旧数据不会被半删半插。这里 delete 再 insert 比逐行 upsert 更容易保证一致性代价是删除瞬间有短暂的空档如果吞吐量要求不高无所谓如果要求查询不能中断可以改成先插入新版本再删除旧版本但那样需要版本字段。4.2 批量插入的写法与参数细节MyBatis 的 batchInsert 可以用foreach拼成一个 INSERT 语句也可以使用 ExecutorType.BATCH。一年最多 366 条拼成一个语句完全可行只要注意 MySQL max_allowed_packet。推荐使用 MyBatis 的批量插入insert idbatchInsert parameterTypejava.util.List INSERT INTO daily_calendar (date_key, year, month, day_of_week, day_type, holiday_name, is_adjusted, rule_version, created_at, updated_at) VALUES foreach collectionlist itemitem separator, (#{item.dateKey}, #{item.year}, #{item.month}, #{item.dayOfWeek}, #{item.dayType}, #{item.holidayName}, #{item.isAdjusted}, #{item.ruleVersion}, NOW(), NOW()) /foreach /insert这里有一个容易踩的问题NOW()在 insert 时由数据库生成但updated_at是ON UPDATE CURRENT_TIMESTAMP如果是 insert它会自动等于当前时间。实际上我们不需要给 created_at/updated_at 传值但为了统一我在 SQL 里写死了 NOW()。注意ruleVersion需要在插入前确定而不是依赖数据库默认值因为版本信息是业务字段数据库无法自己推断。4.3 定时任务与手动补偿接口生成频率不用太高一年一次即可。可以安排在每年 12 月 25 日早上 8 点执行检查如果次年不存在记录则生成。同时提供一个手动触发接口用于官方突然调整节假日安排后的重算。定时任务用 Spring 的 ScheduledScheduled(cron 0 0 8 25 12 *) public void scheduleNextYear() { int nextYear Year.now().getValue() 1; boolean exists dailyCalendarMapper.existsByYear(nextYear); if (!exists) { generateAndSave(nextYear); } }这个 cron 表意清楚但有个隐藏点如果服务器时区不是东八区Year.now()可能拿到前一天导致 12 月 25 日 0 点变成 12 月 24 日。所以建议在启动参数里显式指定时区后面的避坑章节会细说。手动补偿接口可以这样写PostMapping(/admin/calendar/regen/{year}) public ResultString regen(PathVariable int year) { if (year 2020 || year 2100) { return Result.error(年份超出合理范围); } calendarGeneratorService.generateAndSave(year); return Result.ok(); }这里的限制条件一方面防止误操作另一方面也提示团队这类接口不要暴露在公网至少要在内网或加权限控制。4.4 生成后的完整性与日志检查生成完毕后建议不要直接返回先做一次“自我体检”。比如查一下当年记录数是否等于 365 或 366再查每个月的工作日数量是否在合理区间。如果有异常立刻抛出异常触发事务回滚而不是把坏数据留在库里。日志里记录开始生成的时间、加载配置的条数、实际插入条数方便事后回溯。log.info([calendar] year{} start, configCount{}, year, configs.size()); // ...生成逻辑 log.info([calendar] year{} done, total{}, year, records.size());这个日志成本很低但排查问题时能少走很多弯路。特别是当你接到“某一年数据不对”的线上反馈时第一件事就是看日志里当时加载了多少条配置、生成了多少条记录。5. 避坑指南日期处理、时区与调休数据的五个典型坑5.1 现象跨年那两天类型错乱产生“幽灵工作日”某次生成次年数据时1 月 1 日被标成了节假日但 1 月 2 日应该是调休上班日却显示成周末。原因是配置表只导入了次年 1 月的调休记录而没有把去年 12 月底的窗口一起导入导致 1 月 2 日没有命中任何配置。解决加载配置时范围扩大为year-1和year1并把跨年日期段的配置映射到具体日期上生成当前年记录时只过滤date.getYear() year。从那以后我每次生成都会先跑一条 SQL 检查跨年窗口内的配置是否完整。5.2 现象API 拉下来的日期全都变成前一天有一年某开发者使用SimpleDateFormat加时区解析字符串结果部分日期解析成前一天。原因是接口返回的字符串是YYYY-MM-DD但用Date对象接收再转LocalDate时会因为 JDBC 时区或应用时区导致 UTC 与东八区相差 8 小时。解决一律用LocalDate.parse(..., DateTimeFormatter.ISO_LOCAL_DATE)解析日期字符串彻底抛弃Date。数据库 JDBC URL 里显式加serverTimezoneAsia/Shanghai否则连接时区可能被默认成 UTC插入后日期再偏一天。5.3 现象周几字段值与实际不符周一显示成周日Java 的Calendar.DAY_OF_WEEK中Calendar.SUNDAY 1、Calendar.MONDAY 2如果写在配置表里的是 1 表示周一就会全部错位。解决不用Calendar统一使用java.time.DayOfWeek.getValue()它返回 ISO 标准值周一到周日对应 1-7。如果历史代码已经用了 Calendar那在入库前做一次转换(cal.get(Calendar.DAY_OF_WEEK) 5) % 7。5.4 现象批量插入一次插 500 条时报错某公司用 MyBatisforeach拼一条 insert 时超过 MySQLmax_allowed_packet限制报 PacketTooBigException。其实一年才 366 条不至于但如果每天记录有很长的备注字段仍然可能超过默认 4M。解决把max_allowed_packet调到 16M或者在代码里每 200 条分批插入。从性能看MyBatis 的 batchExecutor 也可以但批量插入一条 SQL 的速度更快注意参数总量即可。5.5 现象国庆假期长度统计总是少一天统计每月节假日天数时发现国庆只有 4 天实际有 5 天。原因很简单接口或人工配置时把国庆假期的结束日期写成了 10 月 6 日而 10 月 7 日被遗漏。解决导入配置后对每年 1 月、10 月、2 月做一次人工抽检清单至少确认几个关键假期是否存在。另外可以写一个校验规则如果法定节假日总数明显少于往年发出告警。6. 进阶数据核对脚本与增量维护技巧生成之后怎么确认数据没毛病经验是不要让代码说“对”要让另一套独立逻辑说“对”。我会维护一个小的测试用例集包含已知日期ListDateCheck cases List.of( new DateCheck(LocalDate.of(2024, 2, 10), 2), // 春节 new DateCheck(LocalDate.of(2024, 2, 18), 3), // 调休上班 new DateCheck(LocalDate.of(2024, 4, 4), 2), // 清明 new DateCheck(LocalDate.of(2024, 5, 1), 2), // 劳动节 new DateCheck(LocalDate.of(2024, 10, 1), 2), new DateCheck(LocalDate.of(2024, 12, 31), 0) // 普通工作日 ); for (DateCheck c : cases) { int actual dailyCalendarMapper.selectDayType(c.date); Assert.assertEquals(c.expect, actual); }这些用例来自官方发布后人工核实的日期每年更新一次。除了抽查我还会写一个按月统计的查询SELECT month, SUM(day_type IN (0,3)) AS work_days, COUNT(*) AS total_days FROM daily_calendar WHERE year 2024 GROUP BY month;一般工作日在 2023 个之间如果某月出现 26 个工作日或者出现 10 个周末那大概率配置有误。统计脚本适合做快速回归但最终判断还是要靠抽查。数据库里的类型会变化吗会的。官方临时调整或者某个地区有自己的节假日安排都需要改配置后重新生成。增量维护的常见做法是把配置表按“发布日期”拆分例如某条生效日期是 2024-03-01另一条是 2024-03-31那么在 3 月 15 日修改时不能覆盖全部只能更新受影响日期。我的习惯是写一个updateDayType(date)的接口只重算指定日期附近三天的类型而不是整年重生成。这样既能快速修正又不会影响其他日期的版本记录。从那以后我每次生成后都会先跑一遍统计脚本确认每月工作日数量在合理区间再抽查三个法定节日最后才开放给下游。这个方法看着笨但真的拦住过两次配置错位的问题。希望帮到你。本文还有配套的精品资源点击获取