ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

按年落库的工作日历设计:从考勤排班到批量幂等生成

按年落库的工作日历设计:从考勤排班到批量幂等生成

简介:面向Java开发者与业务系统搭建人员的实用型代码资源,聚焦「年度日历数据入库」这一常见需求:如何借助java.time中的LocalDate、DayOfWeek等类逐年遍历日期,区分工作日与周末,结合节假日规则标注法定假日,并通过JDBC与批量插入、事务控制把结果稳定写入数据库表。压缩包为7z格式,仅含1个java源文件,体积约2KB,即SaveYearlyWorkSchedule主类,集中呈现日期判定、表结构字段设计(日期、工作日标识、周末标识、节假日标识)及批量提交等关键实现,体量轻便,便于直接阅读与改造复用。该资源已有3741人浏览学习,适合需要快速落地排班、考勤、工时统计等场景的中初级开发者参考,也能为节假日规则扩展、闰年边界测试与SQL预编译优化提供现成思路。

1. 一张算错的考勤表:为什么日历要按年落库,而不是每次都临时判断

排班系统的第一个线上问题,往往不在算法,而在日历。某公司的考勤模块上线三个月,工资条被退回两次:一次是把调休补班的周六当成了休息日,一次是把法定节假日算进了应出勤天数。追查下去,代码里判断「今天是不是工作日」只有一行dayOfWeek != SATURDAY && dayOfWeek != SUNDAY,节假日靠一张谁想起谁更新的表格文件。这就是典型的把日历当常量,而不是把yearly work schedule detail当数据。

Java 侧要把每一年的节假日、周末、工作日详情稳定记录进数据库表,靠的不是引入某个日历库,而是一套能按年生成、可被覆盖、可回滚重跑的落库方案。这套结构适合做考勤、排班、工时统计、请假额度计算的后端同学,也适合课程设计里需要一张日历基础表打底、后面所有业务表都挂在它上面的项目。

2. 需求拆解与数据建模:五种日期类型、两张表与按年生成的边界

动手建表之前,先把「一天到底有几种状态」想清楚。这一步偷懒,后面所有统计口径都要返工。

2.1 日期类型到底有几种:一个布尔值不够用

最容易犯的错是只存一个is_workday。听起来够用,但薪酬侧会立刻反问:法定节假日是三倍工资,周末加班是两倍,调休补班按正常工作日算,你只给我一个 0/1,我拿什么区分?所以类型字段必须比布尔值更细,通常拆成五种:

枚举编码是否工作日说明
WORKDAY1是普通周一到周五
WEEKEND2否周六、周日,未被任何规则覆盖
HOLIDAY3否法定节假日,带节日名称
MAKEUP_WORKDAY4是调休补班,通常落在周六周日
EXTRA_HOLIDAY5否调休放假,通常落在周一到周五

编码用数字而不是枚举名字符串入库,是为了索引体积和后续统计 SQL 好写;枚举本身放在 Java 侧,用getCode()和of(int)双向映射。is_workday这个字段虽然和day_type冗余,但我一般会保留:绝大多数查询只关心「这天算不算出勤」,冗余一个 tinyint 能让统计 SQL 少写一长串IN (1,4),而且这个冗余是可控的,写入时统一由枚举推导,不给人工改的入口。

2.2 表结构设计:年历主表与日期明细表

两张表分工明确:一张管「年」的状态,一张管「天」的明细。这样做的直接好处是发布和回滚有粒度——整年数据要么整体生效,要么整体不生效,不会出现半年的日历是新的、半年是旧的。

-- 年历主表:控制每一年日历的生成状态与版本 CREATE TABLE cal_year ( id BIGINT NOT NULL AUTO_INCREMENT, year INT NOT NULL COMMENT '公历年份,如 2025', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已归档', holiday_version VARCHAR(32) DEFAULT NULL COMMENT '节假日配置的版本号,便于回溯', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_year (year) ) COMMENT='年历主表'; -- 日期明细表:一年 365/366 行,全量落库 CREATE TABLE cal_day_detail ( id BIGINT NOT NULL AUTO_INCREMENT, year INT NOT NULL COMMENT '冗余年份,避免范围查询回表主表', cal_date DATE NOT NULL COMMENT '具体日期,唯一', day_type TINYINT NOT NULL COMMENT '1工作日 2周末 3法定节假日 4调休补班 5调休放假', is_workday TINYINT NOT NULL COMMENT '0否 1是,由 day_type 推导', week_index TINYINT NOT NULL COMMENT '1-7,周一为1,对齐 ISO 标准', week_of_year TINYINT DEFAULT NULL COMMENT '年内第几周,按 ISO 周计算', holiday_name VARCHAR(32) DEFAULT NULL COMMENT '节日名称,如 春节', remark VARCHAR(128) DEFAULT NULL COMMENT '备注,记录人工调整原因', source TINYINT NOT NULL DEFAULT 1 COMMENT '1规则生成 2人工导入', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_cal_date (cal_date), KEY idx_year_type (year, day_type) ) COMMENT='日期明细表';

几个字段的取舍值得说明。uk_cal_date是整套方案可重复执行的基础,有了它,重跑当年生成不会产生重复行,配合 upsert 就是天然幂等。year在明细表里冗余一份,是因为统计 SQL 里WHERE year = ? AND day_type = ?比WHERE cal_date BETWEEN ? AND ?更好走索引,尤其是跨年查询时优化器不用反复计算范围。week_index用 1 代表周一,和java.time.DayOfWeek保持一致,这一条在后面讲避坑时会再提一次,因为它是最容易在 Java 和 SQL 之间对不上的地方。source字段看着可有可无,但上线后排查问题全靠它——分得清哪一行是规则算出来的、哪一行是人工导进去的,出问题才知道该找谁。

2.3 为什么按年生成,而不是一次生成十年

有人会问,一年才 366 行,一次生成二十年也就七千多行,为什么不图省事全量铺开?原因不在数据量,在变更的边界。节假日与调休安排是按年公布的,每年的规则都可能不同,如果全量生成,任何一次配置修正都会变成「跨越十年的大范围更新」,影响面无法评估,回滚也无从下手。按年生成之后,一年的数据是一个完整的交付单元:生成、校验、发布、出问题整年回滚,边界清晰。

由此还要定两条规则。第一,只允许生成「当前年份前后各一年」的日历,再远的年份数据准确性没有保障,字段里留个remark也没人填得明白。第二,未发布的年份不对外提供查询,业务侧查不到时回落到纯规则判断(周末即休息),保证接口不 500,这个兜底逻辑后面会给代码。按年生成的另一个隐性收益是能支撑灰度:新的一年配置导入后先置为草稿,跑一遍对账 SQL 确认无误,再把status改成已发布,业务侧才开始读到新数据。

3. 总体架构与取数选型:三层结构加一条节假日覆盖链

数据模型定下来之后,接下来的问题不是怎么写循环,而是「节假日从哪来」。这一层选错了,代码写得再漂亮也白搭。

3.1 取数来源的三条路,以及它们的代价

取数方式可控性维护成本适用场景
纯规则生成高极低只关心周末、不涉及法定节假日的内部系统
配置导入高中考勤、薪酬、请假额度等强合规场景
外部接口拉取低低演示项目、非核心统计

纯规则生成最简单,一个DayOfWeek判断就能覆盖百分之九十的日期,但它天然处理不了法定节假日和调休补班,这是业务上最较真的部分。外部接口拉取省事,代价是把数据准确性交给了不受控的第三方,接口改了字段、限了频、某天返回空了,你的工资单就跟着错。我一般选配置导入:节假日与调休安排由运营或管理员在后台维护,导入后落库,系统只信任自己的表。这样做的前提是把导入做成可校验的——日期格式、年份归属、补班日是否落在周末,都要在入库前拦一遍。

3.2 分层结构:Controller、Service、Generator、Repository

按职责切四层,每层只做一件事:

  • Controller 层对外暴露「生成某年日历」「导入节假日配置」「查询某天类型」三类接口,不写业务判断。
  • Service 层负责流程编排:先校验参数,再调 Generator 生成基础日历,再合并节假日配置,最后交给 Repository 落库,全程一个事务。
  • Generator 层是纯函数式的,输入年份和配置、输出List<DayDetailPO>,不碰数据库,这样单测最好写——给一个年份,断言返回 366 条、断言某天是补班,几行代码搞定。
  • Repository 层只做批量 upsert 和查询,不含任何日期计算。

3.3 生成流程的六个步骤

整体流程按顺序执行,任一步失败即整体回滚:

  1. 参数校验:年份在允许区间内,该年未处于「已发布」状态,或者允许覆盖但要求显式传force=true。
  2. 调用generateBase(year),按周一到周五、周六周日铺出全量基础日历。
  3. 加载该年的节假日配置,转成Map<LocalDate, HolidayRule>。
  4. 调用validate(year, override)校验配置合法性,不合法直接抛业务异常。
  5. 调用mergeHoliday(rows, override)用配置覆盖基础日历。
  6. 按 500 条一批做 upsert,全部成功后把年历主表置为已发布。

这个顺序不能调换。先校验后合并,是为了避免脏数据已经写进内存对象才发现配置错;先合并后落库,是为了让整年的数据在一次事务里写进去,不会出现「前半年的节假日生效了、后半年没生效」的中间态。

4. 核心实现:从日期枚举到批量 upsert 的四段代码

接下来是能直接抄的部分。代码基于 Java 8+ 的java.time,持久层用 MyBatis-Plus 或原生 MyBatis 都能套。

4.1 基础日历生成:一个 while 循环铺满一年

/** * 生成某一年的基础日历:只区分工作日与周末,不处理法定节假日与调休 * @param year 公历年份 * @return 按日期升序排列的明细列表,长度等于该年天数 */ public List<DayDetailPO> generateBase(int year) { LocalDate cursor = LocalDate.of(year, 1, 1); LocalDate lastDay = LocalDate.of(year, 12, 31); List<DayDetailPO> rows = new ArrayList<>(lastDay.getDayOfYear()); while (!cursor.isAfter(lastDay)) { DayOfWeek dow = cursor.getDayOfWeek(); // 周一=1 ... 周日=7 boolean weekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY; DayDetailPO po = new DayDetailPO(); po.setYear(year); po.setCalDate(cursor); po.setWeekIndex((byte) dow.getValue()); // ISO 周:跨年周归属可能落到上一年或下一年,仅作展示用,不参与业务判断 po.setWeekOfYear((byte) cursor.get(WeekFields.ISO.weekOfWeekBasedYear())); po.setDayType(weekend ? DayType.WEEKEND.getCode() : DayType.WORKDAY.getCode()); po.setIsWorkday((byte) (weekend ? 0 : 1)); po.setSource((byte) 1); rows.add(po); cursor = cursor.plusDays(1); } return rows; }

这段代码有三个细节值得说。用while而不是for (int i = 0; i < 365; i++),是因为闰年二月会多一天,plusDays(1)让循环条件自己收敛,不需要单独判断闰年。lastDay.getDayOfYear()作为初始容量,正好等于该年天数,366 天的那年不会触发扩容。week_of_year用WeekFields.ISO计算,跨年那一周会归属到上一年或下一年,所以注释里写清楚「仅作展示」,任何加班费计算都不许拿它当依据——这是血泪经验,用过week_of_year算月度工时的人基本都吃过亏。

4.2 节假日覆盖与优先级:一张 Map 干掉所有分支

覆盖逻辑的核心不是写多少个if,而是先定优先级,再让代码照着优先级走。这套方案里优先级从高到低是:调休补班 > 法定节假日 > 调休放假 > 周末 > 普通工作日。落成代码就是把配置放进 Map,遍历基础日历逐个覆盖。

/** * 用节假日与调休配置覆盖基础日历 * @param rows 基础日历,必须按日期升序 * @param override 人工配置,key 为具体日期 */ public List<DayDetailPO> mergeHoliday(List<DayDetailPO> rows, Map<LocalDate, HolidayRule> override) { if (override == null || override.isEmpty()) { return rows; } for (DayDetailPO po : rows) { HolidayRule rule = override.get(po.getCalDate()); if (rule == null) { continue; // 没配置的日期保留基础规则结果 } po.setDayType(rule.getDayType().getCode()); po.setIsWorkday((byte) (rule.getDayType().isWorkday() ? 1 : 0)); po.setHolidayName(rule.getHolidayName()); po.setRemark(rule.getRemark()); po.setSource((byte) 2); // 标记为人工配置来源,便于日后排查 } return rows; } /** * 入库前校验配置合法性,宁可生成失败也不写脏数据 */ public void validate(int year, Map<LocalDate, HolidayRule> override) { for (Map.Entry<LocalDate, HolidayRule> e : override.entrySet()) { LocalDate date = e.getKey(); if (date.getYear() != year) { throw new BizException("配置日期不在目标年份内: " + date); } boolean weekend = date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; if (e.getValue().getDayType() == DayType.MAKEUP_WORKDAY && !weekend) { throw new BizException("调休补班日必须落在周末: " + date); } } }

mergeHoliday只覆盖、不新增、不删除行,保证输出列表长度永远等于该年天数,这一点在验收时是个关键断言。validate里那条补班日校验看起来吹毛求疵,但它是实际项目里最常见的数据错误:后台人员填配置时把补班的周六填成了周一,系统不拦,考勤就多算一天出勤,等到发工资才发现,追溯起来要翻好几个月的记录。

4.3 批量入库与幂等:唯一键加 upsert 是最省心的组合

365 行一条条 insert 不是不行,但没必要。按批提交,配合cal_date上的唯一键做 upsert,重跑、补跑、修正跑都能复用同一段代码。

/** * 整年落库:分批提交,全部成功后才发布年历 */ @Transactional(rollbackFor = Exception.class) public int saveYear(int year, List<DayDetailPO> rows) { final int batchSize = 500; for (int i = 0; i < rows.size(); i += batchSize) { List<DayDetailPO> part = rows.subList(i, Math.min(i + batchSize, rows.size())); dayDetailMapper.upsertBatch(part); // 依赖 uk_cal_date 做幂等 } calYearMapper.markPublished(year); // 最后一步才改状态 return rows.size(); }

对应的 Mapper XML:

<insert id="upsertBatch"> INSERT INTO cal_day_detail (year, cal_date, day_type, is_workday, week_index, week_of_year, holiday_name, source) VALUES <foreach collection="list" item="it" separator=","> (#{it.year}, #{it.calDate}, #{it.dayType}, #{it.isWorkday}, #{it.weekIndex}, #{it.weekOfYear}, #{it.holidayName}, #{it.source}) </foreach> AS new ON DUPLICATE KEY UPDATE day_type = new.day_type, is_workday = new.is_workday, holiday_name = new.holiday_name, source = new.source </insert>

markPublished放在循环之后,是为了让「明细写完」和「状态发布」在同一次事务里,中间任何一批失败都整体回滚,业务侧读不到半成品数据。AS new这种别名写法是 MySQL 8.0.19 之后推荐的,早期版本得用VALUES(day_type),如果你的库版本比较老,把new.day_type换成VALUES(day_type)即可。

注意:ON DUPLICATE KEY UPDATE会消耗自增主键,重跑多次后id会出现大段空洞。这不影响业务,但如果有人拿id做排序或分页,最好改成按cal_date排序。

4.4 查询侧:按天查、按区间统计与兜底

写入只是上半场,真正被高频调用的是查询。按天查适合走缓存,按区间统计适合直接走 SQL。

/** * 判断某天是否工作日,未生成该年份时回落到纯规则,保证接口不抛异常 */ public boolean isWorkday(LocalDate date) { DayDetailPO po = dayCache.get(date, this::selectByDate); if (po == null) { DayOfWeek dow = date.getDayOfWeek(); return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY; } return po.getIsWorkday() == 1; }
-- 区间内的工作日天数:请假额度、应出勤统计的高频查询 SELECT COUNT(*) AS workday_cnt FROM cal_day_detail WHERE cal_date BETWEEN #{start} AND #{end} AND is_workday = 1; -- 按类型汇总:月度报表里区分节假日加班与周末加班的口径来源 SELECT day_type, COUNT(*) AS cnt FROM cal_day_detail WHERE year = #{year} GROUP BY day_type;

按天查用Caffeine或 Redis 缓存都行,键就是日期,一年 366 个键,内存占用可以忽略。缓存失效时机只有一个:某年日历重新发布时按年清理,别做精细化的单键淘汰,容易漏。兜底那条分支看着不优雅,但线上很实用——新的一年来临前如果忘了生成日历,考勤接口至少还能按周末规则跑,不至于整站报错,等运维补完配置再自动切回读库。

5. 避坑与排查:五个让日历数据对不上的常见问题

5.1 日期整体偏移一天

现象:库里存的是 3 月 1 日,前端展示成 2 月 28 日,或者考勤统计出来的工作日总数比预期少一天。

原因:实体类用java.util.Date,序列化时区与数据库会话时区不一致,JDBC 驱动把DATE当成DATETIME处理,跨时区差出 8 小时,落库就退了一天。

解决:日期字段一律用LocalDate,MyBatis 3.4.5 以上原生支持;连接串固定serverTimezone,应用启动时打印一次TimeZone.getDefault()确认;cal_date列类型必须是DATE而不是DATETIME,这一点在建表时就定死。

5.2 周几对不上:Java 的 1 和 SQL 的 1 不是一个意思

现象:week_index存的是 1,前端显示成周日;或者用 SQL 的DAYOFWEEK()做统计,结果和 Java 算出来的分布对不上。

原因:java.time.DayOfWeek里周一等于 1、周日等于 7;MySQL 的DAYOFWEEK()里周日等于 1、周六等于 7;WEEKDAY()又是周一等于 0。三套编号混着用,必然错。

解决:入库统一用 ISO 编号(周一=1),Java 侧直接取dow.getValue(),不要在 SQL 里用DAYOFWEEK()反推;确实需要在 SQL 里算周几,用WEEKDAY(date) + 1转成同一套口径,并在注释里写清楚。

5.3 重跑生成,把人工修正全冲掉了

现象:某天本来是补班,运维手工在后台改成了休息,第二天跑了一次年度重新生成,改动没了。

原因:生成逻辑把「规则结果」当成唯一真相,没区分哪些行是人工维护的。source = 2的行被规则结果覆盖,等于人工白干。

解决:重跑时对source = 2且未标记过期的行做保护,只覆盖source = 1的行;或者干脆把人工调整单独存一张调整表,生成时以调整表为准做最后一道覆盖。我的做法是后者,调整表有操作人、有原因、有生效时间,出问题能查。

5.4 闰年与年末边界漏数据

现象:生成 2 月只有 28 天,或者 12 月 31 日那行不存在,统计全年工作日总数少一条。

原因:循环用固定的 365 次,或者用Month枚举拼日期时把结束条件写成了cursor.isBefore(lastDay),少了最后一天。

解决:循环条件用!cursor.isAfter(lastDay),容量用lastDay.getDayOfYear();验收时断言rows.size()等于Year.isLeap(year) ? 366 : 365,这一条断言能挡掉绝大多数边界问题。

5.5 大批量插入超时或事务过长

现象:生成十年的日历,接口跑了两分钟超时,或者事务持有锁太久,把其他写入阻塞了。

原因:一次性把几千行塞进一个INSERT,SQL 包体超过max_allowed_packet;或者事务里混了远程调用,锁持有时间被拉长。

解决:按 500 条分批,每批一个INSERT ... VALUES (...);事务里只留数据库操作,配置加载、日志、消息通知全部挪到事务外;单年生成控制在 366 行以内,一次事务不会超过两批,性能上没有压力。真要生成多个年份,按年拆成独立事务,一年一提交,失败也只回滚那一年。

6. 验收与答辩:用六条断言证明这张表是对的

日历数据的特点是「错了也不报错」,它不会抛异常,只会让工资单悄悄差几百块。所以验收不能靠肉眼看,得靠可执行的断言。我一般准备一张检查清单,写成 SQL 或单元测试,每次生成完跑一遍。

检查项做法通过标准
完整性SELECT COUNT(*) FROM cal_day_detail WHERE year = ?等于 365 或 366
唯一性按cal_date分组统计无重复,最大计数为 1
连续性用DATEDIFF检查相邻行间隔全年间隔恒为 1,无断档
类型覆盖GROUP BY day_type五种类型分布合理,法定节假日数量与配置一致
冗余一致day_type IN (1,4)的行is_workday = 1无一例外
幂等性同一年连续生成两次行数不变,updated_at更新而id不变

其中幂等性那条最容易被忽略,也最能体现方案成熟度。答辩或者方案评审时,如果能现场演示「连续跑三次生成,数据行数不变、内容不变」,基本就说明这套落库逻辑是经得起反复执行的。

对账 SQL 我通常写死一条,跑完直接看结果:

-- 全年日期连续性校验:出现结果行说明有断档 SELECT a.cal_date AS prev_date, b.cal_date AS next_date FROM cal_day_detail a JOIN cal_day_detail b ON b.cal_date = a.cal_date + INTERVAL 1 DAY WHERE a.year = #{year} AND b.id IS NULL; -- 冗余字段一致性校验:出现结果行说明写入逻辑有分支漏改 SELECT * FROM cal_day_detail WHERE year = #{year} AND ((day_type IN (1, 4) AND is_workday <> 1) OR (day_type IN (2, 3, 5) AND is_workday <> 0));

讲方案的时候,顺序比内容重要。我的习惯是先讲数据模型的取舍——为什么是两张表、为什么留is_workday冗余、为什么唯一键建在cal_date上;再讲覆盖优先级,把「补班 > 节假日 > 周末」这条链用一两个具体日期举例说明;最后演示一次重跑,让大家看到数据不脏、状态可控。这套顺序走下来,评审关注的点基本都能覆盖,也不会被追问到答不上来的地方。

从那以后我每次做日历相关的模块,都会强制走一遍「生成 → 校验 → 发布 → 重跑验证」这四步,哪怕项目再小也不省。日历这张表平时没人看,出事就是大事,多花半小时写断言,比事后翻三个月工资单划算得多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表