ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot考勤系统设计实战:业务规则、数据建模与状态机

Spring Boot考勤系统设计实战:业务规则、数据建模与状态机 每年毕业季或者公司内部做管理类系统总有一批人会选“员工考勤系统”这个题目。说实话这个题目看着人畜无害不就是员工打卡、管理员查记录、按月导出统计表吗真正动手做过的都知道Spring Boot本身没有任何难度最难的是你把考勤业务里那些细碎的规则一条条理清楚再转化成严谨的代码逻辑。我前阵子刚用Spring Boot重做了一套考勤系统从需求梳理、数据建模到最终上线踩了不少坑这篇就把完整的设计思路和实现细节写出来给正在做类似项目的人一个参考。1. 先别急着建表考勤系统真正的复杂度在业务规则里很多初学者拿到这个需求第一反应就是建员工表、打卡记录表然后写个/sign/in接口再写个统计列表完事。如果你只是做课程设计这么糊弄确实能交差。但如果你想做出一个真正能用的考勤系统或者面试时能讲出亮点就必须先想清楚一个核心问题考勤系统到底在解决什么业务问题考勤系统的本质不是记录“几点打卡”而是判定“员工是否按公司规定出勤”。这个“规定”才是整个系统的灵魂也是复杂度最集中的地方。我当初梳理需求时和公司的HR部门开了三次会最后整理出这么几条核心规则班次定义不同岗位有不同的上下班时间有固定班次比如9点到18点、轮班制早班/中班/夜班轮流、弹性工时一天工作满8小时即可。异常判定迟到、早退、缺卡、旷工每种异常的阈值不同。有的公司允许迟到5分钟有的严格到秒。请假与出差请假期间不算缺勤出差期间按出勤处理但需要审批单据作为依据。加班规则工作日加班、周末加班、法定节假日加班的计算倍率不一样。补卡流程员工忘记打卡或者打卡设备故障需要走补卡申请由主管审批。月度封存每个月考勤数据汇总后锁定之后不允许修改防止薪资核算出问题。看到没有任何一个规则背后都牵着一堆业务判断。比如“迟到”这个最简单的概念你至少得知道员工今天应该上哪个班次班次的上班时间是什么员工有没有请假请假的时段是否覆盖了迟到的时间段员工有没有补卡申请申请是否已经审批通过这些问题不回答清楚统计出来的迟到记录就是错的。所以我的建议是开工之前先写出一份业务规则清单一条条列出来然后针对每一条规则想清楚判定逻辑和数据流。这份清单既是你的设计文档也是你写代码时的验收标准。我见过太多人栽在“先建表后想业务”这个顺序上等到代码写了一半才发现缺字段、缺状态、缺约束返工反到怀疑人生。2. 数据建模的三种选择我为什么最终选择了排班计划表考勤系统的数据模型和一个普通CRUD系统最大的不同在于它需要表达“员工在某一天应该上哪个班、实际打了什么卡、最终判定的结果是什么”这三层信息。这三层如果混在一张表里后续统计和纠错会非常痛苦。我的核心表设计是这样的分享出来给你们参考-- 员工表只列关键字段 CREATE TABLE t_employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) UNIQUE NOT NULL COMMENT 工号, emp_name VARCHAR(50) NOT NULL COMMENT 姓名, dept_id BIGINT NOT NULL COMMENT 部门, position VARCHAR(50) COMMENT 岗位, hire_date DATE COMMENT 入职日期, status TINYINT DEFAULT 1 COMMENT 1在职 0离职 ); -- 班次表 CREATE TABLE t_shift ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shift_name VARCHAR(50) NOT NULL COMMENT 班次名称, start_time TIME NOT NULL COMMENT 上班时间, end_time TIME NOT NULL COMMENT 下班时间, late_threshold INT DEFAULT 0 COMMENT 迟到容忍分钟数, early_threshold INT DEFAULT 0 COMMENT 早退容忍分钟数, work_hours DECIMAL(4,2) COMMENT 标准工时, is_flexible TINYINT DEFAULT 0 COMMENT 是否弹性班次 ); -- 排班计划表 CREATE TABLE t_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_id BIGINT NOT NULL COMMENT 员工ID, work_date DATE NOT NULL COMMENT 日期, shift_id BIGINT NOT NULL COMMENT 班次ID, schedule_type TINYINT DEFAULT 1 COMMENT 1正常排班 2调休 3节假日, UNIQUE KEY uk_emp_date (emp_id, work_date) ); -- 打卡记录表 CREATE TABLE t_attendance_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_id BIGINT NOT NULL, work_date DATE NOT NULL COMMENT 打卡日期, clock_in_time DATETIME COMMENT 上班打卡时间, clock_out_time DATETIME COMMENT 下班打卡时间, source TINYINT DEFAULT 1 COMMENT 1考勤机 2手机定位 3手动补录, UNIQUE KEY uk_emp_date (emp_id, work_date) ); -- 考勤结果表 CREATE TABLE t_attendance_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_id BIGINT NOT NULL, work_date DATE NOT NULL, schedule_id BIGINT COMMENT 关联排班, status VARCHAR(20) NOT NULL COMMENT NORMAL/LATE/EARLY/ABSENT/LEAVE/TRAVEL/PENDING, actual_work_hours DECIMAL(4,2) COMMENT 实际工时, is_manual_fixed TINYINT DEFAULT 0 COMMENT 是否人工修正, UNIQUE KEY uk_emp_date (emp_id, work_date) );这里最关键的也是我特别想重点说的是排班计划表设计中的取舍问题。我当时在三种方案里纠结了很久第一种方案是“员工-班次直接关联”也就是员工表上直接挂一个默认班次ID每天就按这个班次判定。这最简单但一旦员工临时调班你得去改员工表改完历史数据的判定又出问题了。第二种方案是“班组轮转规则”比如一个班组有三种班次按日期取模轮转。这种适合生产企业的固定轮班但灵活性差遇到调休、特殊情况代码会写得很绕。第三种是我最终采用的“排班计划表”。说白了就是先把未来一个月甚至一年的排班都算好写到这张表里。哪个人哪天在哪个班次一条记录清清楚楚。查询统计时直接关联这张表和打卡表逻辑非常直观临时调班就更新对应日期的记录也不会影响历史。有人可能会担心排班计划表数据量会不会太大一千个员工一年也才36万条MySQL完全没压力。而且因为有了这个明确的“应到班次”后面的考勤判定就变得很干净打卡记录表和排班计划表关联状态一个个算清楚就行。实际上很多开源考勤系统也都是这么设计的这算是行业里经过验证的标准做法。3. 打卡判定状态机让迟到、早退、缺卡不再是一堆if-else打卡数据拿到手之后下一步就是对每条记录做状态判定。很多新手喜欢在Service里写一堆if-else什么if(clockInTime startTime)就置为迟到代码又臭又长而且状态多了以后改起来特别容易出错。我处理这个问题的方式是引入状态机。考勤结果有几种状态我设计了一套流转规则状态含义触发条件可流转到的状态PENDING待判定日终任务扫描到打卡记录但未计算NORMAL / LATE / ABSENT 等NORMAL正常出勤上班未迟到且下班未早退FIXED人工修正LATE迟到上班打卡时间晚于班次开始时间容忍值FIXED人工修正EARLY早退下班打卡时间早于班次结束时间FIXED人工修正ABSENT缺卡/旷工当天有排班但无打卡记录FIXED人工修正LEAVE请假请假单审批通过且覆盖排班时段FIXED人工修正TRAVEL出差出差单审批通过FIXED人工修正FIXED人工修正HR手动调整考勤结果后进入终态不可再流转这个状态机的好处是每个状态从哪里来、能到哪里去都有明确规则。比如“员工迟到后补提交了请假申请审批通过后该怎么处理”在状态机设计里就不需要写特殊逻辑直接在代码里定义LATE状态允许流向LEAVE状态即可。规则清晰测试起来也好写。核心判定的伪代码大概是这样的public AttendanceStatus evaluate(AttendanceLog log, Schedule schedule) { // 先处理请假优先逻辑请假覆盖全天直接返回LEAVE if (leaveService.hasApprovedLeave(log.getEmpId(), log.getWorkDate())) { return AttendanceStatus.LEAVE; } // 无排班但有打卡可能是加班或异常打卡标记为NORMAL但不计入工时 if (schedule null) { return AttendanceStatus.UNSCHEDULED; } // 无打卡但有排班缺卡或旷工 if (log.getClockInTime() null || log.getClockOutTime() null) { return AttendanceStatus.ABSENT; } LocalTime shiftStart schedule.getStartTime(); LocalTime shiftEnd schedule.getEndTime(); boolean late log.getClockInTime().toLocalTime() .isAfter(shiftStart.plusMinutes(schedule.getLateThreshold())); boolean early log.getClockOutTime().toLocalTime() .isBefore(shiftEnd.minusMinutes(schedule.getEarlyThreshold())); if (late early) { return AttendanceStatus.LATE_AND_EARLY; } if (late) { return AttendanceStatus.LATE; } if (early) { return AttendanceStatus.EARLY; } return AttendanceStatus.NORMAL; }这里有个特别容易踩的坑就是请假优先级的处理。我当时没考虑“员工上午请假下午正常上班”这种半天假的情况只做了全天假的判定后来发现统计结果把下午也当成请假了只能回头看数据重新算。所以如果你们的业务里有半天假判定逻辑必须区分上下午时段而不是简单地看当天有没有请假单。4. 节假日与调休补班一个比想象中麻烦得多的工作日历模块做考勤系统之前我也以为节假日很简单建一张节假日表往里面存几个日期就行了。真做起来才发现这里面名堂多得很而且处理不好每个月的考勤统计都是错的HR铁定来找你麻烦。国内的工作日规则至少有这么几层一是国家法定节假日比如国庆七天假二是调休补班日很多周末要上班三是公司自己的特殊放假安排比如年会下午放假、台风天停班四是法定节假日碰上周末时的顺延规则。这些规则叠加在一起你如果只是简单往表里插日期根本管不过来。我最终的方案是做了一张工作日历表一年生成一次。每年年底根据国办发布的通知外加公司行政部的安排把下一年的每一天都标注好类型// 工作日历表 CREATE TABLE t_work_calendar ( id BIGINT PRIMARY KEY AUTO_INCREMENT, cal_date DATE NOT NULL UNIQUE COMMENT 日期, day_type TINYINT NOT NULL COMMENT 1工作日 2周末 3法定节假日 4调休补班 5公司特殊假期, holiday_name VARCHAR(50) COMMENT 节假日名称, is_holiday TINYINT DEFAULT 0 COMMENT 是否节假日, is_workday TINYINT DEFAULT 1 COMMENT 是否工作日 );判断逻辑就变成查工作日历表is_workday1且is_holiday0才是正常上班日。到了调休补班那几天直接插入一条is_workday1的记录覆盖就可以了不需要在任何业务代码里写死“国庆期间不上班”这种逻辑。另外还要注意法定节假日的加班工资计算规则和周末不一样考勤统计时要用到工作日历上的is_holiday和day_type。比如国庆节期间来加班按三倍工资算这个数据必须能查出来。我当时做工资模块对接的时候就发现如果工作日历表里没有区分“法定节假日”和“周末调休”财务那边算加班费就没法自动区分只能人工核对效率极低。不过这里也想提醒一句工作日历初始化时要留一个人工维护的后台页面让行政人员可以在特殊情况下临时调整某一天的属性。这个入口一开始我没做后来赶上台风放假只能直接改数据库后续还差点被人吐槽。5. 考勤统计报表日终汇总任务与缓存优化的实战组合考勤数据的日常查询其实不算复杂真正容易性能翻车的是月底统计报表。你想啊一个中等规模的公司一千号人一个月的打卡数据轻松破十万条。如果每次查看月度报表都实时去关联打卡记录、请假单、加班单数据库再快也经不起这么折腾。我的做法是变“月底现算”为“日终汇总”。每天凌晨两点跑一个定时任务把前一天的考勤结果同步到一张考勤日汇总表里。这张表按员工、按日期一条记录存的就是最终的判定状态和工时。这样到月底做月报的时候直接SUM日汇总表就可以了不用再碰底层明细数据。// 日终汇总定时任务 Component public class DailyAttendanceSummaryJob { Scheduled(cron 0 0 2 * * ?) public void summarizeYesterday() { LocalDate yesterday LocalDate.now().minusDays(1); ListLong empIds employeeService.listAllActiveEmpIds(); for (Long empId : empIds) { AttendanceResult result attendanceResultService.getByEmpAndDate(empId, yesterday); DailySummary summary new DailySummary(); summary.setEmpId(empId); summary.setWorkDate(yesterday); summary.setStatus(result.getStatus()); summary.setWorkHours(result.getActualWorkHours()); dailySummaryMapper.insertOrUpdate(summary); } } }这个月报模块我用了三层数据组合个人明细月报查t_attendance_result按员工分组按月筛选加个索引性能就够了。部门月度统计查t_daily_summary关联部门表做GROUP BY。全局看板统计全公司迟到率、缺勤率这些指标先把每日汇总的数据放进Redis缓存缓存时间设置成30分钟。缓存这块需要专门说一下策略。类似“全公司今天的迟到人数”这种指标查一次要全表扫描一次接口慢了还容易被领导盯上。我当时是写了个DashboardCacheService每天早上定时把核心指标算好放Redis页面直接读缓存除非管理员手动点了刷新按钮否则不会触发实时计算。这样查询性能基本无压力也避免了多个领导同时打开看板时数据库被打死的情况。统计报表这块还容易漏掉一个重要问题统计口径。有的公司按月自然月算考勤比如从1号到31号有的公司把考勤月设置成从上月26号到本月25号为了和工资核算周期对齐。如果口径没和HR确认好你辛苦做的报表可能完全不能用。我做这个项目时和财务部门确认过好几次最后把考勤周期设置成可配置的才免去了后续维护的麻烦。6. 容易被忽略的坑时区问题、人工改卡、审批并发最后分享几个我实际开发中踩过的坑。这些坑平时看教程绝对看不到但遇到了才知道有多难受。6.1 MySQL时区导致打卡时间偏移8小时我们的服务器在云端数据库连接串里没有配置时区参数导致JDBC读取DATETIME字段时把数据库里的时间当成了UTC时间转换成本地时间后整整多了8小时。员工明明下午6点下班打卡系统里显示的是第二天凌晨2点。这个问题在开发和测试阶段完全看不出来因为用的同一套时区结果上线后的第一天统计考勤所有加班记录全乱了。解决方式是统一时区规范数据库连接后面加?serverTimezoneAsia/Shanghai同时程序里全部用LocalDateTime坚决不用Date。这种事如果一开始就定好规范后面会省掉很多烦恼。6.2 人工改卡和自动重算的冲突考勤结果表里我留了一个is_manual_fixed字段这个字段是因为有个真实的业务场景员工某天打卡异常主管审批补卡之后系统重新计算考勤结果可能会把HR之前手动修正的数据覆盖掉。举例说明员工张三月考勤显示缺卡HR手动改成“正常”并备注了原因。结果下午排班模块同步排班触发了重新计算逻辑一天的工作又把张三月考勤状态改回了缺卡。这就很尴尬。解决办法是所有自动重算的逻辑都判断一下is_manual_fixed如果是人工修正过的记录直接跳过不去动它。6.3 同一天请假加补卡审批的并发问题有一次测试反馈员工当天既提交了请假单又提交了补卡申请两条审批流程同时通过结果考勤结果被写了两次状态被后提交的覆盖了。我在表设计上加了联合唯一索引uk_emp_date同时把审批通过后的状态更新逻辑放进Transactional里先查后改并且判断当前状态才决定是否流转这样就彻底避免了并发覆盖的隐患。6.4 外勤打卡的定位校验如果你做的系统支持手机端外勤打卡一定要想清楚定位校验的边界精度范围设多少米打卡失败后允不允许补卡GPS信号弱如何处理这些都要由业务发话不能拍脑袋。我当时的做法是把定位逻辑和考勤判定解耦定位信息只记录在打卡日志里不作为考勤判定的硬性条件特殊情况走补卡审批流程。考勤系统做到最后你就会发现Spring Boot的技术难点几乎为零真正磨人的是对业务的敬畏和对边界的耐心。每一次规则的调整都意味着状态流转、数据统计、报表展示三处的同步修改所以一开始就把这些机制设计好比后面缝缝补补要省心太多。如果你正在做类似的系统我建议你也先花一周时间把规则理清楚再动代码一定不会后悔。
返回列表